Files
deepset-ai--haystack/haystack/components/agents/tool_calling.py
T
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

668 lines
29 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import contextvars
import inspect
import json
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from haystack import logging, tracing
from haystack.components.agents.state.state import State
from haystack.core.component.sockets import Sockets
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.dataclasses.streaming_chunk import StreamingCallbackT, StreamingChunk, _invoke_streaming_callback
from haystack.tools import ComponentTool, Tool, ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets
from haystack.tools.errors import ToolInvocationError
from haystack.tools.parameters_schema_utils import _unwrap_optional
from haystack.tracing.utils import _serializable_value
logger = logging.getLogger(__name__)
class _AllStateKeys:
"""Sentinel representing "every state key", used for tools that receive the full State object."""
def __repr__(self) -> str:
return "<all state keys>"
_ALL_STATE_KEYS = _AllStateKeys()
# A set of state keys, or the _ALL_STATE_KEYS sentinel meaning "every key".
_StateKeys = set[str] | _AllStateKeys
class ToolNotFoundException(Exception):
"""Exception raised when a tool is not found in the list of available tools."""
def __init__(self, tool_name: str, available_tools: list[str]) -> None:
message = f"Tool '{tool_name}' not found. Available tools: {', '.join(available_tools)}"
super().__init__(message)
def _validate_and_prepare_tools(tools: ToolsType) -> dict[str, Tool]:
"""
Flatten, deduplicate-check, and index tools by name.
:raises ValueError: If no tools are provided or if duplicate tool names are found.
"""
if not tools:
raise ValueError("Tool execution requires at least one tool.")
available_tools = flatten_tools_or_toolsets(tools)
_check_duplicate_tool_names(available_tools)
tool_names = [tool.name for tool in available_tools]
return dict(zip(tool_names, available_tools, strict=True))
def _merge_tool_outputs_into_state(tool: Tool, result: Any, state: State) -> None:
"""
Write tool outputs into State according to the tool's `outputs_to_state` mapping.
:raises RuntimeError: If writing an output value into the state fails.
"""
if not isinstance(result, dict):
return
for state_key, config in (tool.outputs_to_state or {}).items():
source_key = config.get("source", None)
if source_key and source_key not in result:
continue
output_value = result.get(source_key) if source_key else result
try:
state.set(state_key, output_value, handler_override=config.get("handler"))
except Exception as e:
raise RuntimeError(f"Tool '{tool.name}': failed to merge outputs into state. {e}") from e
def _result_to_string(result: Any) -> str:
"""
Convert a tool result to a string.
Strings are returned as-is; all other types are passed through a JSON serialization step to produce more readable
output, with a fallback to plain str() conversion if serialization fails.
:param result: The tool result to convert.
:returns: A string representation of the tool result.
"""
if isinstance(result, str):
return result
serializable = _serializable_value(value=result, use_placeholders=False)
try:
return json.dumps(serializable, ensure_ascii=False)
except Exception as error:
logger.warning(
"Tool result is not JSON serializable. Falling back to str conversion. Result: {result}\nError: {err}",
result=result,
err=error,
)
return str(result)
def _process_tool_output(config: dict[str, Any], result: Any, tool_call: ToolCall, *, raise_on_failure: bool) -> Any:
"""
Extract and convert a single tool output according to `config`.
`config` may contain `source` (key to extract from result dict), `handler` (conversion callable), and
`raw_result` (return the value without string conversion).
If a configured `handler` raises, the exception is re-raised when `raise_on_failure` is True; otherwise
a warning is logged and the value is converted via `_result_to_string`.
"""
source_key = config.get("source")
value = result.get(source_key) if source_key is not None and isinstance(result, dict) else result
handler = config.get("handler")
raw_result = config.get("raw_result", False)
if handler is None:
# raw result is mostly used to allow ImageContent or TextContent blocks to be directly returned and consumed
# by ChatMessage.from_tool without string conversion.
if raw_result:
return value
return _result_to_string(value)
try:
return handler(value)
except Exception as e:
if raise_on_failure:
raise
logger.warning(
"Output handler '{handler}' for tool '{tool}' failed, falling back to string conversion. Error: {err}",
handler=handler.__name__,
tool=tool_call.tool_name,
err=e,
)
return _result_to_string(value)
def _build_tool_result_message(result: Any, tool_call: ToolCall, tool: Tool, *, raise_on_failure: bool) -> ChatMessage:
"""Convert a raw tool result into a ChatMessage, applying `outputs_to_string` config if present."""
outputs_config = tool.outputs_to_string or {}
# Single-output config (or no config): keys are at the root level
if not outputs_config or any(k in outputs_config for k in ("source", "handler", "raw_result")):
tool_result = _process_tool_output(outputs_config, result, tool_call, raise_on_failure=raise_on_failure)
return ChatMessage.from_tool(tool_result=tool_result, origin=tool_call)
# Multi-output config: each key maps to its own sub-config — stringify each value, then stringify the whole dict
tool_result_dict = {
output_key: _process_tool_output(
{**cfg, "raw_result": False}, result, tool_call, raise_on_failure=raise_on_failure
)
for output_key, cfg in outputs_config.items()
}
return ChatMessage.from_tool(tool_result=_result_to_string(tool_result_dict), origin=tool_call)
def _create_tool_result_streaming_chunk(tool_message: ChatMessage, tool_call: ToolCall, index: int) -> StreamingChunk:
"""
Create a streaming chunk that carries a tool result.
:param tool_message: The tool result message to stream.
:param tool_call: The ToolCall object that triggered the tool invocation.
:param index: The position of this tool result in the stream (in execution order).
:returns: A StreamingChunk containing the tool result and metadata about the tool call.
"""
return StreamingChunk(
content="",
index=index,
tool_call_result=tool_message.tool_call_results[0],
start=True,
meta={"tool_result": tool_message.tool_call_results[0].result, "tool_call": tool_call},
)
def _create_tool_span(tool: Tool, tool_call: ToolCall) -> Any:
"""
Create one tracing span for a single tool call, nested under the currently active span.
The established standard for tracing agents is one span per tool call, so each call gets its own span rather than
grouping all of a step's calls together. The OpenTelemetry GenAI semantic conventions codify this with a dedicated
"execute tool" span per invocation
(https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md#execute-tool-span),
and tracing backends such as Langfuse follow the same model. The span is tagged with the tool's identity; the caller
adds the call arguments and result as content tags.
"""
return tracing.tracer.trace(
"haystack.agent.step.tool",
tags={"haystack.tool.name": tool_call.tool_name, "haystack.tool.description": tool.description},
parent_span=tracing.tracer.current_span(),
)
def _make_context_bound_invoke(tool: Tool, args: dict[str, Any], tool_call: ToolCall) -> Callable[[], Any]:
"""
Return a zero-arg callable that runs `tool.invoke(**args)` under the current contextvars snapshot.
This preserves tracing spans and other context-local state across thread-pool boundaries, so the per-call span
created inside the worker nests correctly under the step span. The callable returns a ToolInvocationError instead
of raising so that parallel executions can collect failures without aborting the whole batch.
"""
ctx = contextvars.copy_context()
def _invoke() -> Any:
with _create_tool_span(tool, tool_call) as span:
span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments)
try:
result = tool.invoke(**args)
except ToolInvocationError as e:
span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)})
return e
span.set_content_tag("haystack.agent.step.tool.output", result)
return result
def _runner() -> Any:
return ctx.run(_invoke)
return _runner
def _make_bounded_invoke_async(
tool: Tool, args: dict[str, Any], semaphore: asyncio.Semaphore, tool_call: ToolCall
) -> Callable[[], Any]:
"""
Return a zero-arg async callable that awaits `tool.invoke_async(**args)` while holding `semaphore`.
Concurrency is bounded uniformly across native-async tools and sync-fallback tools (which dispatch
to a worker thread inside `Tool.invoke_async`). ContextVars naturally inherit into child tasks for
the native-async branch, and `asyncio.to_thread` propagates them for the fallback branch, so the per-call
span nests correctly under the step span.
Returns a `ToolInvocationError` instead of raising so that gathered executions can collect failures
without aborting the whole batch.
"""
async def _runner() -> Any:
async with semaphore:
with _create_tool_span(tool, tool_call) as span:
span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments)
try:
result = await tool.invoke_async(**args)
except ToolInvocationError as e:
span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)})
return e
span.set_content_tag("haystack.agent.step.tool.output", result)
return result
return _runner
def _get_func_params(tool: Tool) -> dict[str, Any]:
"""
Return parameter names → annotations for a tool's invocation function.
- For ComponentTool, this is the annotated input schema defined on the underlying component.
- For regular Tools, this is the function signature of the `function` callable, falling back to `async_function`
for async-only tools.
:param tool: The tool to inspect.
:returns: A dict mapping parameter names to their type annotations.
"""
if isinstance(tool, ComponentTool):
assert hasattr(tool._component, "__haystack_input__") and isinstance(
tool._component.__haystack_input__, Sockets
)
return {name: socket.type for name, socket in tool._component.__haystack_input__._sockets_dict.items()}
# Tool.__post_init__ guarantees that at least one of `function` / `async_function` is set.
target = tool.function if tool.function is not None else tool.async_function
return {name: param.annotation for name, param in inspect.signature(target).parameters.items()} # type: ignore[arg-type]
def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]:
"""
Merge LLM-provided arguments with state-sourced arguments.
LLM args take precedence. State values are pulled in only for the keys a tool explicitly declares via its
`inputs_from_state` mapping, then the live State object is injected for any param annotated as State.
:param tool: The tool being invoked, used to determine parameter mappings and State injection.
:param llm_args: The arguments provided by the LLM, which take precedence over state values.
:param state: The current runtime state, used to source additional arguments as needed.
:returns: A dict of arguments to invoke the tool with, combining LLM and state values according to the rules
described above.
"""
final_args = dict(llm_args)
func_params = _get_func_params(tool)
# A tool reads from State by name only via an explicit `inputs_from_state` mapping
for state_key, param_name in (tool.inputs_from_state or {}).items():
if param_name not in final_args and state.has(state_key):
final_args[param_name] = state.get(state_key)
# We also inject the full State object for any parameter annotated as State
for param_name, param_type in func_params.items():
if _unwrap_optional(param_type) is State:
final_args[param_name] = state
return final_args
def _prepare_tool_args(
*,
tool: Tool,
tool_call_arguments: dict[str, Any],
state: State,
streaming_callback: StreamingCallbackT | None = None,
enable_streaming_passthrough: bool = False,
) -> dict[str, Any]:
"""
Prepare the final arguments for a tool by injecting state inputs and optionally a streaming callback.
:param tool:
The tool instance to prepare arguments for.
:param tool_call_arguments:
The initial arguments provided for the tool call.
:param state:
The current state containing inputs to be injected into the tool arguments.
:param streaming_callback:
Optional streaming callback to be injected if enabled and applicable.
:param enable_streaming_passthrough:
Flag indicating whether to inject the streaming callback into the tool arguments.
:returns:
A dictionary of final arguments ready for tool invocation.
"""
# Combine user + state inputs
final_args = _inject_state_args(tool, tool_call_arguments.copy(), state)
# Check whether to inject streaming_callback
if (
enable_streaming_passthrough
and streaming_callback is not None
and "streaming_callback" not in final_args
and "streaming_callback" in _get_func_params(tool)
):
final_args["streaming_callback"] = streaming_callback
return final_args
def _resolve_tool_calls(
messages_with_tool_calls: list[ChatMessage], tools_with_names: dict[str, Tool], *, raise_on_failure: bool
) -> tuple[list[ToolCall], list[Tool], list[ChatMessage]]:
"""
Walk all tool calls in `messages_with_tool_calls` and resolve each to its Tool.
Argument preparation is deliberately *not* done here: args are prepared per execution batch (see
`_schedule_tool_calls`) so that a tool reading from State observes writes made by tools that ran earlier in the same
step.
:returns: (tool_calls, resolved_tools, error_messages)
- tool_calls: ToolCall objects for each valid call, in call order
- resolved_tools: the resolved Tool for each entry in `tool_calls` (parallel list)
- error_messages: ChatMessages for tool-not-found errors (when raise_on_failure is False)
"""
tool_calls: list[ToolCall] = []
resolved_tools: list[Tool] = []
error_messages: list[ChatMessage] = []
for message in messages_with_tool_calls:
for tool_call in message.tool_calls:
tool_name = tool_call.tool_name
if tool_name not in tools_with_names:
error = ToolNotFoundException(tool_name, list(tools_with_names.keys()))
if raise_on_failure:
raise error
logger.error("{error_exception}", error_exception=error)
error_messages.append(ChatMessage.from_tool(tool_result=str(error), origin=tool_call, error=True))
continue
tool_calls.append(tool_call)
resolved_tools.append(tools_with_names[tool_name])
return tool_calls, resolved_tools, error_messages
def _keys_intersect(a: _StateKeys, b: _StateKeys) -> bool:
"""
Return whether two State-key sets share at least one key, treating `_ALL_STATE_KEYS` as a wildcard.
Used to detect read-after-write dependencies between tool calls: the reader's read set is tested against the
writer's write set.
:param a: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key".
:param b: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key".
:returns: True if the sets overlap (a wildcard overlaps any non-empty set, and two wildcards always overlap).
"""
if a is _ALL_STATE_KEYS:
# `a` covers every key, so it overlaps `b` as long as `b` touches any key. Two wildcards always overlap;
# otherwise `bool(b)` is True iff the concrete set `b` is non-empty.
return b is _ALL_STATE_KEYS or bool(b)
if b is _ALL_STATE_KEYS:
# Symmetric case: wildcard `b` overlaps `a` iff the concrete set `a` is non-empty (`bool(set)` == non-empty).
return bool(a)
# Both are concrete sets: they overlap iff their set intersection is non-empty.
return bool(a & b) # type: ignore[operator]
def _state_io_for_call(tool: Tool, llm_args: dict[str, Any]) -> tuple[_StateKeys, _StateKeys]:
"""
Compute the State keys a tool call reads from and writes to.
Mirrors the resolution logic in `_inject_state_args`:
- A tool with a `State`-annotated parameter can read/write any key, so both sets are the `_ALL_STATE_KEYS` wildcard.
- Otherwise reads come from the tool's explicit `inputs_from_state` mapping, excluding any parameter the LLM already
supplied (LLM args take precedence and short-circuit the state lookup).
- Writes are the keys in `outputs_to_state`.
:returns: A `(reads, writes)` tuple of state-key sets (or the `_ALL_STATE_KEYS` wildcard).
"""
func_params = _get_func_params(tool)
# Check if State is in func_params
if any(_unwrap_optional(param_type) is State for param_type in func_params.values()):
return _ALL_STATE_KEYS, _ALL_STATE_KEYS
# Calculate reads
param_mappings = tool.inputs_from_state or {}
reads = {state_key for state_key, param_name in param_mappings.items() if param_name not in llm_args}
# Calculate writes
writes = set((tool.outputs_to_state or {}).keys())
return reads, writes
def _schedule_tool_calls(tool_calls: list[ToolCall], tools: list[Tool]) -> list[list[int]]:
"""
Group tool calls into ordered execution batches based on their State read/write sets.
Calls within a batch are mutually independent and run in parallel; batches run sequentially. The schedule guarantees
that a call reading a State key always runs in a later batch than any call (in the same step) that writes that
key — so read-after-write dependencies are honored regardless of the order the LLM requested the calls in.
This is a layered topological sort: each round, every call whose dependencies have all been scheduled forms the
next parallel batch. Dependency cycles — e.g. a tool that both reads and writes the same key, requested more than
once — cannot be ordered by the read-after-write rule alone, so they are broken deterministically by call order
(the lowest-index remaining call runs next, on its own).
Pure write-write overlaps create no dependency: nobody reads the contended key, and outputs are merged into State
sequentially in call order afterward, so the result stays deterministic without serializing execution.
:param tool_calls: The tool calls to schedule, in call order.
:param tools: The resolved Tool for each entry in `tool_calls` (parallel list).
:returns: A list of batches, each a list of indices into `tool_calls`.
"""
# Per-call (reads, writes) State-key sets, in call order.
io_list = [_state_io_for_call(tool, tc.arguments) for tc, tool in zip(tool_calls, tools, strict=True)]
n = len(io_list)
# deps[j] = indices that must run before j because j reads a key they write (read-after-write).
deps: list[set[int]] = [set() for _ in range(n)]
for j in range(n):
reads_j, _ = io_list[j]
for i in range(n):
if i == j:
continue
_, writes_i = io_list[i]
if _keys_intersect(reads_j, writes_i):
deps[j].add(i)
scheduled = [False] * n
done: set[int] = set()
batches: list[list[int]] = []
while len(done) < n:
# A call is ready once every writer it depends on has already been scheduled (`deps[k] <= done`, i.e. its
# dependency set is a subset of the already-done set). All ready calls have no dependency on each other —
# if one read a key another writes, it would still be waiting — so the whole `ready` list runs in parallel.
ready = [k for k in range(n) if not scheduled[k] and deps[k] <= done]
if not ready:
# A dependency cycle remains: break it deterministically by running the lowest-index call next.
ready = [next(k for k in range(n) if not scheduled[k])]
for k in ready:
scheduled[k] = True
done.update(ready)
batches.append(ready)
return batches
def _finalize_tool_result(
result: Any, tool_call: ToolCall, tool: Tool, state: State, *, raise_on_failure: bool
) -> ChatMessage:
"""
Turn a single tool invocation result into a tool-result ChatMessage, merging outputs into State.
On a `ToolInvocationError`, either re-raise (when `raise_on_failure`) or return an error message. Otherwise
merge the tool's outputs into State (in call order, so write-write merges stay deterministic) and build the
result message.
"""
if isinstance(result, ToolInvocationError):
if raise_on_failure:
raise result
logger.error("{error_exception}", error_exception=result)
return ChatMessage.from_tool(tool_result=str(result), origin=tool_call, error=True)
_merge_tool_outputs_into_state(tool, result, state)
return _build_tool_result_message(result, tool_call, tool, raise_on_failure=raise_on_failure)
def _run_tool(
*,
messages: list[ChatMessage],
state: State,
tools: ToolsType,
streaming_callback: StreamingCallbackT | None = None,
raise_on_failure: bool = True,
enable_streaming_callback_passthrough: bool = False,
max_workers: int = 4,
) -> tuple[list[ChatMessage], State]:
"""
Invoke all tools referenced by tool calls in `messages`.
:param messages: ChatMessage objects that may contain tool calls.
:param state: Runtime state passed to and updated by tools.
:param tools: The tools available for invocation.
:param streaming_callback: Called once per tool result as it becomes available.
:param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message.
:param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it.
:param max_workers: Maximum number of parallel tool invocations.
:returns: (tool_messages, updated_state)
"""
tools_with_names = _validate_and_prepare_tools(tools)
messages_with_tool_calls = [m for m in messages if m.tool_calls]
if not messages_with_tool_calls:
return [], state
tool_calls, resolved_tools, error_messages = _resolve_tool_calls(
messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure
)
if not tool_calls:
return error_messages, state
# Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`).
batches = _schedule_tool_calls(tool_calls, resolved_tools)
# Results are indexed by call position so the returned messages stay in call order, even though batches may
# execute the calls in a different order.
results: list[ChatMessage | None] = [None] * len(tool_calls)
stream_index = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for batch in batches:
# Prepare args at the start of each batch so tools that read from State observe writes merged by earlier
# batches.
futures = {}
for idx in batch:
args = _prepare_tool_args(
tool=resolved_tools[idx],
tool_call_arguments=tool_calls[idx].arguments,
state=state,
streaming_callback=streaming_callback,
enable_streaming_passthrough=enable_streaming_callback_passthrough,
)
futures[idx] = executor.submit(_make_context_bound_invoke(resolved_tools[idx], args, tool_calls[idx]))
# Merge results in call order within the batch so write-write merges stay deterministic.
for idx in batch:
message = _finalize_tool_result(
futures[idx].result(),
tool_calls[idx],
resolved_tools[idx],
state,
raise_on_failure=raise_on_failure,
)
results[idx] = message
if streaming_callback is not None:
streaming_callback(_create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index))
stream_index += 1
tool_messages = error_messages + [m for m in results if m is not None]
# We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream.
if tool_messages and streaming_callback is not None:
streaming_callback(
StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"})
)
return tool_messages, state
async def _run_tool_async(
*,
messages: list[ChatMessage],
state: State,
tools: ToolsType,
streaming_callback: StreamingCallbackT | None = None,
raise_on_failure: bool = True,
enable_streaming_callback_passthrough: bool = False,
max_workers: int = 4,
) -> tuple[list[ChatMessage], State]:
"""
Asynchronous variant of `run_tool`. Tool calls execute concurrently via a thread pool.
:param messages: ChatMessage objects that may contain tool calls.
:param state: Runtime state passed to and updated by tools.
:param tools: The tools available for invocation.
:param streaming_callback: Async callback called once per tool result.
:param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message.
:param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it.
:param max_workers: Maximum number of parallel tool invocations.
:returns: (tool_messages, updated_state)
"""
tools_with_names = _validate_and_prepare_tools(tools)
messages_with_tool_calls = [m for m in messages if m.tool_calls]
if not messages_with_tool_calls:
return [], state
tool_calls, resolved_tools, error_messages = _resolve_tool_calls(
messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure
)
if not tool_calls:
return error_messages, state
# Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`).
batches = _schedule_tool_calls(tool_calls, resolved_tools)
# Results are indexed by call position so the returned messages stay in call order, even though batches may
# execute the calls in a different order.
results: list[ChatMessage | None] = [None] * len(tool_calls)
stream_index = 0
# `max_workers` + Semaphore bounds concurrency for both sync and async tool calls async tools are awaited directly,
# and sync tools are dispatched to a worker thread inside `Tool.invoke_async`.
semaphore = asyncio.Semaphore(max_workers)
for batch in batches:
# Prepare args at the start of each batch so readers observe writes merged by earlier batches.
tasks = {}
for idx in batch:
args = _prepare_tool_args(
tool=resolved_tools[idx],
tool_call_arguments=tool_calls[idx].arguments,
state=state,
streaming_callback=streaming_callback,
enable_streaming_passthrough=enable_streaming_callback_passthrough,
)
tasks[idx] = _make_bounded_invoke_async(resolved_tools[idx], args, semaphore, tool_calls[idx])()
batch_results = await asyncio.gather(*tasks.values())
# Merge results in call order within the batch so write-write merges stay deterministic.
for idx, result in zip(tasks.keys(), batch_results, strict=True):
message = _finalize_tool_result(
result, tool_calls[idx], resolved_tools[idx], state, raise_on_failure=raise_on_failure
)
results[idx] = message
if streaming_callback is not None:
await _invoke_streaming_callback(
streaming_callback, _create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index)
)
stream_index += 1
tool_messages = error_messages + [m for m in results if m is not None]
# We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream.
if tool_messages and streaming_callback is not None:
await _invoke_streaming_callback(
streaming_callback,
StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"}),
)
return tool_messages, state