Sagents.Message.DisplayHelpers (Sagents v0.16.2)

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.

Narration

Some models label an utterance as narration: the model saying what it is about to do, rather than its reply. It is a status update about work in progress, and it reads wrong as a chat bubble.

A text or thinking item carries the label in its content under "utterance", holding "narration" or "answer", and the key is absent when the model did not label the part. A host that wants to render narration differently matches on content["utterance"] == "narration"; a host that does not care ignores the key and renders as it always has.

The label is per item, not per message, because one message can hold a narration part and an answer part. narration?/1 answers the message-level question, which is whether the model has said anything conclusive yet.

Summary

Types

Why a message stopped, normalized from LangChain.Message.status.

Functions

Extracts all displayable items from a Message.

Returns true when the message is narration: the model describing work in progress rather than answering.

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.

Narration

A text or thinking item whose part is labelled carries "utterance" in its content, holding "narration" or "answer". The key is absent when the model did not label the part, so a host reads it on presence and one that ignores it renders as it always has. It rides in content for the same reason "stop_reason" does: hosts persist that map verbatim.

Unlike "stop_reason", which marks only the last item, this is per item. A single response can hold a narration part and an answer part, and they are two items with different labels.

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

narration?(message)

@spec narration?(LangChain.Message.t()) :: boolean()

Returns true when the message is narration: the model describing work in progress rather than answering.

A narration message is not the end of a turn, so an agent runs the model again after one. A host reading a live conversation can use this to keep a progress indicator up, and one rendering a stored conversation can use it to show the message as a status line rather than a reply.

A message that holds narration and an answer is not narration: it contains the reply. Use the per-item "utterance" key from extract_display_items/1 to tell those two parts apart when rendering.

Durability

The classification survives a state round trip. The marker lives on the content part, which Sagents.Persistence.StateSerializer round-trips, so a message restored from persisted agent state classifies the way it did in the turn that produced it.

Examples

iex> alias LangChain.Message.ContentPart
iex> message = LangChain.Message.new_assistant!(%{content: [ContentPart.narration!("Checking the logs.")]})
iex> Sagents.Message.DisplayHelpers.narration?(message)
true

iex> Sagents.Message.DisplayHelpers.narration?(LangChain.Message.new_assistant!("done"))
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.