Coverage for haystack/components/agents/tool_calling.py: 99%

252 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import asyncio 

6import contextvars 

7import json 

8from collections.abc import Callable 

9from concurrent.futures import ThreadPoolExecutor 

10from typing import Any 

11 

12from haystack import logging, tracing 

13from haystack.components.agents.state.state import State 

14from haystack.core.component.sockets import Sockets 

15from haystack.core.type_utils import _resolve_parameter_types 

16from haystack.dataclasses import ChatMessage, ToolCall 

17from haystack.dataclasses.streaming_chunk import StreamingCallbackT, StreamingChunk, _invoke_streaming_callback 

18from haystack.tools import ComponentTool, Tool, ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets 

19from haystack.tools.errors import ToolInvocationError 

20from haystack.tools.parameters_schema_utils import _is_state_annotation 

21from haystack.tracing.utils import _serializable_value 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class _AllStateKeys: 

27 """Sentinel representing "every state key", used for tools that receive the full State object.""" 

28 

29 def __repr__(self) -> str: 

30 return "<all state keys>" 

31 

32 

33_ALL_STATE_KEYS = _AllStateKeys() 

34 

35# A set of state keys, or the _ALL_STATE_KEYS sentinel meaning "every key". 

36_StateKeys = set[str] | _AllStateKeys 

37 

38 

39class ToolNotFoundException(Exception): 

40 """Exception raised when a tool is not found in the list of available tools.""" 

41 

42 def __init__(self, tool_name: str, available_tools: list[str]) -> None: 

43 message = f"Tool '{tool_name}' not found. Available tools: {', '.join(available_tools)}" 

44 super().__init__(message) 

45 

46 

47def _validate_and_prepare_tools(tools: ToolsType) -> dict[str, Tool]: 

48 """ 

49 Flatten, deduplicate-check, and index tools by name. 

50 

51 :raises ValueError: If no tools are provided or if duplicate tool names are found. 

52 """ 

53 if not tools: 

54 raise ValueError("Tool execution requires at least one tool.") 

55 

56 available_tools = flatten_tools_or_toolsets(tools) 

57 _check_duplicate_tool_names(available_tools) 

58 tool_names = [tool.name for tool in available_tools] 

59 

60 return dict(zip(tool_names, available_tools, strict=True)) 

61 

62 

63def _merge_tool_outputs_into_state(tool: Tool, result: Any, state: State) -> None: 

64 """ 

65 Write tool outputs into State according to the tool's `outputs_to_state` mapping. 

66 

67 :raises RuntimeError: If writing an output value into the state fails. 

68 """ 

69 if not isinstance(result, dict): 

70 return 

71 

72 for state_key, config in (tool.outputs_to_state or {}).items(): 

73 source_key = config.get("source", None) 

74 if source_key and source_key not in result: 

75 continue 

76 output_value = result.get(source_key) if source_key else result 

77 try: 

78 state.set(state_key, output_value, handler_override=config.get("handler")) 

79 except Exception as e: 

80 raise RuntimeError(f"Tool '{tool.name}': failed to merge outputs into state. {e}") from e 

81 

82 

83def _result_to_string(result: Any) -> str: 

84 """ 

85 Convert a tool result to a string. 

86 

87 Strings are returned as-is; all other types are passed through a JSON serialization step to produce more readable 

88 output, with a fallback to plain str() conversion if serialization fails. 

89 

90 :param result: The tool result to convert. 

91 :returns: A string representation of the tool result. 

92 """ 

93 if isinstance(result, str): 

94 return result 

95 serializable = _serializable_value(value=result, use_placeholders=False) 

96 try: 

97 return json.dumps(serializable, ensure_ascii=False) 

98 except Exception as error: 

99 logger.warning( 

100 "Tool result is not JSON serializable. Falling back to str conversion. Result: {result}\nError: {err}", 

101 result=result, 

102 err=error, 

103 ) 

104 return str(result) 

105 

106 

107def _process_tool_output(config: dict[str, Any], result: Any, tool_call: ToolCall, *, raise_on_failure: bool) -> Any: 

108 """ 

109 Extract and convert a single tool output according to `config`. 

110 

111 `config` may contain `source` (key to extract from result dict), `handler` (conversion callable), and 

112 `raw_result` (return the value without string conversion). 

113 

114 If a configured `handler` raises, the exception is re-raised when `raise_on_failure` is True; otherwise 

115 a warning is logged and the value is converted via `_result_to_string`. 

116 """ 

