Current section

Files

Jump to
planck_agent lib planck agent.ex
Raw

lib/planck/agent.ex

defmodule Planck.Agent do
@moduledoc """
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?")
"""
use GenServer
require Logger
alias Planck.Agent.Hooks
alias Planck.Agent.{
AIBehaviour,
Message,
MessageBuilder,
Session,
SessionStore,
SkillIndex,
StreamBuffer,
Tool,
ToolRunner,
TurnContext,
TurnState,
Usage
}
alias Planck.AI.Context
@typedoc "A reference to a running agent — pid, registered name, or via-tuple."
@type agent :: pid() | atom() | {:via, module(), term()}
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@typedoc """
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
- `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`
"""
@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
description: String.t() | nil,
type: String.t() | nil,
team_id: String.t() | nil,
team_name: String.t() | nil,
session_id: String.t() | nil,
delegator_id: String.t() | nil,
role: :orchestrator | :worker,
model: Planck.AI.Model.t() | nil,
compactor: module() | nil,
prompt_hook: module() | nil,
turn_end_hook: module() | nil,
sidecar_node: atom() | nil,
skills: SkillIndex.t(),
cwd: String.t(),
system_prompt: String.t(),
messages: [Message.t()],
tools: %{String.t() => Tool.t()},
opts: keyword(),
available_models: [Planck.AI.Model.t()],
status: :idle | :streaming | :executing_tools,
stream_task: Task.t() | nil,
stream_ref: reference() | nil,
stream_start: non_neg_integer(),
turn_state: TurnState.t(),
stream_buffer: StreamBuffer.t(),
usage: Usage.t(),
tool_runner: ToolRunner.t()
}
defstruct [
:id,
:name,
:description,
:type,
:team_id,
:team_name,
:session_id,
:delegator_id,
:role,
:model,
compactor: nil,
prompt_hook: nil,
turn_end_hook: nil,
sidecar_node: nil,
skills: %SkillIndex{},
cwd: "",
system_prompt: "",
messages: [],
tools: %{},
opts: [],
available_models: [],
status: :idle,
stream_task: nil,
stream_ref: nil,
stream_start: 0,
turn_state: %TurnState{},
stream_buffer: %StreamBuffer{},
usage: %Usage{},
tool_runner: %ToolRunner{}
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc "Start an agent under a supervisor."
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, [])
end
@doc false
def child_spec(opts) do
%{
id: Keyword.fetch!(opts, :id),
start: {__MODULE__, :start_link, [opts]},
restart: :temporary
}
end
@doc "Send a user message and kick off the agent loop. Returns once the agent status is :streaming."
@spec prompt(agent(), String.t() | [Planck.AI.Message.content_part()], keyword()) :: :ok
def prompt(agent, content, opts \\ []) do
GenServer.call(agent, {:prompt, content, opts})
end
@doc """
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.
"""
@spec nudge(agent()) :: :ok
def nudge(agent) do
GenServer.cast(agent, :nudge)
end
@doc """
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).
"""
@spec abort(agent()) :: :ok
def abort(agent) do
GenServer.call(agent, :abort)
end
@doc """
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.
"""
@spec rewind_to_message(agent(), pos_integer()) :: :ok
def rewind_to_message(agent, message_id) do
GenServer.cast(agent, {:rewind_to_message, message_id})
end
@doc """
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.
"""
@spec checkpoint(agent(), String.t()) :: :ok
def checkpoint(agent, summary_text) do
GenServer.call(agent, {:checkpoint, summary_text})
end
@doc """
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.
"""
@spec inject_tool_result(agent(), String.t(), String.t()) :: :ok
def inject_tool_result(agent, name, result) do
GenServer.call(agent, {:inject_tool_result, name, result})
end
@doc "Stop the agent. Cancels any in-flight work and removes it from the supervisor."
@spec stop(agent()) :: :ok
def stop(agent) do
GenServer.stop(agent)
end
@doc "Synchronous state snapshot."
@spec get_state(agent()) :: map()
def get_state(agent) do
GenServer.call(agent, :get_state)
end
@doc "Lightweight summary: id, name, description, type, role, status, turn_index, usage."
@spec get_info(agent()) :: map()
def get_info(agent) do
GenServer.call(agent, :get_info)
end
@doc "Estimate the number of tokens currently in the agent's context window."
@spec estimate_tokens(agent()) :: non_neg_integer()
def estimate_tokens(agent) do
GenServer.call(agent, :estimate_tokens)
end
@doc "Replace the model used for subsequent LLM turns without interrupting the current state."
@spec change_model(agent(), Planck.AI.Model.t()) :: :ok
def change_model(agent, model) do
GenServer.call(agent, {:change_model, model})
end
@doc """
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.
"""
@spec subscribe(String.t() | agent()) :: :ok | {:error, term()}
def subscribe(agent_id) when is_binary(agent_id) do
Phoenix.PubSub.subscribe(Planck.Agent.PubSub, "agent:#{agent_id}")
end
def subscribe(agent) do
%{id: id} = get_info(agent)
Phoenix.PubSub.subscribe(Planck.Agent.PubSub, "agent:#{id}")
end
@doc "Resolve an agent id to its pid via the Registry."
@spec whereis(String.t()) :: {:ok, pid()} | {:error, :not_found}
def whereis(id) do
case Registry.lookup(Planck.Agent.Registry, {:agent, id}) do
[{pid, _}] -> {:ok, pid}
_ -> {:error, :not_found}
end
end
@doc "Add a tool at runtime."
@spec add_tool(agent(), Tool.t()) :: :ok
def add_tool(agent, tool) do
GenServer.cast(agent, {:add_tool, tool})
end
@doc "Remove a tool by name at runtime."
@spec remove_tool(agent(), String.t()) :: :ok
def remove_tool(agent, name) do
GenServer.cast(agent, {:remove_tool, name})
end
# ---------------------------------------------------------------------------
# GenServer callbacks
# ---------------------------------------------------------------------------
@impl true
def init(opts) do
tool_list = Keyword.get(opts, :tools, [])
tool_map = Map.new(tool_list, &{&1.name, &1})
role = if Map.has_key?(tool_map, "spawn_agent"), do: :orchestrator, else: :worker
state = %__MODULE__{
id: Keyword.fetch!(opts, :id),
name: Keyword.get(opts, :name),
description: Keyword.get(opts, :description),
type: Keyword.get(opts, :type),
team_id: Keyword.get(opts, :team_id),
team_name: Keyword.get(opts, :team_name),
session_id: Keyword.get(opts, :session_id),
delegator_id: Keyword.get(opts, :delegator_id),
role: role,
model: Keyword.fetch!(opts, :model),
cwd: Keyword.get(opts, :cwd, ""),
system_prompt: Keyword.get(opts, :system_prompt, ""),
compactor: Keyword.get(opts, :compactor),
prompt_hook: Keyword.get(opts, :prompt_hook),
turn_end_hook: Keyword.get(opts, :turn_end_hook),
sidecar_node: Keyword.get(opts, :sidecar_node),
skills: SkillIndex.from_opts(opts),
tools: tool_map,
opts: Keyword.get(opts, :opts, []),
available_models: Keyword.get(opts, :available_models, []),
usage: Usage.from_opts(opts)
}
register_agent(state)
link_to_orchestrator(state)
# Orchestrators trap exits so they survive individual worker crashes.
if role == :orchestrator, do: Process.flag(:trap_exit, true)
# Notify session subscribers so UIs can refresh the agent list.
if state.delegator_id, do: broadcast(state, :worker_spawned, %{})
# Load existing session history (if any) and strip orphaned tool-call turns
# left by a previous crash. Deferred to a continue so the process is
# registered before any synchronous Session calls run.
if state.session_id do
{:ok, state, {:continue, :load_session_history}}
else
{:ok, state}
end
end
@impl true
def handle_call(event, from, state)
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_call(:get_info, _from, state) do
info = %{
id: state.id,
name: state.name,
description: state.description,
type: state.type,
role: state.role,
status: state.status,
turn_index: state.turn_state.index,
usage: %{input_tokens: state.usage.input_tokens, output_tokens: state.usage.output_tokens},
cost: state.usage.cost
}
{:reply, info, state}
end
def handle_call({:change_model, model}, _from, state) do
{:reply, :ok, %{state | model: model}}
end
def handle_call(:estimate_tokens, _from, state) do
{:reply, Message.estimate_tokens(state.messages), state}
end
def handle_call({:prompt, content, _opts}, _from, %{status: :idle} = state) do
do_prompt(content, state)
end
def handle_call({:prompt, content, _opts}, _from, state) do
# Agent is busy — append without persisting yet. Persisting now would give
# the queued message a db_id smaller than the current turn's assistant
# response, breaking edit-message truncation order. The message is flushed
# to the session in handle_continue(:run_llm) after the current turn ends.
parts = MessageBuilder.normalize_content(content)
msg = Message.new(:user, parts)
{:reply, :ok, %{state | messages: state.messages ++ [msg]}}
end
def handle_call(:abort, _from, state) do
cancel_stream(state)
cancel_running_tools(state)
new_state = reset_streaming(state)
if TurnContext.has_pending_input?(new_state.messages, new_state.stream_start) do
broadcast(new_state, :turn_start, %{index: new_state.turn_state.index})
{:reply, :ok, %{new_state | status: :streaming}, {:continue, {:run_llm, :new_turn}}}
else
{:reply, :ok, new_state}
end
end
def handle_call({:checkpoint, summary_text}, _from, state) do
msg = Message.new({:custom, :summary}, [{:text, summary_text}])
msg = persist_message(state, msg)
{:reply, :ok, %{state | messages: state.messages ++ [msg]}}
end
def handle_call({:inject_tool_result, name, result}, _from, state) do
call_id = Base.encode16(:crypto.strong_rand_bytes(4), case: :lower)
tool_call_msg =
Message.new(:assistant, [{:tool_call, call_id, name, %{}}])
|> then(&persist_message(state, &1))
tool_result_msg =
Message.new(:tool_result, [{:tool_result, call_id, result}])
|> then(&persist_message(state, &1))
new_messages = state.messages ++ [tool_call_msg, tool_result_msg]
{:reply, :ok, %{state | messages: new_messages}}
end
@impl true
def handle_cast(event, state)
def handle_cast(:nudge, %{status: :idle} = state) do
broadcast(state, :turn_start, %{index: state.turn_state.index})
{:noreply, %{state | status: :streaming}, {:continue, {:run_llm, :new_turn}}}
end
def handle_cast(:nudge, state) do
{:noreply, state}
end
def handle_cast({:rewind_to_message, _message_id}, %{session_id: nil} = state) do
{:noreply, state}
end
def handle_cast({:rewind_to_message, message_id}, state) do
Session.truncate_after(state.session_id, message_id)
{:noreply, reload_messages_from_session(state)}
end
def handle_cast({:add_tool, tool}, state) do
{:noreply, %{state | tools: Map.put(state.tools, tool.name, tool)}}
end
def handle_cast({:remove_tool, name}, state) do
{:noreply, %{state | tools: Map.delete(state.tools, name)}}
end
@impl true
def handle_continue(message, state)
def handle_continue(:load_session_history, state) do
# Load history without stripping orphans — resume_session may not have
# injected its recovery message yet, so stripping here would race with it.
# Orphan stripping happens later in reload_messages_from_session (called from
# flush_unpersisted_messages) once the session state is fully settled.
{:noreply, load_messages_from_session(state)}
end
def handle_continue(:run_llm, state) do
{:noreply, do_run_llm(state, :continuation)}
end
def handle_continue({:run_llm, :new_turn}, state) do
{:noreply, do_run_llm(state, :new_turn)}
end
def handle_continue({:execute_tools, calls}, state) do
{:noreply, start_tool_tasks(calls, state)}
end
@impl true
def handle_info(event, state)
def handle_info({:stream_event, ref, event}, %{stream_ref: ref} = state) do
{:noreply, process_event(state, event)}
end
def handle_info({:stream_event, _stale, _event}, state) do
{:noreply, state}
end
def handle_info({:stream_done, ref}, %{stream_ref: ref} = state) do
do_stream_done(state)
end
def handle_info({:stream_done, _stale}, state) do
{:noreply, state}
end
def handle_info({:tool_done, call_id, name, result}, state) do
case ToolRunner.mark_done(state.tool_runner, call_id, result) do
:not_running ->
{:noreply, state}
{:ok, runner} ->
error = match?({:error, _}, result)
broadcast(state, :tool_end, %{id: call_id, name: name, result: result, error: error})
new_state = %{state | tool_runner: runner}
if ToolRunner.done?(runner) do
{:noreply, finish_tool_execution(runner.results, new_state), {:continue, :run_llm}}
else
{:noreply, new_state}
end
end
end
def handle_info({:agent_response, response, sender}, state) do
do_agent_response(response, sender, state)
end
def handle_info({:EXIT, pid, reason}, state) do
broadcast(state, :worker_exit, %{pid: pid, reason: reason})
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
cancel_stream(state)
cancel_running_tools(state)
end
# ---------------------------------------------------------------------------
# Callback implementations
# ---------------------------------------------------------------------------
@spec do_prompt(String.t() | [Planck.AI.Message.content_part()], t()) ::
{:reply, :ok, t(), {:continue, {:run_llm, :new_turn}}}
defp do_prompt(content, state) do
parts = MessageBuilder.normalize_content(content)
msg = Message.new(:user, parts)
checkpoint = length(state.messages)
msg = persist_message(state, msg)
new_state = %{
state
| messages: state.messages ++ [msg],
turn_state: TurnState.push_checkpoint(state.turn_state, checkpoint)
}
broadcast(new_state, :turn_start, %{index: new_state.turn_state.index})
{:reply, :ok, %{new_state | status: :streaming}, {:continue, {:run_llm, :new_turn}}}
end
defp do_run_llm(state, turn_type) do
{messages, state} = apply_compact(state)
state = flush_unpersisted_messages(state)
# Only advance stream_start for fresh turns. Tool continuations keep the
# original stream_start so any user message queued during tool execution
# remains detectable by maybe_turn_start after the turn ends.
stream_start =
case turn_type do
:new_turn -> length(state.messages)
:continuation -> state.stream_start
end
ai_tools = state.tools |> Map.values() |> Enum.map(&Tool.to_ai_tool/1)
system = build_system_prompt(state)
context = %Context{
system: presence(system),
messages: Message.to_ai_messages(messages),
tools: ai_tools
}
ref = make_ref()
parent = self()
{:ok, task} =
Task.Supervisor.start_child(Planck.Agent.TaskSupervisor, fn ->
try do
AIBehaviour.client().stream(state.model, context, state.opts)
|> Enum.each(fn event -> send(parent, {:stream_event, ref, event}) end)
rescue
e -> send(parent, {:stream_event, ref, {:error, Exception.message(e)}})
catch
kind, reason ->
send(parent, {:stream_event, ref, {:error, "#{kind}: #{inspect(reason)}"}})
end
send(parent, {:stream_done, ref})
end)
%{
state
| stream_task: task,
stream_ref: ref,
stream_start: stream_start,
status: :streaming,
turn_state: TurnState.advance(state.turn_state)
}
end
@spec start_tool_tasks([map()], t()) :: t()
defp start_tool_tasks(tool_calls, state) do
parent = self()
entries =
Enum.map(tool_calls, fn %{id: id, name: name, args: args} ->
broadcast(state, :tool_start, %{id: id, name: name, args: args})
execute_fn = resolve_tool_fn(state.tools, state.id, name, id, args)
{:ok, pid} =
Task.Supervisor.start_child(Planck.Agent.TaskSupervisor, fn ->
result =
try do
execute_fn.()
rescue
e -> {:error, Exception.message(e)}
catch
kind, reason -> {:error, "#{kind}: #{inspect(reason)}"}
end
send(parent, {:tool_done, id, name, result})
end)
{id, name, pid}
end)
%{state | tool_runner: ToolRunner.start(entries), status: :executing_tools}
end
@spec finish_tool_execution(list(), t()) :: t()
defp finish_tool_execution(results, state) do
tool_result_msg = results |> Enum.reverse() |> MessageBuilder.build_tool_result()
tool_result_msg = persist_message(state, tool_result_msg)
%{
state
| messages: state.messages ++ [tool_result_msg],
status: :streaming
}
end
@spec resolve_tool_fn(%{String.t() => Tool.t()}, String.t(), String.t(), String.t(), map()) ::
(-> {:ok, String.t()} | {:error, term()})
defp resolve_tool_fn(tools, agent_id, name, id, args) do
case Map.get(tools, name) do
nil ->
fn -> {:error, "unknown tool: #{name}"} end
%Tool{} = tool ->
fn -> run_tool(tool, agent_id, id, args) end
end
end
@spec run_tool(Tool.t(), String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, term()}
defp run_tool(%Tool{execute_fn: fun} = tool, agent_id, id, args) do
with :ok <- Tool.validate_args(tool, args) do
safe_execute(fun, agent_id, id, args)
end
end
@spec cancel_running_tools(t()) :: :ok
defp cancel_running_tools(state) do
ToolRunner.cancel_all(state.tool_runner)
end
@spec do_stream_done(t()) ::
{:noreply, t()} | {:noreply, t(), {:continue, {:execute_tools, [map()]}}}
defp do_stream_done(state) do
pending = state.stream_buffer.calls
assistant_msg = MessageBuilder.build_assistant(state.stream_buffer)
assistant_msg = persist_message(state, assistant_msg)
new_state =
%{state | messages: state.messages ++ [assistant_msg]}
|> reset_streaming()
case pending do
[] ->
turn_msgs = readable_turn_messages(new_state.messages, state.stream_start)
broadcast(new_state, :turn_end, %{
message: assistant_msg,
usage: new_state.usage,
turn_messages: turn_msgs
})
fire_turn_end_hook(new_state)
maybe_turn_start(new_state)
calls ->
{:noreply, %{new_state | status: :executing_tools}, {:continue, {:execute_tools, calls}}}
end
end
@spec do_agent_response(String.t(), term(), t()) ::
{:noreply, t()}
| {:noreply, t(), {:continue, {:run_llm, :new_turn}}}
defp do_agent_response(response, sender, state) do
metadata =
case sender do
%{id: id, name: name} -> %{sender_id: id, sender_name: name}
_ -> %{}
end
msg = Message.new({:custom, :agent_response}, [{:text, response}], metadata)
msg = persist_message(state, msg)
new_state = %{state | messages: state.messages ++ [msg]}
if state.status == :idle do
broadcast(new_state, :turn_start, %{index: new_state.turn_state.index})
{:noreply, %{new_state | status: :streaming}, {:continue, {:run_llm, :new_turn}}}
else
{:noreply, new_state}
end
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
@spec link_to_orchestrator(t()) :: :ok
defp link_to_orchestrator(%{delegator_id: nil}), do: :ok
defp link_to_orchestrator(%{delegator_id: id}) do
case whereis(id) do
{:ok, pid} -> Process.link(pid)
_ -> :ok
end
end
@spec register_agent(t()) :: :ok
defp register_agent(%{id: id, team_id: team_id, type: type, name: name, description: desc}) do
Registry.register(Planck.Agent.Registry, {:agent, id}, nil)
if team_id do
meta = %{id: id, type: type, name: name, description: desc}
Registry.register(Planck.Agent.Registry, {team_id, :member}, meta)
if type, do: Registry.register(Planck.Agent.Registry, {team_id, type}, id)
if name, do: Registry.register(Planck.Agent.Registry, {team_id, name}, id)
end
:ok
end
@spec broadcast(t(), atom(), map()) :: :ok
defp broadcast(
%{id: id, session_id: session_id, name: agent_name, team_name: team_name},
type,
payload
) do
event = {:agent_event, type, payload}
Phoenix.PubSub.broadcast(Planck.Agent.PubSub, "agent:#{id}", event)
if session_id do
session_event = {:agent_event, type, Map.put(payload, :agent_id, id)}
Phoenix.PubSub.broadcast(Planck.Agent.PubSub, "session:#{session_id}", session_event)
if type in [:turn_end, :compacted] do
global_payload =
payload
|> Map.put(:agent_id, id)
|> Map.put(:agent_name, agent_name)
|> Map.put(:team_name, team_name)
|> Map.put(:session_id, session_id)
Phoenix.PubSub.broadcast(
Planck.Agent.PubSub,
"planck:sessions",
{:agent_event, type, global_payload}
)
end
end
end
@spec flush_unpersisted_messages(t()) :: t()
defp flush_unpersisted_messages(state) do
case SessionStore.flush_unpersisted(state.session_id, state.id, state.messages) do
:noop -> state
:flushed -> reload_messages_from_session(state)
end
end
@spec reload_messages_from_session(t()) :: t()
defp reload_messages_from_session(state), do: do_load_session(state, strip_orphans: true)
@spec load_messages_from_session(t()) :: t()
defp load_messages_from_session(state), do: do_load_session(state, strip_orphans: false)
@spec do_load_session(t(), keyword()) :: t()
defp do_load_session(state, opts) do
case SessionStore.load_messages(state.session_id, state.id, opts) do
{:ok, messages} ->
turn_state = TurnState.rebuild_checkpoints(state.turn_state, messages)
%{state | messages: messages, turn_state: turn_state}
:error ->
state
end
end
# Persist a message and return it with its DB row id set. For ephemeral
# agents (no session_id), the message is returned unchanged.
@spec persist_usage(t()) :: :ok
defp persist_usage(state) do
SessionStore.persist_usage(state.session_id, state.id, state.usage)
end
@spec persist_message(t(), Message.t()) :: Message.t()
defp persist_message(state, msg) do
SessionStore.persist_message(state.session_id, state.id, msg)
end
@spec process_event(t(), Planck.AI.Stream.t()) :: t()
defp process_event(state, {:text_delta, text}) do
broadcast(state, :text_delta, %{text: text})
%{state | stream_buffer: StreamBuffer.append_text(state.stream_buffer, text)}
end
defp process_event(state, {:thinking_delta, text}) do
broadcast(state, :thinking_delta, %{text: text})
%{state | stream_buffer: StreamBuffer.append_thinking(state.stream_buffer, text)}
end
defp process_event(state, {:tool_call_complete, call}) do
%{state | stream_buffer: StreamBuffer.add_call(state.stream_buffer, call)}
end
defp process_event(state, {:error, reason}) do
broadcast(state, :error, %{reason: reason})
reset_streaming(state)
end
defp process_event(state, {:done, %{usage: %{input_tokens: i, output_tokens: o}}}) do
new_usage = Usage.add_turn(state.usage, i, o, state.model)
turn_cost = new_usage.cost - state.usage.cost
new_state = %{state | usage: new_usage}
persist_usage(new_state)
broadcast(new_state, :usage_delta, %{
delta: %{
input_tokens: i,
output_tokens: o,
cost: turn_cost
},
total: %{
input_tokens: new_usage.input_tokens,
output_tokens: new_usage.output_tokens,
cost: new_usage.cost
},
context_tokens: Message.estimate_tokens(new_state.messages)
})
new_state
end
defp process_event(state, _other), do: state
@spec apply_compact(t()) :: {[Message.t()], t()}
defp apply_compact(%__MODULE__{messages: messages} = state) do
recent = TurnContext.messages_since_last_summary(messages)
case Hooks.Compactor.compact(state.compactor, state.model, recent, state.sidecar_node) do
:skip ->
{recent, state}
{:compact, %Message{} = summary_msg, kept} ->
broadcast(state, :compacting, %{})
summary_msg = persist_message(state, summary_msg)
prefix_len = length(messages) - length(recent)
prefix = Enum.take(messages, prefix_len)
new_messages = prefix ++ [summary_msg | kept]
new_state = %{state | messages: new_messages}
refreshed = %{new_state | skills: SkillIndex.refresh(new_state.skills)}
broadcast(refreshed, :compacted, %{})
{[summary_msg | kept], refreshed}
end
end
@spec maybe_turn_start(t()) ::
{:noreply, t()}
| {:noreply, t(), {:continue, {:run_llm, :new_turn}}}
defp maybe_turn_start(state)
defp maybe_turn_start(%__MODULE__{} = state) do
if TurnContext.has_pending_input?(state.messages, state.stream_start) do
broadcast(state, :turn_start, %{index: state.turn_state.index})
{:noreply, %{state | status: :streaming}, {:continue, {:run_llm, :new_turn}}}
else
{:noreply, state}
end
end
@spec cancel_stream(t()) :: :ok
defp cancel_stream(%{stream_task: nil}), do: :ok
defp cancel_stream(%{stream_task: task}) do
Task.Supervisor.terminate_child(Planck.Agent.TaskSupervisor, task)
end
@spec reset_streaming(t()) :: t()
defp reset_streaming(state) do
%{
state
| status: :idle,
stream_task: nil,
stream_ref: nil,
stream_buffer: StreamBuffer.new(),
tool_runner: ToolRunner.new()
}
end
@spec safe_execute(Tool.execute_fn(), String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, term()}
defp safe_execute(fun, agent_id, id, args) do
fun.(agent_id, id, args)
rescue
e -> {:error, e}
catch
:exit, reason -> {:error, {:exit, reason}}
end
@spec build_system_prompt(t()) :: String.t()
defp build_system_prompt(state) do
Planck.Agent.SystemPrompt.build(%{
system_prompt: state.system_prompt,
name: state.name,
type: state.type,
tools: state.tools,
skill_pool: state.skills.pool,
ranked_skill_names: state.skills.ranked,
top_skills: state.skills.top_n,
prompt_hook: state.prompt_hook,
session_id: state.session_id,
sidecar_node: state.sidecar_node
})
end
@spec readable_turn_messages([Message.t()], non_neg_integer()) :: [Message.t()]
defp readable_turn_messages(messages, stream_start) do
Enum.drop(messages, max(0, stream_start - 1))
end
@spec fire_turn_end_hook(t()) :: :ok
defp fire_turn_end_hook(%{turn_end_hook: nil}), do: :ok
defp fire_turn_end_hook(state) do
turn_messages = Enum.drop(state.messages, state.stream_start)
Task.Supervisor.start_child(Planck.Agent.TaskSupervisor, fn ->
Hooks.TurnEnd.reflect(
state.turn_end_hook,
state.id,
turn_messages,
state.sidecar_node
)
end)
:ok
end
@spec presence(String.t()) :: String.t() | nil
defp presence(""), do: nil
defp presence(str), do: str
end