Sagents.Message.DisplayHelpers (Sagents v0.13.2)
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 verbatim and
agent state stores it as it stands, with no projection.
@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. Message.status is
serialized, and Sagents.Persistence.StateSerializer projects the two metadata
keys this module reads, so a message restored from persisted agent state
classifies the way it did in the turn that produced it — including where
metadata is the only discriminator, which is how LangChain releases below
v0.10.0 record a dead stream.
The projection is narrower than the live value. streaming_error/1 returns an
error carrying the failure's type and message but not the :original term
behind it, which can be any term and does not belong in a persisted state.
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.
Survives a state round trip carrying type and message; see the durability
note on stop_reason/1.