117 source_key = config.get("source") 

118 value = result.get(source_key) if source_key is not None and isinstance(result, dict) else result 

119 

120 handler = config.get("handler") 

121 raw_result = config.get("raw_result", False) 

122 

123 if handler is None: 

124 # raw result is mostly used to allow ImageContent or TextContent blocks to be directly returned and consumed 

125 # by ChatMessage.from_tool without string conversion. 

126 if raw_result: 

127 return value 

128 return _result_to_string(value) 

129 

130 try: 

131 return handler(value) 

132 except Exception as e: 

133 if raise_on_failure: 

134 raise 

135 logger.warning( 

136 "Output handler '{handler}' for tool '{tool}' failed, falling back to string conversion. Error: {err}", 

137 handler=handler.__name__, 

138 tool=tool_call.tool_name, 

139 err=e, 

140 ) 

141 return _result_to_string(value) 

142 

143 

144def _build_tool_result_message(result: Any, tool_call: ToolCall, tool: Tool, *, raise_on_failure: bool) -> ChatMessage: 

145 """Convert a raw tool result into a ChatMessage, applying `outputs_to_string` config if present.""" 

146 outputs_config = tool.outputs_to_string or {} 

147 

148 # Single-output config (or no config): keys are at the root level 

149 if not outputs_config or any(k in outputs_config for k in ("source", "handler", "raw_result")): 

150 tool_result = _process_tool_output(outputs_config, result, tool_call, raise_on_failure=raise_on_failure) 

151 return ChatMessage.from_tool(tool_result=tool_result, origin=tool_call) 

152 

153 # Multi-output config: each key maps to its own sub-config — stringify each value, then stringify the whole dict 

154 tool_result_dict = { 

155 output_key: _process_tool_output( 

156 {**cfg, "raw_result": False}, result, tool_call, raise_on_failure=raise_on_failure 

157 ) 

158 for output_key, cfg in outputs_config.items() 

159 } 

160 return ChatMessage.from_tool(tool_result=_result_to_string(tool_result_dict), origin=tool_call) 

161 

162 

163def _create_tool_result_streaming_chunk(tool_message: ChatMessage, tool_call: ToolCall, index: int) -> StreamingChunk: 

164 """ 

165 Create a streaming chunk that carries a tool result. 

166 

167 :param tool_message: The tool result message to stream. 

168 :param tool_call: The ToolCall object that triggered the tool invocation. 

169 :param index: The position of this tool result in the stream (in execution order). 

170 :returns: A StreamingChunk containing the tool result and metadata about the tool call. 

171 """ 

172 return StreamingChunk( 

173 content="", 

174 index=index, 

175 tool_call_result=tool_message.tool_call_results[0], 

176 start=True, 

177 meta={"tool_result": tool_message.tool_call_results[0].result, "tool_call": tool_call}, 

178 ) 

179 

180 

181def _create_tool_span(tool: Tool, tool_call: ToolCall, parent_span: tracing.Span | None) -> Any: 

182 """ 

183 Create one tracing span for a single tool call, nested under `parent_span`. 

184 

185 The established standard for tracing agents is one span per tool call, so each call gets its own span rather than 

186 grouping all of a step's calls together. The OpenTelemetry GenAI semantic conventions codify this with a dedicated 

187 "execute tool" span per invocation 

188 (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md#execute-tool-span), 

189 and tracing backends such as Langfuse follow the same model. The span is tagged with the tool's identity; the caller 

190 adds the call arguments and result as content tags, and sets `haystack.tool.error` to `True` when the call fails. 

191 

192 The parent is passed in rather than read from the tracer here: the calls of a step run concurrently, so a tracer 

193 that tracks the active span globally would report a sibling tool span as current and the spans would end up chained 

194 instead of side by side. 

195 """ 

196 return tracing.tracer.trace( 

197 "haystack.agent.step.tool", 

198 tags={"haystack.tool.name": tool_call.tool_name, "haystack.tool.description": tool.description}, 

199 parent_span=parent_span, 

200 ) 

201 

202 

203def _make_context_bound_invoke( 

204 tool: Tool, args: dict[str, Any], tool_call: ToolCall, parent_span: tracing.Span | None 

205) -> Callable[[], Any]: 

