Behaviour and default implementation for conversation persistence in Planck.Agent.
Behaviour
Use use Planck.Agent.Hooks.Persistence to implement a custom persistence
backend, in a sidecar or directly in your own app. Six callbacks are
required — the first five are agent-scoped (an agent only ever touches its
own history); the sixth, load_session_messages/2, is session-scoped, for
callers outside any agent process that need every agent's history in one
session, in original insertion order (e.g. a chat UI listing a whole
conversation, orchestrator and workers together).
defmodule MyApp.Persistence.Postgres do
use Planck.Agent.Hooks.Persistence
@impl true
def persist_message(session_id, agent_id, message) do
# write `message`, return it with its own row id set on `message.id`
end
@impl true
def persist_usage(session_id, agent_id, usage), do: :ok
@impl true
def load_messages(session_id, agent_id, opts) do
# return {:ok, [Message.t()]} in insertion order, or :error
end
@impl true
def flush_unpersisted(session_id, agent_id, messages) do
# write any of `messages` not yet persisted; :flushed | :noop
end
@impl true
def truncate_after(session_id, message_id), do: :ok
@impl true
def load_session_messages(session_id, opts) do
# return {:ok, [session_row()]} for every agent in the session,
# interleaved by insertion order, or :error
end
endDispatch
Planck.Agent calls the first five (never the concrete
Planck.Agent.Session/SessionStore modules directly) anywhere it needs to
touch storage:
Hooks.Persistence.persist_message(state.persistence, state.session_id, state.id, msg, state.sidecar_node)load_session_messages/2 is never called by Planck.Agent itself — it
exists for a caller like planck_headless (which knows a session's
resolved persistence module from the team that materialized it) to hand a
UI a cross-agent history without going through any single agent process, so
it works for closed sessions too:
Hooks.Persistence.load_session_messages(module, session_id, sidecar_node)Ephemeral agents (session_id: nil) never reach a persistence module at
all — every agent-scoped dispatcher function no-ops before dispatching, the
same way Planck.Agent.SessionStore does today.
module: nil— usesPlanck.Agent.Hooks.Persistence.Default, the built-in SQLite-backed strategy (Planck.Agent.Session/SessionStore, wrapped to satisfy this behaviour). Not special-cased beyond this:Defaultsatisfies the same behaviour a custom module would.moduleset,sidecar_node: nil— calls the resolved module in-process.moduleset,sidecar_nodeset — calls the module on the remote node via RPC; falls back toDefaulton:badrpcfrom any of the six calls.
The default RPC timeout is 5000 ms; override
persistence_timeout/0 to declare a custom expected latency.
Summary
Types
Options accepted by load_messages/3 — currently just strip_orphans:.
A single persisted message as returned by load_session_messages/2 —
the same shape Planck.Agent.Session.messages/2 rows already have, since
callers like a chat UI need to know which agent said what, not just the
message content.
Callbacks
Persist any of messages not yet written to storage.
Load message history for agent_id from session_id, in insertion order.
Load every agent's messages for session_id, interleaved by insertion
order — the cross-agent history a chat UI displays, not any one agent's
own view of it. Unlike the five callbacks above, this is never called by
Planck.Agent itself; it's for a caller outside any agent process (see
the moduledoc's "Dispatch" section).
Persist message and return it with its own row id set on message.id.
Persist accumulated usage and cost for the agent.
RPC call timeout in milliseconds when this persistence module is invoked remotely. Defaults to 5000 ms.
Delete all messages at or after message_id, across all agents in the
session. Used when editing a previous message.
Functions
Default RPC timeout used when a module omits persistence_timeout/0.
Flush any unpersisted messages, dispatching to module (or Default
when nil), locally or via sidecar_node. Always :noop for ephemeral
agents (session_id: nil).
Load message history, dispatching to module (or Default when nil),
locally or via sidecar_node. Returns :error for ephemeral agents
(session_id: nil) — there's nothing to load.
Load every agent's messages for session_id, dispatching to module (or
Default when nil), locally or via sidecar_node. See the moduledoc —
this is the one callback Planck.Agent itself never calls.
Persist message, dispatching to module (or Default when nil),
locally or via sidecar_node. Returns message unchanged for ephemeral
agents (session_id: nil).
Persist usage, dispatching to module (or Default when nil), locally
or via sidecar_node. No-op for ephemeral agents (session_id: nil).
Truncate the session, dispatching to module (or Default when nil),
locally or via sidecar_node.
Types
@type load_opts() :: [{:strip_orphans, boolean()}]
Options accepted by load_messages/3 — currently just strip_orphans:.
@type session_row() :: %{ db_id: term(), agent_id: String.t(), message: Planck.Agent.Message.t(), inserted_at: integer() }
A single persisted message as returned by load_session_messages/2 —
the same shape Planck.Agent.Session.messages/2 rows already have, since
callers like a chat UI need to know which agent said what, not just the
message content.
Callbacks
@callback flush_unpersisted( session_id :: String.t(), agent_id :: String.t(), messages :: [Planck.Agent.Message.t()] ) :: :flushed | :noop
Persist any of messages not yet written to storage.
Returns :flushed when at least one message was written, :noop when all
messages were already persisted.
@callback load_messages( session_id :: String.t(), agent_id :: String.t(), opts :: load_opts() ) :: {:ok, [Planck.Agent.Message.t()]} | :error
Load message history for agent_id from session_id, in insertion order.
Pass strip_orphans: true to remove a trailing assistant turn with
unanswered tool calls — this repairs state after a crash.
@callback load_session_messages(session_id :: String.t(), opts :: keyword()) :: {:ok, [session_row()]} | :error
Load every agent's messages for session_id, interleaved by insertion
order — the cross-agent history a chat UI displays, not any one agent's
own view of it. Unlike the five callbacks above, this is never called by
Planck.Agent itself; it's for a caller outside any agent process (see
the moduledoc's "Dispatch" section).
@callback persist_message( session_id :: String.t(), agent_id :: String.t(), message :: Planck.Agent.Message.t() ) :: Planck.Agent.Message.t()
Persist message and return it with its own row id set on message.id.
@callback persist_usage( session_id :: String.t(), agent_id :: String.t(), usage :: Planck.Agent.Usage.t() ) :: :ok
Persist accumulated usage and cost for the agent.
@callback persistence_timeout() :: pos_integer()
RPC call timeout in milliseconds when this persistence module is invoked remotely. Defaults to 5000 ms.
@callback truncate_after(session_id :: String.t(), message_id :: pos_integer()) :: :ok | {:error, term()}
Delete all messages at or after message_id, across all agents in the
session. Used when editing a previous message.
Functions
@spec default_timeout() :: pos_integer()
Default RPC timeout used when a module omits persistence_timeout/0.
@spec flush_unpersisted( module() | nil, String.t() | nil, String.t(), [Planck.Agent.Message.t()], atom() | nil ) :: :flushed | :noop
Flush any unpersisted messages, dispatching to module (or Default
when nil), locally or via sidecar_node. Always :noop for ephemeral
agents (session_id: nil).
@spec load_messages( module() | nil, String.t() | nil, String.t(), load_opts(), atom() | nil ) :: {:ok, [Planck.Agent.Message.t()]} | :error
Load message history, dispatching to module (or Default when nil),
locally or via sidecar_node. Returns :error for ephemeral agents
(session_id: nil) — there's nothing to load.
@spec load_session_messages(module() | nil, String.t(), atom() | nil) :: {:ok, [session_row()]} | :error
Load every agent's messages for session_id, dispatching to module (or
Default when nil), locally or via sidecar_node. See the moduledoc —
this is the one callback Planck.Agent itself never calls.
@spec persist_message( module() | nil, String.t() | nil, String.t(), Planck.Agent.Message.t(), atom() | nil ) :: Planck.Agent.Message.t()
Persist message, dispatching to module (or Default when nil),
locally or via sidecar_node. Returns message unchanged for ephemeral
agents (session_id: nil).
@spec persist_usage( module() | nil, String.t() | nil, String.t(), Planck.Agent.Usage.t(), atom() | nil ) :: :ok
Persist usage, dispatching to module (or Default when nil), locally
or via sidecar_node. No-op for ephemeral agents (session_id: nil).
@spec truncate_after(module() | nil, String.t(), pos_integer(), atom() | nil) :: :ok | {:error, term()}
Truncate the session, dispatching to module (or Default when nil),
locally or via sidecar_node.