Current section

Files

Jump to
sagents priv templates coordinator.ex.eex
Raw

priv/templates/coordinator.ex.eex

defmodule <%= module %> do
@moduledoc """
Coordinates agent lifecycle and session bridging for conversation-centric
agents.
This module owns three concerns:
1. **Lifecycle** — `start_conversation_session/2`, `stop_conversation_session/1`,
`session_running?/1`, and id mapping (`conversation_agent_id/1`).
2. **Session bridging** — `ensure_session_running/1` composes start +
subscribe into a single host-agnostic operation. Takes a plain state
map; returns changes for the caller to merge.
3. **Presence tracking** — `track_conversation_viewer/3` /
`untrack_conversation_viewer/2` / `list_conversation_viewers/1` for
the smart-shutdown driver.
Subscription mechanics live in `Sagents.Subscriber`, which can subscribe
to an agent by id whether or not it is running and auto-fulfill pending
subscriptions when the agent appears in Phoenix.Presence.
## The two paths a host (LiveView, GenServer, etc.) takes
### Load path (no factory config required)
Opening a conversation just needs to subscribe to its events. The agent
may or may not be running — `Sagents.Subscriber.subscribe_to_agent/3`
returns a `:pending` entry when it isn't, which auto-upgrades to
`:subscribed` via `presence_diff` once the agent appears.
subs =
Sagents.Subscriber.subscribe_to_agent(
subs,
<%= module %>.conversation_agent_id(conversation_id)
)
### Action path (factory config required)
When the user takes an action that requires the agent to actually do
work, call `ensure_session_running/1` with a state map. The function
starts the agent (if it isn't running) with the host seeded as an
initial subscriber to close the race against `init/1`/`handle_continue/2`
broadcasts, and upgrades the local subs map to `:subscribed`.
# LiveView
case <%= module %>.ensure_session_running(socket.assigns) do
{:ok, changes} -> assign(socket, changes)
{:error, reason} -> ...
end
# GenServer
case <%= module %>.ensure_session_running(state) do
{:ok, changes} -> {:noreply, Map.merge(state, changes)}
{:error, reason} -> ...
end
`ensure_session_running/1` is the **only** site where factory
configuration flows.
## Configuration
Customize this module for your application:
- Change agent_id mapping strategy in `conversation_agent_id/1`
- Modify inactivity timeout in `start_conversation_session/2`
- Add custom lifecycle hooks (telemetry, logging, permissions)
- If you don't use the FileSystem middleware, remove the
`:filesystem_scope` raise in `start_conversation_session/2`.
"""
alias Sagents.{State, AgentServer, AgentSupervisor, AgentsDynamicSupervisor, Subscriber}
require Logger
# Presence configuration for tracking conversation viewers
@presence_module <%= presence_module %>
# Default inactivity timeout (can be overridden per session)
@inactivity_timeout_minutes 10
@doc """
Starts or resumes an agent session for a conversation.
This function is idempotent — safe to call multiple times. If the agent
is already running, returns the existing session. Most callers should
use `ensure_session_running/1` instead, which composes this with
subscription bookkeeping.
## Options
- `:filesystem_scope` - Required. Filesystem scope tuple (e.g., `{:user, user_id}`)
- `:scope` - The Phoenix scope (e.g., `current_scope`). In production this should
come from the caller's session (e.g., a LiveView's `socket.assigns.current_scope`).
`nil` is allowed for tests, admin scripts, or background jobs, but tenant-scoped
queries downstream will have no owner to filter by. Set on the agent's `:scope`
field via the Factory; sagents propagates it as the first positional argument to
persistence callbacks and as `context.scope` to tool functions.
**Sizing note:** the scope struct is copied across process boundaries on every
hop (LiveView → AgentServer → tool invocations). If your Phoenix Scope preloads
heavy Ecto associations, consider passing a slim version instead. See
`sagents/docs/tool_context_and_state.md` ("Keep scope lean") for the pattern.
- `:inactivity_timeout` - Milliseconds before agent stops (default: 10 minutes)
- `:tool_context` - Map of caller-supplied data, exposed to tool functions
via `context.<key>`.
- `:factory_opts` - Additional options passed to your Factory module.
- `:initial_subscribers` - List of `{channel, pid}` tuples seeded as
subscribers before `init/1` returns. Use to atomically start-and-subscribe;
ignored if the agent is already running.
## Returns
- `{:ok, session}` - Session info (whether just started or already running)
- `{:error, reason}` - Failed to start
"""
def start_conversation_session(conversation_id, opts \\ []) do
filesystem_scope =
case Keyword.fetch(opts, :filesystem_scope) do
{:ok, scope_value} ->
scope_value
:error ->
raise ArgumentError, """
Missing required :filesystem_scope option.
Please pass the filesystem scope when starting a session:
<%= module %>.start_conversation_session(
conversation_id,
filesystem_scope: {:<%= owner_type %>, <%= owner_type %>_id}
)
"""
end
scope = Keyword.get(opts, :scope)
tool_context = Keyword.get(opts, :tool_context, %{})
agent_id = conversation_agent_id(conversation_id)
case AgentServer.get_pid(agent_id) do
nil ->
do_start_session(
conversation_id,
agent_id,
filesystem_scope,
scope,
tool_context,
opts
)
pid ->
Logger.debug("Agent session already running for conversation #{conversation_id}")
{:ok,
%{
agent_id: agent_id,
pid: pid,
conversation_id: conversation_id
}}
end
end
@doc """
Ensure the agent session for `state.conversation_id` is running and that
the calling process is subscribed to its events.
This is the **action path** — call it from sites that take an action
requiring the agent to do work (send a message, wake from idle). It is
the single site where factory configuration flows.
The function takes a plain state map rather than a host-specific value
so it can be reused by any process that proxies to an agent. The caller
merges the returned changes back in whatever way it manages updates:
- LiveView: `socket = assign(socket, changes)` (each key is properly
tracked in `__changed__` for re-render diffing).
- GenServer / plain process: `state = Map.merge(state, changes)`.
## State keys
Required:
- `:conversation_id` - the conversation whose agent to ensure running
- `:current_scope` - Phoenix scope for tenant-aware DB queries
Optional:
- `:filesystem_scope` - filesystem scope tuple (e.g. `{:user, id}`).
Only meaningful if your factory wires the FileSystem middleware; if
omitted, it isn't forwarded.
- `:sagents_subs` (defaults to `%{}`) - existing `Sagents.Subscriber`
subs map.
## Returns
- `{:ok, %{sagents_subs: new_subs, agent_id: agent_id}}` - merge into
your state. The `subs` entry for `agent_id` is `:subscribed`; the
monitor and recovery dispatch already wired in your `handle_info`
will fire on crash / migration.
- `{:error, reason}` - agent failed to start; caller decides how to
surface.
## Examples
# LiveView caller
case Coordinator.ensure_session_running(socket.assigns) do
{:ok, changes} ->
socket = assign(socket, changes)
# ...send message via changes.agent_id...
{:error, reason} ->
put_flash(socket, :error, "Failed to start agent: \#{inspect(reason)}")
end
# GenServer caller
case Coordinator.ensure_session_running(state) do
{:ok, changes} -> {:noreply, Map.merge(state, changes)}
{:error, reason} -> ...
end
"""
def ensure_session_running(state) do
conversation_id = Map.fetch!(state, :conversation_id)
agent_id = conversation_agent_id(conversation_id)
subs = Map.get(state, :sagents_subs, %{})
start_opts =
[
scope: Map.fetch!(state, :current_scope),
initial_subscribers: [{:main, self()}]
]
|> maybe_put(:filesystem_scope, Map.get(state, :filesystem_scope))
case start_conversation_session(conversation_id, start_opts) do
{:ok, %{pid: pid}} ->
# Upgrade :pending → :subscribed (and avoid duplicate Process.monitor
# if we were already :subscribed to the same pid).
new_subs =
case Map.get(subs, {:agent, agent_id}) do
%{state: :subscribed, server_pid: ^pid} -> subs
_ -> Subscriber.subscribe_to_agent(subs, agent_id)
end
{:ok, %{sagents_subs: new_subs, agent_id: agent_id}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Stops an agent session for a conversation.
Note: Agents automatically stop after inactivity timeout.
Only call this for explicit cleanup (e.g., conversation archival).
"""
def stop_conversation_session(conversation_id) do
agent_id = conversation_agent_id(conversation_id)
case AgentServer.get_pid(agent_id) do
nil ->
{:ok, :not_running}
_pid ->
AgentServer.stop(agent_id)
{:ok, :stopped}
end
end
@doc """
Checks if an agent session is currently running.
"""
def session_running?(conversation_id) do
agent_id = conversation_agent_id(conversation_id)
AgentServer.get_pid(agent_id) != nil
end
@doc """
Maps a conversation ID to an agent ID.
## Customization
Change this function to implement different mapping strategies:
# User-centric agents (one agent per user)
def conversation_agent_id(conversation_id) do
user_id = Conversations.get_user_id(conversation_id)
"user-\#{user_id}"
end
# Conversation-centric with prefix (current)
def conversation_agent_id(conversation_id) do
"conversation-\#{conversation_id}"
end
# Simple pass-through
def conversation_agent_id(conversation_id), do: conversation_id
"""
def conversation_agent_id(conversation_id) do
"conversation-#{conversation_id}"
end
@doc """
Track a viewer's presence in a conversation.
Call this in your LiveView mount after the socket is connected to enable
smart agent shutdown — when no viewers are present and the agent becomes
idle, it can shutdown immediately to free resources.
"""
def track_conversation_viewer(conversation_id, viewer_id, metadata \\ %{}) do
topic = presence_topic(conversation_id)
full_metadata = Map.merge(%{joined_at: System.system_time(:second)}, metadata)
Sagents.Presence.track(@presence_module, topic, viewer_id, full_metadata)
end
@doc """
Untrack a viewer's presence from a conversation.
"""
def untrack_conversation_viewer(conversation_id, viewer_id) do
topic = presence_topic(conversation_id)
Sagents.Presence.untrack(@presence_module, topic, viewer_id)
end
@doc """
List all viewers currently present in a conversation.
"""
def list_conversation_viewers(conversation_id) do
topic = presence_topic(conversation_id)
Sagents.Presence.list(@presence_module, topic)
end
# Private Functions
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp presence_topic(conversation_id) do
"conversation:#{conversation_id}"
end
defp do_start_session(
conversation_id,
agent_id,
filesystem_scope,
scope,
tool_context,
opts
) do
Logger.info(
"Starting agent session for conversation #{conversation_id} with filesystem_scope #{inspect(filesystem_scope)}"
)
factory_opts = Keyword.get(opts, :factory_opts, [])
initial_subscribers = Keyword.get(opts, :initial_subscribers, [])
merged_factory_opts =
factory_opts
|> Keyword.put(:agent_id, agent_id)
|> Keyword.put(:filesystem_scope, filesystem_scope)
|> Keyword.put(:scope, scope)
|> Keyword.put(:tool_context, tool_context)
{:ok, agent} = <%= factory_module %>.create_agent(merged_factory_opts)
{:ok, state} = create_conversation_state(conversation_id, scope)
inactivity_timeout =
Keyword.get(opts, :inactivity_timeout, :timer.minutes(@inactivity_timeout_minutes))
supervisor_name = AgentSupervisor.get_name(agent_id)
presence_tracking = [
enabled: true,
presence_module: @presence_module,
topic: presence_topic(conversation_id)
]
supervisor_config = [
agent_id: agent_id,
name: supervisor_name,
agent: agent,
initial_state: state,
pubsub: {Phoenix.PubSub, <%= pubsub_module %>},
inactivity_timeout: inactivity_timeout,
presence_tracking: presence_tracking,
presence_module: @presence_module,
conversation_id: conversation_id,
agent_persistence: <%= agent_persistence_module %>,
display_message_persistence: <%= display_message_persistence_module %>,
initial_subscribers: initial_subscribers
]
case AgentsDynamicSupervisor.start_agent_sync(supervisor_config) do
{:ok, _supervisor_pid} ->
pid = AgentServer.get_pid(agent_id)
{:ok,
%{
agent_id: agent_id,
pid: pid,
conversation_id: conversation_id
}}
{:ok, _supervisor_pid, :already_started} ->
pid = AgentServer.get_pid(agent_id)
{:ok,
%{
agent_id: agent_id,
pid: pid,
conversation_id: conversation_id
}}
{:error, reason} ->
Logger.error("Failed to start agent session: #{inspect(reason)}")
{:error, reason}
end
end
defp create_conversation_state(conversation_id, scope) do
agent_id = conversation_agent_id(conversation_id)
load_result =
<%= agent_persistence_module %>.load_state(scope, %{
agent_id: agent_id,
conversation_id: conversation_id
})
case load_result do
{:ok, exported_state} ->
Logger.info(
"Found saved state for conversation #{conversation_id}, attempting to restore..."
)
nested_state = exported_state["state"]
if is_nil(nested_state) do
Logger.warning(
"Exported state for conversation #{conversation_id} has no 'state' field, using fresh state"
)
{:ok, State.new!(%{})}
else
case State.from_serialized(agent_id, nested_state) do
{:ok, state} ->
Logger.info(
"Successfully restored agent state for conversation #{conversation_id} with #{length(state.messages)} messages"
)
{:ok, state}
{:error, reason} ->
Logger.warning(
"Failed to deserialize agent state for conversation #{conversation_id}: #{inspect(reason)}, using fresh state"
)
{:ok, State.new!(%{})}
end
end
{:error, :not_found} ->
Logger.info(
"No saved state found for conversation #{conversation_id}, creating fresh state"
)
{:ok, State.new!(%{})}
end
end
end