206 """ 

207 Return a zero-arg callable that runs `tool.invoke(**args)` under the current contextvars snapshot. 

208 

209 This preserves tracing spans and other context-local state across thread-pool boundaries, so spans opened by the 

210 tool itself stay attached to the calling trace. The callable returns a ToolInvocationError instead of raising so 

211 that parallel executions can collect failures without aborting the whole batch. 

212 """ 

213 ctx = contextvars.copy_context() 

214 

215 def _invoke() -> Any: 

216 with _create_tool_span(tool, tool_call, parent_span) as span: 

217 span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments) 

218 try: 

219 result = tool.invoke(**args) 

220 except ToolInvocationError as e: 

221 span.set_tag("haystack.tool.error", True) 

222 span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)}) 

223 return e 

224 span.set_content_tag("haystack.agent.step.tool.output", result) 

225 return result 

226 

227 def _runner() -> Any: 

228 return ctx.run(_invoke) 

229 

230 return _runner 

231 

232 

233def _make_bounded_invoke_async( 

234 tool: Tool, 

235 args: dict[str, Any], 

236 semaphore: asyncio.Semaphore, 

237 tool_call: ToolCall, 

238 parent_span: tracing.Span | None, 

239) -> Callable[[], Any]: 

240 """ 

241 Return a zero-arg async callable that awaits `tool.invoke_async(**args)` while holding `semaphore`. 

242 

243 Concurrency is bounded uniformly across native-async tools and sync-fallback tools (which dispatch 

244 to a worker thread inside `Tool.invoke_async`). ContextVars naturally inherit into child tasks for 

245 the native-async branch, and `asyncio.to_thread` propagates them for the fallback branch, so spans opened by 

246 the tool itself stay attached to the calling trace. 

247 

248 Returns a `ToolInvocationError` instead of raising so that gathered executions can collect failures 

249 without aborting the whole batch. 

250 """ 

251 

252 async def _runner() -> Any: 

253 async with semaphore: 

254 with _create_tool_span(tool, tool_call, parent_span) as span: 

255 span.set_content_tag("haystack.agent.step.tool.input", tool_call.arguments) 

256 try: 

257 result = await tool.invoke_async(**args) 

258 except ToolInvocationError as e: 

259 span.set_tag("haystack.tool.error", True) 

260 span.set_content_tag("haystack.agent.step.tool.output", {"error": str(e)}) 

261 return e 

262 span.set_content_tag("haystack.agent.step.tool.output", result) 

263 return result 

264 

265 return _runner 

266 

267 

268def _get_func_params(tool: Tool) -> dict[str, Any]: 

269 """ 

270 Return parameter names → annotations for a tool's invocation function. 

271 

272 - For ComponentTool, this is the annotated input schema defined on the underlying component. 

273 - For regular Tools, this is the function signature of the `function` callable, falling back to `async_function` 

274 for async-only tools. 

275 

276 :param tool: The tool to inspect. 

277 :returns: A dict mapping parameter names to their type annotations. 

278 """ 

279 if isinstance(tool, ComponentTool): 

280 assert hasattr(tool._component, "__haystack_input__") and isinstance( 

281 tool._component.__haystack_input__, Sockets 

282 ) 

283 return {name: socket.type for name, socket in tool._component.__haystack_input__._sockets_dict.items()} 

284 # Tool.__post_init__ guarantees that at least one of `function` / `async_function` is set. 

285 target = tool.function if tool.function is not None else tool.async_function 

286 return _resolve_parameter_types(target) # type: ignore[arg-type] 

287 

288 

289def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]: 

290 """ 

291 Merge LLM-provided arguments with state-sourced arguments. 

292 

293 LLM args take precedence. State values are pulled in only for the keys a tool explicitly declares via its 

294 `inputs_from_state` mapping, then the live State object is injected for any param annotated as State. 

295 

296 :param tool: The tool being invoked, used to determine parameter mappings and State injection. 

297 :param llm_args: The arguments provided by the LLM, which take precedence over state values. 

298 :param state: The current runtime state, used to source additional arguments as needed. 

299 :returns: A dict of arguments to invoke the tool with, combining LLM and state values according to the rules 

300 described above. 

301 """ 

302 final_args = dict(llm_args) 

303 func_params = _get_func_params(tool) 

304 

305 # A tool reads from State by name only via an explicit `inputs_from_state` mapping 

306 for state_key, param_name in (tool.inputs_from_state or {}).items(): 

