Internal — use ALLM.step/3 / ALLM.stream_step/3 instead. See spec §17.
Executes a list of %ALLM.ToolCall{} values via the engine's tool
executor, encodes results via the tool result encoder, and returns
either:
- a list of
:tool-role%ALLM.Message{}values (non-streaming), or - a lazy stream of
ALLM.Eventvalues (streaming).
Both variants share execution logic: parallel dispatch via
Task.async_stream/5, per-tool timeout, and the on_tool_error
policy.
Phase 6 extension
stream_tool_calls/3 is a Phase 6 addition; spec §17 stipulates only
run_tool_calls/3 on this module. The streaming variant is required
by ALLM.stream_step/3's event-stream composition and shares
execute_one_tool/3 with its non-streaming sibling so both paths
exercise identical execution semantics (see PHASE_6_DESIGN.md Non-obvious
Decision #2).
Result ordering
run_tool_calls/3emits messages intool_callsinput order — results are sorted by input index before returning.stream_tool_calls/3emits events in completion order (Task.async_stream/5withordered: false).
The SET of messages is identical across both paths when compared after
sorting by :tool_call_id; only the emission ordering differs.
Sibling drain on halt
When a handler returns {:halt, _, _} or {:ask_user, _, _} — or when
on_tool_error: :halt fires on a failure — the runner continues
reducing Task.async_stream/5 until it naturally exhausts. Completed
siblings still emit their results; the first-observed halt wins for
the returned halt_metadata (see PHASE_6_DESIGN.md Non-obvious Decision #5).
Error policy
opts[:on_tool_error] is one of:
:continue(default) — the error is encoded as the tool-result content and the batch proceeds normally.:halt— the batch drains to completion and the final return is{:ok, msgs, halt_metadata}withhalted_reason: :tool_error.(ToolCall.t(), term() -> {:continue, term()} | :halt)(Phase 7) — called synchronously inside the per-tool task with the failing tool call and the error term.{:continue, replacement}encodesreplacementvia the encoder as the tool-result content;:halthalts the batch as if the atom form had been supplied. A function that raises or returns an invalid shape is wrapped as%ToolError{reason: :invalid_return}and routed as:haltWITHOUT re-invoking the function (recursion-avoidance).
Function-form semantics
See Phase 7 design Non-obvious Decision #8. Inside the per-tool
Task.async_stream/5 task, the runner resolves the function's return
to a concrete :continue / :halt decision FIRST, then delegates to
the same route_error/3 path used for atom-form on_tool_error. The
function reference is dropped from the dispatch context before the
delegated call, so a re-entry with the same function is impossible.
Reserved halt atoms
Per spec §5.2, the following atoms are reserved as orchestrator-owned
halt reasons and MUST NOT be used by handlers in {:halt, reason, _}
returns: :ask_user, :max_turns, :halt_when, :tool_error,
:cancelled, :completed. A handler that returns one is wrapped as
%ToolError{reason: :invalid_return, metadata: %{reserved_halt_atom: r}}
and routed via on_tool_error.
Summary
Functions
Execute a batch of tool calls synchronously and return :tool-role
messages in input order.
Execute a batch of tool calls and return a lazy stream of
ALLM.Event values. Events per tool call (in start order for each
id, interleaved across ids in completion order)
Types
@type halt_metadata() :: ask_user_metadata() | tool_halt_metadata() | tool_error_halt_metadata()
@type on_tool_error() :: :continue | :halt | (ALLM.ToolCall.t(), error_term :: term() -> {:continue, term()} | :halt)
@type run_opts() :: [ engine: ALLM.Engine.t() | nil, context: map(), request_id: String.t() | nil, session_id: String.t() | nil, tool_executor: module() | nil, tool_result_encoder: module() | nil, on_tool_error: on_tool_error(), tool_timeout: timeout(), max_concurrency: pos_integer() ]
@type run_outcome() :: {:ok, [ALLM.Message.t()]} | {:ok, [ALLM.Message.t()], halt_metadata()} | {:error, ALLM.Error.EngineError.t()}
@type tool_error_halt_metadata() :: %{ :halted_reason => :tool_error, :halt_tool_call_id => String.t(), optional(:on_tool_error_exception) => Exception.t() }
Functions
@spec run_tool_calls([ALLM.ToolCall.t()], [ALLM.Tool.t()], run_opts()) :: run_outcome()
Execute a batch of tool calls synchronously and return :tool-role
messages in input order.
Pre-flights the batch by looking up each %ToolCall{} name against
tools; if any lookup fails the function returns
{:error, %ALLM.Error.EngineError{reason: :unknown_tool}}
synchronously and no tool runs. An empty tool_calls list is a
short-circuit: {:ok, []}.
Opts
| Key | Default | Purpose |
|---|---|---|
:engine | nil | Engine for context / executor / encoder fallback. |
:tool_executor | engine.tool_executor || ALLM.ToolExecutor.Default | Override the executor module. |
:tool_result_encoder | engine.tool_result_encoder || ALLM.ToolResultEncoder.JSON | Override the encoder module. |
| :on_tool_error | :continue | :continue, :halt, or (tool_call, error -> {:continue, term} | :halt) (Phase 7). |
| :tool_timeout | 30_000 | Milliseconds before Task.async_stream/5 kills a task. Timed-out tasks surface as %ToolError{reason: :timeout}. |
| :max_concurrency | max(1, min(length(tool_calls), System.schedulers_online() * 2)) | Upper bound on concurrent handler invocations. |
| :context | engine.context | Passed to arity-2 handlers. |
| :session_id | nil | Phase 6 — session integration lands in Phase 8. |
| :request_id | nil | Forwarded from the adapter's Response.request_id. |
Error reason table
| Condition | Return |
|---|---|
One tool call's name is not in tools | {:error, %EngineError{reason: :unknown_tool, metadata: %{tool_name: name}}} |
Encoder raises Protocol.UndefinedError / Jason.EncodeError | Wrapped as %ToolError{reason: :encoding_failed} and routed via on_tool_error |
| Handler raises / exits / returns an invalid shape | %ToolError{reason: :handler_raised | :handler_exit | :invalid_return} (from the executor) routed via on_tool_error |
Handler exceeds tool_timeout | %ToolError{reason: :timeout} routed via on_tool_error |
on_tool_error is a function returning {:continue, replacement} | replacement encoded as tool-result content; batch continues. |
on_tool_error is a function returning :halt | Batch drains; final return {:ok, msgs, %{halted_reason: :tool_error, halt_tool_call_id: id}}. When the function form raises, the captured exception is also lifted into halt_metadata as :on_tool_error_exception. |
on_tool_error function returns invalid shape / raises | Wrapped as %ToolError{reason: :invalid_return}; routed as :halt. Function NOT re-invoked. |
on_tool_error is a function of arity ≠ 2 | Raises ArgumentError at run_tool_calls/3 entry. |
Examples
iex> engine = ALLM.Engine.new(adapter: ALLM.Providers.Fake)
iex> tool = ALLM.Tool.new(
...> name: "echo",
...> description: "",
...> schema: %{},
...> handler: fn args -> {:ok, args} end
...> )
iex> call = ALLM.ToolCall.new(id: "c0", name: "echo", arguments: %{"x" => 1})
iex> {:ok, [msg]} = ALLM.ToolRunner.run_tool_calls([call], [tool], engine: engine)
iex> msg.role
:tool
iex> msg.tool_call_id
"c0"
iex> Jason.decode!(msg.content)
%{"x" => 1}
@spec stream_tool_calls([ALLM.ToolCall.t()], [ALLM.Tool.t()], run_opts()) :: Enumerable.t()
Execute a batch of tool calls and return a lazy stream of
ALLM.Event values. Events per tool call (in start order for each
id, interleaved across ids in completion order):
{:tool_execution_started, %{id, name, arguments}}{:tool_execution_completed, %{id, name, result}}- One of:
{:tool_result_encoded, %{id, content}}— normal handler return (including{:error, _}routed viaon_tool_error){:ask_user_requested, %{tool_call_id, tool_name, question, opts}}— handler returned{:ask_user, _}/{:ask_user, _, _}{:tool_halt, %{tool_call_id, reason, result}}— handler returned{:halt, reason, result}
Pre-flight errors
On unknown tool (Invariant 2), the stream contains a single
{:error, %ALLM.Error.EngineError{reason: :unknown_tool}} element
and terminates. No tools execute.
Timeout semantics
A tool whose handler exceeds tool_timeout has its task killed by
Task.async_stream/5's :on_timeout: :kill_task option. The event
stream emits {:tool_execution_completed, %{result: {:error, %ToolError{reason: :timeout}}}} — no new :tool_execution_cancelled
variant (Phase 6 keeps the ALLM.Event closed union at 16 tags; see
PHASE_6_DESIGN.md Non-obvious Decision #7).
Empty short-circuit
stream_tool_calls([], _, _) returns Stream.concat([]) — an empty
enumerable — without invoking Task.async_stream/5.
Examples
iex> engine = ALLM.Engine.new(adapter: ALLM.Providers.Fake)
iex> tool = ALLM.Tool.new(
...> name: "echo",
...> description: "",
...> schema: %{},
...> handler: fn args -> {:ok, args} end
...> )
iex> call = ALLM.ToolCall.new(id: "c0", name: "echo", arguments: %{"x" => 1})
iex> events =
...> [call]
...> |> ALLM.ToolRunner.stream_tool_calls([tool], engine: engine)
...> |> Enum.to_list()
iex> Enum.map(events, &elem(&1, 0))
[:tool_execution_started, :tool_execution_completed, :tool_result_encoded]