Pipe-friendly building blocks for composing custom execution modes.
Every step function follows the pipeline convention:
- Input/output:
{:continue, chain}means keep processing - Any other tuple (
:ok,:pause,:error) is a terminal — passed through unchanged - Step functions take the pipeline result as first arg, optional config as second
Example: Custom Mode
defmodule MyApp.Modes.Simple do
@behaviour LangChain.Chains.LLMChain.Mode
import LangChain.Chains.LLMChain.Mode.Steps
@impl true
def run(chain, opts) do
chain = ensure_mode_state(chain)
{:continue, chain}
|> call_llm()
|> execute_tools()
|> check_max_runs(opts)
|> continue_or_done(&run/2, opts)
end
end
Summary
Functions
Call the LLM (single step). Wraps LLMChain.execute_step/1.
Check if max runs have been exceeded.
Check if execution should pause (e.g., node draining).
Check if any tool results in the most recent tool message are interrupts.
Check if a target tool was called in the most recent tool results.
Decide whether to loop or return.
Initialize mode_state in custom_context if not already present.
Execute pending tool calls. Wraps LLMChain.execute_tool_calls/1.
Expand any tool results that asked to, before the model is called.
Get the current run count from mode_state.
Set mode_state.run_count to 0, keeping any other mode_state keys.
Types
@type pipeline_result() :: {:continue, LangChain.Chains.LLMChain.t()} | {:ok, LangChain.Chains.LLMChain.t()} | {:ok, LangChain.Chains.LLMChain.t(), term()} | {:pause, LangChain.Chains.LLMChain.t()} | {:error, LangChain.Chains.LLMChain.t(), term()} | {:interrupt, LangChain.Chains.LLMChain.t(), term()}
Functions
Call the LLM (single step). Wraps LLMChain.execute_step/1.
On success, increments mode_state.run_count in custom_context.
Check if max runs have been exceeded.
Reads run_count from custom_context.mode_state and compares against
:max_runs in opts (default: 25).
Check if execution should pause (e.g., node draining).
Reads :should_pause? from opts — a zero-arity function that returns boolean.
Check if any tool results in the most recent tool message are interrupts.
Returns {:interrupt, chain, interrupt_data} if any tool results have
is_interrupt: true. The interrupt_data is extracted from the first
interrupted result (for single interrupts) or aggregated (for multiple).
This step is generic — it doesn't know why a tool interrupted. The consumer (e.g., Sagents) interprets the interrupt data.
Check if a target tool was called in the most recent tool results.
Reads :tool_names from opts — a list of tool name strings.
If a matching tool result is found, returns {:ok, chain, tool_result}.
Decide whether to loop or return.
{:continue, chain}withneeds_response: true→ callrun_fn.(chain, opts)(loop){:continue, chain}withneeds_response: false→{:ok, chain}(done)- Any terminal result → pass through as-is
Initialize mode_state in custom_context if not already present.
Call this at the top of your mode's run/2 to set up run_count tracking.
On the first call, creates mode_state: %{run_count: 0}.
On recursive calls (mode_state already exists), returns chain unchanged.
Execute pending tool calls. Wraps LLMChain.execute_tool_calls/1.
Expand any tool results that asked to, before the model is called.
A tool result can carry a LangChain.MessageExpansion asking for messages to
be inserted into the conversation and for the result's own content to be
trimmed once they are. This step is what honours that request. Placing it
immediately before call_llm/1 is what makes the guarantee the tool is
relying on true: the messages are in the conversation for the very next model
call, in the same run.
{:continue, chain}
|> expand_tool_results(opts)
|> call_llm()What it does
Reads chain.last_message. If it is a :tool message, every result carrying
an expansion is applied in tool-call order:
- The result's content becomes the expansion's
result_content, or is left alone when that is nil. - The expansion is cleared from the result, so applying the step twice inserts once.
- The expansion's messages are appended with
LLMChain.add_messages/2, which keepsmessages,exchanged_messages,last_messageandneeds_responsein agreement.
Any other pipeline result passes through untouched, including a terminal. A turn that interrupted or satisfied an until-tool contract is over, so nothing is inserted into it.
Where it must not go
Not after the steps that decide whether the run is over. check_until_tool/2,
Sagents.Mode.Steps.check_until_tool_success/2 and the chain's telemetry all
read chain.last_message to answer that question, and every one of them is
entitled to find a message the model produced or the tools returned there.
Expanding before the model call keeps that true; expanding after the terminal
checks would put a synthetic message under code that cannot tell the
difference.
Inserted messages fire no callbacks, so they produce no transcript rows. A
host that mirrors a conversation from :on_message_processed sees them when
it next reads the whole chain, not as they are inserted.
Get the current run count from mode_state.
Set mode_state.run_count to 0, keeping any other mode_state keys.
mode_state lives in the chain's custom_context, which persists when the
same chain is run again after a new message is added. A mode that bounds
each run with check_max_runs/2 calls this once on entry, before it starts
recursing, so the count covers the current run rather than the chain's
whole history.