307 if param_name not in final_args and state.has(state_key): 

308 final_args[param_name] = state.get(state_key) 

309 

310 # We also inject the full State object for any parameter annotated as State 

311 for param_name, param_type in func_params.items(): 

312 if _is_state_annotation(param_type): 

313 final_args[param_name] = state 

314 

315 return final_args 

316 

317 

318def _prepare_tool_args( 

319 *, 

320 tool: Tool, 

321 tool_call_arguments: dict[str, Any], 

322 state: State, 

323 streaming_callback: StreamingCallbackT | None = None, 

324 enable_streaming_passthrough: bool = False, 

325) -> dict[str, Any]: 

326 """ 

327 Prepare the final arguments for a tool by injecting state inputs and optionally a streaming callback. 

328 

329 :param tool: 

330 The tool instance to prepare arguments for. 

331 :param tool_call_arguments: 

332 The initial arguments provided for the tool call. 

333 :param state: 

334 The current state containing inputs to be injected into the tool arguments. 

335 :param streaming_callback: 

336 Optional streaming callback to be injected if enabled and applicable. 

337 :param enable_streaming_passthrough: 

338 Flag indicating whether to inject the streaming callback into the tool arguments. 

339 

340 :returns: 

341 A dictionary of final arguments ready for tool invocation. 

342 """ 

343 # Combine user + state inputs 

344 final_args = _inject_state_args(tool, tool_call_arguments.copy(), state) 

345 # Check whether to inject streaming_callback 

346 if ( 

347 enable_streaming_passthrough 

348 and streaming_callback is not None 

349 and "streaming_callback" not in final_args 

350 and "streaming_callback" in _get_func_params(tool) 

351 ): 

352 final_args["streaming_callback"] = streaming_callback 

353 return final_args 

354 

355 

356def _resolve_tool_calls( 

357 messages_with_tool_calls: list[ChatMessage], tools_with_names: dict[str, Tool], *, raise_on_failure: bool 

358) -> tuple[list[ToolCall], list[Tool], list[ChatMessage]]: 

359 """ 

360 Walk all tool calls in `messages_with_tool_calls` and resolve each to its Tool. 

361 

362 Argument preparation is deliberately *not* done here: args are prepared per execution batch (see 

363 `_schedule_tool_calls`) so that a tool reading from State observes writes made by tools that ran earlier in the same 

364 step. 

365 

366 :returns: (tool_calls, resolved_tools, error_messages) 

367 - tool_calls: ToolCall objects for each valid call, in call order 

368 - resolved_tools: the resolved Tool for each entry in `tool_calls` (parallel list) 

369 - error_messages: ChatMessages for tool-not-found errors (when raise_on_failure is False) 

370 """ 

371 tool_calls: list[ToolCall] = [] 

372 resolved_tools: list[Tool] = [] 

373 error_messages: list[ChatMessage] = [] 

374 

375 for message in messages_with_tool_calls: 

376 for tool_call in message.tool_calls: 

377 tool_name = tool_call.tool_name 

378 

379 if tool_name not in tools_with_names: 

380 error = ToolNotFoundException(tool_name, list(tools_with_names.keys())) 

381 if raise_on_failure: 

382 raise error 

383 logger.error("{error_exception}", error_exception=error) 

384 error_messages.append(ChatMessage.from_tool(tool_result=str(error), origin=tool_call, error=True)) 

385 continue 

386 

387 tool_calls.append(tool_call) 

388 resolved_tools.append(tools_with_names[tool_name]) 

389 

390 return tool_calls, resolved_tools, error_messages 

391 

392 

393def _keys_intersect(a: _StateKeys, b: _StateKeys) -> bool: 

394 """ 

395 Return whether two State-key sets share at least one key, treating `_ALL_STATE_KEYS` as a wildcard. 

396 

397 Used to detect read-after-write dependencies between tool calls: the reader's read set is tested against the 

398 writer's write set. 

399 

400 :param a: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key". 

401 :param b: A set of state keys, or the `_ALL_STATE_KEYS` wildcard meaning "every key". 

402 :returns: True if the sets overlap (a wildcard overlaps any non-empty set, and two wildcards always overlap). 

403 """ 

404 if a is _ALL_STATE_KEYS: 

405 # `a` covers every key, so it overlaps `b` as long as `b` touches any key. Two wildcards always overlap; 

406 # otherwise `bool(b)` is True iff the concrete set `b` is non-empty. 

