Minimal host-facing agent runtime for Elixir applications.

AgentEngine intentionally stays narrow. It provides:

  • config-driven agent loading
  • functional session state
  • budget-awareness hints
  • quality-trend hints
  • collaboration state for multi-agent work
  • a host-owned turn loop for single-agent conversations

It intentionally does not ship richer staged guidance systems, provider routing, or tool execution. Host applications own those and layer them on top.

Installation

Add agent_engine to your list of dependencies in mix.exs:

def deps do
  [
    {:agent_engine, "~> 0.2.0"}
  ]
end

agent_engine shares its core dependencies (comm_bus, llm_core, llm_toolkit) with companion libraries. If your application also declares any of them directly, add override: true on those entries in your app so Mix resolves them to a single version.

Core Modules

Running a turn

AgentEngine.Turn.turn/3 advances a session by one exchange: it appends the user message (or host-resolved tool results), calls a host-supplied LLM function once, and records the assistant reply. AgentEngine never executes host tools and performs no provider routing — the :llm function is the host's seam for both.

The success shape is frozen:

{:ok, content, runtime, signals}
  • content — the assistant's final text (a binary), or nil when the turn is suspended awaiting tool results.
  • runtime — an AgentEngine.Turn.Runtime that embeds the advanced AgentEngine.Session (.session) and carries the working transcript, so a suspended turn can be resumed by passing it back into turn/3. It does not extend AgentEngine.Session's public fields.
  • signals — produced by the optional :extract_signals callback; [] when no extractor is supplied.
alias AgentEngine.{Session, Turn}
alias LlmCore.LLM.Response

# Host-supplied LLM function — forward to llm_core, a CLI, or a mock.
llm = fn _messages, _opts ->
  {:ok, %Response{content: "Hello!", tool_calls: nil}}
end

session = Session.new(%{"slug" => "ops"}, [])

{:ok, content, runtime, signals} = Turn.turn(session, "Hi", llm: llm)
# content  => "Hello!"
# signals  => []
# runtime  => %AgentEngine.Turn.Runtime{session: session, transcript: ...}

Tool-call round trip

When the LLM requests tool calls, the turn suspends. The host executes the calls and resumes by passing the runtime back with :tool_results:

alias LlmToolkit.Tool.{Call, Result}

llm = fn _messages, _opts ->
  {:ok,
   %Response{
     content: nil,
     tool_calls: [%Call{id: "call_1", name: "read_file", arguments: %{"path" => "a.txt"}}]
   }}
end

{:ok, nil, runtime, []} = Turn.turn(session, "Read a.txt", llm: llm)

# Host executes the tool and submits results to complete the turn.
llm2 = fn _messages, _opts ->
  {:ok, %Response{content: "The file contains: hello", tool_calls: nil}}
end

{:ok, "The file contains: hello", runtime, []} =
  Turn.turn(runtime, nil,
    llm: llm2,
    tool_results: [%Result{tool_call_id: "call_1", name: "read_file", content: "hello"}]
  )

Submitting a new user message while tool calls are still pending is refused:

{:error, {:tool_results_required, ["call_1"]}}

LLM errors pass through unchanged: {:error, term()}.

License

MIT — see LICENSE.