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:
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user