407 return b is _ALL_STATE_KEYS or bool(b) 

408 if b is _ALL_STATE_KEYS: 

409 # Symmetric case: wildcard `b` overlaps `a` iff the concrete set `a` is non-empty (`bool(set)` == non-empty). 

410 return bool(a) 

411 # Both are concrete sets: they overlap iff their set intersection is non-empty. 

412 return bool(a & b) # type: ignore[operator] 

413 

414 

415def _state_io_for_call(tool: Tool, llm_args: dict[str, Any]) -> tuple[_StateKeys, _StateKeys]: 

416 """ 

417 Compute the State keys a tool call reads from and writes to. 

418 

419 Mirrors the resolution logic in `_inject_state_args`: 

420 - A tool with a `State`-annotated parameter can read/write any key, so both sets are the `_ALL_STATE_KEYS` wildcard. 

421 - Otherwise reads come from the tool's explicit `inputs_from_state` mapping, excluding any parameter the LLM already 

422 supplied (LLM args take precedence and short-circuit the state lookup). 

423 - Writes are the keys in `outputs_to_state`. 

424 

425 :returns: A `(reads, writes)` tuple of state-key sets (or the `_ALL_STATE_KEYS` wildcard). 

426 """ 

427 func_params = _get_func_params(tool) 

428 # Check if State is in func_params 

429 if any(_is_state_annotation(param_type) for param_type in func_params.values()): 

430 return _ALL_STATE_KEYS, _ALL_STATE_KEYS 

431 

432 # Calculate reads 

433 param_mappings = tool.inputs_from_state or {} 

434 reads = {state_key for state_key, param_name in param_mappings.items() if param_name not in llm_args} 

435 # Calculate writes 

436 writes = set((tool.outputs_to_state or {}).keys()) 

437 

438 return reads, writes 

439 

440 

441def _schedule_tool_calls(tool_calls: list[ToolCall], tools: list[Tool]) -> list[list[int]]: 

442 """ 

443 Group tool calls into ordered execution batches based on their State read/write sets. 

444 

445 Calls within a batch are mutually independent and run in parallel; batches run sequentially. The schedule guarantees 

446 that a call reading a State key always runs in a later batch than any call (in the same step) that writes that 

447 key — so read-after-write dependencies are honored regardless of the order the LLM requested the calls in. 

448 

449 This is a layered topological sort: each round, every call whose dependencies have all been scheduled forms the 

450 next parallel batch. Dependency cycles — e.g. a tool that both reads and writes the same key, requested more than 

451 once — cannot be ordered by the read-after-write rule alone, so they are broken deterministically by call order 

452 (the lowest-index remaining call runs next, on its own). 

453 

454 Write-write overlaps alone create no dependency. Outputs are merged into State after each batch, in the 

455 LLM's requested order within that batch, so subsequent batches see the updated values. This keeps merging 

456 deterministic, but does not guarantee that writes follow the LLM's requested order across batches. 

457 

458 :param tool_calls: The tool calls to schedule, in call order. 

459 :param tools: The resolved Tool for each entry in `tool_calls` (parallel list). 

460 :returns: A list of batches, each a list of indices into `tool_calls`. 

461 """ 

462 # Per-call (reads, writes) State-key sets, in call order. 

463 io_list = [_state_io_for_call(tool, tc.arguments) for tc, tool in zip(tool_calls, tools, strict=True)] 

464 n = len(io_list) 

465 

466 # deps[j] = indices that must run before j because j reads a key they write (read-after-write). 

467 deps: list[set[int]] = [set() for _ in range(n)] 

468 for j in range(n): 

469 reads_j, _ = io_list[j] 

470 for i in range(n): 

471 if i == j: 

472 continue 

473 _, writes_i = io_list[i] 

474 if _keys_intersect(reads_j, writes_i): 

475 deps[j].add(i) 

476 

477 scheduled = [False] * n 

478 done: set[int] = set() 

479 batches: list[list[int]] = [] 

480 

481 while len(done) < n: 

482 # A call is ready once every writer it depends on has already been scheduled (`deps[k] <= done`, i.e. its 

483 # dependency set is a subset of the already-done set). All ready calls have no dependency on each other — 

484 # if one read a key another writes, it would still be waiting — so the whole `ready` list runs in parallel. 

485 ready = [k for k in range(n) if not scheduled[k] and deps[k] <= done] 

