AgentEngine

Minimal host-facing agent runtime for Elixir applications.

AgentEngine intentionally stays narrow. It provides:

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}
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.