fix(agent): keep the todo predicate off the model_tools/executor import path

is_todo_tool_call lived in agent/tool_executor.py and went through
canonical_tool_name, which imports model_tools. TUI resume calls it from
_todo_state_from_history on the RPC path, so the first resume in a
gateway loaded ~405 modules (2-3s) synchronously. tui_gateway/server.py
and run_agent.py also imported agent.tool_executor at module level,
adding ~142 modules to every TUI/desktop launch and breaking run_agent's
lazy-forward rule.

The predicate now lives in tools/todo_tool.py, which both startup paths
already load. It matches TODO_TOOL_NAMES ({TODO_SCHEMA name} + the legacy
aliases) and imports the bridge parser only when a tool_call entry's
args mention "todo". model_tools._LEGACY_TOOL_ALIASES derives its todo
entry from TODO_LEGACY_ALIASES, so there is one source of truth ("todo"
is the only alias mapping to todo_list). The live tool.complete path in
tool_progress uses is_todo_tool_name and the hand-kept _TODO_TOOL_NAMES
tuple is gone. The server.py noqa import is replaced by a function-local
import next to MAX_TODO_RESULT_CHARS, so a pruned name can't be swallowed
by the broad except. run_agent imports lazily. The dead TypeError arm is
dropped, and field reads use message_sanitization._tc_field.
agent/tool_executor.py is back to its pre-stack state.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
This commit is contained in:
kshitijk4poor
2026-09-27 16:43:23 +05:30
committed by kshitij
parent 05254937a4
commit 622a296f7a
6 changed files with 54 additions and 39 deletions

View File

@@ -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:

View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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