1300 lines
54 KiB
Python
1300 lines
54 KiB
Python
"""
|
|
Model Tools Module
|
|
|
|
Thin orchestration layer over the tool registry: importing this module runs tool
|
|
discovery (each tools/*.py self-registers via tools.registry.register()), then
|
|
exposes get_tool_definitions() (schemas sent to the model, toolset-filtered) and
|
|
handle_function_call() (dispatch with hooks/middleware) plus registry wrappers.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import re
|
|
import asyncio
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
|
|
from tools.registry import (
|
|
CHECK_FN_CACHE_BYPASS,
|
|
check_fn_cache_scope,
|
|
discover_builtin_tools,
|
|
registry,
|
|
tool_error,
|
|
)
|
|
from toolsets import resolve_toolset, validate_toolset
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_post_tool_call_hook_suppressed: ContextVar[bool] = ContextVar(
|
|
"post_tool_call_hook_suppressed", default=False
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def suppress_post_tool_call_hook():
|
|
"""Let an outer executor own the terminal post-tool event."""
|
|
token = _post_tool_call_hook_suppressed.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_post_tool_call_hook_suppressed.reset(token)
|
|
|
|
# Platform-bundle names already flagged in disabled_toolsets (advisory logged once per name).
|
|
_WARNED_DISABLED_BUNDLES: set = set()
|
|
|
|
|
|
def _is_delegated_child_context() -> bool:
|
|
try:
|
|
from agent.delegation_context import is_delegated_child_context
|
|
|
|
return is_delegated_child_context()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _is_dispatcher_owned_worker() -> bool:
|
|
"""False when HERMES_KANBAN_* is present but this execution does not own it
|
|
(delegate_task child, or a cron job fired in-process from a worker)."""
|
|
try:
|
|
from agent.delegation_context import is_dispatcher_owned_worker_context
|
|
|
|
return is_dispatcher_owned_worker_context()
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
# =============================================================================
|
|
# Async Bridging (single source of truth -- used by registry.dispatch too)
|
|
# =============================================================================
|
|
# Loops are persistent (never asyncio.run per call): cached httpx/AsyncOpenAI
|
|
# clients stay bound to a live loop, so their GC cleanup can't hit
|
|
# "Event loop is closed". Main thread shares one loop; worker threads
|
|
# (parallel tool execution) each own a thread-local loop to avoid contention.
|
|
|
|
_tool_loop = None # persistent loop for the main (CLI) thread
|
|
_tool_loop_lock = threading.Lock()
|
|
_worker_thread_local = threading.local() # per-worker-thread persistent loops
|
|
|
|
|
|
def _get_tool_loop():
|
|
"""Long-lived event loop for async tool handlers on the main thread."""
|
|
global _tool_loop
|
|
with _tool_loop_lock:
|
|
if _tool_loop is None or _tool_loop.is_closed():
|
|
_tool_loop = asyncio.new_event_loop()
|
|
return _tool_loop
|
|
|
|
|
|
def _get_worker_loop():
|
|
"""Persistent event loop for the current worker thread (thread-local)."""
|
|
loop = getattr(_worker_thread_local, 'loop', None)
|
|
if loop is None or loop.is_closed():
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
_worker_thread_local.loop = loop
|
|
return loop
|
|
|
|
|
|
def _run_async(coro):
|
|
"""Run a coroutine from sync code; safe under a running loop (gateway/RL env)."""
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
|
|
if loop and loop.is_running():
|
|
# Already inside an event loop: run in a fresh thread whose loop we
|
|
# hold a reference to, so on timeout we can cancel the task inside it
|
|
# (ThreadPoolExecutor.cancel() is a no-op on a running worker and
|
|
# would leak the thread on every 300 s timeout).
|
|
import concurrent.futures
|
|
|
|
worker_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
loop_ready = threading.Event()
|
|
|
|
def _run_in_worker():
|
|
nonlocal worker_loop
|
|
worker_loop = asyncio.new_event_loop()
|
|
loop_ready.set()
|
|
try:
|
|
asyncio.set_event_loop(worker_loop)
|
|
return worker_loop.run_until_complete(coro)
|
|
finally:
|
|
try:
|
|
# Drain tasks still pending after an external cancel.
|
|
pending = asyncio.all_tasks(worker_loop)
|
|
for t in pending:
|
|
t.cancel()
|
|
if pending:
|
|
worker_loop.run_until_complete(
|
|
asyncio.gather(*pending, return_exceptions=True)
|
|
)
|
|
except Exception:
|
|
pass
|
|
worker_loop.close()
|
|
|
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
# Carry profile + approval/sudo context so get_hermes_home() resolves correctly.
|
|
from tools.thread_context import propagate_context_to_thread
|
|
|
|
future = pool.submit(propagate_context_to_thread(_run_in_worker))
|
|
try:
|
|
return future.result(timeout=300)
|
|
except concurrent.futures.TimeoutError:
|
|
# Cancel inside the worker's own loop so the thread can wind down.
|
|
if loop_ready.wait(timeout=1.0) and worker_loop is not None:
|
|
try:
|
|
for t in asyncio.all_tasks(worker_loop):
|
|
worker_loop.call_soon_threadsafe(t.cancel)
|
|
except RuntimeError:
|
|
pass # loop already closed
|
|
raise
|
|
finally:
|
|
# wait=False: never block the caller on a stuck coroutine.
|
|
pool.shutdown(wait=False)
|
|
|
|
if threading.current_thread() is not threading.main_thread():
|
|
return _get_worker_loop().run_until_complete(coro)
|
|
return _get_tool_loop().run_until_complete(coro)
|
|
|
|
|
|
# =============================================================================
|
|
# Tool Discovery (importing each module triggers its registry.register calls)
|
|
# =============================================================================
|
|
|
|
discover_builtin_tools()
|
|
|
|
# MCP discovery is deliberately NOT run here: it blocks up to 120 s and the
|
|
# gateway lazy-imports this module inside its event loop. Each entry point
|
|
# (gateway/run.py, cli.py, tui_gateway, acp_adapter) runs it at its own startup.
|
|
|
|
# Plugin tool discovery (user/project/pip plugins)
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins
|
|
discover_plugins()
|
|
except Exception as e:
|
|
logger.debug("Plugin discovery failed: %s", e)
|
|
|
|
|
|
# =============================================================================
|
|
# Backward-compat constants (built once after discovery)
|
|
# =============================================================================
|
|
|
|
TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map()
|
|
|
|
TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements()
|
|
|
|
# Tool names from the last get_tool_definitions() call (execute_code sandbox fallback).
|
|
_last_resolved_tool_names: List[str] = []
|
|
|
|
|
|
# =============================================================================
|
|
# Legacy toolset name mapping (old _tools-suffixed names -> tool name lists)
|
|
# =============================================================================
|
|
|
|
_LEGACY_TOOLSET_MAP = {
|
|
"web_tools": ["web_search", "web_extract"],
|
|
"terminal_tools": ["terminal"],
|
|
"vision_tools": ["vision_analyze"],
|
|
"image_tools": ["image_generate"],
|
|
"skills_tools": ["skills_list", "skill_view", "skill_manage"],
|
|
"browser_tools": [
|
|
"browser_navigate", "browser_snapshot", "browser_click",
|
|
"browser_type", "browser_scroll", "browser_back",
|
|
"browser_press", "browser_get_images",
|
|
"browser_vision", "browser_console"
|
|
],
|
|
"cronjob_tools": ["cronjob_manage"],
|
|
"file_tools": ["read_file", "write_file", "patch", "search_files"],
|
|
"tts_tools": ["text_to_speech"],
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# get_tool_definitions (the main schema provider)
|
|
# =============================================================================
|
|
|
|
# Memo for get_tool_definitions(), active only with quiet_mode=True (the
|
|
# non-quiet path prints). Hot callers (gateway runner, AIAgent.__init__) hit it
|
|
# every turn; a miss costs ~7 ms of registry walk + check_fn probing. The key
|
|
# includes registry._generation (bumped on register/deregister/alias) so
|
|
# invalidation is transparent; check_fn drift is handled by registry.py's 30 s TTL.
|
|
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
|
|
_tool_defs_cache_lock = threading.Lock()
|
|
|
|
# FIFO cap: a long-lived gateway sees many toolset/config fingerprints; 8
|
|
# covers the warm working set of platform/toolset combos it actually serves.
|
|
_TOOL_DEFS_CACHE_MAX = 8
|
|
|
|
|
|
def _clear_tool_defs_cache() -> None:
|
|
"""Drop memoized results when a dynamic-schema dependency changes (discord caps, sandbox mode)."""
|
|
with _tool_defs_cache_lock:
|
|
_tool_defs_cache.clear()
|
|
|
|
|
|
def get_tool_definitions(
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
disabled_toolsets: Optional[List[str]] = None,
|
|
quiet_mode: bool = False,
|
|
skip_tool_search_assembly: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Tool definitions for model API calls, filtered by toolset.
|
|
|
|
Args:
|
|
enabled_toolsets: Only include tools from these toolsets (None = all).
|
|
disabled_toolsets: Toolsets subtracted after enabling.
|
|
quiet_mode: Suppress status prints (and enable memoization).
|
|
skip_tool_search_assembly: Return the pre-assembly list (raw schemas for
|
|
every enabled tool). Only the tool_search bridge should use this so
|
|
it reads the real catalog rather than the collapsed one.
|
|
"""
|
|
# Memo key covers every argument plus everything that changes the result
|
|
# without an argument changing: registry generation, config.yaml mtime/size
|
|
# (dynamic schemas: execute_code mode, discord allowlist), kanban context,
|
|
# profile scope. check_fn results are TTL-cached inside registry.get_definitions.
|
|
cache_key = None
|
|
if quiet_mode:
|
|
try:
|
|
from hermes_cli.config import get_config_path
|
|
cfg_stat = get_config_path().stat()
|
|
cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size)
|
|
except (FileNotFoundError, OSError, ImportError):
|
|
cfg_fp = None
|
|
profile_scope = check_fn_cache_scope()
|
|
if profile_scope != CHECK_FN_CACHE_BYPASS:
|
|
cache_key = (
|
|
registry.current_scope_key(),
|
|
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
|
|
frozenset(disabled_toolsets) if disabled_toolsets else None,
|
|
registry._generation,
|
|
cfg_fp,
|
|
bool(os.environ.get("HERMES_KANBAN_TASK")),
|
|
bool(skip_tool_search_assembly),
|
|
_is_delegated_child_context(),
|
|
_is_dispatcher_owned_worker(),
|
|
profile_scope,
|
|
)
|
|
with _tool_defs_cache_lock:
|
|
cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None
|
|
if cached is not None:
|
|
global _last_resolved_tool_names
|
|
_last_resolved_tool_names = [t["function"]["name"] for t in cached]
|
|
return list(cached)
|
|
|
|
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
|
|
skip_tool_search_assembly=skip_tool_search_assembly)
|
|
if quiet_mode and cache_key is not None:
|
|
with _tool_defs_cache_lock:
|
|
# Another thread may have filled this key meanwhile; reuse it.
|
|
cached = _tool_defs_cache.get(cache_key)
|
|
if cached is None:
|
|
if len(_tool_defs_cache) >= _TOOL_DEFS_CACHE_MAX:
|
|
_tool_defs_cache.pop(next(iter(_tool_defs_cache)))
|
|
_tool_defs_cache[cache_key] = result
|
|
cached = result
|
|
return list(cached)
|
|
# Quiet callers always get a shallow copy: run_agent appends memory/LCM
|
|
# schemas to its list, and a shared list would accumulate duplicate tool
|
|
# names across agent inits (rejected with HTTP 400 by DeepSeek/Kimi/MiMo).
|
|
if quiet_mode:
|
|
return list(result)
|
|
return result
|
|
|
|
|
|
def _apply_toolset_selection(tools: set, names: List[str], quiet_mode: bool, *, disable: bool) -> None:
|
|
"""Add (or subtract) every toolset in *names* to/from *tools*, printing the selection unless quiet."""
|
|
from toolsets import bundle_non_core_tools, get_toolset
|
|
verb, icon = ("Disabled", "🚫") if disable else ("Enabled", "✅")
|
|
for name in names:
|
|
if validate_toolset(name):
|
|
label = f"{verb} toolset"
|
|
if disable and (name.startswith("hermes-") or (get_toolset(name) or {}).get("posture")):
|
|
# Platform bundles and posture toolsets re-list the core tools
|
|
# without owning them; subtracting the whole set would empty
|
|
# the tool list. Remove only the non-core delta.
|
|
resolved = sorted(bundle_non_core_tools(name))
|
|
if not quiet_mode and name.startswith("hermes-") and name not in _WARNED_DISABLED_BUNDLES:
|
|
_WARNED_DISABLED_BUNDLES.add(name)
|
|
logger.info(
|
|
"agent.disabled_toolsets contains platform-bundle "
|
|
"name '%s'; core tools are preserved and only its "
|
|
"platform-specific tools (%s) are removed. Bundle "
|
|
"names usually belong in `toolsets:`, not "
|
|
"`disabled_toolsets` (#33924).",
|
|
name,
|
|
", ".join(resolved) if resolved else "none",
|
|
)
|
|
else:
|
|
resolved = resolve_toolset(name)
|
|
elif name in _LEGACY_TOOLSET_MAP:
|
|
label = f"{verb} legacy toolset"
|
|
resolved = _LEGACY_TOOLSET_MAP[name]
|
|
else:
|
|
if not quiet_mode:
|
|
print(f"⚠️ Unknown toolset: {name}")
|
|
continue
|
|
(tools.difference_update if disable else tools.update)(resolved)
|
|
if not quiet_mode:
|
|
print(f"{icon} {label} '{name}': {', '.join(resolved) if resolved else 'no tools'}")
|
|
|
|
|
|
def _select_tool_names(
|
|
enabled_toolsets: Optional[List[str]],
|
|
disabled_toolsets: Optional[List[str]],
|
|
quiet_mode: bool,
|
|
) -> set:
|
|
"""Tool names requested by the toolset selection (before check_fn filtering)."""
|
|
tools: set = set()
|
|
if enabled_toolsets is not None:
|
|
enabled = list(enabled_toolsets)
|
|
# Dispatcher-spawned kanban workers always get the lifecycle handoff
|
|
# tools, even when the assignee profile restricts its chat toolsets.
|
|
if (
|
|
os.environ.get("HERMES_KANBAN_TASK")
|
|
and not _is_delegated_child_context()
|
|
and _is_dispatcher_owned_worker()
|
|
and "kanban" not in enabled
|
|
):
|
|
enabled.append("kanban")
|
|
_apply_toolset_selection(tools, enabled, quiet_mode, disable=False)
|
|
else:
|
|
from toolsets import get_all_toolsets
|
|
for ts_name in get_all_toolsets():
|
|
tools.update(resolve_toolset(ts_name))
|
|
# Disabled toolsets are always subtracted LAST, so a tool in a disabled
|
|
# toolset is stripped even when a composite (hermes-cli) re-enables it.
|
|
if disabled_toolsets:
|
|
_apply_toolset_selection(tools, disabled_toolsets, quiet_mode, disable=True)
|
|
return tools
|
|
|
|
|
|
# --- Dynamic schema rewrites -------------------------------------------------
|
|
# Each rewriter receives the tool definition and the set of tool names that
|
|
# passed check_fn filtering, and returns the (possibly replaced) definition or
|
|
# None to drop the tool. Cross-references must use that set (not the requested
|
|
# names) so the model is never told about a tool that isn't in the list.
|
|
|
|
_BROWSER_NAVIGATE_WEB_HINT = " For simple information retrieval, prefer web_search or web_extract (faster, cheaper)."
|
|
|
|
|
|
def _rewrite_execute_code(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]:
|
|
"""List only sandbox tools that are actually available."""
|
|
from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema, _get_execution_mode
|
|
schema = build_execute_code_schema(SANDBOX_ALLOWED_TOOLS & available, mode=_get_execution_mode())
|
|
return {"type": "function", "function": schema}
|
|
|
|
|
|
def _discord_rewriter(schema_fn_name: str):
|
|
"""Schema depends on the bot's privileged intents and the config action allowlist; None drops the tool."""
|
|
def _rewrite(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
from tools import discord_tool as _dt
|
|
dynamic = getattr(_dt, schema_fn_name)()
|
|
except Exception:
|
|
dynamic = None
|
|
return None if dynamic is None else {"type": "function", "function": dynamic}
|
|
return _rewrite
|
|
|
|
|
|
def _rewrite_browser_navigate(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]:
|
|
"""Drop the "prefer web_search or web_extract" hint when neither web tool is present (else the model hallucinates them)."""
|
|
if {"web_search", "web_extract"} & available:
|
|
return td
|
|
desc = td["function"].get("description", "").replace(_BROWSER_NAVIGATE_WEB_HINT, "")
|
|
return {"type": "function", "function": {**td["function"], "description": desc}}
|
|
|
|
|
|
def _rewrite_browser_exec(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]:
|
|
"""browser_exec runs arbitrary host Python; a session without the terminal
|
|
surface must not regain host execution through the browser toolset. This is
|
|
a session-level gate rather than a check_fn: check_fns are TTL-cached
|
|
process-wide while one gateway serves sessions with different toolsets."""
|
|
return td if "terminal" in available else None
|
|
|
|
|
|
def _rewrite_delegate_task(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]:
|
|
"""Trim the child-restrictions line to sibling tools actually present, or drop
|
|
the line when none apply, so the model never learns ghost vocabulary. Two
|
|
source variants exist (depth-off also names delegate_task itself); test the
|
|
longer one first because the sibling list is a substring of it."""
|
|
blocked_present = [t for t in ("clarify", "memory", "cronjob_manage") if t in available]
|
|
if len(blocked_present) == 3:
|
|
return td
|
|
fn = td.get("function", {})
|
|
desc = fn.get("description", "")
|
|
full_offvariant = "delegate_task, clarify, memory, or cronjob"
|
|
full_onvariant = "clarify, memory, or cronjob"
|
|
if full_offvariant in desc:
|
|
full, keep_self = full_offvariant, True
|
|
elif full_onvariant in desc:
|
|
full, keep_self = full_onvariant, False
|
|
else:
|
|
return td
|
|
names = (["delegate_task"] if keep_self else []) + blocked_present
|
|
if blocked_present:
|
|
if len(names) == 1:
|
|
replacement = names[0]
|
|
elif len(names) == 2:
|
|
replacement = f"{names[0]} or {names[1]}"
|
|
else:
|
|
replacement = ", ".join(names[:-1]) + ", or " + names[-1]
|
|
desc = desc.replace(full, replacement)
|
|
else:
|
|
# Both variants end at the following newline.
|
|
start = desc.find("- Children cannot call " + full)
|
|
if start != -1:
|
|
end = desc.index("\n", start) + 1
|
|
desc = desc[:start] + desc[end:]
|
|
return {**td, "function": {**fn, "description": desc}}
|
|
|
|
|
|
_DYNAMIC_SCHEMA_REWRITERS = {
|
|
"execute_code": _rewrite_execute_code,
|
|
"discord": _discord_rewriter("get_dynamic_schema_core"),
|
|
"discord_admin": _discord_rewriter("get_dynamic_schema_admin"),
|
|
"browser_navigate": _rewrite_browser_navigate,
|
|
"browser_exec": _rewrite_browser_exec,
|
|
"delegate_task": _rewrite_delegate_task,
|
|
}
|
|
|
|
|
|
def _apply_dynamic_schemas(tool_defs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Apply _DYNAMIC_SCHEMA_REWRITERS in list order; the availability set is a
|
|
snapshot taken before any rewrite (no rewriter's inputs are droppable)."""
|
|
available = {t["function"]["name"] for t in tool_defs}
|
|
out = []
|
|
for td in tool_defs:
|
|
rewrite = _DYNAMIC_SCHEMA_REWRITERS.get(td["function"]["name"])
|
|
if rewrite is not None:
|
|
td = rewrite(td, available)
|
|
if td is not None:
|
|
out.append(td)
|
|
return out
|
|
|
|
|
|
def _compute_tool_definitions(
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
disabled_toolsets: Optional[List[str]] = None,
|
|
quiet_mode: bool = False,
|
|
skip_tool_search_assembly: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Uncached implementation of :func:`get_tool_definitions`."""
|
|
tools_to_include = _select_tool_names(enabled_toolsets, disabled_toolsets, quiet_mode)
|
|
# Registry returns only tools whose check_fn passes.
|
|
filtered_tools = _apply_dynamic_schemas(registry.get_definitions(tools_to_include, quiet=quiet_mode))
|
|
|
|
if not quiet_mode:
|
|
if filtered_tools:
|
|
tool_names = [t["function"]["name"] for t in filtered_tools]
|
|
print(f"🛠️ Final tool selection ({len(filtered_tools)} tools): {', '.join(tool_names)}")
|
|
else:
|
|
print("🛠️ No tools selected (all filtered out or unavailable)")
|
|
|
|
global _last_resolved_tool_names
|
|
_last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools]
|
|
|
|
# Normalize schema shapes llama.cpp's grammar converter rejects (bare
|
|
# "type": "object", string-valued nodes from malformed MCP servers).
|
|
try:
|
|
from tools.schema_sanitizer import sanitize_tool_schemas
|
|
filtered_tools = sanitize_tool_schemas(filtered_tools)
|
|
except Exception as e: # pragma: no cover — defensive
|
|
logger.warning("Schema sanitization skipped: %s", e)
|
|
|
|
# Tool Search (progressive disclosure): replace MCP/plugin tools with the
|
|
# tool_search/describe/call bridge when the deferrable surface exceeds the
|
|
# configured share of the context window. Core tools are never deferred.
|
|
# Must be the LAST step (after sanitization); idempotent if called twice.
|
|
try:
|
|
from tools.tool_search import assemble_tool_defs, load_config as _load_ts_config
|
|
ts_cfg = _load_ts_config()
|
|
if not skip_tool_search_assembly and ts_cfg.enabled != "off":
|
|
assembly = assemble_tool_defs(
|
|
filtered_tools,
|
|
context_length=_resolve_active_context_length(),
|
|
config=ts_cfg,
|
|
)
|
|
if assembly.activated and not quiet_mode:
|
|
_forms = {"full": "catalog listing embedded",
|
|
"names": "names-only listing embedded",
|
|
"mixed": "listing embedded (oversized servers summarized)",
|
|
"groups": "server summary embedded (search-only discovery)",
|
|
"none": "no listing (search-only)"}
|
|
print(
|
|
f"🔎 Tool Search (tier {assembly.tier}): {assembly.deferred_count} "
|
|
f"MCP/plugin tools deferred (~{assembly.deferred_tokens} tokens) behind "
|
|
f"tool_search/describe/call — {_forms.get(assembly.listing_form, assembly.listing_form)}."
|
|
)
|
|
filtered_tools = assembly.tool_defs
|
|
except Exception as e: # pragma: no cover — never break tool loading
|
|
logger.warning("Tool search assembly skipped: %s", e)
|
|
|
|
return filtered_tools
|
|
|
|
|
|
def _resolve_active_context_length() -> int:
|
|
"""Active model's context length for the tool-search gate (0 if unresolvable).
|
|
|
|
Order: explicit `model.context_length` in config.yaml; provider-aware
|
|
resolution (Codex OAuth enforces a smaller window than the direct API for
|
|
the same slug); the on-disk metadata cache (a slightly stale window is fine
|
|
for picking a disclosure tier and avoids a ~200 ms /models probe per CLI
|
|
startup); then the full live resolver.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config as _load
|
|
cfg = _load() or {}
|
|
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
|
|
_raw_model_id = model_cfg.get("model") or model_cfg.get("default") or ""
|
|
if isinstance(_raw_model_id, dict):
|
|
from hermes_cli.config import split_model_config_default
|
|
_raw_model_id, _ = split_model_config_default(_raw_model_id)
|
|
model_id = str(_raw_model_id).strip()
|
|
if not model_id:
|
|
return 0
|
|
from agent.model_metadata import get_model_context_length
|
|
raw_ctx = model_cfg.get("context_length")
|
|
config_ctx = raw_ctx if isinstance(raw_ctx, int) and raw_ctx > 0 else None
|
|
provider = str(model_cfg.get("provider") or "").strip()
|
|
base_url = str(model_cfg.get("base_url") or "").strip()
|
|
api_key = ""
|
|
if provider:
|
|
# Credential resolution failing (offline, no keys) degrades to a
|
|
# provider+base_url-only lookup so static fallbacks still apply.
|
|
try:
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
rt = resolve_runtime_provider(
|
|
requested=provider, target_model=model_id
|
|
) or {}
|
|
base_url = str(rt.get("base_url") or base_url or "").strip()
|
|
api_key = str(rt.get("api_key") or "").strip()
|
|
except Exception as rt_exc:
|
|
logger.debug(
|
|
"Runtime credential resolution failed for tool-search "
|
|
"context gate (provider=%s): %s — using config values only",
|
|
provider, rt_exc,
|
|
)
|
|
if config_ctx is None and base_url:
|
|
try:
|
|
from agent.model_metadata import get_cached_context_length
|
|
cached_ctx = get_cached_context_length(model_id, base_url)
|
|
if isinstance(cached_ctx, int) and cached_ctx > 0:
|
|
return cached_ctx
|
|
except Exception:
|
|
pass
|
|
return int(get_model_context_length(
|
|
model_id,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
config_context_length=config_ctx,
|
|
provider=provider,
|
|
) or 0)
|
|
except Exception as e:
|
|
logger.debug("Could not resolve active context length: %s", e)
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# handle_function_call (the main dispatcher)
|
|
# =============================================================================
|
|
|
|
# Tools the agent loop (run_agent.py) intercepts because they need agent-level
|
|
# state. The registry still holds their schemas; dispatch returns a stub error.
|
|
_AGENT_LOOP_TOOLS = {"todo_list", "memory", "session_search", "delegate_task"}
|
|
|
|
# Legacy tool-name aliases (2026-08 renames), accepted at every dispatch seam so
|
|
# old sessions and saved prompts keep working; schemas advertise only new names.
|
|
_LEGACY_TOOL_ALIASES = {
|
|
"todo": "todo_list",
|
|
"cronjob": "cronjob_manage",
|
|
"process": "process_manage",
|
|
"tour": "gui_tour",
|
|
"tip": "show_tip",
|
|
}
|
|
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
|
|
|
|
|
# =========================================================================
|
|
# Tool error sanitization
|
|
# =========================================================================
|
|
# Defense-in-depth: json.dumps already prevents framing escape, but the model
|
|
# still reads the text, so strip role tags / CDATA / code fences from exception
|
|
# messages and cap length. The cap is shared with tools/registry.py so text never
|
|
# passes two different caps with two different markers.
|
|
_TOOL_ERROR_ROLE_TAG_RE = re.compile(
|
|
r'</?(?:tool_call|function_call|result|response|output|input|system|assistant|user)>',
|
|
re.IGNORECASE,
|
|
)
|
|
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*', re.MULTILINE)
|
|
_TOOL_ERROR_FENCE_CLOSE_RE = re.compile(r'\s*```\s*$', re.MULTILINE)
|
|
_TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
|
|
from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN
|
|
|
|
|
|
def _sanitize_tool_error(error_msg: str) -> str:
|
|
"""Strip structural framing tokens from a tool error before the model sees it."""
|
|
if not error_msg:
|
|
return "[TOOL_ERROR] "
|
|
sanitized = _TOOL_ERROR_ROLE_TAG_RE.sub("", error_msg)
|
|
sanitized = _TOOL_ERROR_FENCE_OPEN_RE.sub("", sanitized)
|
|
sanitized = _TOOL_ERROR_FENCE_CLOSE_RE.sub("", sanitized)
|
|
sanitized = _TOOL_ERROR_CDATA_RE.sub("", sanitized)
|
|
if len(sanitized) > _TOOL_ERROR_MAX_LEN:
|
|
sanitized = sanitized[:_TOOL_ERROR_MAX_LEN - 3] + "..."
|
|
return f"[TOOL_ERROR] {sanitized}"
|
|
|
|
|
|
# =========================================================================
|
|
# Tool argument type coercion
|
|
# =========================================================================
|
|
|
|
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Coerce string-typed args to their JSON-Schema types; originals kept on failure.
|
|
|
|
Models emit "42" for integers, "true" for booleans, JSON-encoded strings for
|
|
arrays/objects (also nested inside containers), and bare scalars where an
|
|
array is expected (wrapped in a one-element list).
|
|
"""
|
|
if not args or not isinstance(args, dict):
|
|
return args
|
|
|
|
schema = registry.get_schema(tool_name)
|
|
if not schema:
|
|
return args
|
|
|
|
properties = (schema.get("parameters") or {}).get("properties")
|
|
if not properties:
|
|
return args
|
|
|
|
# The model saw the SANITIZED schema (provider-illegal property keys were
|
|
# renamed); map those keys back to the registry's wire names first.
|
|
try:
|
|
from tools.schema_sanitizer import unrename_tool_args
|
|
args = unrename_tool_args(schema.get("parameters"), args)
|
|
except Exception: # pragma: no cover — never break dispatch
|
|
pass
|
|
|
|
for key, value in list(args.items()):
|
|
prop_schema = properties.get(key)
|
|
if not prop_schema:
|
|
continue
|
|
expected = prop_schema.get("type")
|
|
|
|
# Bare non-list value for an array schema. Strings go through
|
|
# _coerce_value first so a JSON-encoded array is parsed and a nullable
|
|
# "null" becomes None (not ["null"]). None itself is preserved: the tool's
|
|
# own default handling decides between "omit" and "empty list".
|
|
if expected == "array" and value is not None and not isinstance(value, (list, tuple)):
|
|
if isinstance(value, str):
|
|
coerced = _coerce_value(value, expected, schema=prop_schema)
|
|
if coerced is not value:
|
|
args[key] = coerced
|
|
continue
|
|
if value.strip().startswith("["):
|
|
logger.warning(
|
|
"coerce_tool_args: %s.%s looks like a JSON array string "
|
|
"but could not be parsed — model may have emitted a "
|
|
"JSON-encoded string instead of a native array. "
|
|
"Falling back to single-element list.",
|
|
tool_name, key,
|
|
)
|
|
args[key] = [value]
|
|
logger.info(
|
|
"coerce_tool_args: wrapped bare string in list for %s.%s",
|
|
tool_name, key,
|
|
)
|
|
continue
|
|
args[key] = [value]
|
|
logger.info(
|
|
"coerce_tool_args: wrapped bare %s in list for %s.%s",
|
|
type(value).__name__, tool_name, key,
|
|
)
|
|
continue
|
|
|
|
if not isinstance(value, str):
|
|
# Native container: still normalize JSON-encoded elements/sub-fields.
|
|
if (expected == "array" and isinstance(value, (list, tuple))) or (
|
|
expected == "object" and isinstance(value, dict)
|
|
):
|
|
args[key] = _normalize_json_strings_for_schema(value, prop_schema)
|
|
continue
|
|
if not expected and not _schema_allows_null(prop_schema):
|
|
continue
|
|
coerced = _coerce_value(value, expected, schema=prop_schema)
|
|
if coerced is not value:
|
|
args[key] = coerced
|
|
if isinstance(coerced, (list, tuple, dict)):
|
|
args[key] = _normalize_json_strings_for_schema(coerced, prop_schema)
|
|
|
|
return args
|
|
|
|
|
|
def _schema_accepts_kind(schema: Any, kind: str) -> bool:
|
|
"""True when *schema* permits JSON type *kind* via ``type`` or any anyOf/oneOf/allOf branch."""
|
|
if not isinstance(schema, dict):
|
|
return False
|
|
t = schema.get("type")
|
|
if t == kind or (isinstance(t, list) and kind in t):
|
|
return True
|
|
for union_key in ("anyOf", "oneOf", "allOf"):
|
|
branches = schema.get(union_key)
|
|
if isinstance(branches, list) and any(
|
|
_schema_accepts_kind(b, kind) for b in branches
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any:
|
|
"""Recursively parse JSON-encoded strings where the schema expects array/object.
|
|
|
|
Schema-guided: a string is only parsed when its schema position expects a
|
|
container, so legitimate JSON-looking ``type: string`` fields survive.
|
|
Returns the same object when nothing changed (identity = cheap no-op check).
|
|
"""
|
|
if not isinstance(schema, dict):
|
|
return value
|
|
|
|
if isinstance(value, str):
|
|
trimmed = value.strip()
|
|
expects_array = _schema_accepts_kind(schema, "array")
|
|
expects_object = _schema_accepts_kind(schema, "object")
|
|
if (expects_array and trimmed.startswith("[")) or (
|
|
expects_object and trimmed.startswith("{")
|
|
):
|
|
try:
|
|
parsed = json.loads(trimmed)
|
|
except (ValueError, TypeError):
|
|
return value
|
|
if (isinstance(parsed, list) and expects_array) or (isinstance(parsed, dict) and expects_object):
|
|
value = parsed
|
|
else:
|
|
return value
|
|
else:
|
|
return value
|
|
|
|
if isinstance(value, list):
|
|
items_schema = schema.get("items")
|
|
if not isinstance(items_schema, dict):
|
|
return value
|
|
changed = False
|
|
out = []
|
|
for item in value:
|
|
nxt = _normalize_json_strings_for_schema(item, items_schema)
|
|
changed = changed or (nxt is not item)
|
|
out.append(nxt)
|
|
return out if changed else value
|
|
|
|
if isinstance(value, dict):
|
|
props = schema.get("properties")
|
|
if not isinstance(props, dict):
|
|
return value
|
|
changed = False
|
|
out = dict(value)
|
|
for k, prop_schema in props.items():
|
|
if k not in value or not isinstance(prop_schema, dict):
|
|
continue
|
|
nxt = _normalize_json_strings_for_schema(value[k], prop_schema)
|
|
if nxt is not value[k]:
|
|
out[k] = nxt
|
|
changed = True
|
|
return out if changed else value
|
|
|
|
return value
|
|
|
|
|
|
def _coerce_value(value: str, expected_type, schema: dict | None = None):
|
|
"""Coerce string *value* to *expected_type* (str or union list); original on failure."""
|
|
if _schema_allows_null(schema) and value.strip().lower() == "null":
|
|
return None
|
|
|
|
if isinstance(expected_type, list):
|
|
for t in expected_type:
|
|
result = _coerce_value(value, t, schema=schema)
|
|
if result is not value:
|
|
return result
|
|
return value
|
|
|
|
if expected_type in {"integer", "number"}:
|
|
return _coerce_number(value, integer_only=(expected_type == "integer"))
|
|
if expected_type == "boolean":
|
|
return _coerce_boolean(value)
|
|
if expected_type == "array":
|
|
return _coerce_json(value, list)
|
|
if expected_type == "object":
|
|
return _coerce_json(value, dict)
|
|
if expected_type == "null" and value.strip().lower() == "null":
|
|
return None
|
|
return value
|
|
|
|
|
|
def _schema_allows_null(schema: dict | None) -> bool:
|
|
"""True when a JSON Schema fragment explicitly permits null."""
|
|
if not isinstance(schema, dict):
|
|
return False
|
|
schema_type = schema.get("type")
|
|
if schema_type == "null" or (isinstance(schema_type, list) and "null" in schema_type):
|
|
return True
|
|
if schema.get("nullable") is True:
|
|
return True
|
|
for union_key in ("anyOf", "oneOf"):
|
|
variants = schema.get(union_key)
|
|
if isinstance(variants, list) and any(
|
|
isinstance(v, dict) and v.get("type") == "null" for v in variants
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _coerce_json(value: str, expected_python_type: type):
|
|
"""json.loads *value* when the schema expects array/object; original string on mismatch."""
|
|
try:
|
|
parsed = json.loads(value)
|
|
except (ValueError, TypeError) as exc:
|
|
logger.warning(
|
|
"coerce_tool_args: failed to parse string as JSON for expected type %s: %s",
|
|
expected_python_type.__name__,
|
|
exc,
|
|
)
|
|
return value
|
|
if isinstance(parsed, expected_python_type):
|
|
logger.debug(
|
|
"coerce_tool_args: coerced string to %s via json.loads",
|
|
expected_python_type.__name__,
|
|
)
|
|
return parsed
|
|
logger.warning(
|
|
"coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion",
|
|
type(parsed).__name__,
|
|
expected_python_type.__name__,
|
|
)
|
|
return value
|
|
|
|
|
|
def _coerce_number(value: str, integer_only: bool = False):
|
|
"""Parse *value* as a number; original string on failure, inf/nan, or decimals when integer_only."""
|
|
try:
|
|
f = float(value)
|
|
except (ValueError, OverflowError):
|
|
return value
|
|
if f != f or f == float("inf") or f == float("-inf"):
|
|
return value # not JSON-serializable
|
|
if f == int(f):
|
|
return int(f)
|
|
if integer_only:
|
|
return value
|
|
return f
|
|
|
|
|
|
def _coerce_boolean(value: str):
|
|
"""Parse "true"/"false" (case-insensitive); original string otherwise."""
|
|
low = value.strip().lower()
|
|
if low == "true":
|
|
return True
|
|
if low == "false":
|
|
return False
|
|
return value
|
|
|
|
|
|
def _tool_result_observer_fields(
|
|
tool_name: str,
|
|
result: Any,
|
|
) -> tuple[str, Optional[str], Optional[str]]:
|
|
"""Derive (status, error_type, error_message) from a tool result for observer hooks."""
|
|
try:
|
|
parsed_result = json.loads(result) if isinstance(result, str) else result
|
|
if isinstance(parsed_result, dict) and parsed_result.get("error"):
|
|
return "error", "tool_error", str(parsed_result.get("error"))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from agent.display import _detect_tool_failure
|
|
|
|
failed, suffix = _detect_tool_failure(tool_name, result)
|
|
if failed:
|
|
return "error", "tool_error", suffix.strip().strip("[]") or None
|
|
except Exception:
|
|
pass
|
|
return "ok", None, None
|
|
|
|
|
|
def _emit_post_tool_call_hook(
|
|
*,
|
|
function_name: str,
|
|
function_args: Dict[str, Any],
|
|
result: Any,
|
|
task_id: Optional[str] = None,
|
|
session_id: Optional[str] = None,
|
|
tool_call_id: Optional[str] = None,
|
|
turn_id: Optional[str] = None,
|
|
api_request_id: Optional[str] = None,
|
|
duration_ms: int = 0,
|
|
status: Optional[str] = None,
|
|
error_type: Optional[str] = None,
|
|
error_message: Optional[str] = None,
|
|
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
|
) -> None:
|
|
"""Emit the ``post_tool_call`` observer hook.
|
|
|
|
Gated on has_hook so the no-listener path costs one dict lookup; when
|
|
``status`` is None the ok/error fields are derived from the result only
|
|
after that gate.
|
|
"""
|
|
if _post_tool_call_hook_suppressed.get():
|
|
return
|
|
try:
|
|
from hermes_cli.lifecycle import has_hook, invoke_hook
|
|
if not has_hook("post_tool_call"):
|
|
return
|
|
if status is None:
|
|
status, error_type, error_message = _tool_result_observer_fields(
|
|
function_name,
|
|
result,
|
|
)
|
|
invoke_hook(
|
|
"post_tool_call",
|
|
tool_name=function_name,
|
|
args=function_args,
|
|
result=result,
|
|
task_id=task_id or "",
|
|
session_id=session_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
turn_id=turn_id or "",
|
|
api_request_id=api_request_id or "",
|
|
duration_ms=duration_ms,
|
|
status=status,
|
|
error_type=error_type,
|
|
error_message=error_message,
|
|
middleware_trace=list(middleware_trace or []),
|
|
)
|
|
except Exception as _hook_err:
|
|
logger.debug("post_tool_call hook error: %s", _hook_err)
|
|
|
|
|
|
def handle_function_call(
|
|
function_name: str,
|
|
function_args: Dict[str, Any],
|
|
task_id: Optional[str] = None,
|
|
tool_call_id: Optional[str] = None,
|
|
session_id: Optional[str] = None,
|
|
turn_id: Optional[str] = None,
|
|
api_request_id: Optional[str] = None,
|
|
user_task: Optional[str] = None,
|
|
enabled_tools: Optional[List[str]] = None,
|
|
skip_pre_tool_call_hook: bool = False,
|
|
skip_tool_request_middleware: bool = False,
|
|
skip_tool_execution_middleware: bool = False,
|
|
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
disabled_toolsets: Optional[List[str]] = None,
|
|
) -> str:
|
|
"""Route a tool call through hooks/middleware to the registry; returns a JSON string.
|
|
|
|
Args:
|
|
task_id: Terminal/browser session isolation key.
|
|
user_task: The user's original task (browser_snapshot context).
|
|
enabled_tools: Session tool names; execute_code uses them to pick sandbox
|
|
tools (falls back to the process-global ``_last_resolved_tool_names``).
|
|
skip_pre_tool_call_hook: Caller already fired pre_tool_call (single-fire contract).
|
|
enabled_toolsets / disabled_toolsets: The session's toolset selection,
|
|
used to scope the Tool Search bridge catalog so tool_search /
|
|
tool_describe / tool_call only see tools this session was granted.
|
|
None = no restriction, matching get_tool_definitions semantics.
|
|
"""
|
|
function_args = coerce_tool_args(function_name, function_args)
|
|
if not isinstance(function_args, dict):
|
|
function_args = {}
|
|
_tool_middleware_trace = list(tool_request_middleware_trace or [])
|
|
function_name = _LEGACY_TOOL_ALIASES.get(function_name, function_name)
|
|
_dispatch_start = time.monotonic()
|
|
|
|
def _emit(result: Any, **extra: Any) -> Any:
|
|
"""Emit post_tool_call with this call's identity fields; returns *result*."""
|
|
_emit_post_tool_call_hook(
|
|
function_name=function_name,
|
|
function_args=function_args,
|
|
result=result,
|
|
task_id=task_id,
|
|
session_id=session_id,
|
|
tool_call_id=tool_call_id,
|
|
turn_id=turn_id,
|
|
api_request_id=api_request_id,
|
|
middleware_trace=list(_tool_middleware_trace),
|
|
**extra,
|
|
)
|
|
return result
|
|
|
|
# Tool Search bridge: tool_search / tool_describe are catalog reads handled
|
|
# inline; tool_call is unwrapped so every downstream hook (pre/post, edit
|
|
# approval, guardrails) sees the real tool name, never the bridge.
|
|
try:
|
|
from tools import tool_search as _ts_mod
|
|
except Exception:
|
|
_ts_mod = None
|
|
|
|
if _ts_mod is not None and _ts_mod.is_bridge_tool(function_name):
|
|
# Read the un-collapsed catalog, scoped to the session's toolsets so a
|
|
# restricted session (subagent, kanban worker) cannot see or invoke the
|
|
# whole process registry through the bridge.
|
|
try:
|
|
current_defs = get_tool_definitions(
|
|
enabled_toolsets=enabled_toolsets,
|
|
disabled_toolsets=disabled_toolsets,
|
|
quiet_mode=True, skip_tool_search_assembly=True,
|
|
) or []
|
|
except Exception:
|
|
current_defs = []
|
|
|
|
def _elapsed() -> int:
|
|
return int((time.monotonic() - _dispatch_start) * 1000)
|
|
|
|
if function_name == _ts_mod.TOOL_SEARCH_NAME:
|
|
return _emit(_ts_mod.dispatch_tool_search(function_args or {}, current_tool_defs=current_defs),
|
|
duration_ms=_elapsed())
|
|
if function_name == _ts_mod.TOOL_DESCRIBE_NAME:
|
|
return _emit(_ts_mod.dispatch_tool_describe(function_args or {}, current_tool_defs=current_defs),
|
|
duration_ms=_elapsed())
|
|
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 _emit(tool_error(err or "tool_call could not be resolved"), duration_ms=_elapsed())
|
|
# Defense in depth: resolve_underlying_call only checks the global
|
|
# registry; also require membership in the session-scoped catalog.
|
|
if underlying_name not in _ts_mod.scoped_deferrable_names(current_defs):
|
|
return _emit(
|
|
tool_error(
|
|
f"'{underlying_name}' is not available in this session. "
|
|
"Use tool_search to find tools you can call."
|
|
),
|
|
duration_ms=_elapsed(),
|
|
)
|
|
# Validate against the deferred tool's concrete schema — the generic
|
|
# ``arguments: object`` bridge schema can't enforce it.
|
|
_probe_err = _ts_mod.validate_deferred_call_args(underlying_name, underlying_args)
|
|
if _probe_err is not None:
|
|
return _emit(_probe_err, duration_ms=_elapsed())
|
|
return handle_function_call(
|
|
function_name=underlying_name,
|
|
function_args=underlying_args,
|
|
task_id=task_id,
|
|
tool_call_id=tool_call_id,
|
|
session_id=session_id,
|
|
turn_id=turn_id,
|
|
api_request_id=api_request_id,
|
|
user_task=user_task,
|
|
enabled_tools=enabled_tools,
|
|
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
|
|
skip_tool_request_middleware=skip_tool_request_middleware,
|
|
skip_tool_execution_middleware=skip_tool_execution_middleware,
|
|
tool_request_middleware_trace=list(_tool_middleware_trace),
|
|
enabled_toolsets=enabled_toolsets,
|
|
disabled_toolsets=disabled_toolsets,
|
|
)
|
|
|
|
_tool_original_args = dict(function_args)
|
|
if not skip_tool_request_middleware:
|
|
try:
|
|
from hermes_cli.middleware import apply_tool_request_middleware
|
|
|
|
_tool_request_mw = apply_tool_request_middleware(
|
|
function_name,
|
|
function_args,
|
|
task_id=task_id or "",
|
|
session_id=session_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
turn_id=turn_id or "",
|
|
api_request_id=api_request_id or "",
|
|
)
|
|
function_args = _tool_request_mw.payload
|
|
_tool_original_args = _tool_request_mw.original_payload
|
|
_tool_middleware_trace = _tool_request_mw.trace
|
|
except Exception as _mw_err:
|
|
logger.debug("tool_request middleware error: %s", _mw_err)
|
|
|
|
try:
|
|
if function_name in _AGENT_LOOP_TOOLS:
|
|
return tool_error(f"{function_name} must be handled by the agent loop")
|
|
|
|
# pre_tool_call fires exactly once per execution: _dispatch_pre_tool_call_hooks
|
|
# returns the block message (block/approve) and modified args (modify) from a
|
|
# single invoke_hook pass. skip=True means the caller already fired it.
|
|
if not skip_pre_tool_call_hook:
|
|
block_message: Optional[str] = None
|
|
try:
|
|
from hermes_cli.plugins import _dispatch_pre_tool_call_hooks
|
|
block_message, modified_args = _dispatch_pre_tool_call_hooks(
|
|
function_name,
|
|
function_args,
|
|
task_id=task_id or "",
|
|
session_id=session_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
turn_id=turn_id or "",
|
|
api_request_id=api_request_id or "",
|
|
middleware_trace=list(_tool_middleware_trace),
|
|
)
|
|
if modified_args is not None:
|
|
function_args = modified_args
|
|
except Exception as _hook_err:
|
|
logger.debug("pre_tool_call hook error: %s", _hook_err)
|
|
|
|
if block_message is not None:
|
|
return _emit(tool_error(block_message), status="blocked",
|
|
error_type="plugin_block", error_message=block_message)
|
|
|
|
# ACP/Zed edit approval before any file mutation. The requester is bound
|
|
# via ContextVar only for ACP sessions, so CLI/gateway paths are unaffected.
|
|
try:
|
|
from acp_adapter.edit_approval import maybe_require_edit_approval
|
|
|
|
edit_block_message = maybe_require_edit_approval(function_name, function_args)
|
|
if edit_block_message is not None:
|
|
return _emit(edit_block_message, status="blocked", error_type="edit_approval_denied")
|
|
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 _emit(tool_error("Edit approval denied: approval guard failed"),
|
|
status="blocked", error_type="edit_approval_error")
|
|
|
|
# Any non-read/search tool resets the consecutive-read-loop counter.
|
|
if function_name not in _READ_SEARCH_TOOLS:
|
|
try:
|
|
from tools.file_tools import notify_other_tool_call
|
|
notify_other_tool_call(task_id or "default")
|
|
except Exception:
|
|
pass # file_tools may not be loaded yet
|
|
|
|
# duration_ms (monotonic) is exposed to post_tool_call / transform_tool_result.
|
|
_dispatch_start = time.monotonic()
|
|
_approval_tokens = None
|
|
_reset_obs = None
|
|
try:
|
|
from tools.approval import (
|
|
reset_current_observability_context as _reset_obs,
|
|
set_current_observability_context,
|
|
)
|
|
_approval_tokens = set_current_observability_context(
|
|
turn_id=turn_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
session_id=session_id or "",
|
|
)
|
|
except Exception:
|
|
_reset_obs = None
|
|
try:
|
|
dispatch_kwargs: Dict[str, Any] = {"task_id": task_id, "session_id": session_id}
|
|
if function_name == "execute_code":
|
|
# Prefer the caller's list so subagents can't overwrite the
|
|
# parent's tool set via the process-global.
|
|
dispatch_kwargs["enabled_tools"] = (
|
|
enabled_tools if enabled_tools is not None else _last_resolved_tool_names
|
|
)
|
|
else:
|
|
dispatch_kwargs["user_task"] = user_task
|
|
|
|
def _dispatch(next_args: Dict[str, Any]) -> Any:
|
|
return registry.dispatch(function_name, next_args, **dispatch_kwargs)
|
|
|
|
if skip_tool_execution_middleware:
|
|
result = _dispatch(function_args)
|
|
else:
|
|
from hermes_cli.middleware import run_tool_execution_middleware
|
|
|
|
result = run_tool_execution_middleware(
|
|
function_name,
|
|
function_args,
|
|
_dispatch,
|
|
original_args=_tool_original_args,
|
|
task_id=task_id or "",
|
|
session_id=session_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
turn_id=turn_id or "",
|
|
api_request_id=api_request_id or "",
|
|
)
|
|
finally:
|
|
if _approval_tokens is not None and _reset_obs is not None:
|
|
try:
|
|
_reset_obs(_approval_tokens)
|
|
except Exception:
|
|
pass
|
|
duration_ms = int((time.monotonic() - _dispatch_start) * 1000)
|
|
|
|
_emit(result, duration_ms=duration_ms)
|
|
|
|
# transform_tool_result: plugins may replace the final result string.
|
|
# Runs after post_tool_call (observational) and before the result enters
|
|
# context. Fail-open; first valid string return wins; non-strings ignored.
|
|
try:
|
|
from hermes_cli.lifecycle import has_hook, invoke_hook
|
|
if has_hook("transform_tool_result"):
|
|
status, error_type, error_message = _tool_result_observer_fields(
|
|
function_name,
|
|
result,
|
|
)
|
|
hook_results = invoke_hook(
|
|
"transform_tool_result",
|
|
tool_name=function_name,
|
|
args=function_args,
|
|
result=result,
|
|
task_id=task_id or "",
|
|
session_id=session_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
turn_id=turn_id or "",
|
|
api_request_id=api_request_id or "",
|
|
duration_ms=duration_ms,
|
|
status=status,
|
|
error_type=error_type,
|
|
error_message=error_message,
|
|
)
|
|
for hook_result in hook_results:
|
|
if isinstance(hook_result, str):
|
|
result = hook_result
|
|
break
|
|
except Exception as _hook_err:
|
|
logger.debug("transform_tool_result hook error: %s", _hook_err)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error executing {function_name}: {str(e)}"
|
|
logger.exception(error_msg)
|
|
return _emit(
|
|
tool_error(_sanitize_tool_error(error_msg)),
|
|
duration_ms=int((time.monotonic() - _dispatch_start) * 1000),
|
|
status="error",
|
|
error_type=type(e).__name__,
|
|
error_message=str(e),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Backward-compat wrapper functions
|
|
# =============================================================================
|
|
|
|
def get_all_tool_names() -> List[str]:
|
|
"""Return all registered tool names."""
|
|
return registry.get_all_tool_names()
|
|
|
|
|
|
def get_toolset_for_tool(tool_name: str) -> Optional[str]:
|
|
"""Return the toolset a tool belongs to."""
|
|
return registry.get_toolset_for_tool(tool_name)
|
|
|
|
|
|
def get_available_toolsets() -> Dict[str, dict]:
|
|
"""Return toolset availability info for UI display."""
|
|
return registry.get_available_toolsets()
|
|
|
|
|
|
def check_toolset_requirements() -> Dict[str, bool]:
|
|
"""Return {toolset: available_bool} for every registered toolset."""
|
|
return registry.check_toolset_requirements()
|
|
|
|
|
|
def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]:
|
|
"""Return (available_toolsets, unavailable_info)."""
|
|
return registry.check_tool_availability(quiet=quiet)
|