defmodule Sagents.DisplayMessagePersistence do @moduledoc """ Behaviour for persisting display messages (user-facing message representations). Display messages are the UI-friendly representations of conversation turns: text messages, tool call cards, thinking blocks, error notifications, etc. They are separate from the agent's internal state and optimized for rendering. ## Scope-first contract Every callback takes the integrator's scope struct as its first positional argument. ## When callbacks are invoked All callbacks are invoked from within the AgentServer process, ensuring exactly-once semantics regardless of how many LiveViews are connected. ### Message saving `save_message/3` is called when a new LangChain Message is processed. A single Message can produce multiple display messages (e.g., text + tool_calls). The implementation should return the saved records so AgentServer can broadcast `{:display_message_saved, msg}` events to connected LiveViews. ### Tool execution lifecycle Tool execution status updates reflect the lifecycle of tool calls: 1. Tool call identified → message saved with status "pending" (via `save_message/3`) 2. Tool execution starts → `update_tool_status/4` with status `:executing` 3. Tool execution ends → `update_tool_status/4` with status `:completed` or `:failed` ## Configuration Per-agent via AgentServer start options: supervisor_config = [ agent_id: agent_id, conversation_id: conversation_id, display_message_persistence: MyApp.DisplayMessagePersistence, # ... other config ] If not configured, no display messages are persisted. The agent still broadcasts PubSub events for real-time streaming — LiveViews can render messages from events alone without persistence. """ @typedoc """ Context map passed to every callback. Carries cross-cutting identifiers that every implementation needs but don't benefit from positional visibility. """ @type callback_context :: %{ required(:agent_id) => String.t(), required(:conversation_id) => String.t() | nil } @type tool_status :: :executing | :completed | :failed | :interrupted | :cancelled @doc """ Save a LangChain Message as one or more display messages. A single `LangChain.Message` may produce multiple display messages (e.g., an assistant message with text content + tool calls produces a text display message and one or more tool_call display messages). The implementation should: 1. Convert the Message into display message records 2. Persist them to the database (scoped via `scope`) 3. Return the list of saved records AgentServer broadcasts `{:display_message_saved, msg}` for each returned record, so connected LiveViews can update their UI. ## Parameters - `scope` — Integrator-defined scope struct (or `nil`). Use to filter DB writes. - `message` — The `LangChain.Message` struct to persist - `context` — Map with `:agent_id` and `:conversation_id` ## Returns - `{:ok, [saved_messages]}` — List of persisted display message records - `{:error, reason}` — Persistence failed (logged, does not affect agent) """ @callback save_message( scope :: term() | nil, message :: LangChain.Message.t(), context :: callback_context() ) :: {:ok, list()} | {:error, term()} @doc """ Update the status of a persisted tool call display message. Called at each stage of the tool execution lifecycle. The `tool_info` map contains the tool call identifier and any status-specific metadata. ## Parameters - `scope` — Integrator-defined scope struct (or `nil`). Use to filter DB writes. - `status` — The new status: `:executing`, `:completed`, `:failed`, `:interrupted`, or `:cancelled` - `tool_info` — Map with at minimum `:call_id`, plus status-specific fields: | Status | Fields | |--------|--------| | `:executing` | `%{call_id: "...", name: "...", display_text: "..."}` | | `:completed` | `%{call_id: "...", name: "...", result: "..."}` | | `:failed` | `%{call_id: "...", name: "...", error: "..."}` | | `:interrupted` | `%{call_id: "...", display_text: "..."}` | | `:cancelled` | `%{call_id: "...", name: "..."}` | - `context` — Map with `:agent_id` and `:conversation_id` ## Returns - `{:ok, updated_message}` — Updated record, broadcast to LiveViews as `{:display_message_updated, msg}` - `{:error, :not_found}` — No matching tool call exists (normal if persistence wasn't configured when call was saved) """ @callback update_tool_status( scope :: term() | nil, status :: tool_status(), tool_info :: map(), context :: callback_context() ) :: {:ok, term()} | {:error, :not_found | term()} @doc """ Resolve an interrupted tool result display message with actual result content. Called after a sub-agent resumes and completes. Updates the persisted tool result display message to clear the interrupt flag and replace placeholder content with the actual result. Optional callback — implementations that don't need this can skip it. ## Parameters - `scope` — Integrator-defined scope struct (or `nil`). Use to filter DB writes. - `tool_call_id` — The tool call ID matching the interrupted tool result - `result_content` — The actual result content string - `context` — Map with `:agent_id` and `:conversation_id` ## Returns - `{:ok, updated_message}` — Updated record, broadcast to LiveViews - `{:error, :not_found}` — No matching interrupted tool result exists """ @callback resolve_tool_result( scope :: term() | nil, tool_call_id :: String.t(), result_content :: String.t(), context :: callback_context() ) :: {:ok, term()} | {:error, :not_found | term()} @typedoc """ Attributes for a synthetic display message produced by middleware (not by an LLM). The shape mirrors the fields a typical implementation will write to its display-message store. """ @type synthetic_message_attrs :: %{ required(:message_type) => String.t(), required(:content_type) => String.t(), required(:content) => map(), optional(:metadata) => map() } @doc """ Persist a synthetic display message originated by middleware. Used for transcript entries that should appear in the conversation but do not correspond to a `LangChain.Message` — for example, the user's answer to an `ask_user` question, or a "user cancelled" notification. AgentServer invokes this callback in response to `Sagents.AgentServer.save_synthetic_message_from/2` and broadcasts the saved record as `{:display_message_saved, msg}` so LiveViews stream it in via the same path used for LLM-generated display messages. Optional callback — middleware that uses this feature is responsible for ensuring the configured persistence module implements it. ## Parameters - `scope` — Integrator-defined scope struct (or `nil`). Use to filter DB writes. - `attrs` — Map with `:message_type`, `:content_type`, `:content` (and optionally `:metadata`). - `context` — Map with `:agent_id` and `:conversation_id`. ## Returns - `{:ok, display_message}` — Persisted record, broadcast as `{:display_message_saved, msg}`. - `{:error, reason}` — Persistence failed (logged, does not affect agent). """ @callback save_synthetic_message( scope :: term() | nil, attrs :: synthetic_message_attrs(), context :: callback_context() ) :: {:ok, term()} | {:error, term()} @optional_callbacks [resolve_tool_result: 4, save_synthetic_message: 3] end