486 if not ready: 

487 # A dependency cycle remains: break it deterministically by running the lowest-index call next. 

488 ready = [next(k for k in range(n) if not scheduled[k])] 

489 for k in ready: 

490 scheduled[k] = True 

491 done.update(ready) 

492 batches.append(ready) 

493 

494 return batches 

495 

496 

497def _finalize_tool_result( 

498 result: Any, tool_call: ToolCall, tool: Tool, state: State, *, raise_on_failure: bool 

499) -> ChatMessage: 

500 """ 

501 Turn a single tool invocation result into a tool-result ChatMessage, merging outputs into State. 

502 

503 On a `ToolInvocationError`, either re-raise (when `raise_on_failure`) or return an error message. Otherwise 

504 merge the tool's outputs into State and build the result message. 

505 """ 

506 if isinstance(result, ToolInvocationError): 

507 if raise_on_failure: 

508 raise result 

509 logger.error("{error_exception}", error_exception=result) 

510 return ChatMessage.from_tool(tool_result=str(result), origin=tool_call, error=True) 

511 

512 _merge_tool_outputs_into_state(tool, result, state) 

513 return _build_tool_result_message(result, tool_call, tool, raise_on_failure=raise_on_failure) 

514 

515 

516def _run_tool( 

517 *, 

518 messages: list[ChatMessage], 

519 state: State, 

520 tools: ToolsType, 

521 streaming_callback: StreamingCallbackT | None = None, 

522 raise_on_failure: bool = True, 

523 enable_streaming_callback_passthrough: bool = False, 

524 max_workers: int = 4, 

525) -> tuple[list[ChatMessage], State]: 

526 """ 

527 Invoke all tools referenced by tool calls in `messages`. 

528 

529 :param messages: ChatMessage objects that may contain tool calls. 

530 :param state: Runtime state passed to and updated by tools. 

531 :param tools: The tools available for invocation. 

532 :param streaming_callback: Called once per tool result as it becomes available. 

533 :param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message. 

534 :param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it. 

535 :param max_workers: Maximum number of parallel tool invocations. 

536 :returns: (tool_messages, updated_state) 

537 """ 

538 tools_with_names = _validate_and_prepare_tools(tools) 

539 

540 messages_with_tool_calls = [m for m in messages if m.tool_calls] 

541 if not messages_with_tool_calls: 

542 return [], state 

543 

544 tool_calls, resolved_tools, error_messages = _resolve_tool_calls( 

545 messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure 

546 ) 

547 if not tool_calls: 

548 return error_messages, state 

549 

550 # Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`). 

551 batches = _schedule_tool_calls(tool_calls, resolved_tools) 

552 

553 # Results are indexed by call position so the returned messages stay in call order, even though batches may 

554 # execute the calls in a different order. 

555 results: list[ChatMessage | None] = [None] * len(tool_calls) 

556 stream_index = 0 

557 

558 # Resolved once, before any tool runs, so all per-call spans of this step become siblings under the same parent. 

559 parent_span = tracing.tracer.current_span() 

560 

561 with ThreadPoolExecutor(max_workers=max_workers) as executor: 

562 for batch in batches: 

563 # Prepare args at the start of each batch so tools that read from State observe writes merged by earlier 

564 # batches. 

565 futures = {} 

566 for idx in batch: 

567 args = _prepare_tool_args( 

568 tool=resolved_tools[idx], 

569 tool_call_arguments=tool_calls[idx].arguments, 

570 state=state, 

571 streaming_callback=streaming_callback, 

572 enable_streaming_passthrough=enable_streaming_callback_passthrough, 

573 ) 

574 futures[idx] = executor.submit( 

575 _make_context_bound_invoke( 

576 tool=resolved_tools[idx], args=args, tool_call=tool_calls[idx], parent_span=parent_span 

577 ) 

578 ) 

579 

580 # Merge results in call order within the batch so write-write merges stay deterministic. 

581 for idx in batch: 

582 message = _finalize_tool_result( 

583 futures[idx].result(), 

584 tool_calls[idx], 

585 resolved_tools[idx], 

586 state, 

587 raise_on_failure=raise_on_failure, 

588 ) 

589 results[idx] = message 

590 if streaming_callback is not None: 

591 streaming_callback(_create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index)) 

592 stream_index += 1 

593 

594 tool_messages = error_messages + [m for m in results if m is not None] 

