Planck.Agent (Planck.Agent v0.3.0)

Copy Markdown View Source

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:

EventPayload keys
:turn_startindex
:turn_endmessage, usage
:text_deltatext
:thinking_deltatext
:usage_deltadelta (input_tokens, output_tokens, cost), total (input_tokens, output_tokens, cost), context_tokens
:tool_startid, name, args
:tool_endid, name, result, error
:worker_spawned—
:worker_exitpid, reason
:errorreason
:message_cancelledid

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?")

Summary

Types

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

t()

Internal GenServer state for an agent.

Functions

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 a tool at runtime.

Cancel a message still queued (unpersisted) in the agent's in-memory message list.

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

Insert a summary checkpoint into the agent's conversation.

Delete all messages in the session and reset the agent's in-memory history.

Dispatch a custom slash command to the agent.

Force a compaction pass on demand, bypassing the compactor's own trigger heuristic.

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.context_tokens's own doc), not a fresh recomputation.

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

Synchronous state snapshot.

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.

Load a skill into the agent's context.

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

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

Remove a tool by name at runtime.

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.

Start an agent under a supervisor.

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

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

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

Types

agent()

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

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

t()

@type t() :: %Planck.Agent{
  available_models: [Planck.AI.Model.t()],
  context: Planck.Agent.Context.t(),
  hooks: Planck.Agent.Hooks.t(),
  identity: Planck.Agent.Identity.t(),
  turn: Planck.Agent.Turn.t()
}

Internal GenServer state for an agent.

Identity fields (grouped in identity):

  • 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

Context fields:

  • 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
  • skills — frozen skill index for system prompt building and tool dispatch
  • usage — accumulated token counts and cost for this session
  • 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
  • opts — pass-through keyword options (e.g. tool_timeout)

Turn fields (grouped in turn):

  • status — :idle, :streaming, or :executing_tools
  • index / checkpoints — monotonically increasing turn counter and checkpoint stack
  • stream_task / stream_ref — in-flight async LLM stream
  • stream_start — length of context.messages when the current stream began; used to detect messages appended during streaming that the LLM did not see
  • buffer_text / buffer_thinking / buffer_calls — accumulated deltas during streaming
  • running / results / loop_counts — in-flight tool tasks, accumulated results, loop detection

Hook fields (grouped in hooks):

  • compactor — resolved module atom for context compaction; nil uses the built-in LLM-based compactor
  • persistence — resolved module atom for conversation persistence; nil uses the built-in SQLite-backed store
  • prompt — resolved module atom for per-turn system prompt injection (prepend/append); nil means no injection
  • turn_end — 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
  • available_models — model catalog used by list_models and spawn_agent

Functions

abort(agent)

@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(agent, tool)

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

Add a tool at runtime.

cancel_queued(agent, id)

@spec cancel_queued(agent(), String.t() | non_neg_integer()) ::
  :ok | {:error, :not_found} | {:error, :already_sent}

Cancel a message still queued (unpersisted) in the agent's in-memory message list.

Covers queued user messages, custom-command messages, and the {:custom, :clear} / {:custom, :compact} primitive markers. Returns {:error, :already_sent} if the message has already been flushed to the session (its id is now a db id, not the agent's string id), or {:error, :not_found} if no message with that id is in the list. No broadcast — the caller removes its own UI entry on :ok.

change_model(agent, model)

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

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

checkpoint(agent, summary_text)

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

clear(agent)

@spec clear(agent()) :: :ok

Delete all messages in the session and reset the agent's in-memory history.

The session process itself stays alive and its metadata is preserved. No LLM call is made. If the agent is busy, the clear is queued and executed at the next turn boundary, before any queued user messages.

command(agent, command, args)

@spec command(agent(), Planck.Agent.Command.t(), String.t() | nil) ::
  :ok | {:error, :already_sent}

Dispatch a custom slash command to the agent.

Enqueues a {:custom, :command} message carrying command_meta and the rendered command body. When the agent is idle, a new turn starts immediately; when busy, the message stacks in state.context.messages (broadcast as :message_queued with role: :command) and starts a turn at the next turn boundary.

compact(agent, args \\ %{prompt: nil})

@spec compact(agent(), args) :: :ok
when args: %{prompt: prompt}, prompt: String.t() | nil

Force a compaction pass on demand, bypassing the compactor's own trigger heuristic.

args is a map that may contain :prompt — a user-supplied string (from /compact [prompt]) that can steer the summarization. When the agent is idle, compaction runs immediately. When busy, it is queued and runs at the next turn boundary, before any queued user message starts a new turn.

Returns :ok immediately — the actual compaction runs in a handle_continue so the caller's GenServer.call doesn't timeout while the delegate agent summarises (which can take tens of seconds). Progress is reported via :compacting / :compacted PubSub events.

estimate_tokens(agent)

@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.context_tokens's own doc), not a fresh recomputation.

get_info(agent)

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

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

get_state(agent)

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

Synchronous state snapshot.

inject_tool_result(agent, name, result)

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

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.

load_skill(agent, skill, user_text)

@spec load_skill(agent(), Planck.Agent.Skill.t(), String.t() | nil) :: :ok

Load a skill into the agent's context.

Reads the skill's SKILL.md, enqueues a {:custom, :skill} message carrying the Skill.t() struct and the rendered content in metadata. If user_text is non-empty, it is enqueued as a :user message after the skill message. When the agent is idle a turn starts immediately; when busy both messages stack and run at the next turn boundary.

User-initiated skill loading does not record usage — only the autonomous load_skill tool path records via on_skill_use.

Replaces the previous inject_tool_result + prompt pair used for slash-command skill loading. The LLM sees the skill content as a :user message (via Message.to_ai_messages/1).

nudge(agent)

@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(agent, content, opts \\ [])

@spec prompt(agent(), content, keyword()) :: :ok | {:error, :already_sent}
when content: String.t() | [Planck.AI.Message.content_part()]

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

Pass edit: id to instead replace the text of a message with that id — only succeeds if it's still the last, unpersisted message queued while the agent was busy; fails with {:error, :already_sent} once it's been flushed to the session (the caller decides how to fall back, e.g. the normal rewind-based edit, once the message has a real db id).

remove_tool(agent, name)

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

Remove a tool by name at runtime.

rewind_to_message(agent, message_id)

@spec rewind_to_message(agent(), pos_integer() | String.t()) :: :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(opts)

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

Start an agent under a supervisor.

stop(agent)

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

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

subscribe(agent_id)

@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(id)

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