diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 3a0a360c55..53efc4e1c2 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -57,9 +57,6 @@ 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 @@ -385,39 +382,13 @@ 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]]: @@ -480,7 +451,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/model_tools.py b/model_tools.py index 9a068d5a1b..55f8de5bfd 100644 --- a/model_tools.py +++ b/model_tools.py @@ -22,6 +22,7 @@ from tools.registry import CHECK_FN_CACHE_BYPASS, check_fn_cache_scope, discover from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN from toolsets import resolve_toolset, validate_toolset from tools.arg_coercion import coerce_tool_args +from tools.todo_tool import TODO_LEGACY_ALIASES, TODO_SCHEMA from utils import file_signature logger = logging.getLogger(__name__) @@ -614,7 +615,7 @@ _AGENT_LOOP_TOOLS = {"todo_list", "memory", "session_search", "delegate_task"} # Legacy tool-name aliases accepted at every dispatch seam (old sessions/saved # prompts keep working); schemas advertise only new names. _LEGACY_TOOL_ALIASES = { - "todo": "todo_list", "cronjob": "cronjob_manage", "process": "process_manage", + **dict.fromkeys(TODO_LEGACY_ALIASES, TODO_SCHEMA["name"]), "cronjob": "cronjob_manage", "process": "process_manage", "tour": "gui_tour", "tip": "show_tip", } _READ_SEARCH_TOOLS = {"read_file", "search_files"} diff --git a/run_agent.py b/run_agent.py index 1903abfb90..78f8fdb20d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -116,7 +116,6 @@ 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 @@ -1110,6 +1109,8 @@ 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 tools.todo_tool import is_todo_tool_call + 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 is_todo_tool_call(tc) for tc in tool_calls diff --git a/tools/todo_tool.py b/tools/todo_tool.py index 1f5db2f85a..ecf1319e94 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -274,6 +274,49 @@ TODO_SCHEMA = { } } +# Pre-rename names that replay as the Todo tool. model_tools._LEGACY_TOOL_ALIASES derives its todo +# entries from this, so the alias map and the transcript/TUI predicates below cannot drift. +TODO_LEGACY_ALIASES = ("todo",) +TODO_TOOL_NAMES = frozenset((TODO_SCHEMA["name"], *TODO_LEGACY_ALIASES)) + + +def is_todo_tool_name(name: Any) -> bool: + """True for the Todo tool's current name or a legacy alias (an already-unwrapped dispatch name).""" + return name in TODO_TOOL_NAMES + + +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. Lives here, not in + agent.tool_executor, so TUI resume and run_agent never pull model_tools / the executor in to answer it. + """ + from agent.message_sanitization import _tc_field + + fn = _tc_field(tool_call, "function") + name, raw_args = _tc_field(fn, "name") or "", _tc_field(fn, "arguments") + if is_todo_tool_name(name): + return True + # Cheap pre-check before the bridge modules load: no "todo" in the raw args means no todo inside. + if isinstance(raw_args, str) and "todo" not in raw_args: + return False + from tools.tool_search_catalog import TOOL_CALL_NAME + + if name != TOOL_CALL_NAME: + return False + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except json.JSONDecodeError: + return False + if not isinstance(args, dict): + return False + from tools.tool_search_validation import normalize_tool_call_entries + + entries, error = normalize_tool_call_entries(args) + return not error and len(entries) == 1 and is_todo_tool_name(entries[0]["name"]) + from tools.registry import registry, tool_error diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 12aea93131..013c408529 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -34,7 +34,6 @@ 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 bf9b53fc10..7e9e25bc48 100644 --- a/tui_gateway/tool_progress.py +++ b/tui_gateway/tool_progress.py @@ -15,8 +15,6 @@ from .method_ctx import bind_module _TUI_VERBOSE_TEXT_MAX_CHARS = 1_000 _TUI_VERBOSE_TEXT_MAX_LINES = 16 -_TODO_TOOL_NAMES = ("todo_list", "todo") # legacy alias: pre-rename replays - def _cap_tui_verbose_text(text: str) -> str: if len(text) <= _TUI_VERBOSE_TEXT_MAX_CHARS and text.count("\n") < _TUI_VERBOSE_TEXT_MAX_LINES: @@ -182,7 +180,7 @@ def _todo_state_from_history(history) -> dict | None: if not isinstance(history, list) or not history: return None try: - from tools.todo_tool import MAX_TODO_RESULT_CHARS + from tools.todo_tool import MAX_TODO_RESULT_CHARS, is_todo_tool_call todo_call_ids = { call.get("id") for msg in history if isinstance(msg, dict) @@ -351,13 +349,15 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result payload["summary"] = summary if _session_verbose(sid) and (result_text := _tool_result_text(result)): payload["result_text"] = result_text - todo_state = _normalize_todo_state(payload.get("result")) if name in _TODO_TOOL_NAMES else None + from tools.todo_tool import is_todo_tool_name + + todo_state = _normalize_todo_state(payload.get("result")) if is_todo_tool_name(name) else None if todo_state is not None: payload.update(todo_state) if session is not None: _cache_todo_state(session, todo_state) if (_process_tool_chrome_enabled(sid) or payload.get("inline_diff") or _tool_lifecycle_required_for_ui(name) - or name in _TODO_TOOL_NAMES or _connector_tool_lifecycle(name, args) + or is_todo_tool_name(name) or _connector_tool_lifecycle(name, args) or _tool_result_needs_user(result)): _emit_tool_lifecycle("tool.complete", sid, name, args, payload) # Task state is application data, not tool-progress chrome: a dedicated full-snapshot event lets