595 

596 # We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream. 

597 if tool_messages and streaming_callback is not None: 

598 streaming_callback( 

599 StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"}) 

600 ) 

601 

602 return tool_messages, state 

603 

604 

605async def _run_tool_async( 

606 *, 

607 messages: list[ChatMessage], 

608 state: State, 

609 tools: ToolsType, 

610 streaming_callback: StreamingCallbackT | None = None, 

611 raise_on_failure: bool = True, 

612 enable_streaming_callback_passthrough: bool = False, 

613 max_workers: int = 4, 

614) -> tuple[list[ChatMessage], State]: 

615 """ 

616 Asynchronous variant of `run_tool`. Tool calls execute concurrently via a thread pool. 

617 

618 :param messages: ChatMessage objects that may contain tool calls. 

619 :param state: Runtime state passed to and updated by tools. 

620 :param tools: The tools available for invocation. 

621 :param streaming_callback: Async callback called once per tool result. 

622 :param raise_on_failure: If True, raise on tool invocation failure; otherwise return an error message. 

623 :param enable_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it. 

624 :param max_workers: Maximum number of parallel tool invocations. 

625 :returns: (tool_messages, updated_state) 

626 """ 

627 tools_with_names = _validate_and_prepare_tools(tools) 

628 

629 messages_with_tool_calls = [m for m in messages if m.tool_calls] 

630 if not messages_with_tool_calls: 

631 return [], state 

632 

633 tool_calls, resolved_tools, error_messages = _resolve_tool_calls( 

634 messages_with_tool_calls, tools_with_names, raise_on_failure=raise_on_failure 

635 ) 

636 if not tool_calls: 

637 return error_messages, state 

638 

639 # Group the calls into batches that honor read-after-write dependencies on State (see `_schedule_tool_calls`). 

640 batches = _schedule_tool_calls(tool_calls, resolved_tools) 

641 

642 # Results are indexed by call position so the returned messages stay in call order, even though batches may 

643 # execute the calls in a different order. 

644 results: list[ChatMessage | None] = [None] * len(tool_calls) 

645 stream_index = 0 

646 

647 # `max_workers` + Semaphore bounds concurrency for both sync and async tool calls async tools are awaited directly, 

648 # and sync tools are dispatched to a worker thread inside `Tool.invoke_async`. 

649 semaphore = asyncio.Semaphore(max_workers) 

650 

651 # Resolved once, before any tool runs, so all per-call spans of this step become siblings under the same parent. 

652 parent_span = tracing.tracer.current_span() 

653 

654 for batch in batches: 

655 # Prepare args at the start of each batch so readers observe writes merged by earlier batches. 

656 tasks = {} 

657 for idx in batch: 

658 args = _prepare_tool_args( 

659 tool=resolved_tools[idx], 

660 tool_call_arguments=tool_calls[idx].arguments, 

661 state=state, 

662 streaming_callback=streaming_callback, 

663 enable_streaming_passthrough=enable_streaming_callback_passthrough, 

664 ) 

665 tasks[idx] = _make_bounded_invoke_async( 

666 tool=resolved_tools[idx], 

667 args=args, 

668 semaphore=semaphore, 

669 tool_call=tool_calls[idx], 

670 parent_span=parent_span, 

671 )() 

672 batch_results = await asyncio.gather(*tasks.values()) 

673 

674 # Merge results in call order within the batch so write-write merges stay deterministic. 

675 for idx, result in zip(tasks.keys(), batch_results, strict=True): 

676 message = _finalize_tool_result( 

677 result, tool_calls[idx], resolved_tools[idx], state, raise_on_failure=raise_on_failure 

678 ) 

679 results[idx] = message 

680 if streaming_callback is not None: 

681 await _invoke_streaming_callback( 

682 streaming_callback, _create_tool_result_streaming_chunk(message, tool_calls[idx], stream_index) 

683 ) 

684 stream_index += 1 

685 

686 tool_messages = error_messages + [m for m in results if m is not None] 

687 

688 # We emit a final empty chunk with finish_reason "tool_call_results" to signal the end of the tool results stream. 

689 if tool_messages and streaming_callback is not None: 

690 await _invoke_streaming_callback( 

691 streaming_callback, 

692 StreamingChunk(content="", finish_reason="tool_call_results", meta={"finish_reason": "tool_call_results"}), 

693 ) 

694 

695 return tool_messages, state