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
668 lines
29 KiB
Python
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
|