A tool result's request to expand into conversation messages before the model is next called.
A tool result normally reaches the model as one thing: :tool content. That
is the right container for an answer and the wrong one for a body of material
the model is meant to treat as established. An expansion lets a tool say "put
these messages into the conversation, and trim what my own result keeps once
you have."
One result becomes several messages, and shrinks as they land. That is the expansion: the conversation grows by what the result gives up.
An expansion is applied by
LangChain.Chains.LLMChain.Mode.Steps.expand_tool_results/2, which runs
immediately before the next LLM call. The messages are in the conversation by
the time the model is asked to continue, in the same run.
Fields
:messages- the messages to insert, in order, exactly as the model will see them. Any number of them, at:useror:assistantroles.:result_content- what the tool's own result keeps once the messages have been inserted.nilkeeps the result's existing content.
The two representations
An expanding tool describes its output twice, because the expansion may not happen. Only a mode that composes the step applies one; under any other mode the field is inert.
| What the model reads | |
|---|---|
| Expansion not applied | the tool result's content, as the tool wrote it |
| Expansion applied | :messages, and the result trimmed to :result_content |
expand/3 takes both in one call so the two cannot drift, and so that the
degraded path is something a tool author passes rather than something they
remember:
LangChain.MessageExpansion.expand(
raw_text,
[
Message.new_assistant!(records),
Message.new_user!("Use the records above to answer my question.")
],
result_content: "Loaded 14 records."
)The message list is entirely the tool author's: one message, or six, in whatever order and at whichever of the two roles the prompt needs.
Shaping the ends of the list
Both ends of the message list meet something, and each has a consequence worth knowing. These are prompt-shaping recommendations, not rules; nothing is added or reordered on the author's behalf.
Starting with a user message. The messages follow the tool result
directly, and a :tool message reaches Anthropic as a user-role message.
Consecutive user-role messages are combined, so a list that starts with a
:user message is merged into the tool result's turn, which then holds the
tool result followed by the text. The request is valid, since Anthropic
requires tool results first with any text after them, but the text no longer
stands as a turn of its own. Starting with an :assistant message keeps them
apart.
Ending on an assistant message. Anthropic reads a trailing assistant
message as a prefill to continue rather than a turn to answer, so an expansion
whose last message is an :assistant one becomes the opening of the model's
own next sentence rather than something it responds to. Ending the list with a
short :user message that re-anchors the request avoids that.
Together these make [assistant, user] the shape for established material:
the material as something the model said, then a turn asking it to act. It is
the same shape an application hand-builds when seeding a conversation before
an agent starts.
Limitation: unresolved server tools
Do not expand from a tool that runs in the same turn as an Anthropic server tool (web search, web fetch, code execution) whose result has not arrived yet, or alongside a programmatic tool call that is still pending.
In that turn Anthropic requires the message answering the tool calls to hold
only tool_result blocks, and resumes the server tool on the request that
carries them. A list starting with a :user message merges text into that
message, which ends the turn early and, for a server tool the model called
directly, fails the request with a 400 naming the unresolved tool. A list
starting with an :assistant message keeps that message clean but puts turns
between the tool results and the server tool's resumption, which Anthropic
does not document as supported.
The step does not detect this case.
Roles
Only :user and :assistant can be expanded into.
:system is refused because a conversation carries at most one system
message: LangChain.Utils.split_system_message/2 raises on a second one, far
from the tool that caused it. :tool is refused because a tool message is
only meaningful against a tool call that asked for it.
Summary
Functions
Build a tool result that expands into messages, and return the {:ok, result}
tuple a tool body returns.
Whether a tool result carries an expansion that is ready to be applied.
Types
@type content() :: String.t() | LangChain.Message.ContentPart.t() | [LangChain.Message.ContentPart.t()]
@type t() :: %LangChain.MessageExpansion{ messages: [LangChain.Message.t()], result_content: nil | content() }
Functions
@spec expand(content(), [LangChain.Message.t()], keyword()) :: {:ok, LangChain.Message.ToolResult.t()} | no_return()
Build a tool result that expands into messages, and return the {:ok, result}
tuple a tool body returns.
fallback_content is the tool's whole answer in the shape a tool result can
hold. It is what the result says until the expansion is applied, and what the
model reads under a mode that never applies it. It is not a summary: a mode
without the step has to leave the model with the content, in worse wording,
rather than with a description of content it does not have.
def load_reference(%{"name" => name}, _context) do
{records, summary} = load(name)
LangChain.MessageExpansion.expand(
summary <> "\n\n" <> records,
[
Message.new_assistant!(records),
Message.new_user!("Use the records above to answer my question about #{name}.")
],
result_content: summary
)
endOptions
:result_content- what the result keeps once the messages are inserted. Defaults to a short note saying the content is in the conversation. Passnilto leavefallback_contentin place, which keeps the payload in two places and is rarely what an expansion is for.
The call identity is filled in for you
The result comes back with tool_call_id, name and display_text unset.
LangChain.Chains.LLMChain.execute_tool_call/3 assigns tool_call_id from
the call it dispatched, and falls back to the LangChain.Function's own
name and display_text, which is what it does for any ToolResult a tool
hands back.
A tool that wants different UI text for a particular call sets display_text
on the returned result; the fallback only applies while it is nil. A tool that
needs the id for its own bookkeeping reads context.tool_call_id, which
execute_tool_call/3 puts there before dispatch. Setting tool_call_id on
the result has no effect, because the call is the authority on it.
Code building a result outside a tool body has to assign all three itself.
@spec expandable?(LangChain.Message.ToolResult.t()) :: boolean()
Whether a tool result carries an expansion that is ready to be applied.
An interrupted result is excluded: its tool has not finished, and the turn is about to stop rather than continue.