Current section

Files

Jump to
sagents priv templates sagents.gen.persistence context.ex.eex
Raw

priv/templates/sagents.gen.persistence/context.ex.eex

defmodule <%= @context_module %> do
@moduledoc """
Context for conversation persistence with multi-content type support.
This module provides scoped access to conversations, agent states, and display messages.
## Scope-Based Security
All conversation operations require a scope struct (`<%= @scope_module %>`)
as the first argument. This ensures queries are automatically filtered to
the appropriate user, organization, or team context.
## Customization Required
**IMPORTANT**: The generated code uses generic scope filtering. You must
customize the query filters to match your scope struct fields:
```elixir
# Example: If your scope has :current_user_id
defp scope_query(query, %Scope{current_user_id: user_id}) do
from q in query, where: q.<%= @owner_field %> == ^user_id
end
# Example: If your scope has :organization_id (multi-tenant)
defp scope_query(query, %Scope{organization_id: org_id}) do
from q in query, where: q.organization_id == ^org_id
end
```
Update `scope_query/2` and related functions based on YOUR scope structure.
## Multi-Content Type Support
Display messages support multiple content types (text, thinking, images, files, etc.).
Use the provided helper functions to create messages with proper structure:
- `append_text_message/3` - Standard text content
- `append_thinking_message/2` - AI reasoning blocks
- `append_image_message/3` - Images with URL or base64 data
- `append_file_message/4` - File references
- `append_structured_data_message/4` - Tables, JSON, etc.
- `append_notification_message/3` - System notifications
- `append_error_message/3` - Error messages
**IMPORTANT**: All content keys are strings (not atoms) due to JSONB storage.
"""
import Ecto.Query, warn: false
alias Sagents.Todo
alias <%= @repo %>
alias <%= @context_module %>.AgentState
alias <%= @context_module %>.Conversation
alias <%= @context_module %>.DisplayMessage
alias <%= @scope_module %>, as: Scope
#
# Conversation CRUD
#
@doc """
Creates a conversation within the given scope.
The conversation is automatically associated with the scope's owner.
Accepts attrs with either atom or string keys.
"""
def create_conversation(%Scope{} = scope, attrs) do
scope
|> get_owner_id()
|> Conversation.create_changeset(attrs)
|> Repo.insert()
end
@doc """
Gets a conversation by ID, scoped to the given context.
Raises if the conversation doesn't exist or doesn't belong to the scope.
"""
def get_conversation!(%Scope{} = scope, id) do
Conversation
|> scope_query(scope)
|> Repo.get!(id)
end
@doc """
Gets a conversation by ID, scoped to the given context.
Returns `{:ok, conversation}` or `{:error, :not_found}`.
"""
def get_conversation(%Scope{} = scope, id) do
Conversation
|> scope_query(scope)
|> Repo.get(id)
|> case do
nil -> {:error, :not_found}
conversation -> {:ok, conversation}
end
end
@doc """
Lists all conversations accessible within the given scope.
## Options
* `:limit` - Maximum number of conversations to return (default: 50)
* `:offset` - Number of conversations to skip (default: 0)
"""
def list_conversations(%Scope{} = scope, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
Conversation
|> scope_query(scope)
|> order_by([c], desc: c.updated_at)
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
end
def update_conversation(%Conversation{} = conversation, attrs) do
conversation
|> Conversation.changeset(attrs)
|> Repo.update()
end
def delete_conversation(%Conversation{} = conversation) do
Repo.delete(conversation)
end
def delete_conversation(%Scope{} = scope, conversation_id) when is_binary(conversation_id) do
conversation = get_conversation!(scope, conversation_id)
Repo.delete(conversation)
end
#
# Agent State Persistence
#
def save_agent_state(conversation_id, state) do
attrs = %{
conversation_id: conversation_id,
state_data: state,
version: state["version"] || 1
}
case get_agent_state(conversation_id) do
nil ->
%AgentState{}
|> AgentState.changeset(attrs)
|> Repo.insert()
existing ->
existing
|> AgentState.changeset(attrs)
|> Repo.update()
end
end
def load_agent_state(conversation_id) do
case get_agent_state(conversation_id) do
nil -> {:error, :not_found}
state -> {:ok, state.state_data}
end
end
@doc """
Loads just the TODOs from a saved agent state.
This is useful for displaying TODOs in the UI when browsing historical
conversations without starting the agent. Returns an empty list if no
state exists or if there are no todos.
Reuses the same deserialization logic as full state restoration via
`Sagents.Todo.from_map/1`.
"""
def load_todos(conversation_id) do
case load_agent_state(conversation_id) do
{:ok, %{"state" => %{"todos" => todos}}} when is_list(todos) ->
# Reuse Todo.from_map/1 - same logic as StateSerializer.deserialize_state/2
todos
|> Enum.map(fn todo_map ->
case Todo.from_map(todo_map) do
{:ok, todo} -> todo
{:error, _} -> nil
end
end)
|> Enum.reject(&is_nil/1)
{:ok, _} ->
# State exists but no todos field
[]
{:error, :not_found} ->
# No saved state
[]
end
end
defp get_agent_state(conversation_id) do
AgentState
|> where([a], a.conversation_id == ^conversation_id)
|> Repo.one()
end
#
# Display Messages
#
@doc """
Appends a display message to the conversation.
The message should be a map with keys:
- `message_type` - "user", "assistant", "tool", "system"
- `content_type` - Type of content for rendering
- `content` - Map with structure based on content_type
- `metadata` - Optional metadata
For easier usage, consider using the content-type-specific helper functions.
"""
def append_display_message(conversation_id, attrs) do
conversation_id
|> DisplayMessage.create_changeset(attrs)
|> Repo.insert()
end
@doc """
Loads all display messages for a conversation.
## Options
* `:limit` - Maximum number of messages to return (default: 100)
* `:offset` - Number of messages to skip (default: 0)
"""
def load_display_messages(conversation_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
DisplayMessage
|> where([m], m.conversation_id == ^conversation_id)
|> order_by([m], asc: m.inserted_at, asc: m.sequence)
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
end
#
# Content Type Helper Functions
# NOTE: All helper functions create content maps with STRING keys (not atoms)
# because Ecto :map type (JSONB) stores keys as strings
#
@doc """
Appends a text message to the conversation.
## Parameters
* `conversation_id` - The conversation ID
* `message_type` - The message type ("user", "assistant", "tool", "system")
* `text` - The text content
## Examples
append_text_message(convo_id, "user", "Hello!")
append_text_message(convo_id, "assistant", "How can I help?")
"""
def append_text_message(conversation_id, message_type, text) do
append_display_message(conversation_id, %{
message_type: message_type,
content_type: "text",
content: %{"text" => text}
})
end
@doc """
Updates a pending tool call message to "executing" status.
This is called when a tool begins execution, transitioning from "pending" to "executing".
## Parameters
- call_id: The tool call ID (from content.call_id)
## Returns
- {:ok, %DisplayMessage{}} on success
- {:error, :not_found} if no pending tool call with this call_id exists
- {:error, changeset} on validation failure
## Example
iex> mark_tool_executing("call_123")
{:ok, %DisplayMessage{status: "executing", ...}}
"""
@spec mark_tool_executing(String.t()) ::
{:ok, DisplayMessage.t()} | {:error, :not_found | Ecto.Changeset.t()}
def mark_tool_executing(call_id) do
query =
from m in DisplayMessage,
where: fragment("?->>'call_id' = ?", m.content, ^call_id),
where: m.content_type == "tool_call",
where: m.status == "pending"
case Repo.one(query) do
nil ->
{:error, :not_found}
message ->
message
|> DisplayMessage.changeset(%{"status" => "executing"})
|> Repo.update()
end
end
@doc """
Updates a tool call message to "completed" status and adds result metadata.
This is called when a tool successfully completes execution. Accepts tool calls in
either "pending" or "executing" status.
## Parameters
- call_id: The tool call ID
- result_metadata: Map containing result information (e.g., %{"result" => "success"})
## Returns
- {:ok, %DisplayMessage{}} on success
- {:error, :not_found} if no pending/executing tool call with this call_id exists
- {:error, changeset} on validation failure
## Example
iex> complete_tool_call("call_123", %{"result" => "File written successfully"})
{:ok, %DisplayMessage{status: "completed", metadata: %{"result" => ...}}}
"""
@spec complete_tool_call(String.t(), map()) ::
{:ok, DisplayMessage.t()} | {:error, :not_found | Ecto.Changeset.t()}
def complete_tool_call(call_id, result_metadata \\ %{}) do
query =
from m in DisplayMessage,
where: fragment("?->>'call_id' = ?", m.content, ^call_id),
where: m.content_type == "tool_call",
where: m.status in ["pending", "executing"]
case Repo.one(query) do
nil ->
{:error, :not_found}
message ->
updated_metadata = Map.merge(message.metadata, result_metadata)
message
|> DisplayMessage.changeset(%{
"status" => "completed",
"metadata" => updated_metadata
})
|> Repo.update()
end
end
@doc """
Updates a tool call message to "failed" status and adds error information.
This is called when a tool execution fails. Accepts tool calls in either "pending"
or "executing" status.
## Parameters
- call_id: The tool call ID
- error_info: Map containing error details (e.g., %{"error" => "File not found"})
## Returns
- {:ok, %DisplayMessage{}} on success
- {:error, :not_found} if no pending/executing tool call with this call_id exists
- {:error, changeset} on validation failure
## Example
iex> fail_tool_call("call_123", %{"error" => "Permission denied"})
{:ok, %DisplayMessage{status: "failed", metadata: %{"error" => ...}}}
"""
@spec fail_tool_call(String.t(), map()) ::
{:ok, DisplayMessage.t()} | {:error, :not_found | Ecto.Changeset.t()}
def fail_tool_call(call_id, error_info \\ %{}) do
query =
from m in DisplayMessage,
where: fragment("?->>'call_id' = ?", m.content, ^call_id),
where: m.content_type == "tool_call",
where: m.status in ["pending", "executing"]
case Repo.one(query) do
nil ->
{:error, :not_found}
message ->
updated_metadata = Map.merge(message.metadata, error_info)
message
|> DisplayMessage.changeset(%{
"status" => "failed",
"metadata" => updated_metadata
})
|> Repo.update()
end
end
@doc """
Searches message content across all types.
Requires scope for security. Searches within all conversations
accessible to the given scope.
## Examples
search_messages(scope, "quarterly report")
"""
def search_messages(%Scope{} = scope, search_term) do
# Extract owner field from scope for security
owner_id = get_owner_id(scope)
from(m in DisplayMessage,
join: c in Conversation,
on: m.conversation_id == c.id,
where: c.<%= @owner_field %> == ^owner_id,
where: fragment("?::text ILIKE ?", m.content, ^"%#{search_term}%")
)
|> Repo.all()
end
#
# Private Helpers
#
# TODO: Customize this based on YOUR scope struct fields
#
# Example implementations:
#
# Single-user scope:
# defp scope_query(query, %Scope{user_id: user_id}) do
# from q in query, where: q.<%= @owner_field %> == ^user_id
# end
#
# Multi-tenant (organization):
# defp scope_query(query, %Scope{organization_id: org_id}) do
# from q in query, where: q.organization_id == ^org_id
# end
#
# Team-based:
# defp scope_query(query, %Scope{team_id: team_id}) do
# from q in query, where: q.team_id == ^team_id
# end
#
defp scope_query(query, %Scope{} = scope) do
# CUSTOMIZE THIS: Use your scope's actual fields
owner_id = get_owner_id(scope)
from q in query, where: q.<%= @owner_field %> == ^owner_id
end
# Extracts the owner ID from the scope struct.
#
# This default assumes your Scope has a `<%= @owner_type %>` field containing
# a struct with an `id` field. Customize if your Scope has a different structure:
#
# Examples:
# defp get_owner_id(%Scope{<%= @owner_type %>: <%= @owner_type %>}), do: <%= @owner_type %>.id # struct with id
# defp get_owner_id(%Scope{<%= @owner_field %>: id}), do: id # direct ID field
# defp get_owner_id(%Scope{current_<%= @owner_field %>: id}), do: id # current_ prefix
#
defp get_owner_id(%Scope{<%= @owner_type %>: <%= @owner_type %>}), do: <%= @owner_type %>.id
end