defmodule <%= module %> do @moduledoc """ Thin bridge between this app and `Sagents.Session`. Session-start logic — router consult, factory invocation, state seeding, supervisor config, subscriber wiring — lives in `Sagents.Session`. This module declares app-specific config and exposes user-editable customization slots (the `conversation_agent_id/1` mapping, presence helpers). ## 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_agent_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_agent_session_running(socket.assigns) do {:ok, changes} -> assign(socket, changes) {:error, reason} -> ... end # GenServer case <%= module %>.ensure_agent_session_running(state) do {:ok, changes} -> {:noreply, Map.merge(state, changes)} {:error, reason} -> ... end See `Sagents.FactoryRouter` for routing among multiple factories. ## Threading per-request data into the FactoryConfig Per-request fields (`:timezone`, `:tool_context`, project context, etc.) flow through the explicit `request_opts` keyword list passed as the second arg to `ensure_agent_session_running/2`. They are forwarded to the router as its third argument and consumed by your `*Config.from_inputs/1`: Coordinator.ensure_agent_session_running(socket.assigns, timezone: socket.assigns.timezone, tool_context: %{user_id: user_id} ) Add new request-scoped fields to your FactoryConfig and pass them through this same arg — no magic state-map keys. """ @presence_module <%= presence_module %> @config %{ factory_router: <%= factory_router_module %>, agent_persistence: <%= agent_persistence_module %>, display_message_persistence: <%= display_message_persistence_module %>, pubsub: {Phoenix.PubSub, <%= pubsub_module %>}, presence_module: <%= presence_module %>, inactivity_timeout: :timer.minutes(10), agent_id_fun: &__MODULE__.conversation_agent_id/1 } @doc """ Start (or return the existing) agent session for a conversation. Idempotent. Most callers should use `ensure_agent_session_running/1` instead, which composes this with subscription bookkeeping. See `Sagents.Session.start/3` for accepted options. """ def start_conversation_session(conversation_id, opts \\ []), do: Sagents.Session.start(@config, conversation_id, opts) @doc """ Ensure the agent session for `state.conversation_id` is running and that the calling process is subscribed to its events. Per-request data for the FactoryConfig flows through the explicit `request_opts` keyword list, not the state map. See `Sagents.Session.ensure_running/3`. <%= module %>.ensure_agent_session_running(socket.assigns, timezone: socket.assigns.timezone ) """ def ensure_agent_session_running(state, request_opts \\ []), do: Sagents.Session.ensure_running(@config, state, request_opts: request_opts) @doc """ Stop 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: Sagents.Session.stop(@config, conversation_id) @doc "Whether an agent session is currently running for `conversation_id`." def session_running?(conversation_id), do: Sagents.Session.running?(@config, conversation_id) # =========================================================================== # Customization slots (edit after generation as needed) # =========================================================================== @doc """ Maps a conversation ID to an agent ID. Customize to change the mapping strategy: # 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 (default) def conversation_agent_id(conversation_id) do "conversation-\#{conversation_id}" end """ def conversation_agent_id(conversation_id) do "conversation-#{conversation_id}" end # =========================================================================== # Presence helpers (per-app because of @presence_module) # =========================================================================== @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 defp presence_topic(conversation_id), do: "conversation:#{conversation_id}" end