# AgentEngine

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`:

```elixir
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

- `AgentEngine.Agent`
- `AgentEngine.Session`
- `AgentEngine.BudgetHints`
- `AgentEngine.TrendHints`
- `AgentEngine.Collaboration`
- `AgentEngine.Turn`
- `AgentEngine.Transcript`

## 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**:

```elixir
{: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.

```elixir
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`:

```elixir
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:

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

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

## License

MIT — see [LICENSE](https://github.com/fosferon/agent_engine/blob/main/LICENSE).
