refactor: migrate hand-rolled error envelopes to shared tool_error()

Replace json.dumps({"error": ...}) boilerplate with the documented
tools/registry.py tool_error() helper across 13 files.

Migrated: 59 sites (58 code sites + 1 docstring example in
path_security.py), incl. multi-key envelopes passed via kwargs
(available_actions, path/already_read, pattern/already_searched,
parameters/hint, needs_reauth/server, error_type/tool/result_type).
Also removed 2 now-redundant local tool_error imports in mcp_tool.py
in favor of a module-level import.

Skipped (not byte/shape-compatible with tool_error):
- {"success": false, "error": ...} envelopes (browser_tool,
  browser_camofox, browser_dialog_tool, web_tools, tts_tool,
  skills_tool, image_generation_tool, project_tools, memory_tool,
  cronjob_tools, x_search_tool, xai_video_tools) — leading keys
  differ; key order would change.
- terminal_tool/code_execution_tool envelopes carrying output/
  exit_code/status leading keys.
- tool_search.py:912-area multi-key success paths (non-error).
- mcp_tool.py MCPSampling._error — returns MCP-spec ErrorData
  object, not a JSON string; incompatible.
- send_message_tool._error — returns a dict (not str) and applies
  secret redaction; return type must be preserved.

Behavior note: sites that previously omitted ensure_ascii=False now
emit raw UTF-8 (tool_error's canonical behavior) — JSON-equivalent.

Tests: 23 targeted files (tool_search, discord, file_tools/read
guards/operations, registry, clarify, homeassistant, code_execution,
send_message, delegate, terminal, mcp, model_tools, sanitize_tool_error,
retaindb plugin) — all pass. ruff clean.
This commit is contained in:
teknium1 2026-07-29 09:02:14 -07:00 committed by Teknium
parent f57cb2e482
commit 1a7f73b8ea
13 changed files with 181 additions and 251 deletions

View file

@ -29,7 +29,7 @@ import threading
import time
from typing import Dict, Any, List, Optional, Tuple
from tools.registry import discover_builtin_tools, registry
from tools.registry import discover_builtin_tools, registry, tool_error
from toolsets import resolve_toolset, validate_toolset
logger = logging.getLogger(__name__)
@ -1170,8 +1170,7 @@ def handle_function_call(
if function_name == _ts_mod.TOOL_CALL_NAME:
underlying_name, underlying_args, err = _ts_mod.resolve_underlying_call(function_args or {})
if err or not underlying_name:
return json.dumps({"error": err or "tool_call could not be resolved"},
ensure_ascii=False)
return tool_error(err or "tool_call could not be resolved")
# Defense in depth: the underlying tool MUST be in the session's
# scoped deferrable catalog. resolve_underlying_call() only checks
# that the name is deferrable in the global registry; this gate
@ -1180,12 +1179,10 @@ def handle_function_call(
# the bridge even if the catalog scoping above regressed.
_scoped_deferrable = _ts_mod.scoped_deferrable_names(current_defs)
if underlying_name not in _scoped_deferrable:
return json.dumps({
"error": (
f"'{underlying_name}' is not available in this session. "
"Use tool_search to find tools you can call."
),
}, ensure_ascii=False)
return tool_error(
f"'{underlying_name}' is not available in this session. "
"Use tool_search to find tools you can call."
)
# Probe-validate against the deferred tool's schema (ironclaw#5149):
# a blind call missing required arguments returns the parameter
# schema instead of dispatching into an opaque downstream failure.
@ -1232,7 +1229,7 @@ def handle_function_call(
try:
if function_name in _AGENT_LOOP_TOOLS:
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
return tool_error(f"{function_name} must be handled by the agent loop")
# Check plugin hooks for a block/approve directive (unless caller
# already checked — e.g. run_agent._invoke_tool passes skip=True to
@ -1263,7 +1260,7 @@ def handle_function_call(
logger.debug("pre_tool_call hook error: %s", _hook_err)
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
result = tool_error(block_message)
_emit_post_tool_call_hook(
function_name=function_name,
function_args=function_args,
@ -1292,7 +1289,7 @@ def handle_function_call(
except Exception as _edit_approval_err:
logger.debug("ACP edit approval guard error: %s", _edit_approval_err)
if function_name in {"write_file", "patch"}:
return json.dumps({"error": "Edit approval denied: approval guard failed"}, ensure_ascii=False)
return tool_error("Edit approval denied: approval guard failed")
# Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine).
@ -1419,7 +1416,7 @@ def handle_function_call(
except Exception as e:
error_msg = f"Error executing {function_name}: {str(e)}"
logger.exception(error_msg)
return json.dumps({"error": _sanitize_tool_error(error_msg)}, ensure_ascii=False)
return tool_error(_sanitize_tool_error(error_msg))
# =============================================================================

View file

@ -157,18 +157,12 @@ def clarify_tool(
choices = None # empty list → open-ended
if callback is None:
return json.dumps(
{"error": "Clarify tool is not available in this execution context."},
ensure_ascii=False,
)
return tool_error("Clarify tool is not available in this execution context.")
try:
raw_response = _invoke_callback(callback, question, choices, multi_select)
except Exception as exc:
return json.dumps(
{"error": f"Failed to get user input: {exc}"},
ensure_ascii=False,
)
return tool_error(f"Failed to get user input: {exc}")
if multi_select and choices is not None:
user_response = _parse_multi_select_response(raw_response)

View file

@ -621,7 +621,7 @@ def _rpc_server_loop(
# sandbox-script-supplied JSON.
str(request.get("token") or "").encode(), rpc_token.encode()
):
resp = json.dumps({"error": "Unauthorized RPC request"})
resp = tool_error("Unauthorized RPC request")
conn.sendall((resp + "\n").encode())
continue
@ -631,23 +631,19 @@ def _rpc_server_loop(
# Enforce the allow-list
if tool_name not in allowed_tools:
available = ", ".join(sorted(allowed_tools))
resp = json.dumps({
"error": (
f"Tool '{tool_name}' is not available in execute_code. "
f"Available: {available}"
)
})
resp = tool_error(
f"Tool '{tool_name}' is not available in execute_code. "
f"Available: {available}"
)
conn.sendall((resp + "\n").encode())
continue
# Enforce tool call limit
if tool_call_counter[0] >= max_tool_calls:
resp = json.dumps({
"error": (
f"Tool call limit reached ({max_tool_calls}). "
"No more tool calls allowed in this execution."
)
})
resp = tool_error(
f"Tool call limit reached ({max_tool_calls}). "
"No more tool calls allowed in this execution."
)
conn.sendall((resp + "\n").encode())
continue
@ -923,20 +919,16 @@ def _rpc_poll_loop(
# Enforce allow-list
if tool_name not in allowed_tools:
available = ", ".join(sorted(allowed_tools))
tool_result = json.dumps({
"error": (
f"Tool '{tool_name}' is not available in execute_code. "
f"Available: {available}"
)
})
tool_result = tool_error(
f"Tool '{tool_name}' is not available in execute_code. "
f"Available: {available}"
)
# Enforce tool call limit
elif tool_call_counter[0] >= max_tool_calls:
tool_result = json.dumps({
"error": (
f"Tool call limit reached ({max_tool_calls}). "
"No more tool calls allowed in this execution."
)
})
tool_result = tool_error(
f"Tool call limit reached ({max_tool_calls}). "
"No more tool calls allowed in this execution."
)
else:
# Strip forbidden terminal parameters
if tool_name == "terminal" and isinstance(tool_args, dict):
@ -1207,10 +1199,10 @@ def execute_code(
JSON string with execution results.
"""
if not SANDBOX_AVAILABLE:
return json.dumps({
"error": "execute_code sandbox is unavailable in this environment. "
"Use normal tool calls (terminal, read_file, write_file, ...) instead."
})
return tool_error(
"execute_code sandbox is unavailable in this environment. "
"Use normal tool calls (terminal, read_file, write_file, ...) instead."
)
if not code or not code.strip():
return tool_error("No code provided.")

View file

@ -2828,16 +2828,12 @@ def delegate_task(
depth = getattr(parent_agent, "_delegate_depth", 0)
max_spawn = _get_max_spawn_depth()
if depth >= max_spawn:
return json.dumps(
{
"error": (
f"Delegation depth limit reached (depth={depth}, "
f"max_spawn_depth={max_spawn}). Raise "
f"delegation.max_spawn_depth in config.yaml if deeper "
f"nesting is required (no hard ceiling, but each level "
f"multiplies API cost)."
)
}
return tool_error(
f"Delegation depth limit reached (depth={depth}, "
f"max_spawn_depth={max_spawn}). Raise "
f"delegation.max_spawn_depth in config.yaml if deeper "
f"nesting is required (no hard ceiling, but each level "
f"multiplies API cost)."
)
# Load config

View file

@ -34,7 +34,7 @@ import urllib.parse
import urllib.request
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from tools.registry import registry
from tools.registry import registry, tool_error
if TYPE_CHECKING:
from pathlib import Path
@ -1003,26 +1003,24 @@ def _run_discord_action(
"""Shared handler logic for both discord tools."""
token = _get_bot_token()
if not token:
return json.dumps({"error": "DISCORD_BOT_TOKEN not configured."})
return tool_error("DISCORD_BOT_TOKEN not configured.")
action_fn = valid_actions.get(action)
if not action_fn:
return json.dumps({
"error": f"Unknown action: {action}",
"available_actions": list(valid_actions.keys()),
})
return tool_error(
f"Unknown action: {action}",
available_actions=list(valid_actions.keys()),
)
# Config-level allowlist gate (defense in depth — schema already filtered,
# but a stale cached schema from a prior config should not let denied
# actions through).
allowlist = _load_allowed_actions_config()
if allowlist is not None and action not in allowlist:
return json.dumps({
"error": (
f"Action '{action}' is disabled by config (discord.server_actions). "
f"Allowed: {', '.join(allowlist) if allowlist else '<none>'}"
),
})
return tool_error(
f"Action '{action}' is disabled by config (discord.server_actions). "
f"Allowed: {', '.join(allowlist) if allowlist else '<none>'}"
)
local_vars = {
"guild_id": guild_id,
@ -1036,9 +1034,9 @@ def _run_discord_action(
missing = [p for p in _REQUIRED_PARAMS.get(action, []) if not local_vars.get(p)]
if missing:
return json.dumps({
"error": f"Missing required parameters for '{action}': {', '.join(missing)}",
})
return tool_error(
f"Missing required parameters for '{action}': {', '.join(missing)}"
)
try:
return action_fn(
@ -1058,11 +1056,11 @@ def _run_discord_action(
except DiscordAPIError as e:
logger.warning("Discord API error in %s action '%s': %s", tool_label, action, e)
if e.status == 403:
return json.dumps({"error": _enrich_403(action, e.body)})
return json.dumps({"error": str(e)})
return tool_error(_enrich_403(action, e.body))
return tool_error(str(e))
except Exception as e:
logger.exception("Unexpected error in %s action '%s'", tool_label, action)
return json.dumps({"error": f"Unexpected error: {e}"})
return tool_error(f"Unexpected error: {e}")
def discord_core(action: str, **kwargs) -> str:

View file

@ -1116,12 +1116,10 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# blocking on input). Pure path check — no I/O.
device_base = None if Path(path).expanduser().is_absolute() else _resolve_base_dir(task_id)
if _is_blocked_device(path, base_dir=device_base):
return json.dumps({
"error": (
f"Cannot read '{path}': this is a device file that would "
"block or produce infinite output."
),
})
return tool_error(
f"Cannot read '{path}': this is a device file that would "
"block or produce infinite output."
)
_resolved = _resolve_path_for_task(path, task_id)
@ -1188,12 +1186,10 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# Block binary files by extension (no I/O).
if has_binary_extension(str(_resolved)):
_ext = _resolved.suffix.lower()
return json.dumps({
"error": (
f"Cannot read binary file '{path}' ({_ext}). "
"Use vision_analyze for images, or terminal to inspect binary files."
),
})
return tool_error(
f"Cannot read binary file '{path}' ({_ext}). "
"Use vision_analyze for images, or terminal to inspect binary files."
)
# ── Hermes internal path guard ────────────────────────────────
# Prevent prompt injection via catalog or hub metadata files,
@ -1204,7 +1200,7 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# the Python process cwd, which can differ.
block_error = get_read_block_error(str(_resolved))
if block_error:
return json.dumps({"error": block_error})
return tool_error(block_error)
# ── Dedup check ───────────────────────────────────────────────
# If we already read this exact (path, offset, limit) and the
@ -1242,19 +1238,17 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
_cap_read_tracker_data(task_data)
if hits >= 2:
return json.dumps({
"error": (
f"BLOCKED: You have called read_file on this "
f"exact region {hits + 1} times and the file "
"has NOT changed. STOP calling read_file for "
"this path — the content from your earlier "
"read_file result in this conversation is "
"still current. Proceed with your task using "
"the information you already have."
),
"path": path,
"already_read": hits + 1,
}, ensure_ascii=False)
return tool_error(
f"BLOCKED: You have called read_file on this "
f"exact region {hits + 1} times and the file "
"has NOT changed. STOP calling read_file for "
"this path — the content from your earlier "
"read_file result in this conversation is "
"still current. Proceed with your task using "
"the information you already have.",
path=path,
already_read=hits + 1,
)
return json.dumps({
"status": "unchanged",
@ -1380,15 +1374,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
if count >= 4:
# Hard block: stop returning content to break the loop
return json.dumps({
"error": (
f"BLOCKED: You have read this exact file region {count} times in a row. "
"The content has NOT changed. You already have this information. "
"STOP re-reading and proceed with your task."
),
"path": path,
"already_read": count,
}, ensure_ascii=False)
return tool_error(
f"BLOCKED: You have read this exact file region {count} times in a row. "
"The content has NOT changed. You already have this information. "
"STOP re-reading and proceed with your task.",
path=path,
already_read=count,
)
elif count >= 3:
result_dict["_warning"] = (
f"You have read this exact file region {count} times consecutively. "
@ -1878,15 +1870,13 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
count = task_data["consecutive"]
if count >= 4:
return json.dumps({
"error": (
f"BLOCKED: You have run this exact search {count} times in a row. "
"The results have NOT changed. You already have this information. "
"STOP re-searching and proceed with your task."
),
"pattern": pattern,
"already_searched": count,
}, ensure_ascii=False)
return tool_error(
f"BLOCKED: You have run this exact search {count} times in a row. "
"The results have NOT changed. You already have this information. "
"STOP re-searching and proceed with your task.",
pattern=pattern,
already_searched=count,
)
try:
resolved_path = _resolve_path_for_task(path, task_id)
@ -1894,7 +1884,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
resolved_path = None
block_error = get_read_block_error(str(resolved_path) if resolved_path else path)
if block_error:
return json.dumps({"error": block_error}, ensure_ascii=False)
return tool_error(block_error)
file_ops = _get_file_ops(task_id)
result = file_ops.search(

View file

@ -264,10 +264,10 @@ def _handle_call_service(args: dict, **kw) -> str:
return tool_error(f"Invalid service format: {service!r}")
if domain in _BLOCKED_DOMAINS:
return json.dumps({
"error": f"Service domain '{domain}' is blocked for security. "
return tool_error(
f"Service domain '{domain}' is blocked for security. "
f"Blocked domains: {', '.join(sorted(_BLOCKED_DOMAINS))}"
})
)
entity_id = args.get("entity_id")
if entity_id and not _ENTITY_ID_RE.match(entity_id):

View file

@ -111,6 +111,8 @@ from datetime import datetime
from typing import Any, Coroutine, Dict, List, Optional
from urllib.parse import urlparse
from tools.registry import tool_error
logger = logging.getLogger(__name__)
# Upper bound for the OSV malware preflight during stdio MCP startup. The
@ -3899,16 +3901,14 @@ def _handle_auth_error_and_retry(
# needs_reauth error. Bumps the circuit breaker so the model stops
# retrying the tool.
_bump_server_error(server_name)
return json.dumps({
"error": (
f"MCP server '{server_name}' requires re-authentication. "
f"Run `hermes mcp login {server_name}` (or delete the tokens "
f"file under ~/.hermes/mcp-tokens/ and restart). Do NOT retry "
f"this tool — ask the user to re-authenticate."
),
"needs_reauth": True,
"server": server_name,
}, ensure_ascii=False)
return tool_error(
f"MCP server '{server_name}' requires re-authentication. "
f"Run `hermes mcp login {server_name}` (or delete the tokens "
f"file under ~/.hermes/mcp-tokens/ and restart). Do NOT retry "
f"this tool — ask the user to re-authenticate.",
needs_reauth=True,
server=server_name,
)
# Substrings (lower-cased match) that indicate the MCP server rejected
@ -4498,9 +4498,7 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
def _interrupted_call_result() -> str:
"""Standardized JSON error for a user-interrupted MCP tool call."""
return json.dumps({
"error": "MCP call interrupted: user sent a new message"
}, ensure_ascii=False)
return tool_error("MCP call interrupted: user sent a new message")
# ---------------------------------------------------------------------------
@ -4696,23 +4694,19 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
age = time.monotonic() - opened_at
if age < _CIRCUIT_BREAKER_COOLDOWN_SEC:
remaining = max(1, int(_CIRCUIT_BREAKER_COOLDOWN_SEC - age))
return json.dumps({
"error": (
f"MCP server '{server_name}' is unreachable after "
f"{_server_error_counts[server_name]} consecutive "
f"failures. Auto-retry available in ~{remaining}s. "
f"Do NOT retry this tool yet — use alternative "
f"approaches or ask the user to check the MCP server."
)
}, ensure_ascii=False)
return tool_error(
f"MCP server '{server_name}' is unreachable after "
f"{_server_error_counts[server_name]} consecutive "
f"failures. Auto-retry available in ~{remaining}s. "
f"Do NOT retry this tool yet — use alternative "
f"approaches or ask the user to check the MCP server."
)
# Cooldown elapsed → fall through as a half-open probe.
server = _get_connected_server_for_call(server_name)
if not server:
_bump_server_error(server_name)
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(f"MCP server '{server_name}' is not connected")
if not server.session:
# No live session. A reconnect may already be completing (the
@ -4737,16 +4731,12 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
# _reset_server_error).
_bump_server_error(server_name)
if _signal_reconnect(server):
return json.dumps({
"error": (
f"MCP server '{server_name}' transport is down; "
f"reconnect requested. Do NOT retry this tool "
f"immediately — give it a few seconds to come back."
)
}, ensure_ascii=False)
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(
f"MCP server '{server_name}' transport is down; "
f"reconnect requested. Do NOT retry this tool "
f"immediately — give it a few seconds to come back."
)
return tool_error(f"MCP server '{server_name}' is not connected")
async def _call():
_mark_server_call_started(server)
@ -4779,11 +4769,9 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
res_text = getattr(getattr(block, "resource", None), "text", None)
if res_text:
error_text += str(res_text)
return json.dumps({
"error": _sanitize_error(
error_text or "MCP tool returned an error"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
error_text or "MCP tool returned an error"
))
# Collect text from content blocks. MCP tool results can also
# include ImageContent blocks (screenshot / Blockbench / Playwright
@ -4891,11 +4879,9 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
"MCP tool %s/%s call failed: %s",
server_name, tool_name, exc,
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
))
return _handler
@ -4906,9 +4892,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float):
def _handler(args: dict, **kwargs) -> str:
server = _get_connected_server_for_call(server_name)
if not server or not server.session:
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(f"MCP server '{server_name}' is not connected")
async def _call():
_mark_server_call_started(server)
@ -4951,11 +4935,9 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float):
logger.error(
"MCP %s/list_resources failed: %s", server_name, exc,
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
))
return _handler
@ -4964,13 +4946,9 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float):
"""Return a sync handler that reads a resource by URI from an MCP server."""
def _handler(args: dict, **kwargs) -> str:
from tools.registry import tool_error
server = _get_connected_server_for_call(server_name)
if not server or not server.session:
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(f"MCP server '{server_name}' is not connected")
uri = args.get("uri")
if not uri:
@ -5018,11 +4996,9 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float):
logger.error(
"MCP %s/read_resource failed: %s", server_name, exc,
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
))
return _handler
@ -5033,9 +5009,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float):
def _handler(args: dict, **kwargs) -> str:
server = _get_connected_server_for_call(server_name)
if not server or not server.session:
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(f"MCP server '{server_name}' is not connected")
async def _call():
_mark_server_call_started(server)
@ -5083,11 +5057,9 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float):
logger.error(
"MCP %s/list_prompts failed: %s", server_name, exc,
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
))
return _handler
@ -5096,13 +5068,9 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float):
"""Return a sync handler that gets a prompt by name from an MCP server."""
def _handler(args: dict, **kwargs) -> str:
from tools.registry import tool_error
server = _get_connected_server_for_call(server_name)
if not server or not server.session:
return json.dumps({
"error": f"MCP server '{server_name}' is not connected"
}, ensure_ascii=False)
return tool_error(f"MCP server '{server_name}' is not connected")
name = args.get("name")
if not name:
@ -5154,11 +5122,9 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float):
logger.error(
"MCP %s/get_prompt failed: %s", server_name, exc,
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
return tool_error(_sanitize_error(
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
))
return _handler

View file

@ -23,7 +23,7 @@ def validate_within_dir(path: Path, root: Path) -> Optional[str]:
error = validate_within_dir(user_path, allowed_root)
if error:
return json.dumps({"error": error})
return tool_error(error)
"""
try:
resolved = path.resolve()

View file

@ -667,12 +667,12 @@ class ToolRegistry:
name,
result_type,
)
return json.dumps({
"error": f"Tool handler returned unsupported result type: {result_type}",
"error_type": "tool_result_contract",
"tool": name,
"result_type": result_type,
}, ensure_ascii=False)
return tool_error(
f"Tool handler returned unsupported result type: {result_type}",
error_type="tool_result_contract",
tool=name,
result_type=result_type,
)
def dispatch(self, name: str, args: dict, **kwargs) -> str | dict:
"""Execute a tool handler by name.
@ -685,7 +685,7 @@ class ToolRegistry:
"""
entry = self.get_entry(name)
if not entry:
return json.dumps({"error": f"Unknown tool: {name}"})
return tool_error(f"Unknown tool: {name}")
try:
if entry.is_async:
from model_tools import _run_async
@ -704,7 +704,7 @@ class ToolRegistry:
sanitized = _sanitize_tool_error(raw)
except Exception:
sanitized = raw # defensive: never let the sanitizer block error propagation
return json.dumps({"error": sanitized})
return tool_error(sanitized)
# ------------------------------------------------------------------
# Query helpers (replace redundant dicts in model_tools.py)

View file

@ -383,15 +383,15 @@ def _handle_send(args):
if resolved:
chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
else:
return json.dumps({
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
return tool_error(
f"Could not resolve '{target_ref}' on {platform_name}. "
f"Use send_message(action='list') to see available targets."
})
)
except Exception:
return json.dumps({
"error": f"Could not resolve '{target_ref}' on {platform_name}. "
return tool_error(
f"Could not resolve '{target_ref}' on {platform_name}. "
f"Try using a numeric channel ID instead."
})
)
from tools.interrupt import is_interrupted
if is_interrupted():
@ -460,11 +460,11 @@ def _handle_send(args):
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
)
return json.dumps({
"error": f"No home channel set for {platform_name} to determine where to send the message. "
return tool_error(
f"No home channel set for {platform_name} to determine where to send the message. "
f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
f"or set a home channel via: hermes config set {home_env} <channel_id>"
})
)
duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
if duplicate_skip:

View file

@ -56,6 +56,7 @@ logger = logging.getLogger(__name__)
# long-running subprocesses immediately instead of blocking until timeout.
# ---------------------------------------------------------------------------
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
from tools.registry import tool_error
# display_hermes_home imported lazily at call site (stale-module safety during hermes update)
@ -2209,13 +2210,11 @@ def terminal_tool(
# Reject foreground commands where the model explicitly requests
# a timeout above FOREGROUND_MAX_TIMEOUT — nudge it toward background.
if not background and timeout and timeout > FOREGROUND_MAX_TIMEOUT:
return json.dumps({
"error": (
f"Foreground timeout {timeout}s exceeds the maximum of "
f"{FOREGROUND_MAX_TIMEOUT}s. Use background=true with "
f"notify_on_complete=true for long-running commands."
),
}, ensure_ascii=False)
return tool_error(
f"Foreground timeout {timeout}s exceeds the maximum of "
f"{FOREGROUND_MAX_TIMEOUT}s. Use background=true with "
f"notify_on_complete=true for long-running commands."
)
# Guardrail: long-lived server/watch commands should run as managed
# background sessions, not foreground shell hacks.

View file

@ -44,6 +44,8 @@ import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Tuple
from tools.registry import tool_error
logger = logging.getLogger("tools.tool_search")
@ -868,7 +870,7 @@ def dispatch_tool_search(args: Dict[str, Any],
config = load_config()
query = str(args.get("query") or "").strip()
if not query:
return json.dumps({"error": "query is required"}, ensure_ascii=False)
return tool_error("query is required")
raw_limit = args.get("limit")
if raw_limit is None:
@ -892,14 +894,12 @@ def dispatch_tool_describe(args: Dict[str, Any],
"""Execute the ``tool_describe`` bridge tool. Returns a JSON string."""
name = str(args.get("name") or "").strip()
if not name:
return json.dumps({"error": "name is required"}, ensure_ascii=False)
return tool_error("name is required")
if not is_deferrable_tool_name(name):
return json.dumps({
"error": (
f"'{name}' is not a deferrable tool. If you see it in the tools list "
"already, call it directly; otherwise check the spelling against tool_search."
),
}, ensure_ascii=False)
return tool_error(
f"'{name}' is not a deferrable tool. If you see it in the tools list "
"already, call it directly; otherwise check the spelling against tool_search."
)
_, deferrable = classify_tools(current_tool_defs)
for td in deferrable:
fn = td.get("function") or {}
@ -909,9 +909,9 @@ def dispatch_tool_describe(args: Dict[str, Any],
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
}, ensure_ascii=False)
return json.dumps({
"error": f"'{name}' is not currently available. Re-run tool_search to refresh.",
}, ensure_ascii=False)
return tool_error(
f"'{name}' is not currently available. Re-run tool_search to refresh."
)
def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]:
@ -973,17 +973,15 @@ def validate_deferred_call_args(name: str, args: Dict[str, Any]) -> Optional[str
missing = [r for r in required if isinstance(r, str) and r not in args]
if not missing:
return None
return json.dumps({
"error": (
f"tool_call to '{name}' is missing required argument(s): "
f"{', '.join(missing)}. The tool was NOT invoked."
),
"parameters": params,
"hint": (
return tool_error(
f"tool_call to '{name}' is missing required argument(s): "
f"{', '.join(missing)}. The tool was NOT invoked.",
parameters=params,
hint=(
"Retry tool_call with 'arguments' matching the parameters "
"schema above."
),
}, ensure_ascii=False)
)
except Exception: # pragma: no cover — never block dispatch on validator bugs
logger.debug("validate_deferred_call_args failed for %s", name, exc_info=True)
return None