Sagents.Message.DisplayHelpers (Sagents v0.12.1)
Copy MarkdownUtilities for extracting displayable content from LangChain Messages.
These helpers bridge the gap between LangChain Message structs and application-specific display schemas. They handle the complexity of:
- Extracting text, thinking, tool_calls, and tool_results
- Proper sequencing when a single Message contains multiple display items
- Converting structs to maps with string keys (JSON-compatible)
Usage in Generated Code
Mix task templates should demonstrate this pattern:
# In your LiveView or context module
def persist_message(%Message{} = message, conversation_id) do
message
|> DisplayHelpers.extract_display_items()
|> Enum.with_index()
|> Enum.map(fn {item, sequence} ->
attrs = Map.put(item, "sequence", sequence)
create_display_message(conversation_id, attrs)
end)
endThis gives users full control over their schema while providing library utilities that handle the extraction complexity.
Summary
Types
Why a message stopped, normalized from LangChain.Message.status.
Functions
Extracts all displayable items from a Message.
Returns the provider's detail about why the response was stopped, or nil.
Classifies why a message stopped.
Returns the error that killed the stream, or nil.
Types
@type stop_reason() :: nil | :length | :cancelled | :content_filtered | :stream_error
Why a message stopped, normalized from LangChain.Message.status.
nil- the model finished on its own:length- the model hit the output token cap:cancelled- the caller stopped the run:content_filtered- the provider's content filter stopped the response:stream_error- the stream died mid-flight (overloaded, an invalid request, transport-level filtering)
Every value other than nil means the same thing to a reader ("it stopped
early"), so a host can treat the classification as one concept and vary the
wording per value. :content_filtered may additionally carry provider detail
naming the cause; see stop_details/1.
Functions
@spec extract_display_items(LangChain.Message.t()) :: [map()]
Extracts all displayable items from a Message.
Returns a list of maps, each representing one displayable item. A single Message can produce multiple items (e.g., text + tool_calls).
Return Format
Each map contains atom keys:
:type- One of: :text, :thinking, :tool_call, :tool_result:message_type- Role-based: :user, :assistant, :tool, :system:content- Map with type-specific content (string keys for JSONB storage)
The order of items in the list represents the display order. The caller should assign sequence numbers (0, 1, 2, ...) when persisting.
Note: No mixed maps - top-level keys are atoms, content payload uses string keys.
Messages that stopped early
When stop_reason/1 classifies the message as unfinished, the last item's
content carries a "stop_reason" string: "length", "cancelled",
"content_filtered", or "stream_error". Messages the model finished carry no
such key, so a host renders the mark on the truthiness of
content["stop_reason"] rather than having to compare against nil.
When the provider named a cause beyond the status, "stop_details" sits
alongside it on the same item, carrying the provider's own map. It is absent
otherwise, including for stops that carry a reason but no detail.
Only the last item is marked. A single message can yield several items (thinking, then text, then tool calls), and the mark reads as "and then it stopped", which is true only of the final thing said. The framework decides this so that every host renders it the same way.
The key rides in content rather than alongside it because hosts persist
item.content verbatim into a JSONB column. Nothing has to be mapped, and no
content_type whitelist or migration is involved.
Examples
# Assistant message with text and tool calls
message = Message.new_assistant!(%{
content: [ContentPart.text!("Let me search...")],
tool_calls: [
ToolCall.new!(%{call_id: "1", name: "search", arguments: %{q: "elixir"}}),
ToolCall.new!(%{call_id: "2", name: "weather", arguments: %{city: "NYC"}})
]
})
DisplayHelpers.extract_display_items(message)
# => [
# %{type: :text, message_type: :assistant, content: %{"text" => "Let me search..."}},
# %{type: :tool_call, message_type: :assistant, content: %{"call_id" => "1", "name" => "search", "arguments" => %{q: "elixir"}}},
# %{type: :tool_call, message_type: :assistant, content: %{"call_id" => "2", "name" => "weather", "arguments" => %{city: "NYC"}}}
# ]
# Tool result message with multiple results
message = Message.new_tool_result!(%{
tool_results: [
ToolResult.new!(%{tool_call_id: "1", name: "search", content: "Found...", is_error: false}),
ToolResult.new!(%{tool_call_id: "2", name: "weather", content: "Sunny", is_error: false})
]
})
DisplayHelpers.extract_display_items(message)
# => [
# %{type: :tool_result, message_type: :tool, content: %{"tool_call_id" => "1", "name" => "search", "content" => "Found...", "is_error" => false}},
# %{type: :tool_result, message_type: :tool, content: %{"tool_call_id" => "2", "name" => "weather", "content" => "Sunny", "is_error" => false}}
# ]
@spec stop_details(LangChain.Message.t()) :: map() | nil
Returns the provider's detail about why the response was stopped, or nil.
Populated by providers that name a cause beyond the status itself. Anthropic
sends it on a refusal as %{"type" => "refusal", "category" => ..., "explanation" => ...} and omits it for every other stop reason, so a stop can
carry a reason with no detail.
Unlike streaming_error/1 this is plain JSON rather than a struct, so
extract_display_items/1 carries it into the item's content and it survives
persistence. It is not durable across an agent-state restore, for the reason
given under stop_reason/1.
@spec stop_reason(LangChain.Message.t()) :: stop_reason()
Classifies why a message stopped.
Returns nil when the model finished. See stop_reason/0 for the other
values.
Durability
The classification survives a state round trip, because
Sagents.Persistence.StateSerializer carries Message.status. The error
detail does not: Message.metadata is not serialized, so streaming_error/1
returns nil for a message read back from persisted agent state even though
stop_reason/1 still answers :stream_error.
Where metadata is the only discriminator, the classification is not durable
either, and a restored message reports :cancelled.
Examples
iex> Sagents.Message.DisplayHelpers.stop_reason(LangChain.Message.new_assistant!("done"))
nil
iex> message = %LangChain.Message{role: :assistant, status: :length}
iex> Sagents.Message.DisplayHelpers.stop_reason(message)
:length
@spec streaming_error(LangChain.Message.t()) :: LangChain.LangChainError.t() | nil
Returns the error that killed the stream, or nil.
Keyed on the metadata alone rather than on the status, so it answers the same
way for either shape the supported LangChain range records a dead stream in.
Only populated during the turn that produced the message; see the durability
note on stop_reason/1.