Sagents.Session (Sagents v0.11.1)

Copy Markdown

Session 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 implementing Sagents.FactoryRouter.
  • :agent_persistence - module implementing Sagents.AgentPersistence.
  • :display_message_persistence - module implementing Sagents.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 mapping conversation_idagent_id string.

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 into Sagents.State.new!/1 on 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 to Sagents.AgentsDynamicSupervisor.start_agent_sync/1. Use this for per-factory AgentSupervisor / AgentServer configuration such as :message_preprocessor or 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.

Answer a pending interrupt, waking the agent first if it has gone to sleep.

Whether an agent session is currently running for conversation_id.

Starts (or returns the existing) agent session for a conversation.

Stop the agent session for a conversation. No-op if nothing is running.

Types

config()

@type config() :: %{
  factory_router: module(),
  agent_persistence: module(),
  display_message_persistence: module(),
  pubsub: {module(), atom()},
  presence_module: module(),
  inactivity_timeout: pos_integer(),
  agent_id_fun: (term() -> String.t())
}

session_info()

@type session_info() :: %{
  agent_id: String.t(),
  pid: pid(),
  conversation_id: term(),
  started: boolean()
}

Functions

dismiss(config, state, opts \\ [])

@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. Merge changes (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

ensure_running(config, state, opts \\ [])

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

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.

resume(config, state, resume_data, opts \\ [])

@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 to ensure_running/3 on 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 next ensure_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

running?(config, conversation_id)

@spec running?(config(), conversation_id :: term()) :: boolean()

Whether an agent session is currently running for conversation_id.

start(config, conversation_id, opts \\ [])

@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 into factory_opts.
  • :initial_subscribers — list of {channel, pid} tuples seeded as subscribers before the agent's init/1 returns. Use to atomically start-and-subscribe.
  • :pending_resume — a resume payload applied during boot, before the initial status broadcast. See resume/4, which is the supported way to set this.

stop(config, conversation_id)

@spec stop(config(), conversation_id :: term()) :: {:ok, :stopped | :not_running}

Stop the agent session for a conversation. No-op if nothing is running.