diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 53efc4e1c2..3a0a360c55 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -57,6 +57,9 @@ from tools.tool_result_storage import ( extract_persisted_path, ) from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context_window +from tools.todo_tool import TODO_SCHEMA +from tools.tool_search_catalog import TOOL_CALL_NAME +from tools.tool_search_validation import normalize_tool_call_entries # A tool result this large (raw stdout, file dumps) is the biggest allocation a turn ever drops. # The commit only flags it: the string is still referenced by the publish frames here, so the @@ -382,13 +385,39 @@ def _tool_search_scoped_names(agent) -> frozenset: return names -def _canonical_tool_name(function_name: str) -> str: +def canonical_tool_name(function_name: str) -> str: """Map legacy tool-name aliases BEFORE agent-loop dispatch.""" from model_tools import _LEGACY_TOOL_ALIASES as _lta return _lta.get(function_name, function_name) +def is_todo_tool_call(tool_call: Any) -> bool: + """True when a transcript tool_call entry (dict or object) invoked the Todo tool. + + Covers the current name, legacy aliases, and the ``tool_call`` bridge (``todo_list`` is deferred by + default, and the transcript keeps the bridge name). The bridge is peeled from the recorded arguments + only, never live tool-search config, and must wrap exactly one call. + """ + fn = tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None) + if isinstance(fn, dict): + name, raw_args = fn.get("name") or "", fn.get("arguments") + else: + name, raw_args = getattr(fn, "name", "") or "", getattr(fn, "arguments", None) + if name == TOOL_CALL_NAME: + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except (json.JSONDecodeError, TypeError): + return False + if not isinstance(args, dict): + return False + entries, error = normalize_tool_call_entries(args) + if error or len(entries) != 1: + return False + name = entries[0]["name"] + return canonical_tool_name(name) == TODO_SCHEMA["name"] + + def _unwrap_tool_search_call( agent, function_name: str, function_args: dict, *, flatten_probe: bool = False ) -> tuple[str, dict, Optional[str]]: @@ -451,7 +480,7 @@ class _ParsedCall: def _parse_tool_call(agent, tool_call, *, flatten_probe: bool = False) -> _ParsedCall: - name = _canonical_tool_name(tool_call.function.name) + name = canonical_tool_name(tool_call.function.name) args, parse_error = _parse_tool_arguments(tool_call.function.arguments) scope_block = None if parse_error is None: diff --git a/run_agent.py b/run_agent.py index d146217c98..1903abfb90 100644 --- a/run_agent.py +++ b/run_agent.py @@ -116,6 +116,7 @@ def _gateway_origin_json(agent: "AIAgent") -> Optional[str]: from agent.iteration_budget import IterationBudget +from agent.tool_executor import is_todo_tool_call from hermes_cli.env_loader import load_hermes_dotenv from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout @@ -1092,8 +1093,9 @@ class AIAgent( @classmethod def _tool_response_matches_todo_call(cls, history: List[Dict[str, Any]], tool_index: int) -> bool: - """True when the nearest prior assistant message issued a ``todo`` call with this ``tool_call_id``; a - ``user``/``system`` boundary or missing id means unpaired → must not hydrate.""" + """True when the nearest prior assistant message issued a Todo-tool call (legacy aliases and the + ``tool_call`` bridge canonicalized) with this ``tool_call_id``; a ``user``/``system`` boundary or + missing id means unpaired → must not hydrate.""" tool_call_id = history[tool_index].get("tool_call_id") if 0 <= tool_index < len(history) else None if not tool_call_id: return False @@ -1108,14 +1110,9 @@ class AIAgent( @classmethod def _assistant_has_todo_tool_call(cls, assistant_msg: Dict[str, Any], tool_call_id: str) -> bool: """True when the paired call resolves to the registered Todo tool.""" - from agent.tool_executor import _canonical_tool_name - from tools.todo_tool import TODO_SCHEMA - tool_calls = assistant_msg.get("tool_calls") return isinstance(tool_calls, list) and any( - cls._get_tool_call_id_static(tc) == tool_call_id - and _canonical_tool_name(cls._get_tool_call_name_static(tc)) == TODO_SCHEMA["name"] - for tc in tool_calls + cls._get_tool_call_id_static(tc) == tool_call_id and is_todo_tool_call(tc) for tc in tool_calls ) @property diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 013c408529..12aea93131 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -34,6 +34,7 @@ from agent.reasoning_effort import clamp_effort, route_supported_efforts from agent.compaction_display import project_compaction_message_for_display # noqa: F401 from agent.skill_commands import describe_skill_invocation # noqa: F401 from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX # noqa: F401 +from agent.tool_executor import is_todo_tool_call # noqa: F401 from tui_gateway import git_probe from tui_gateway._env import env_float, env_int from tui_gateway.turn_marker import clear_turn_marker, marker_writer_state, read_turn_marker, record_turn_start # noqa: F401 diff --git a/tui_gateway/tool_progress.py b/tui_gateway/tool_progress.py index aafa26f154..bf9b53fc10 100644 --- a/tui_gateway/tool_progress.py +++ b/tui_gateway/tool_progress.py @@ -177,7 +177,8 @@ def _todo_state_from_db(db, session_id: str) -> dict | None: def _todo_state_from_history(history) -> dict | None: """Latest todo snapshot from a loaded transcript, for resume paths that answer before an AIAgent (and - its live TodoStore) exists: the newest tool result paired with an assistant ``todo`` call IS it.""" + its live TodoStore) exists: the newest tool result paired with an assistant Todo-tool call (aliases and + the ``tool_call`` bridge included) IS it.""" if not isinstance(history, list) or not history: return None try: @@ -186,7 +187,7 @@ def _todo_state_from_history(history) -> dict | None: call.get("id") for msg in history if isinstance(msg, dict) for call in msg.get("tool_calls") or [] - if (call.get("function") or {}).get("name") in _TODO_TOOL_NAMES and call.get("id") + if isinstance(call, dict) and call.get("id") and is_todo_tool_call(call) } if not todo_call_ids: return None