Files
daily_stock_analysis/src/agent/runner.py

890 lines
34 KiB
Python

# -*- coding: utf-8 -*-
"""
Shared runner — extracted LLM + tool execution loop.
Provides ``run_agent_loop``, the single authoritative implementation of the
ReAct execute-loop that was previously inlined inside ``AgentExecutor._run_loop``.
All current and future agents should delegate to this runner instead of
re-implementing the loop themselves.
Design goals:
- Keep the same observable behaviour as the original ``_run_loop``
- Accept pluggable callbacks for progress, message history, and result handling
- Remain stateless — all mutable state lives in the caller
"""
from __future__ import annotations
import json
import logging
import math
import re
import time
import threading
import contextvars
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from src.agent.llm_adapter import LLMToolAdapter
from src.agent.dashboard_payload import sanitize_agent_dashboard_payload
from src.agent.protocols import StageFailureReason
from src.agent.stream_events import stream_event
from src.agent.tools.registry import ToolRegistry
from src.agent.tools.execution import (
TOOL_CANCEL_EVENT,
_build_tool_cache_key,
_guard_tool_stock_scope,
_is_non_retriable_tool_result,
_is_stock_scoped_tool,
_normalize_guard_stock_code,
_normalize_tool_stock_code,
execute_runner_tool_call,
serialize_tool_result,
)
from src.agent.stock_scope import StockScope
from src.llm.usage import should_persist_usage_telemetry
from src.utils.data_processing import normalize_report_signal_attribution
from src.storage import persist_llm_usage as _persist_usage
logger = logging.getLogger(__name__)
__all__ = [
"RunLoopResult",
"parse_dashboard_json",
"run_agent_loop",
"serialize_tool_result",
"try_parse_json",
"_build_tool_cache_key",
"_guard_tool_stock_scope",
"_is_non_retriable_tool_result",
"_is_stock_scoped_tool",
"_normalize_guard_stock_code",
"_normalize_tool_stock_code",
]
# Tool name → friendly label for progress messages
_THINKING_TOOL_LABELS: Dict[str, str] = {
"get_realtime_quote": "行情获取",
"get_daily_history": "K线数据获取",
"analyze_trend": "技术指标分析",
"get_chip_distribution": "筹码分布分析",
"search_stock_news": "新闻搜索",
"search_comprehensive_intel": "综合情报搜索",
"get_market_indices": "市场概览获取",
"get_sector_rankings": "行业板块分析",
"get_analysis_context": "历史分析上下文",
"get_stock_info": "基本信息获取",
"analyze_pattern": "K线形态识别",
"get_volume_analysis": "量能分析",
"calculate_ma": "均线计算",
"get_skill_backtest_summary": "技能回测概览",
"get_strategy_backtest_summary": "策略回测概览",
"get_stock_backtest_summary": "个股回测数据",
}
# ============================================================
# RunLoopResult — the output of one run_agent_loop invocation
# ============================================================
@dataclass
class RunLoopResult:
"""Output produced by :func:`run_agent_loop`."""
success: bool = False
content: str = ""
tool_calls_log: List[Dict[str, Any]] = field(default_factory=list)
total_steps: int = 0
total_tokens: int = 0
provider: str = ""
models_used: List[str] = field(default_factory=list)
error: Optional[str] = None
failure_reason: Optional[StageFailureReason] = None
# Raw messages list at the end of the loop (callers may want to persist)
messages: List[Dict[str, Any]] = field(default_factory=list)
@property
def model(self) -> str:
"""Comma-separated de-duplicated model names used during the run."""
return ", ".join(dict.fromkeys(m for m in self.models_used if m))
# ============================================================
# Helpers
# ============================================================
def parse_dashboard_json(content: str) -> Optional[Dict[str, Any]]:
"""Extract and parse a Decision Dashboard JSON from agent text.
Tries multiple strategies:
1. Markdown code blocks (```json ... ```)
2. Raw JSON parse
3. ``json_repair`` library
4. Brace-delimited substring
"""
if not content:
return None
from json_repair import repair_json
# Strategy 1: markdown code blocks
json_blocks = re.findall(r"```(?:json)?\s*\n?(.*?)\n?```", content, re.DOTALL)
if json_blocks:
for block in json_blocks:
parsed = _try_parse_json(block)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
parsed = _try_repair_json(block, repair_json)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
# Strategy 2: raw parse
parsed = _try_parse_json(content)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
# Strategy 3: json_repair on full content
parsed = _try_repair_json(content, repair_json)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
# Strategy 4: brace-delimited
brace_start = content.find("{")
brace_end = content.rfind("}")
if brace_start >= 0 and brace_end > brace_start:
candidate = content[brace_start : brace_end + 1]
parsed = _try_parse_json(candidate)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
parsed = _try_repair_json(candidate, repair_json)
if parsed is not None:
return _finalize_dashboard_payload(parsed)
logger.warning("Failed to parse dashboard JSON from agent response")
return None
def _finalize_dashboard_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize reserved fields before running normal dashboard normalization."""
sanitized = sanitize_agent_dashboard_payload(payload)
normalize_report_signal_attribution(sanitized)
return sanitized
def try_parse_json(text: str) -> Optional[Dict[str, Any]]:
"""Best-effort JSON dict extraction from LLM text.
Handles:
1. Direct JSON parse
2. Markdown code fences (```json ... ```)
3. Brace-delimited substring
4. ``json_repair`` fallback for slightly malformed JSON
This is the shared utility that all agent ``post_process`` methods
should use instead of duplicating the same logic.
"""
if not text:
return None
candidates: List[str] = []
cleaned = text.strip()
if cleaned:
candidates.append(cleaned)
if cleaned.startswith("```"):
unfenced = re.sub(r'^```(?:json)?\s*', '', cleaned)
unfenced = re.sub(r'\s*```$', '', unfenced)
if unfenced:
candidates.append(unfenced.strip())
fenced_blocks = re.findall(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
for block in fenced_blocks:
block = block.strip()
if block:
candidates.append(block)
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
snippet = text[start:end + 1].strip()
if snippet:
candidates.append(snippet)
seen: set[str] = set()
unique_candidates: List[str] = []
for candidate in candidates:
if candidate not in seen:
seen.add(candidate)
unique_candidates.append(candidate)
for candidate in unique_candidates:
try:
obj = json.loads(candidate)
if isinstance(obj, dict):
return obj
except (json.JSONDecodeError, ValueError):
continue
try:
from json_repair import repair_json
except Exception:
repair_json = None
if repair_json is not None:
for candidate in unique_candidates:
repaired = _try_repair_json(candidate, repair_json)
if repaired is not None:
return repaired
return None
# Keep private alias used internally by parse_dashboard_json
_try_parse_json = try_parse_json
def _try_repair_json(text: str, repair_fn: Callable) -> Optional[Dict[str, Any]]:
try:
repaired = repair_fn(text)
obj = json.loads(repaired)
return obj if isinstance(obj, dict) else None
except Exception:
return None
def _remaining_timeout_seconds(
start_time: float,
max_wall_clock_seconds: Optional[float],
) -> Optional[float]:
"""Return remaining wall-clock budget in seconds, or None when disabled."""
if max_wall_clock_seconds is None or max_wall_clock_seconds <= 0:
return None
return max(0.0, float(max_wall_clock_seconds) - (time.time() - start_time))
def _build_timeout_result(
*,
start_time: float,
max_wall_clock_seconds: float,
step: int,
tool_calls_log: List[Dict[str, Any]],
total_tokens: int,
provider_used: str,
models_used: List[str],
messages: List[Dict[str, Any]],
) -> RunLoopResult:
elapsed = time.time() - start_time
return RunLoopResult(
success=False,
content="",
tool_calls_log=tool_calls_log,
total_steps=step,
total_tokens=total_tokens,
provider=provider_used,
models_used=models_used,
error=f"Agent timed out after {elapsed:.2f}s (limit: {max_wall_clock_seconds:.2f}s)",
failure_reason=StageFailureReason.TIMEOUT,
messages=messages,
)
def _build_budget_guard_result(
*,
start_time: float,
step: int,
tool_calls_log: List[Dict[str, Any]],
total_tokens: int,
provider_used: str,
models_used: List[str],
messages: List[Dict[str, Any]],
remaining_timeout_s: float,
min_step_budget_s: float,
) -> RunLoopResult:
elapsed = time.time() - start_time
return RunLoopResult(
success=False,
content="",
tool_calls_log=tool_calls_log,
total_steps=step,
total_tokens=total_tokens,
provider=provider_used,
models_used=models_used,
error=(
"Agent step skipped due to insufficient budget: "
f"{remaining_timeout_s:.2f}s remaining, minimum {min_step_budget_s:.1f}s required"
),
failure_reason=StageFailureReason.BUDGET_SKIP,
messages=messages,
)
# ============================================================
# Core loop
# ============================================================
def run_agent_loop(
*,
messages: List[Dict[str, Any]],
tool_registry: ToolRegistry,
llm_adapter: LLMToolAdapter,
max_steps: int = 10,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
thinking_labels: Optional[Dict[str, str]] = None,
max_wall_clock_seconds: Optional[float] = None,
tool_call_timeout_seconds: Optional[float] = None,
stock_scope: Optional[StockScope] = None,
emit_stage_events: bool = True,
) -> RunLoopResult:
"""Execute the ReAct LLM ↔ tool loop.
This is the *single shared implementation* of the agent execution loop.
Both the legacy ``AgentExecutor`` and any future multi-agent runner
should delegate here.
Args:
messages: The initial message list (system + user + optional history).
**Mutated in-place** — tool results are appended.
tool_registry: Registry of callable tools.
llm_adapter: LLM backend (handles multi-provider fallback).
max_steps: Maximum number of LLM round-trips.
progress_callback: Optional callback receiving progress dicts.
thinking_labels: Override map of tool_name → friendly label.
max_wall_clock_seconds: Optional overall timeout budget for the loop.
tool_call_timeout_seconds: Optional explicit per-run tool-call timeout.
Highest-priority (first-wins) in the per-tool timeout chain —
overrides per-tool declarations and category defaults — but is
capped by ``max_wall_clock_seconds`` when both are set.
emit_stage_events: Whether to emit the synthetic ``agent_loop``
stage lifecycle. Orchestrated business stages disable this so
``stage_start`` / ``stage_done`` only describe real stages.
Returns:
A :class:`RunLoopResult` with the final content, stats, and the
(mutated) messages list.
"""
labels = thinking_labels or _THINKING_TOOL_LABELS
tool_decls = tool_registry.to_openai_tools()
start_time = time.time()
tool_calls_log: List[Dict[str, Any]] = []
non_retriable_tool_results: Dict[str, str] = {}
total_tokens = 0
provider_used = ""
models_used: List[str] = []
# Minimum seconds needed for a meaningful LLM round-trip. If the
# remaining budget is positive but below this threshold, the step will
# almost certainly timeout mid-call, wasting a billed request. Only
# enforced from step 2 onwards so the first step always gets a chance
# even when the total budget is small.
_MIN_STEP_BUDGET_S = 8.0
def _finish(result: RunLoopResult) -> RunLoopResult:
if progress_callback and emit_stage_events:
progress_callback(
stream_event(
"stage_done",
stage="agent_loop",
status="completed" if result.success else "failed",
duration=round(time.time() - start_time, 2),
)
)
return result
if progress_callback and emit_stage_events:
progress_callback(
stream_event(
"stage_start",
stage="agent_loop",
message="Starting agent analysis...",
)
)
for step in range(max_steps):
remaining_timeout = _remaining_timeout_seconds(start_time, max_wall_clock_seconds)
timeout_exhausted = remaining_timeout is not None and remaining_timeout <= 0
budget_guard_triggered = (
not timeout_exhausted
and remaining_timeout is not None
and step > 0
and remaining_timeout <= _MIN_STEP_BUDGET_S
)
if timeout_exhausted or budget_guard_triggered:
if budget_guard_triggered:
logger.warning(
"Agent budget too low for step %d (%.1fs remaining, min %.1fs)",
step + 1,
remaining_timeout,
_MIN_STEP_BUDGET_S,
)
return _finish(_build_budget_guard_result(
start_time=start_time,
step=step,
tool_calls_log=tool_calls_log,
total_tokens=total_tokens,
provider_used=provider_used,
models_used=models_used,
messages=messages,
remaining_timeout_s=remaining_timeout,
min_step_budget_s=_MIN_STEP_BUDGET_S,
))
if remaining_timeout <= 0:
logger.warning("Agent timed out before step %d", step + 1)
return _finish(_build_timeout_result(
start_time=start_time,
max_wall_clock_seconds=float(max_wall_clock_seconds),
step=step,
tool_calls_log=tool_calls_log,
total_tokens=total_tokens,
provider_used=provider_used,
models_used=models_used,
messages=messages,
))
logger.info("Agent step %d/%d", step + 1, max_steps)
# --- progress: thinking ---
if progress_callback:
if not tool_calls_log:
thinking_msg = "正在制定分析路径..."
else:
last_tool = tool_calls_log[-1].get("tool", "")
label = labels.get(last_tool, last_tool)
thinking_msg = f"{label}」已完成,继续深入分析..."
progress_callback(stream_event("thinking", step=step + 1, message=thinking_msg))
# --- LLM call ---
response = llm_adapter.call_with_tools(
messages,
tool_decls,
timeout=remaining_timeout,
)
provider_used = response.provider
total_tokens += (response.usage or {}).get("total_tokens", 0)
m = getattr(response, "model", "") or response.provider
if m and m != "error":
models_used.append(m)
model_for_usage = m or response.provider
if model_for_usage and model_for_usage != "error" and should_persist_usage_telemetry(response.usage):
_persist_usage(response.usage, model_for_usage, call_type="agent")
remaining_timeout = _remaining_timeout_seconds(start_time, max_wall_clock_seconds)
if remaining_timeout is not None and remaining_timeout <= 0:
logger.warning("Agent timed out after LLM call at step %d", step + 1)
return _finish(_build_timeout_result(
start_time=start_time,
max_wall_clock_seconds=float(max_wall_clock_seconds),
step=step + 1,
tool_calls_log=tool_calls_log,
total_tokens=total_tokens,
provider_used=provider_used,
models_used=models_used,
messages=messages,
))
if response.tool_calls:
# ---- tool execution branch ----
logger.info(
"Agent requesting %d tool call(s): %s",
len(response.tool_calls),
[tc.name for tc in response.tool_calls],
)
# Append assistant message (with tool_calls) to history
assistant_msg: Dict[str, Any] = {
"role": "assistant",
"content": response.content,
"_trace_provider": response.provider,
"_trace_model": m,
"tool_calls": [
{
"id": tc.id,
"name": tc.name,
"arguments": tc.arguments,
**({"provider_specific_fields": tc.provider_specific_fields} if tc.provider_specific_fields else {}),
**({"thought_signature": tc.thought_signature} if tc.thought_signature is not None else {}),
}
for tc in response.tool_calls
],
}
if response.reasoning_content is not None:
assistant_msg["reasoning_content"] = response.reasoning_content
if response.provider_blocks:
assistant_msg["provider_blocks"] = response.provider_blocks
messages.append(assistant_msg)
# Execute tools (parallel when > 1). ``tool_call_timeout_seconds`` is
# the caller's explicit per-run override — highest priority in the
# first-wins chain (Issue #1890 contract) — while ``remaining_timeout``
# is the unbreakable outer wall-clock cap for this batch.
tool_results = _execute_tools(
response.tool_calls,
tool_registry,
step + 1,
progress_callback,
tool_calls_log,
non_retriable_tool_results,
tool_call_timeout_seconds=tool_call_timeout_seconds,
tool_wait_timeout_seconds=remaining_timeout,
stock_scope=stock_scope,
)
# Append tool results preserving original call order
tc_order = {tc.id: i for i, tc in enumerate(response.tool_calls)}
tool_results.sort(key=lambda x: tc_order.get(x["tc"].id, 0))
for tr in tool_results:
messages.append(
{
"role": "tool",
"name": tr["tc"].name,
"tool_call_id": tr["tc"].id,
"content": tr["result_str"],
}
)
remaining_timeout = _remaining_timeout_seconds(start_time, max_wall_clock_seconds)
if remaining_timeout is not None and remaining_timeout <= 0:
logger.warning("Agent timed out after tool execution at step %d", step + 1)
return _finish(_build_timeout_result(
start_time=start_time,
max_wall_clock_seconds=float(max_wall_clock_seconds),
step=step + 1,
tool_calls_log=tool_calls_log,
total_tokens=total_tokens,
provider_used=provider_used,
models_used=models_used,
messages=messages,
))
else:
# ---- final answer branch ----
logger.info(
"Agent completed in %d steps (%.1fs, %d tokens)",
step + 1,
time.time() - start_time,
total_tokens,
)
if progress_callback:
progress_callback(stream_event("generating", step=step + 1, message="正在生成最终分析..."))
final_content = response.content or ""
is_error = response.provider == "error"
return _finish(RunLoopResult(
success=not is_error and bool(final_content),
content=final_content if not is_error else "",
tool_calls_log=tool_calls_log,
total_steps=step + 1,
total_tokens=total_tokens,
provider=provider_used,
models_used=models_used,
error=final_content if is_error else None,
failure_reason=(StageFailureReason.STAGE_FAILURE if is_error else None),
messages=messages,
))
# Max steps exceeded
logger.warning("Agent hit max steps (%d)", max_steps)
return _finish(RunLoopResult(
success=False,
content="",
tool_calls_log=tool_calls_log,
total_steps=max_steps,
total_tokens=total_tokens,
provider=provider_used,
models_used=models_used,
error=f"Agent exceeded max steps ({max_steps}). Try increasing AGENT_MAX_STEPS if analysis tasks are complex.",
failure_reason=StageFailureReason.STAGE_FAILURE,
messages=messages,
))
# ============================================================
# Internal tool execution
# ============================================================
def _coerce_positive_timeout(value) -> Optional[float]:
"""Coerce a timeout candidate to a positive finite float, else ``None``.
``None`` / non-numeric / non-positive / ``inf`` / ``nan`` all map to
``None`` ("no limit at this level"). Rejecting non-finite values prevents
an ``OverflowError`` from ``future.result(timeout=inf)`` and avoids the
undefined ordering a ``nan`` would produce.
"""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(v) or v <= 0:
return None
return v
def _build_timeout_result_payload(
tool_name: str,
arguments: Dict[str, Any],
timeout_s: float,
non_retriable_tool_results: Optional[Dict[str, str]],
) -> str:
"""Build the timeout-shaped result string and record it as non-retriable.
A timed-out call is marked ``"retriable": False`` and inserted into
``non_retriable_tool_results`` so the LLM's retry of the *same* call reuses
the cached failure instead of spinning up a second (possibly side-effecting)
execution — a best-effort guard against duplicate work, since Python cannot
forcibly cancel an already-started tool thread.
"""
label = f"{timeout_s:.2f}s"
result_str = json.dumps({
"error": f"Tool execution timed out after {label}",
"timeout": True,
"retriable": False,
})
if non_retriable_tool_results is not None:
cache_key = _build_tool_cache_key(tool_name, arguments)
# Non-dict / missing ``arguments`` yields ``None`` here; skip the write so
# unrelated no-arg tool calls cannot collide on a shared ``None`` key.
if cache_key:
non_retriable_tool_results[cache_key] = result_str
return result_str
def _resolve_per_tool_timeout(
tool_call,
tool_registry: Optional[ToolRegistry],
explicit_timeout: Optional[float] = None,
wall_clock_budget: Optional[float] = None,
) -> Optional[float]:
"""Resolve the effective timeout for a single tool call (Issue #1890).
Precedence is *first-wins*, exactly as confirmed in the issue contract:
1. explicit per-run timeout (``tool_call_timeout_seconds``)
2. per-tool declaration (``ToolDefinition.timeout_seconds``)
3. category default (``AGENT_*_TOOL_TIMEOUT_S``)
4. none (no per-tool limit)
An earlier level is never lowered by a later one: an explicit
``tool_call_timeout_seconds`` can *relax* a stricter per-tool or category
default (the previous ``min()`` across all levels made that impossible), and
a per-tool declaration is never overridden by a smaller category default.
``wall_clock_budget`` (the remaining overall loop budget) is then applied as
an *unbreakable outer cap*: the winner can never exceed it, so a long
explicit timeout cannot blow past the caller's overall wall-clock budget.
"""
explicit = _coerce_positive_timeout(explicit_timeout)
budget = _coerce_positive_timeout(wall_clock_budget)
if tool_registry is None:
base = explicit
else:
tool_def = tool_registry.get(tool_call.name)
if tool_def is None:
base = explicit
else:
per_tool = _coerce_positive_timeout(getattr(tool_def, "timeout_seconds", None))
category = _coerce_positive_timeout(
tool_registry.category_default_timeout(tool_def.category)
)
# First-wins: explicit per-run > per-tool declaration > category default.
base = (
explicit
if explicit is not None
else (per_tool if per_tool is not None else category)
)
if base is None:
# No explicit/per-tool/category limit; the wall-clock budget governs.
return budget
if budget is not None:
return min(base, budget)
return base
def _execute_tools(
tool_calls,
tool_registry: ToolRegistry,
step: int,
progress_callback: Optional[Callable],
tool_calls_log: List[Dict[str, Any]],
non_retriable_tool_results: Optional[Dict[str, str]] = None,
tool_call_timeout_seconds: Optional[float] = None,
tool_wait_timeout_seconds: Optional[float] = None,
stock_scope: Optional[StockScope] = None,
) -> List[Dict[str, Any]]:
"""Execute one or more tool calls, returning ordered result dicts.
A single tool with no resolved timeout runs inline (no thread); otherwise
all tools share one executor whose per-tool timeouts are enforced by a
deadline-driven wait loop — there are no per-tool nested pools, so a batch
of N tools uses at most ``min(N, 5)`` threads (review: unify the single and
parallel timeout wrapping). ``tool_wait_timeout_seconds`` also caps the
whole batch as an outer ceiling.
``tool_call_timeout_seconds`` is the caller's explicit per-run override
(highest priority, first-wins) and ``tool_wait_timeout_seconds`` the
remaining wall-clock budget (outer cap) — both feed
:func:`_resolve_per_tool_timeout`.
"""
def _exec_single(tc_item):
return execute_runner_tool_call(
tool_call=tc_item,
tool_registry=tool_registry,
stock_scope=stock_scope,
non_retriable_tool_results=non_retriable_tool_results,
)
def _exec_with_deadline(tc_item, per_tool_timeout, deadline_holder):
"""Run one tool and record its per-tool deadline when the worker actually
starts. The batch pool is capped at ``min(N, 5)`` workers, so calls that
queue behind a full pool must not burn their timeout before execution
begins (review OR-COM-3d6b61f8) — otherwise a batch larger than five can
falsely time out a tool that never got a worker.
"""
if per_tool_timeout and per_tool_timeout > 0:
deadline_holder[0] = time.monotonic() + per_tool_timeout
return _exec_single(tc_item)
def _record(tc_item, *, timed_out, timeout_s=None, out=None, guard_result=None):
"""Emit ``tool_done``, build the log entry, and append to ``results``."""
if out is not None:
_, result_str, success, dur, cached, guard_result = out
else:
result_str = _build_timeout_result_payload(
tc_item.name, tc_item.arguments, timeout_s, non_retriable_tool_results,
)
success = False
dur = round(timeout_s, 2)
cached = False
if progress_callback:
progress_callback(stream_event(
"tool_done", step=step, tool=tc_item.name, success=success, duration=dur,
))
log_entry = {
"step": step, "tool": tc_item.name, "arguments": tc_item.arguments,
"success": success, "duration": dur, "result_length": len(result_str),
"cached": cached,
}
if timed_out:
log_entry["timeout"] = True
if guard_result is not None:
log_entry.update({
"guarded": True,
"expected_stock_code": guard_result.get("expected_stock_code"),
"requested_stock_code": guard_result.get("requested_stock_code"),
"allowed_stock_codes": guard_result.get("allowed_stock_codes", []),
})
tool_calls_log.append(log_entry)
results.append({"tc": tc_item, "result_str": result_str})
results: List[Dict[str, Any]] = []
if not tool_calls:
return results
plan = []
for tc in tool_calls:
per_tool_timeout = _resolve_per_tool_timeout(
tc, tool_registry, tool_call_timeout_seconds, tool_wait_timeout_seconds,
)
plan.append((tc, per_tool_timeout))
if progress_callback:
progress_callback(stream_event("tool_start", step=step, tool=tc.name))
# Fast path: a single tool with no resolved timeout runs inline (no thread).
if len(plan) == 1 and not (plan[0][1] and plan[0][1] > 0):
_record(plan[0][0], timed_out=False, out=_exec_single(plan[0][0]))
return results
pool = ThreadPoolExecutor(max_workers=min(len(plan), 5))
futures: Dict[Any, Any] = {}
cancel_of: Dict[Any, threading.Event] = {}
# ``deadline_of`` maps a Future to a single-element holder list; the worker
# writes the deadline into it when it *starts* running (see
# ``_exec_with_deadline``). ``None`` until then means "still queued / no
# per-tool timeout" and is never treated as expired.
deadline_of: Dict[Any, List[Optional[float]]] = {}
timeout_of: Dict[Any, float] = {}
timeout_triggered = False
try:
# One executor for the whole batch. Each future gets its own cancel
# event (keyed by Future, not by tool name, so two parallel calls to the
# same tool cannot shadow each other) and its own per-tool deadline.
for tc, per_tool_timeout in plan:
cancel_event = threading.Event()
ctx = contextvars.copy_context()
ctx.run(TOOL_CANCEL_EVENT.set, cancel_event)
deadline_holder: List[Optional[float]] = [None]
fut = pool.submit(
ctx.run, _exec_with_deadline, tc, per_tool_timeout, deadline_holder,
)
futures[fut] = tc
cancel_of[fut] = cancel_event
timeout_of[fut] = per_tool_timeout or 0.0
deadline_of[fut] = deadline_holder
batch_deadline = (
time.monotonic() + tool_wait_timeout_seconds
if tool_wait_timeout_seconds and tool_wait_timeout_seconds > 0
else None
)
pending = set(futures)
while pending:
now = time.monotonic()
deadlines = []
for f in pending:
holder = deadline_of.get(f)
if holder is not None and holder[0] is not None:
deadlines.append(holder[0])
if batch_deadline is not None:
deadlines.append(batch_deadline)
next_deadline = min(deadlines) if deadlines else None
if next_deadline is not None:
wait_timeout = max(0.0, next_deadline - now)
elif any(timeout_of.get(f, 0.0) > 0 for f in pending):
# A pending call has a per-tool timeout but its worker has not
# started yet (it is queued behind the capped pool). Poll briefly
# so a tool that starts and then hangs still gets its own timeout
# (review OR-COM-3d6b61f8) instead of being forgotten.
wait_timeout = 0.01
else:
# No deadline and no pending call has a timeout — nothing can time
# out, so block until a call completes (efficient for the default
# no-timeout path).
wait_timeout = None
done, _ = wait(pending, timeout=wait_timeout, return_when=FIRST_COMPLETED)
for fut in done:
pending.discard(fut)
_record(futures[fut], timed_out=False, out=fut.result())
now = time.monotonic()
for fut in list(pending):
holder = deadline_of.get(fut)
deadline = holder[0] if holder is not None else None
expired = (deadline is not None and now >= deadline) or (
batch_deadline is not None and now >= batch_deadline
)
if not expired:
continue
pending.discard(fut)
cancel_of[fut].set()
timeout_triggered = True
timeout_s = timeout_of.get(fut) or (tool_wait_timeout_seconds or 0.0)
logger.warning(
"Tool '%s' timed out after %.2fs at step %d",
futures[fut].name, timeout_s, step,
)
_record(futures[fut], timed_out=True, timeout_s=timeout_s)
finally:
# Do not wait when a timeout fired — the timed-out handler may still be
# running in a worker and would otherwise block this call indefinitely.
pool.shutdown(wait=not timeout_triggered, cancel_futures=True)
return results