Current section

Files

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

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

defmodule <%= @context_module %>.DisplayMessage do
@moduledoc """
Schema for display messages with multi-content type support.
Stores user-facing messages for display in the UI.
These are separate from the full agent state for efficient querying.
## Content Types
The `content_type` field determines how the message content should be rendered:
- `"text"` - Standard text content (default)
- `"thinking"` - AI reasoning/thought process
- `"image"` - Image content (URL or base64 data)
- `"file_reference"` - Reference to a file
- `"structured_data"` - Structured/formatted data (tables, JSON, etc.)
- `"notification"` - System/UI notification
- `"error"` - Error message
- `"tool_call"` - Tool invocation request from assistant
- `"tool_result"` - Tool execution result
## Content Structure
The `content` field is a map (JSONB) with structure depending on `content_type`.
**IMPORTANT**: All content keys are strings (not atoms) because of JSONB storage.
Examples:
- Text: `%{"text" => "message"}`
- Image: `%{"url" => "path"}` or `%{"data" => "base64...", "mime_type" => "image/png"}`
- File: `%{"path" => "/path", "name" => "report.pdf"}`
- Tool call: `%{"call_id" => "call_123", "name" => "search", "arguments" => %{...}}`
- Tool result: `%{"tool_call_id" => "call_123", "name" => "search", "content" => "...", "is_error" => false}`
## Sequence Field (Message-Local Ordering)
The `sequence` field (integer, default 0) ensures correct ordering of multiple content parts
within a single message. It is **message-local**, not conversation-wide.
### When to Use Sequence:
- **Single-part messages**: Always use `sequence: 0` (default)
- User messages
- Simple assistant text responses
- Individual tool results
- **Multi-part messages**: Use `sequence: 0, 1, 2, ...` for each part
- Assistant thinking (sequence: 0) + text response (sequence: 1)
- Text (sequence: 0) + image (sequence: 1)
- Multiple tool calls arriving together
### Example:
# Single assistant message with multiple content parts
# (all created within microseconds)
# Part 0: Thinking block
%DisplayMessage{sequence: 0, content_type: "thinking", ...}
# Part 1: Text response
%DisplayMessage{sequence: 1, content_type: "text", ...}
# Part 2: Image
%DisplayMessage{sequence: 2, content_type: "image", ...}
# Next user message (resets to 0)
%DisplayMessage{sequence: 0, content_type: "text", ...}
### Querying:
Always order by both timestamp and sequence:
from m in DisplayMessage,
where: m.conversation_id == ^conversation_id,
order_by: [asc: m.inserted_at, asc: m.sequence]
This guarantees deterministic ordering even when multiple messages share
the same microsecond timestamp.
"""
use Ecto.Schema
import Ecto.Changeset
alias __MODULE__
alias <%= @context_module %>.Conversation
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
# Valid content types
@content_types ~w(text thinking image file_reference structured_data notification error tool_call tool_result)
schema "<%= @table_prefix %>display_messages" do
belongs_to :conversation, Conversation
# "user", "assistant", "tool", "system"
field :message_type, :string
# Flexible JSONB storage
field :content, :map
# Type of content for rendering
field :content_type, :string
# Message-local ordering (0-based, resets per message)
field :sequence, :integer, default: 0
# Tool execution status: "pending", "executing", "completed", "failed"
field :status, :string, default: "completed"
field :metadata, :map, default: %{}
timestamps(type: :utc_datetime_usec, updated_at: false)
end
@doc false
def create_changeset(conversation_id, attrs) do
%DisplayMessage{}
|> cast(attrs, [:message_type, :content, :content_type, :sequence, :status, :metadata])
|> put_change(:conversation_id, conversation_id)
|> common_validations()
end
@doc false
def changeset(%DisplayMessage{} = message, attrs) do
message
|> cast(attrs, [:message_type, :content, :content_type, :sequence, :status, :metadata])
|> common_validations()
end
defp common_validations(changeset) do
changeset
|> validate_required([:conversation_id, :message_type, :content, :content_type])
|> validate_inclusion(:content_type, @content_types)
|> validate_inclusion(:status, ["pending", "executing", "completed", "failed"])
|> validate_number(:sequence, greater_than_or_equal_to: 0)
|> validate_content_structure()
|> foreign_key_constraint(:conversation_id)
|> unique_constraint(:conversation_id,
name: :unique_tool_call_per_conversation,
message: "tool call already exists for this conversation"
)
end
# Validate content structure based on content_type
# NOTE: content keys are strings (from JSONB), not atoms
defp validate_content_structure(changeset) do
content_type = get_field(changeset, :content_type)
content = get_field(changeset, :content)
case {content_type, content} do
{"text", %{"text" => _}} -> changeset
{"thinking", %{"text" => _}} -> changeset
{"image", %{"url" => _}} -> changeset
{"image", %{"data" => _, "mime_type" => _}} -> changeset
{"file_reference", %{"path" => _, "name" => _}} -> changeset
{"structured_data", %{"format" => _, "data" => _}} -> changeset
{"notification", %{"text" => _}} -> changeset
{"error", %{"text" => _}} -> changeset
# Tool call validation
{"tool_call", %{"call_id" => _, "name" => _, "arguments" => _}} -> changeset
# Tool result validation
{"tool_result", %{"tool_call_id" => _, "name" => _, "content" => _}} -> changeset
{nil, _} -> changeset # Allow nil during build
_ -> add_error(changeset, :content, "invalid structure for content_type #{content_type}")
end
end
@doc """
Returns the text representation of any content type for searching/indexing.
NOTE: content keys are strings (from JSONB), not atoms
"""
def to_text(%DisplayMessage{content_type: "text", content: %{"text" => text}}), do: text
def to_text(%DisplayMessage{content_type: "thinking", content: %{"text" => text}}), do: text
def to_text(%DisplayMessage{content_type: "image", content: content}) do
Map.get(content, "caption") || Map.get(content, "alt_text") || ""
end
def to_text(%DisplayMessage{content_type: "file_reference", content: %{"name" => name}}), do: "File: #{name}"
def to_text(%DisplayMessage{content_type: "structured_data", content: content}), do: "Data: #{Map.get(content, "format", "")}"
def to_text(%DisplayMessage{content_type: "notification", content: %{"text" => text}}), do: text
def to_text(%DisplayMessage{content_type: "error", content: %{"text" => text}}), do: text
def to_text(%DisplayMessage{
content_type: "tool_call",
content: %{"name" => name, "arguments" => args}
}) do
"Tool call: #{name}(#{inspect(args)})"
end
def to_text(%DisplayMessage{
content_type: "tool_result",
content: %{"name" => name, "content" => content}
}) do
"Tool result from #{name}: #{content}"
end
def to_text(_), do: ""
end