# `Planck.Agent`
[🔗](https://github.com/alexdesousa/planck/blob/v0.2.2/lib/planck/agent.ex#L1)

OTP-based LLM agent.

Each agent is a `GenServer` that drives the LLM loop:
stream a response → collect tool calls → execute them concurrently →
append results → re-stream until the model stops.

## Roles

An agent's role is derived from its tool list at start time:

- **Orchestrator** — has a tool named `"spawn_agent"` in its list. Owns a
  `team_id`; all agents sharing that `team_id` are terminated when this
  agent exits.
- **Worker** — no `"spawn_agent"` tool. Receives tasks and reports back.

## Events

Subscribers receive `{:agent_event, type, payload}` messages:

| Event | Payload keys |
|---|---|
| `:turn_start` | `index` |
| `:turn_end` | `message`, `usage` |
| `:text_delta` | `text` |
| `:thinking_delta` | `text` |
| `:usage_delta` | `delta` (`input_tokens`, `output_tokens`, `cost`), `total` (`input_tokens`, `output_tokens`, `cost`), `context_tokens` |
| `:tool_start` | `id`, `name`, `args` |
| `:tool_end` | `id`, `name`, `result`, `error` |
| `:worker_spawned` | — |
| `:worker_exit` | `pid`, `reason` |
| `:error` | `reason` |

## Example

    {:ok, pid} = DynamicSupervisor.start_child(
      Planck.Agent.AgentSupervisor,
      {Planck.Agent,
        id: "agent-1",
        model: model,
        system_prompt: "You are helpful.",
        tools: [read_tool]}
    )

    Planck.Agent.subscribe(pid)
    Planck.Agent.prompt(pid, "What is in lib/app.ex?")

# `agent`

```elixir
@type agent() :: pid() | atom() | {:via, module(), term()}
```

A reference to a running agent — pid, registered name, or via-tuple.

# `t`

```elixir
@type t() :: %Planck.Agent{
  available_models: [Planck.AI.Model.t()],
  compactor: module() | nil,
  context_tokens: non_neg_integer(),
  cwd: String.t(),
  delegator_id: String.t() | nil,
  description: String.t() | nil,
  id: String.t(),
  messages: [Planck.Agent.Message.t()],
  model: Planck.AI.Model.t() | nil,
  name: String.t() | nil,
  opts: keyword(),
  prompt_hook: module() | nil,
  role: :orchestrator | :worker,
  session_id: String.t() | nil,
  sidecar_node: atom() | nil,
  skills: Planck.Agent.SkillIndex.t(),
  status: :idle | :streaming | :executing_tools,
  stream_buffer: Planck.Agent.StreamBuffer.t(),
  stream_ref: reference() | nil,
  stream_start: non_neg_integer(),
  stream_task: Task.t() | nil,
  system_prompt: String.t(),
  team_id: String.t() | nil,
  team_name: String.t() | nil,
  tool_runner: Planck.Agent.ToolRunner.t(),
  tools: %{required(String.t()) =&gt; Planck.Agent.Tool.t()},
  turn_end_hook: module() | nil,
  turn_state: Planck.Agent.TurnState.t(),
  type: String.t() | nil,
  usage: Planck.Agent.Usage.t()
}
```

Internal GenServer state for an agent.

Public fields (readable via `get_state/1` or `get_info/1`):
- `id` — unique agent identifier
- `name` / `description` / `type` — display metadata set at start time
- `team_id` — registry namespace shared by all agents in the same team
- `team_name` — stable team alias (directory name); `"default"` for dynamic teams
- `session_id` — SQLite session this agent persists messages to; `nil` for
  ephemeral agents
- `delegator_id` — id of the orchestrator that spawned this worker; `nil` for
  orchestrators
- `role` — `:orchestrator` (has `spawn_agent` tool) or `:worker`
- `model` — the `Planck.AI.Model` the agent is configured to use
- `system_prompt` — prepended to every LLM context
- `cwd` — working directory for the session; used to locate `AGENTS.md`
- `messages` — full in-memory conversation history (`Message.t()` list)
- `tools` — map of tool name → `Tool.t()` available to this agent
- `status` — `:idle`, `:streaming`, or `:executing_tools`
- `turn_state` — monotonically increasing turn counter and checkpoint stack
- `usage` — accumulated token counts and cost for this session

Internal fields (not part of the public API):
- `stream_task` / `stream_ref` — in-flight async LLM stream
- `stream_start` — length of `messages` when the current stream began; used to
  detect messages appended *during* streaming that the LLM did not see
- `stream_buffer` — accumulates text/thinking/tool-call deltas during streaming
- `tool_runner` — tracks in-flight tool tasks and their accumulated results
- `context_tokens` — estimated size (system prompt + messages + tool schemas)
  of the request built for the most recently started LLM call; see
  `Planck.AI.Context.estimate_tokens/1`
- `compactor` — resolved module atom for context compaction; `nil` uses the
  built-in LLM-based compactor
- `prompt_hook` — resolved module atom for per-turn system prompt injection
  (prepend/append); `nil` means no injection
- `turn_end_hook` — resolved module atom called after every turn ends;
  `nil` means no post-turn reflection
- `sidecar_node` — connected sidecar node; shared by all hook dispatch calls
- `skills` — frozen skill index for system prompt building and tool dispatch
- `opts` — pass-through keyword options (e.g. `tool_timeout`)
- `available_models` — model catalog used by `list_models` and `spawn_agent`

# `abort`

```elixir
@spec abort(agent()) :: :ok
```

Cancel in-flight streaming and tool execution. Blocks until the agent has
returned to `:idle` (or started a follow-up turn for any queued messages).

# `add_tool`

```elixir
@spec add_tool(agent(), Planck.Agent.Tool.t()) :: :ok
```

Add a tool at runtime.

# `change_model`

```elixir
@spec change_model(agent(), Planck.AI.Model.t()) :: :ok
```

Replace the model used for subsequent LLM turns without interrupting the current state.

# `checkpoint`

```elixir
@spec checkpoint(agent(), String.t()) :: :ok
```

Insert a summary checkpoint into the agent's conversation.

Builds a `{:custom, :summary}` message with `summary_text`, persists it,
and appends it to in-memory history. The agent's next LLM call will only
see the checkpoint and any messages after it via `messages_since_last_summary`.

Works regardless of the agent's current status.

# `estimate_tokens`

```elixir
@spec estimate_tokens(agent()) :: non_neg_integer()
```

Estimate the number of tokens currently in the agent's context window —
system prompt, tool schemas, and conversation, not just the conversation.
Reflects the request built for the most recently started LLM call (see
`state.context_tokens`'s own doc), not a fresh recomputation.

# `get_info`

```elixir
@spec get_info(agent()) :: map()
```

Lightweight summary: id, name, description, type, role, status, turn_index, usage.

# `get_state`

```elixir
@spec get_state(agent()) :: map()
```

Synchronous state snapshot.

# `inject_tool_result`

```elixir
@spec inject_tool_result(agent(), String.t(), String.t()) :: :ok
```

Inject a synthetic tool use + tool result pair into the agent's message history.

Appends an assistant message containing a `{:tool_call, id, name, %{}}` content
part followed by a `:tool_result` message with `result`. Both are persisted.
No new turn is triggered.

The `name` does not need to be in the agent's callable tool list — it appears
only as a history entry. Used by the sidecar SkillReflector to signal skill
creation/update back to the parent agent so the LLM and UI see it passively.

# `nudge`

```elixir
@spec nudge(agent()) :: :ok
```

Trigger the agent to run an LLM turn without adding a new user message.

Used after session resume when a recovery context message is already present
in the agent's history and just needs to be acted upon.

# `prompt`

```elixir
@spec prompt(agent(), String.t() | [Planck.AI.Message.content_part()], keyword()) ::
  :ok
```

Send a user message and kick off the agent loop. Returns once the agent status is :streaming.

# `remove_tool`

```elixir
@spec remove_tool(agent(), String.t()) :: :ok
```

Remove a tool by name at runtime.

# `rewind_to_message`

```elixir
@spec rewind_to_message(agent(), pos_integer()) :: :ok
```

Truncate the session to strictly before `message_id`, then reload the
agent's in-memory message history from the DB (the source of truth).
`turn_checkpoints` is rebuilt from the reloaded message list.

Only meaningful for agents with a `session_id`. A no-op for ephemeral agents.

# `start_link`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

Start an agent under a supervisor.

# `stop`

```elixir
@spec stop(agent()) :: :ok
```

Stop the agent. Cancels any in-flight work and removes it from the supervisor.

# `subscribe`

```elixir
@spec subscribe(String.t() | agent()) :: :ok | {:error, term()}
```

Subscribe the calling process to `{:agent_event, type, payload}` messages.

Accepts either an agent id string or a pid/name. The pid form resolves the id
via `get_info/1` — prefer passing the id directly when available.

# `whereis`

```elixir
@spec whereis(String.t()) :: {:ok, pid()} | {:error, :not_found}
```

Resolve an agent id to its pid, anywhere in the connected distributed
Erlang cluster — not just the calling node's own `Registry`.

Checks the calling node's own `Registry` first (fast, no RPC — and the
only thing that can find it when the caller and the agent share a node,
which every call site originally assumed). Falls back to asking every
node in `Node.list/0`, in order, stopping at the first hit.

The fallback matters for code that runs on a **different** node than
`planck_agent`'s own processes — concretely, sidecar tools and hooks
(`Sidecar.Tools.Beads`, `Sidecar.Tools.UpdateMemory`,
`Sidecar.SkillReflector.Runner`), which execute on the sidecar node while
every real `Planck.Agent` GenServer runs on the connected
`planck_headless` node. A local-only lookup there always returned
`{:error, :not_found}` regardless of whether the agent was actually
running — `Registry` is per-node and does not replicate across a
distributed Erlang connection, confirmed by this failing in exactly that
shape in real (non-test) use. Every one of those call sites' own unit
tests passed anyway, because a unit test starts a real `Planck.Agent` in
the *same* test process/node — the one topology where the gap was never
going to show up.

Headless-side callers (inter-agent tools, `planck_cli`'s session/event
code) always target a same-node agent, so the fallback is a no-op there
in the success case; on a genuine miss it costs one extra, fast RPC round
trip to the connected sidecar (which never has a matching entry) before
returning `{:error, :not_found}` — a small, bounded cost worth paying to
have one function that's simply always correct, rather than a second,
easy-to-forget name for callers that happen to run elsewhere.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
