Current section

Files

Jump to
alloy lib alloy tool executor.ex
Raw

lib/alloy/tool/executor.ex

defmodule Alloy.Tool.Executor do
@moduledoc """
Executes tool calls and returns result messages.
Supports parallel execution via `Task.async_stream` -
multiple tool calls in a single assistant response are
executed concurrently.
"""
alias Alloy.Agent.State
alias Alloy.Message
alias Alloy.Middleware
@doc """
Execute all tool calls and return a list of tool_result content blocks
wrapped in a single user message.
Runs `:before_tool_call` middleware before each tool. If middleware
returns `{:block, reason}`, that tool's result is an error block and
the tool is not executed. If middleware returns `{:halt, reason}`,
the entire execute_all call returns `{:halted, reason}` immediately
without executing any tools.
"""
@spec execute_all([map()], %{String.t() => module()}, State.t()) ::
Message.t() | {:halted, String.t()}
def execute_all(tool_calls, tool_fns, %State{} = state) do
case execute_all(tool_calls, tool_fns, state, on_event: fn _event -> :ok end) do
{:ok, result_msg, _tool_calls} -> result_msg
{:halted, _reason} = halted -> halted
end
end
@doc """
Execute all tool calls and return:
- tool result message (`Alloy.Message.tool_results/1`)
- per-tool execution metadata
Options:
- `:on_event` - callback called with `{:tool_start, payload}` and
`{:tool_end, payload}` events.
- `:event_seq_ref` - shared atomics counter for ordered tool events.
- `:event_correlation_id` - correlation id attached to tool events.
- `:event_turn` - turn number used in telemetry metadata.
"""
@spec execute_all([map()], %{String.t() => module()}, State.t(), keyword()) ::
{:ok, Message.t(), [map()]} | {:halted, String.t()}
def execute_all(tool_calls, tool_fns, %State{} = state, opts) when is_list(opts) do
context = build_context(state)
tool_timeout = state.config.tool_timeout
on_event = Keyword.get(opts, :on_event, fn _event -> :ok end)
event_seq_ref = Keyword.get(opts, :event_seq_ref, :atomics.new(1, signed: false))
event_correlation_id =
Keyword.get(opts, :event_correlation_id, default_event_correlation_id())
event_turn = Keyword.get(opts, :event_turn, state.turn)
# Check middleware before execution — short-circuit on halt
case tag_tool_calls(state, tool_calls) do
{:halted, reason} ->
{:halted, reason}
{:ok, tagged} ->
{results, tool_call_meta} =
tagged
|> Task.async_stream(
fn
{:execute, call} ->
execute_one(
call,
tool_fns,
context,
on_event,
event_seq_ref,
event_correlation_id,
event_turn
)
{:blocked, call, reason} ->
blocked_result(
call,
reason,
on_event,
event_seq_ref,
event_correlation_id,
event_turn
)
end,
timeout: tool_timeout,
ordered: true,
on_timeout: :kill_task
)
|> Enum.zip(tagged)
|> Enum.map(fn
{{:ok, {result, meta}}, _tag} ->
{result, meta}
{{:exit, reason}, tag} ->
call = call_from_tag(tag)
crashed_result(
call,
reason,
on_event,
event_seq_ref,
event_correlation_id,
event_turn
)
end)
|> Enum.unzip()
{:ok, Message.tool_results(results), tool_call_meta}
end
end
defp tag_tool_calls(state, tool_calls) do
result =
Enum.reduce_while(tool_calls, {:ok, []}, fn call, {:ok, acc} ->
case Middleware.run_before_tool_call(state, call) do
:ok -> {:cont, {:ok, [{:execute, call} | acc]}}
{:block, reason} -> {:cont, {:ok, [{:blocked, call, reason} | acc]}}
{:halted, reason} -> {:halt, {:halted, reason}}
end
end)
case result do
{:ok, tagged} -> {:ok, Enum.reverse(tagged)}
{:halted, _} = halted -> halted
end
end
defp execute_one(
call,
tool_fns,
context,
on_event,
event_seq_ref,
event_correlation_id,
event_turn
) do
started_at = System.monotonic_time(:millisecond)
start_event_seq =
emit_tool_start(on_event, call, event_seq_ref, event_correlation_id, event_turn)
tool_name = call[:name]
tool_use_id = call[:id]
input = call[:input] || %{}
{result, error} =
case Map.fetch(tool_fns, tool_name) do
{:ok, module} ->
try do
case module.execute(input, context) do
{:ok, result} ->
{Message.tool_result_block(tool_use_id, result), nil}
{:error, reason} ->
{Message.tool_result_block(tool_use_id, reason, true), reason}
end
rescue
e ->
error = "Tool crashed: #{Exception.message(e)}"
{Message.tool_result_block(tool_use_id, error, true), error}
end
:error ->
error = "Unknown tool: #{tool_name}"
{error_result(tool_use_id, error), error}
end
duration_ms = elapsed_ms(started_at)
base_meta = tool_call_metadata(call, duration_ms, error)
end_event_seq =
emit_tool_end(
on_event,
base_meta,
event_seq_ref,
event_correlation_id,
event_turn,
start_event_seq
)
meta =
Map.merge(base_meta, %{
correlation_id: event_correlation_id,
start_event_seq: start_event_seq,
end_event_seq: end_event_seq
})
{result, meta}
end
defp blocked_result(call, reason, on_event, event_seq_ref, event_correlation_id, event_turn) do
started_at = System.monotonic_time(:millisecond)
start_event_seq =
emit_tool_start(on_event, call, event_seq_ref, event_correlation_id, event_turn)
error = "Blocked: #{reason}"
duration_ms = elapsed_ms(started_at)
base_meta = tool_call_metadata(call, duration_ms, error)
end_event_seq =
emit_tool_end(
on_event,
base_meta,
event_seq_ref,
event_correlation_id,
event_turn,
start_event_seq
)
meta =
Map.merge(base_meta, %{
correlation_id: event_correlation_id,
start_event_seq: start_event_seq,
end_event_seq: end_event_seq
})
{error_result(call[:id], error), meta}
end
defp crashed_result(call, reason, on_event, event_seq_ref, event_correlation_id, event_turn) do
error = "Tool execution crashed: #{inspect(reason)}"
base_meta = tool_call_metadata(call, 0, error)
end_event_seq =
emit_tool_end(
on_event,
base_meta,
event_seq_ref,
event_correlation_id,
event_turn,
nil
)
meta =
Map.merge(base_meta, %{
correlation_id: event_correlation_id,
start_event_seq: nil,
end_event_seq: end_event_seq
})
{error_result(call[:id], error), meta}
end
defp call_from_tag({:execute, call}), do: call
defp call_from_tag({:blocked, call, _reason}), do: call
defp tool_call_metadata(call, duration_ms, error) do
%{
id: call[:id],
name: call[:name],
input: call[:input] || %{},
duration_ms: duration_ms,
error: error
}
end
defp emit_tool_start(on_event, call, event_seq_ref, correlation_id, turn) do
event_seq = next_event_seq(event_seq_ref)
payload = %{
id: call[:id],
name: call[:name],
input: call[:input] || %{},
event_seq: event_seq,
correlation_id: correlation_id
}
on_event.({:tool_start, payload})
:telemetry.execute(
[:alloy, :tool, :start],
%{event_seq: event_seq},
%{
correlation_id: correlation_id,
turn: turn,
tool_id: call[:id],
tool_name: call[:name]
}
)
event_seq
end
defp emit_tool_end(on_event, meta, event_seq_ref, correlation_id, turn, start_event_seq) do
event_seq = next_event_seq(event_seq_ref)
payload =
Map.merge(meta, %{
event_seq: event_seq,
correlation_id: correlation_id,
start_event_seq: start_event_seq
})
on_event.({:tool_end, payload})
:telemetry.execute(
[:alloy, :tool, :stop],
%{event_seq: event_seq, duration_ms: meta.duration_ms || 0},
%{
correlation_id: correlation_id,
turn: turn,
tool_id: meta.id,
tool_name: meta.name,
error: meta.error,
start_event_seq: start_event_seq
}
)
event_seq
end
defp next_event_seq(ref) do
:atomics.add_get(ref, 1, 1)
end
defp default_event_correlation_id do
"run_" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
end
defp elapsed_ms(started_at) do
elapsed = System.monotonic_time(:millisecond) - started_at
max(elapsed, 0)
end
defp error_result(tool_use_id, message) do
Message.tool_result_block(tool_use_id, message, true)
end
defp build_context(%State{} = state) do
Map.merge(state.config.context, %{
working_directory: state.config.working_directory,
config: state.config,
scratchpad_pid: state.scratchpad
})
end
end