Sagents.Message.DisplayHelpers (Sagents v0.13.1)

Copy Markdown

Utilities 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)
end

This 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

stop_reason()

@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

extract_display_items(message)

@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}}
# ]

stop_details(message)

@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.

stop_reason(message)

@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

streaming_error(message)

@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.