# `ALLM.ToolRunner`
[🔗](https://github.com/cykod/ALLM/blob/v0.3.0/lib/allm/tool_runner.ex#L1)

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.Event` values (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/3` emits messages in `tool_calls` **input order**
    — results are sorted by input index before returning.
  * `stream_tool_calls/3` emits events in **completion order**
    (`Task.async_stream/5` with `ordered: 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}` with `halted_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}` encodes
    `replacement` via the encoder as the tool-result content;
    `:halt` halts 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 `:halt`
    WITHOUT 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`.

# `ask_user_metadata`

```elixir
@type ask_user_metadata() :: %{
  halted_reason: :ask_user,
  pending_question: String.t(),
  pending_tool_call_id: String.t(),
  ask_user_opts: keyword()
}
```

# `halt_metadata`

```elixir
@type halt_metadata() ::
  ask_user_metadata() | tool_halt_metadata() | tool_error_halt_metadata()
```

# `on_tool_error`

```elixir
@type on_tool_error() ::
  :continue
  | :halt
  | (ALLM.ToolCall.t(), error_term :: term() -&gt; {:continue, term()} | :halt)
```

# `run_opts`

```elixir
@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()
]
```

# `run_outcome`

```elixir
@type run_outcome() ::
  {:ok, [ALLM.Message.t()]}
  | {:ok, [ALLM.Message.t()], halt_metadata()}
  | {:error, ALLM.Error.EngineError.t()}
```

# `tool_error_halt_metadata`

```elixir
@type tool_error_halt_metadata() :: %{
  :halted_reason =&gt; :tool_error,
  :halt_tool_call_id =&gt; String.t(),
  optional(:on_tool_error_exception) =&gt; Exception.t()
}
```

# `tool_halt_metadata`

```elixir
@type tool_halt_metadata() :: %{
  halted_reason: atom(),
  halt_tool_call_id: String.t(),
  halt_result: term()
}
```

# `run_tool_calls`

```elixir
@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}

# `stream_tool_calls`

```elixir
@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 via `on_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]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
