Sagents.Session (Sagents v0.12.0)
Copy MarkdownSession lifecycle for conversation-centric agents. Owns the procedural work of: router consult, factory invocation, state seeding, supervisor config, subscriber wiring.
Configuration
Session functions take a config map with the following keys:
:factory_router- module implementingSagents.FactoryRouter.:agent_persistence- module implementingSagents.AgentPersistence.:display_message_persistence- module implementingSagents.DisplayMessagePersistence.:pubsub-{Phoenix.PubSub, name}tuple.:presence_module- Phoenix Presence module.:inactivity_timeout- milliseconds before agent stops on idle.:agent_id_fun- 1-arity function mappingconversation_id→agent_idstring.
Factory contract
Factories implement Sagents.Factory.create_agent/2 taking
(agent_id, config) and returning {:ok, agent, session_opts}. The
router supplies config verbatim — by convention a typed struct from
a paired *Config module. The library recognizes these keys in
session_opts:
:fresh_state_attrs- map seeded intoSagents.State.new!/1on fresh state only (ignored when persisted state is restored). Use this to pre-populate todos, scratchpad data, or any other initial state. For seeding todos:{:ok, agent, fresh_state_attrs: %{todos: my_todos}}:supervisor_opts- keyword list merged into the supervisor_config passed toSagents.AgentsDynamicSupervisor.start_agent_sync/1. Use this for per-factoryAgentSupervisor/AgentServerconfiguration such as:message_preprocessoror any other supervisor-level opt. The library does NOT inspect the contents — keys are forwarded verbatim, so factories can pass anything the supervisor accepts (including future additions) without requiring a library change.
All other keys in session_opts are app-internal; the library plumbs
them through but does not inspect them.
Summary
Functions
Dismiss a terminal :halt interrupt, waking the agent first if it has gone
to sleep.
Ensure the agent session for state.conversation_id is running and
that the calling process is subscribed to its events.
Whether an agent session is running for conversation_id, without raising.
Answer a pending interrupt, waking the agent first if it has gone to sleep.
Whether an agent session is currently running for conversation_id.
Raises on a node whose registry is unavailable.
Starts (or returns the existing) agent session for a conversation.
Stop the agent session for a conversation. No-op if nothing is running.
Types
Functions
@spec dismiss(config(), state_map :: map(), opts :: keyword()) :: :ok | {:ok, %{sagents_subs: map(), agent_id: String.t()}} | {:error, term()}
Dismiss a terminal :halt interrupt, waking the agent first if it has gone
to sleep.
The mirror of resume/4 for the one interrupt type that is acknowledged
rather than answered. Use this everywhere a host would otherwise call
Sagents.AgentServer.dismiss_interrupt/1 directly.
A halt is restorable, so a dormant conversation keeps its halt panel on
screen (see Sagents.AgentUtils.shutdown_session_changes/2). The button that
clears it therefore has to work whether or not a process is backing the
session, exactly like the answer to a dormant question.
state is the host's process-level map, with the same required keys as
ensure_running/3 (:conversation_id, :current_scope, optionally
:sagents_subs).
Options
:request_opts- forwarded verbatim to the router on the wake path. Pass the same per-request data you pass when starting a session; an agent woken to accept a dismissal is configured exactly like one woken any other way.
Returns
:ok- a live agent dismissed the halt.{:ok, changes}- the agent was asleep. It was woken and then dismissed. Mergechanges(subscription bookkeeping) into your state map.{:error, reason}- passed through. A live agent that is not interrupted, or one holding an interrupt that needs an explicit response rather than an acknowledgement, returns an error rather than being woken.
Example
case Session.dismiss(config, socket.assigns, request_opts: opts) do
:ok ->
assign(socket, AgentUtils.cleared_interrupt_changes())
{:ok, changes} ->
socket |> assign(changes) |> assign(AgentUtils.cleared_interrupt_changes())
{:error, reason} ->
put_flash(socket, :error, "Could not dismiss: #{inspect(reason)}")
end
@spec ensure_running(config(), state_map :: map(), opts :: keyword()) :: {:ok, %{sagents_subs: map(), agent_id: String.t()}} | {:error, term()}
Ensure the agent session for state.conversation_id is running and
that the calling process is subscribed to its events.
state is the host's process-level map (LiveView socket assigns,
GenServer state, etc.). Required keys:
:conversation_id:current_scope
Optional keys:
:sagents_subs(defaults to%{}) — existingSagents.Subscribersubs map.
Per-request data destined for the FactoryConfig flows through opts,
not the state map:
:request_opts— keyword list forwarded verbatim to the router as its third argument. The router converts this to a map and passes it to your*Config.from_inputs/1. Use this for per-call fields like:timezone,:tool_context, or anything else your Config consumes.
Returns {:ok, %{sagents_subs: new_subs, agent_id: agent_id}} for the
caller to merge back into its state map.
@spec fetch_running(config(), conversation_id :: term()) :: {:ok, boolean()} | {:error, :registry_unavailable}
Whether an agent session is running for conversation_id, without raising.
The non-raising sibling of running?/2, in the same relationship as
Sagents.AgentServer.fetch_pid/1 to get_pid/1 and
Sagents.ProcessRegistry.fetch/1 to lookup/1:
{:ok, true}/{:ok, false}- the registry answered{:error, :registry_unavailable}- this node's registry could not answer
Prefer this anywhere a web request can reach. {:ok, false} means "start
one"; the error means this node cannot know, so it must not guess.
Examples
case Session.fetch_running(config, conversation_id) do
{:ok, true} -> :already_running
{:ok, false} -> start_it()
{:error, :registry_unavailable} -> {:error, :draining}
end
@spec resume(config(), state_map :: map(), resume_data :: term(), opts :: keyword()) :: :ok | {:ok, %{sagents_subs: map(), agent_id: String.t()}} | {:error, term()}
Answer a pending interrupt, waking the agent first if it has gone to sleep.
Use this everywhere a host would otherwise call
Sagents.AgentServer.resume/2 directly. Interrupts are durable: the
persisted state carries interrupt_data, and a fresh boot rebuilds it and
comes up :interrupted. So "the agent is not running" is not a reason an
answer has to fail — it is only a reason to start a process to take it.
state is the host's process-level map, with the same required keys as
ensure_running/3 (:conversation_id, :current_scope, optionally
:sagents_subs).
Options
:request_opts— forwarded verbatim toensure_running/3on the wake path. Pass the same per-request data you pass when starting a session (timezone, tool context, and so on); an agent woken to take an answer is configured exactly like one woken any other way.
Returns
:ok— the answer was delivered, or the agent was started with the answer in hand.{:ok, changes}— same, plus the state-map changes to merge (currently the subscription bookkeeping from a wake). Callers that ignore the second element still behave correctly, but will re-subscribe on their nextensure_running/3.{:error, reason}— passed through. Note a live agent that is not interrupted returns an error rather than being woken, because there is nothing to wake and nothing to resume.
Ordering
On the wake path this does not poll, retry on a timer, or wait for a
broadcast. Sagents.AgentsDynamicSupervisor.start_agent_sync/1 blocks
until the agent is registered, the boot status is computed inside
init/1, and a GenServer.call is serialized after init/1 and
handle_continue/2. The :pending_resume option carries the answer into
that boot, so the woken agent applies it before it broadcasts anything:
subscribers see a single {:status_changed, :running, nil} rather than an
:interrupted snapshot for a question that is already answered.
Example
case Session.resume(config, socket.assigns, response, request_opts: opts) do
:ok ->
assign(socket, answered_changes)
{:ok, changes} ->
socket |> assign(changes) |> assign(answered_changes)
{:error, reason} ->
put_flash(socket, :error, "Could not submit response: #{inspect(reason)}")
end
Whether an agent session is currently running for conversation_id.
Raises on a node whose registry is unavailable.
A ?-suffixed predicate is the least likely function in this API to be
suspected of raising, so the warning is here in the summary rather than
further down: Sagents.RegistryUnavailableError comes out of this whenever
this node's registry cannot answer, which covers the drain window of every
rolling deploy.
A boolean has no room for "cannot tell", and false would be read as
"nothing is running", which callers respond to by starting a duplicate agent.
Use fetch_running/2 where that matters, or ensure_running/3, which reports
the condition as a value.
If you wrap this in your own host predicate, carry the warning into that function's docs too. The wrapper is where the next reader will meet it.
@spec start(config(), conversation_id :: term(), opts :: keyword()) :: {:ok, session_info()} | {:error, term()}
Starts (or returns the existing) agent session for a conversation.
Idempotent: if an agent is already running for conversation_id, returns
the existing session info without consulting the router or factory again.
session_info.started distinguishes the two: true when this call created
the process, false when it found one already up. Callers that pass
start-time-only options (:initial_subscribers, :pending_resume) need
this, because those options are consumed by init/1 and are silently
ignored on the already-running path.
Options
:scope— Phoenix scope (forwarded to factory + persistence).:request_opts— keyword list passed to the router as the third argument. Routers commonly forward this verbatim intofactory_opts.:initial_subscribers— list of{channel, pid}tuples seeded as subscribers before the agent'sinit/1returns. Use to atomically start-and-subscribe.:pending_resume— a resume payload applied during boot, before the initial status broadcast. Seeresume/4, which is the supported way to set this.
@spec stop(config(), conversation_id :: term()) :: {:ok, :stopped | :not_running} | {:error, :registry_unavailable}
Stop the agent session for a conversation. No-op if nothing is running.