mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
_cli's show_banner() calls _resolve_active_context_length() at every
startup. For non-OpenRouter providers (e.g. minimax-cn, kimi-coding,
custom endpoints) the resolver falls through to step 6 (OpenRouter
live /models fetch), which blocks ~2-3s per CLI launch and adds up
to 7+ minutes when openrouter.ai is unreachable through a proxy that
403s CONNECT (#46620).
Two complementary changes:
1. model_tools.py: read model.context_length from config.yaml and pass
it as config_context_length to get_model_context_length. The
step-0 config override short-circuits the entire resolution chain
including the OpenRouter fetch. No network call is made when the
user has set the value explicitly.
2. agent/model_metadata.py: replace flat timeout=10 with (5, 10)
tuple at all five sites (fetch_model_metadata + four endpoint
probes). urllib3 can otherwise block for 10s per retry stage
through proxies that 403 CONNECT. The tuple bounds connect at 5s
while still allowing slow reads.
Complements the in-flight PR #46685 (which adds HERMES_DISABLE_MODEL_METADATA
env var + same timeout tuple change for fetch_model_metadata). This PR
extends the timeout fix to the other four endpoint probes and adds the
config-override path that addresses the slow-but-reachable scenario
where env-var disable is too heavy-handed.
Refs #46620, PR #46685.
(cherry picked from commit e7faa34199)
1381 lines
62 KiB
Python
1381 lines
62 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Model Tools Module
|
|
|
|
Thin orchestration layer over the tool registry. Each tool file in tools/
|
|
self-registers its schema, handler, and metadata via tools.registry.register().
|
|
This module triggers discovery (by importing all tool modules), then provides
|
|
the public API that run_agent.py, cli.py, batch_runner.py, and the RL
|
|
environments consume.
|
|
|
|
Public API (signatures preserved from the original 2,400-line version):
|
|
get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode) -> list
|
|
handle_function_call(function_name, function_args, task_id, user_task) -> str
|
|
TOOL_TO_TOOLSET_MAP: dict (for batch_runner.py)
|
|
TOOLSET_REQUIREMENTS: dict (for cli.py, doctor.py)
|
|
get_all_tool_names() -> list
|
|
get_toolset_for_tool(name) -> str
|
|
get_available_toolsets() -> dict
|
|
check_toolset_requirements() -> dict
|
|
check_tool_availability(quiet) -> tuple
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import re
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Dict, Any, List, Optional, Tuple
|
|
|
|
from tools.registry import discover_builtin_tools, registry
|
|
from toolsets import resolve_toolset, validate_toolset
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Tracks platform-bundle names already flagged in disabled_toolsets so the
|
|
# advisory (#33924) is logged once per name, not on every tool recompute.
|
|
_WARNED_DISABLED_BUNDLES: set = set()
|
|
|
|
|
|
# =============================================================================
|
|
# Async Bridging (single source of truth -- used by registry.dispatch too)
|
|
# =============================================================================
|
|
|
|
_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():
|
|
"""Return a long-lived event loop for running async tool handlers.
|
|
|
|
Using a persistent loop (instead of asyncio.run() which creates and
|
|
*closes* a fresh loop every time) prevents "Event loop is closed"
|
|
errors that occur when cached httpx/AsyncOpenAI clients attempt to
|
|
close their transport on a dead loop during garbage collection.
|
|
"""
|
|
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():
|
|
"""Return a persistent event loop for the current worker thread.
|
|
|
|
Each worker thread (e.g., delegate_task's ThreadPoolExecutor threads)
|
|
gets its own long-lived loop stored in thread-local storage. This
|
|
prevents the "Event loop is closed" errors that occurred when
|
|
asyncio.run() was used per-call: asyncio.run() creates a loop, runs
|
|
the coroutine, then *closes* the loop — but cached httpx/AsyncOpenAI
|
|
clients remain bound to that now-dead loop and raise RuntimeError
|
|
during garbage collection or subsequent use.
|
|
|
|
By keeping the loop alive for the thread's lifetime, cached clients
|
|
stay valid and their cleanup runs on a live loop.
|
|
"""
|
|
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 an async coroutine from a sync context.
|
|
|
|
If the current thread already has a running event loop (e.g., inside
|
|
the gateway's async stack or Atropos's event loop), we spin up a
|
|
disposable thread so asyncio.run() can create its own loop without
|
|
conflicting.
|
|
|
|
For the common CLI path (no running loop), we use a persistent event
|
|
loop so that cached async clients (httpx / AsyncOpenAI) remain bound
|
|
to a live loop and don't trigger "Event loop is closed" on GC.
|
|
|
|
When called from a worker thread (parallel tool execution), we use a
|
|
per-thread persistent loop to avoid both contention with the main
|
|
thread's shared loop AND the "Event loop is closed" errors caused by
|
|
asyncio.run()'s create-and-destroy lifecycle.
|
|
|
|
This is the single source of truth for sync->async bridging in tool
|
|
handlers. Each handler is self-protecting via this function.
|
|
"""
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
|
|
if loop and loop.is_running():
|
|
# Inside an async context (gateway, RL env) — run in a fresh thread
|
|
# with its own event loop we own a reference to, so on timeout we
|
|
# can cancel the task inside that loop (ThreadPoolExecutor.cancel()
|
|
# only works on not-yet-started futures — it's a no-op on a running
|
|
# worker, which previously leaked 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:
|
|
# Cancel anything still pending (e.g. task cancelled
|
|
# externally via call_soon_threadsafe on timeout).
|
|
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 the active profile + approval/sudo callbacks into the worker so
|
|
# async tools resolve get_hermes_home() under the active profile.
|
|
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 the coroutine inside its own loop so the worker thread
|
|
# can wind down instead of running forever.
|
|
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:
|
|
# Loop already closed — nothing to cancel.
|
|
pass
|
|
raise
|
|
finally:
|
|
# wait=False: don't block the caller on a stuck coroutine. We've
|
|
# already requested cancellation above; the worker will exit
|
|
# once the coroutine observes it (usually at the next await).
|
|
pool.shutdown(wait=False)
|
|
|
|
# If we're on a worker thread (e.g., parallel tool execution in
|
|
# delegate_task), use a per-thread persistent loop. This avoids
|
|
# contention with the main thread's shared loop while keeping cached
|
|
# httpx/AsyncOpenAI clients bound to a live loop for the thread's
|
|
# lifetime — preventing "Event loop is closed" on GC cleanup.
|
|
if threading.current_thread() is not threading.main_thread():
|
|
worker_loop = _get_worker_loop()
|
|
return worker_loop.run_until_complete(coro)
|
|
|
|
tool_loop = _get_tool_loop()
|
|
return tool_loop.run_until_complete(coro)
|
|
|
|
|
|
# =============================================================================
|
|
# Tool Discovery (importing each module triggers its registry.register calls)
|
|
# =============================================================================
|
|
|
|
discover_builtin_tools()
|
|
|
|
# MCP tool discovery (external MCP servers from config) used to run here as
|
|
# a module-level side effect. It was removed because discover_mcp_tools()
|
|
# internally uses a blocking future.result(timeout=120) wait, and the
|
|
# gateway lazy-imports this module from inside the asyncio event loop on
|
|
# the first user message — freezing Discord/Telegram heartbeats for up to
|
|
# 120s whenever any configured MCP server was slow or unreachable (#16856).
|
|
#
|
|
# Each entry point now runs discovery explicitly at its own startup:
|
|
# - gateway/run.py -> start_gateway() uses run_in_executor
|
|
# - cli.py, hermes_cli/* -> inline on startup (no event loop)
|
|
# - tui_gateway/server.py -> inline on startup (no event loop)
|
|
# - acp_adapter/server.py -> asyncio.to_thread on session init
|
|
|
|
# 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()
|
|
|
|
# Resolved tool names from the last get_tool_definitions() call.
|
|
# Used by code_execution_tool to know which tools are available in this session.
|
|
_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"],
|
|
"file_tools": ["read_file", "write_file", "patch", "search_files"],
|
|
"tts_tools": ["text_to_speech"],
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# get_tool_definitions (the main schema provider)
|
|
# =============================================================================
|
|
|
|
# Module-level memoization for get_tool_definitions(). Keyed on
|
|
# (frozenset(enabled_toolsets), frozenset(disabled_toolsets), registry._generation).
|
|
# Hot callers (gateway runner, AIAgent.__init__) invoke this on every turn
|
|
# with quiet_mode=True; caching avoids ~7 ms of registry walking + schema
|
|
# filtering + check_fn probing per call. Only active when quiet_mode=True
|
|
# because quiet_mode=False has stdout side effects (tool-selection prints).
|
|
#
|
|
# Invalidation happens transparently via the registry's _generation counter,
|
|
# which bumps on register() / deregister() / register_toolset_alias(). The
|
|
# inner check_fn TTL cache in registry.py handles environment drift (Docker
|
|
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
|
|
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
|
|
|
|
# Hard cap on memoized get_tool_definitions() results. A long-lived Gateway
|
|
# process sees many distinct toolset/config fingerprints over its lifetime
|
|
# (per-session toolset sets, config edits, kanban-task toggles); without a
|
|
# bound the cache grows unboundedly. 8 comfortably covers the warm working
|
|
# set (the handful of distinct platform/toolset combos a gateway actually
|
|
# serves) while keeping the cap small. (#19251)
|
|
_TOOL_DEFS_CACHE_MAX = 8
|
|
|
|
|
|
def _clear_tool_defs_cache() -> None:
|
|
"""Drop memoized get_tool_definitions() results. Called when dynamic
|
|
schema dependencies change (e.g. discord capability cache reset,
|
|
execute_code sandbox reconfigured)."""
|
|
_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]]:
|
|
"""
|
|
Get tool definitions for model API calls with toolset-based filtering.
|
|
|
|
All tools must be part of a toolset to be accessible.
|
|
|
|
Args:
|
|
enabled_toolsets: Only include tools from these toolsets.
|
|
disabled_toolsets: Exclude tools from these toolsets (if enabled_toolsets is None).
|
|
quiet_mode: Suppress status prints.
|
|
skip_tool_search_assembly: When True, return the pre-assembly tool list
|
|
(raw schemas for every enabled tool). Used internally by the
|
|
tool_search / tool_describe bridge handlers so they can read the
|
|
real catalog, not the already-collapsed one. Public callers should
|
|
leave this False.
|
|
|
|
Returns:
|
|
Filtered list of OpenAI-format tool definitions.
|
|
"""
|
|
# Fast path: memoized result when the caller doesn't need stdout prints.
|
|
# The cache key captures every argument-level input; the registry
|
|
# generation captures registry mutations (MCP refresh, plugin load).
|
|
# check_fn results are TTL-cached one level down, inside
|
|
# registry.get_definitions. The config-mtime fingerprint below captures
|
|
# user-visible config edits that affect dynamic schemas (execute_code
|
|
# mode, discord action allowlist, etc.) without needing an explicit
|
|
# invalidate hook on every config-writer.
|
|
if quiet_mode:
|
|
try:
|
|
from hermes_cli.config import get_config_path
|
|
cfg_path = get_config_path()
|
|
cfg_stat = cfg_path.stat()
|
|
cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size)
|
|
except (FileNotFoundError, OSError, ImportError):
|
|
cfg_fp = None
|
|
cache_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),
|
|
)
|
|
cached = _tool_defs_cache.get(cache_key)
|
|
if cached is not None:
|
|
# Update _last_resolved_tool_names so downstream callers see
|
|
# consistent state even on a cache hit.
|
|
global _last_resolved_tool_names
|
|
_last_resolved_tool_names = [t["function"]["name"] for t in cached]
|
|
# Return a shallow copy of the list but share the dict references —
|
|
# schemas are treated as read-only by all known callers.
|
|
return list(cached)
|
|
|
|
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
|
|
skip_tool_search_assembly=skip_tool_search_assembly)
|
|
if quiet_mode:
|
|
# Cache the freshly-computed list, but hand callers a shallow copy so
|
|
# downstream mutations (e.g. run_agent appending memory/LCM tool
|
|
# schemas to self.tools) don't poison the cache. Without this, a
|
|
# long-lived Gateway process accumulates duplicate tool names across
|
|
# agent inits and providers that enforce unique tool names
|
|
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
|
|
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
|
|
# Bound the cache with LRU eviction so a long-lived Gateway process
|
|
# doesn't accumulate entries unboundedly across the many distinct
|
|
# toolset/config fingerprints it sees over its lifetime (#19251).
|
|
if len(_tool_defs_cache) >= _TOOL_DEFS_CACHE_MAX:
|
|
_tool_defs_cache.pop(next(iter(_tool_defs_cache))) # evict oldest
|
|
_tool_defs_cache[cache_key] = result
|
|
return list(result)
|
|
return result
|
|
|
|
|
|
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`."""
|
|
# Determine which tool names the caller wants
|
|
tools_to_include: set = set()
|
|
|
|
if enabled_toolsets is not None:
|
|
effective_enabled_toolsets = list(enabled_toolsets)
|
|
if os.environ.get("HERMES_KANBAN_TASK") and "kanban" not in effective_enabled_toolsets:
|
|
# Dispatcher-spawned workers are scoped by HERMES_KANBAN_TASK and
|
|
# must always receive the lifecycle handoff tools. Assignee
|
|
# profiles may intentionally restrict their normal chat toolsets
|
|
# (for token/cost reasons), but that should not strip the kanban
|
|
# worker's completion/block/heartbeat surface.
|
|
effective_enabled_toolsets.append("kanban")
|
|
for toolset_name in effective_enabled_toolsets:
|
|
if validate_toolset(toolset_name):
|
|
resolved = resolve_toolset(toolset_name)
|
|
tools_to_include.update(resolved)
|
|
if not quiet_mode:
|
|
print(f"✅ Enabled toolset '{toolset_name}': {', '.join(resolved) if resolved else 'no tools'}")
|
|
elif toolset_name in _LEGACY_TOOLSET_MAP:
|
|
legacy_tools = _LEGACY_TOOLSET_MAP[toolset_name]
|
|
tools_to_include.update(legacy_tools)
|
|
if not quiet_mode:
|
|
print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
|
|
elif not quiet_mode:
|
|
print(f"⚠️ Unknown toolset: {toolset_name}")
|
|
else:
|
|
# Default: start with everything
|
|
from toolsets import get_all_toolsets
|
|
for ts_name in get_all_toolsets():
|
|
tools_to_include.update(resolve_toolset(ts_name))
|
|
|
|
# Always apply disabled toolsets as a subtraction step at the end.
|
|
# This ensures that even if a composite toolset (like hermes-cli)
|
|
# is enabled, any tools belonging to a disabled toolset are strictly
|
|
# stripped out. See issue #17309.
|
|
if disabled_toolsets:
|
|
for toolset_name in disabled_toolsets:
|
|
if validate_toolset(toolset_name):
|
|
from toolsets import bundle_non_core_tools, get_toolset
|
|
if toolset_name.startswith("hermes-") or (get_toolset(toolset_name) or {}).get("posture"):
|
|
# Platform bundles (hermes-*) include _HERMES_CORE_TOOLS, and
|
|
# posture toolsets (`posture: True`, e.g. `coding`) re-list
|
|
# those same core tools without owning them, so subtracting
|
|
# the whole toolset would strip core tools shared by other
|
|
# enabled toolsets and empty the tool list (#33924, #57315).
|
|
# Subtract only the non-core delta; keep core.
|
|
to_remove = bundle_non_core_tools(toolset_name)
|
|
tools_to_include.difference_update(to_remove)
|
|
resolved = sorted(to_remove)
|
|
if (not quiet_mode and toolset_name.startswith("hermes-")
|
|
and toolset_name not in _WARNED_DISABLED_BUNDLES):
|
|
_WARNED_DISABLED_BUNDLES.add(toolset_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).",
|
|
toolset_name,
|
|
", ".join(resolved) if resolved else "none",
|
|
)
|
|
else:
|
|
resolved = resolve_toolset(toolset_name)
|
|
tools_to_include.difference_update(resolved)
|
|
if not quiet_mode:
|
|
print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved) if resolved else 'no tools'}")
|
|
elif toolset_name in _LEGACY_TOOLSET_MAP:
|
|
legacy_tools = _LEGACY_TOOLSET_MAP[toolset_name]
|
|
tools_to_include.difference_update(legacy_tools)
|
|
if not quiet_mode:
|
|
print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
|
|
elif not quiet_mode:
|
|
print(f"⚠️ Unknown toolset: {toolset_name}")
|
|
|
|
# Plugin-registered tools are now resolved through the normal toolset
|
|
# path — validate_toolset() / resolve_toolset() / get_all_toolsets()
|
|
# all check the tool registry for plugin-provided toolsets. No bypass
|
|
# needed; plugins respect enabled_toolsets / disabled_toolsets like any
|
|
# other toolset.
|
|
|
|
# Ask the registry for schemas (only returns tools whose check_fn passes)
|
|
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)
|
|
|
|
# The set of tool names that actually passed check_fn filtering.
|
|
# Use this (not tools_to_include) for any downstream schema that references
|
|
# other tools by name — otherwise the model sees tools mentioned in
|
|
# descriptions that don't actually exist, and hallucinates calls to them.
|
|
available_tool_names = {t["function"]["name"] for t in filtered_tools}
|
|
|
|
# Rebuild execute_code schema to only list sandbox tools that are actually
|
|
# available. Without this, the model sees "web_search is available in
|
|
# execute_code" even when the API key isn't configured or the toolset is
|
|
# disabled (#560-discord).
|
|
if "execute_code" in available_tool_names:
|
|
from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema, _get_execution_mode
|
|
sandbox_enabled = SANDBOX_ALLOWED_TOOLS & available_tool_names
|
|
dynamic_schema = build_execute_code_schema(sandbox_enabled, mode=_get_execution_mode())
|
|
for i, td in enumerate(filtered_tools):
|
|
if td.get("function", {}).get("name") == "execute_code":
|
|
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
|
|
break
|
|
|
|
# Rebuild discord / discord_admin schemas based on the bot's privileged
|
|
# intents (detected from GET /applications/@me) and the user's action
|
|
# allowlist in config. Hides actions the bot's intents don't support so
|
|
# the model never attempts them, and annotates fetch_messages when the
|
|
# MESSAGE_CONTENT intent is missing.
|
|
_discord_schema_fns = {
|
|
"discord": "get_dynamic_schema_core",
|
|
"discord_admin": "get_dynamic_schema_admin",
|
|
}
|
|
for discord_tool_name in _discord_schema_fns:
|
|
if discord_tool_name in available_tool_names:
|
|
try:
|
|
from tools import discord_tool as _dt
|
|
schema_fn = getattr(_dt, _discord_schema_fns[discord_tool_name])
|
|
dynamic = schema_fn()
|
|
except Exception:
|
|
dynamic = None
|
|
if dynamic is None:
|
|
filtered_tools = [
|
|
t for t in filtered_tools
|
|
if t.get("function", {}).get("name") != discord_tool_name
|
|
]
|
|
available_tool_names.discard(discord_tool_name)
|
|
else:
|
|
for i, td in enumerate(filtered_tools):
|
|
if td.get("function", {}).get("name") == discord_tool_name:
|
|
filtered_tools[i] = {"type": "function", "function": dynamic}
|
|
break
|
|
|
|
# Strip web tool cross-references from browser_navigate description when
|
|
# web_search / web_extract are not available. The static schema says
|
|
# "prefer web_search or web_extract" which causes the model to hallucinate
|
|
# those tools when they're missing.
|
|
if "browser_navigate" in available_tool_names:
|
|
web_tools_available = {"web_search", "web_extract"} & available_tool_names
|
|
if not web_tools_available:
|
|
for i, td in enumerate(filtered_tools):
|
|
if td.get("function", {}).get("name") == "browser_navigate":
|
|
desc = td["function"].get("description", "")
|
|
desc = desc.replace(
|
|
" For simple information retrieval, prefer web_search or web_extract (faster, cheaper).",
|
|
"",
|
|
)
|
|
filtered_tools[i] = {
|
|
"type": "function",
|
|
"function": {**td["function"], "description": desc},
|
|
}
|
|
break
|
|
|
|
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]
|
|
|
|
# Sanitize schemas for broad backend compatibility. llama.cpp's
|
|
# json-schema-to-grammar converter (used by its OAI server to build
|
|
# GBNF tool-call parsers) rejects some shapes that cloud providers
|
|
# silently accept — bare "type": "object" with no properties,
|
|
# string-valued schema nodes from malformed MCP servers, etc. This
|
|
# is a no-op for schemas that are already well-formed.
|
|
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) ────────────────────────────
|
|
# Conditionally replace MCP + plugin (non-core) tools with three bridge
|
|
# tools (tool_search / tool_describe / tool_call) when the deferrable
|
|
# surface exceeds the configured threshold (default 10% of context
|
|
# window). Core Hermes tools (toolsets._HERMES_CORE_TOOLS) are NEVER
|
|
# deferred. See tools/tool_search.py for full design notes.
|
|
#
|
|
# This is deliberately the last step before returning — sanitization
|
|
# has already normalized schemas, and the assembly is idempotent in
|
|
# case some caller invokes get_tool_definitions 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":
|
|
context_length = _resolve_active_context_length()
|
|
assembly = assemble_tool_defs(
|
|
filtered_tools,
|
|
context_length=context_length,
|
|
config=ts_cfg,
|
|
)
|
|
if assembly.activated and not quiet_mode:
|
|
print(
|
|
f"🔎 Tool Search: {assembly.deferred_count} MCP/plugin tools deferred "
|
|
f"(~{assembly.deferred_tokens} tokens) behind tool_search/describe/call. "
|
|
f"Threshold ~{assembly.threshold_tokens} tokens."
|
|
)
|
|
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:
|
|
"""Look up the active model's context length for the tool-search gate.
|
|
|
|
Returns 0 when the model can't be resolved — ``should_activate`` falls
|
|
back to a fixed token cutoff in that case.
|
|
"""
|
|
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 {}
|
|
if not isinstance(model_cfg, dict):
|
|
model_cfg = {}
|
|
model_id = (model_cfg.get("model") or model_cfg.get("default") or "").strip()
|
|
if not model_id:
|
|
return 0
|
|
from agent.model_metadata import get_model_context_length
|
|
# Honor explicit `model.context_length` in config.yaml — short-circuits
|
|
# the OpenRouter /models probe at get_model_context_length step 0, so
|
|
# non-OpenRouter providers don't pay the ~2-3s OpenRouter fetch at every
|
|
# CLI startup. See issue #46620.
|
|
raw_ctx = model_cfg.get("context_length")
|
|
config_ctx = raw_ctx if isinstance(raw_ctx, int) and raw_ctx > 0 else None
|
|
return int(get_model_context_length(model_id, config_context_length=config_ctx) 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 whose execution is intercepted by the agent loop (run_agent.py)
|
|
# because they need agent-level state (TodoStore, MemoryStore, etc.).
|
|
# The registry still holds their schemas; dispatch just returns a stub error
|
|
# so if something slips through, the LLM sees a sensible message.
|
|
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
|
|
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
|
|
|
|
|
# =========================================================================
|
|
# Tool error sanitization
|
|
# =========================================================================
|
|
#
|
|
# Tool exceptions can carry arbitrary text into the model's context as the
|
|
# `tool` message content. json.dumps() handles quote/backslash escaping so a
|
|
# raw injection of `</tool_call>` won't break message framing, but the model
|
|
# still *reads* those tokens and they can confuse downstream tool-call
|
|
# parsing or, in adversarial cases, nudge it toward role-confusion framing.
|
|
#
|
|
# This helper strips structural framing tokens (XML role tags, CDATA,
|
|
# markdown code fences) and caps the message at a sane upper bound before it
|
|
# becomes part of the conversation. It's defense-in-depth — the json layer
|
|
# already prevents framing escape — but cheap and worth having.
|
|
#
|
|
# Ported from ironclaw#1639.
|
|
_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)
|
|
_TOOL_ERROR_MAX_LEN = 2000
|
|
|
|
|
|
def _sanitize_tool_error(error_msg: str) -> str:
|
|
"""Strip structural framing tokens from a tool error before showing it to the model.
|
|
|
|
See _TOOL_ERROR_ROLE_TAG_RE docstring above for rationale.
|
|
"""
|
|
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 tool call arguments to match their JSON Schema types.
|
|
|
|
LLMs frequently return numbers as strings (``"42"`` instead of ``42``)
|
|
and booleans as strings (``"true"`` instead of ``true``). This compares
|
|
each argument value against the tool's registered JSON Schema and attempts
|
|
safe coercion when the value is a string but the schema expects a different
|
|
type. Original values are preserved when coercion fails.
|
|
|
|
Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``,
|
|
and union types (``"type": ["integer", "string"]``).
|
|
|
|
Also wraps bare scalar values in a single-element list when the schema
|
|
declares ``"type": "array"``. Open-weight models (DeepSeek, Qwen, GLM)
|
|
sometimes emit ``{"urls": "https://a.com"}`` when the tool expects
|
|
``{"urls": ["https://a.com"]}``; wrapping here avoids a confusing tool
|
|
failure on what is otherwise a well-formed call.
|
|
"""
|
|
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
|
|
|
|
for key, value in list(args.items()):
|
|
prop_schema = properties.get(key)
|
|
if not prop_schema:
|
|
continue
|
|
expected = prop_schema.get("type")
|
|
|
|
# Wrap bare non-list values when the schema declares ``array``.
|
|
# Strings still go through _coerce_value first so JSON-encoded
|
|
# arrays (``'["a","b"]'``) get parsed and nullable ``"null"``
|
|
# becomes ``None`` rather than ``["null"]``.
|
|
# ``None`` itself is preserved — we don't know whether the model
|
|
# meant "omit" or "empty list", and tools with sensible defaults
|
|
# (e.g. read_file's normalize_read_pagination) already handle it.
|
|
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:
|
|
# _coerce_value handled it (JSON-parsed list or
|
|
# nullable "null" → None).
|
|
args[key] = coerced
|
|
continue
|
|
# If the string looks like a JSON array but _coerce_value
|
|
# failed to parse it, warn clearly instead of silently wrapping.
|
|
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):
|
|
# Recurse into already-native containers so JSON-encoded
|
|
# *elements* (array items) and *sub-fields* (nested object
|
|
# properties) get normalized too — e.g. ``todos: ['{"id":...}']``
|
|
# or ``tasks: [{"goal": "..."}]`` where an element was emitted as
|
|
# a JSON string. The top-level coercion above only repairs the
|
|
# outermost value.
|
|
if expected == "array" and isinstance(value, (list, tuple)):
|
|
args[key] = _normalize_json_strings_for_schema(value, prop_schema)
|
|
elif 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 we just JSON-parsed a string into a container, recurse so
|
|
# nested JSON-encoded elements/fields get normalized as well.
|
|
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:
|
|
"""Return True when *schema* permits a value of JSON type *kind*.
|
|
|
|
Looks at ``type`` (string or list) and recurses through
|
|
``anyOf``/``oneOf``/``allOf`` branches — matching the JSON-Schema shapes
|
|
open-weight models emit against. ``kind`` is ``"array"`` or ``"object"``.
|
|
"""
|
|
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 string values that a schema expects to
|
|
be arrays or objects, including nested array items and object properties.
|
|
|
|
Open-weight models (DeepSeek, Qwen, GLM, and others) sometimes emit a
|
|
structured field — or an *element* of a structured field — as a
|
|
JSON-encoded string instead of a native value. The top-level
|
|
:func:`coerce_tool_args` pass repairs the outermost value; this helper
|
|
walks the rest of the tree so cases like::
|
|
|
|
{"todos": ["{\\"id\\": \\"1\\", \\"content\\": \\"x\\"}"]}
|
|
|
|
(a list whose elements are JSON strings) and nested object sub-fields are
|
|
repaired too. Parsing is schema-guided: a string is only parsed when the
|
|
matching schema position actually expects an array or object, so
|
|
legitimate JSON-looking string fields (``type: string``) are preserved.
|
|
|
|
Ported from cline/cline#11803, adapted to hermes-agent's coercion layer.
|
|
Returns the original value object when nothing changed (identity preserved
|
|
so callers can cheaply detect no-ops).
|
|
"""
|
|
if not isinstance(schema, dict):
|
|
return value
|
|
|
|
# Parse a JSON-encoded string into the container the schema expects.
|
|
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:
|
|
value = parsed
|
|
elif isinstance(parsed, dict) and expects_object:
|
|
value = parsed
|
|
else:
|
|
return value
|
|
else:
|
|
return value
|
|
|
|
# Recurse into list items using the ``items`` schema.
|
|
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
|
|
|
|
# Recurse into object properties using each property's schema.
|
|
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):
|
|
"""Attempt to coerce a string *value* to *expected_type*.
|
|
|
|
Returns the original string when coercion is not applicable or fails.
|
|
"""
|
|
if _schema_allows_null(schema) and value.strip().lower() == "null":
|
|
return None
|
|
|
|
if isinstance(expected_type, list):
|
|
# Union type — try each in order, return first successful coercion
|
|
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:
|
|
"""Return 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":
|
|
return True
|
|
if 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 not isinstance(variants, list):
|
|
continue
|
|
for variant in variants:
|
|
if isinstance(variant, dict) and variant.get("type") == "null":
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _coerce_json(value: str, expected_python_type: type):
|
|
"""Parse *value* as JSON when the schema expects an array or object.
|
|
|
|
Handles model output drift where a complex oneOf/discriminated-union schema
|
|
causes the LLM to emit the array/object as a JSON string instead of a native
|
|
structure. Returns the original string if parsing fails or yields the wrong
|
|
Python type.
|
|
"""
|
|
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):
|
|
"""Try to parse *value* as a number. Returns original string on failure."""
|
|
try:
|
|
f = float(value)
|
|
except (ValueError, OverflowError):
|
|
return value
|
|
# Guard against inf/nan — not JSON-serializable, keep original string
|
|
if f != f or f == float("inf") or f == float("-inf"):
|
|
return value
|
|
# If it looks like an integer (no fractional part), return int
|
|
if f == int(f):
|
|
return int(f)
|
|
if integer_only:
|
|
# Schema wants an integer but value has decimals — keep as string
|
|
return value
|
|
return f
|
|
|
|
|
|
def _coerce_boolean(value: str):
|
|
"""Try to parse *value* as a boolean. Returns original string on failure."""
|
|
low = value.strip().lower()
|
|
if low == "true":
|
|
return True
|
|
if low == "false":
|
|
return False
|
|
return value
|
|
|
|
|
|
def _tool_result_observer_fields(result: Any) -> tuple[str, Optional[str], Optional[str]]:
|
|
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
|
|
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.
|
|
|
|
No-ops cheaply when no plugin has registered for ``post_tool_call`` —
|
|
the ``has_hook`` gate skips both the result-field derivation and the
|
|
payload dispatch so the no-listener path costs one dict lookup. When
|
|
``status`` is not supplied, the ok/error fields are derived from the
|
|
result *after* the gate (parsing the result is only worth it when a
|
|
listener will actually consume it).
|
|
"""
|
|
try:
|
|
from hermes_cli.plugins 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(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,
|
|
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
disabled_toolsets: Optional[List[str]] = None,
|
|
) -> str:
|
|
"""
|
|
Main function call dispatcher that routes calls to the tool registry.
|
|
|
|
Args:
|
|
function_name: Name of the function to call.
|
|
function_args: Arguments for the function.
|
|
task_id: Unique identifier for terminal/browser session isolation.
|
|
user_task: The user's original task (for browser_snapshot context).
|
|
enabled_tools: Tool names enabled for this session. When provided,
|
|
execute_code uses this list to determine which sandbox
|
|
tools to generate. Falls back to the process-global
|
|
``_last_resolved_tool_names`` for backward compat.
|
|
enabled_toolsets: The session's enabled toolsets. Used to scope the
|
|
Tool Search bridge catalog so ``tool_search`` /
|
|
``tool_describe`` / ``tool_call`` only see and invoke
|
|
tools the session was actually granted. ``None`` means
|
|
"no restriction" (the caller scopes to every toolset),
|
|
matching ``get_tool_definitions`` semantics.
|
|
disabled_toolsets: The session's disabled toolsets, applied as a
|
|
subtraction when scoping the bridge catalog.
|
|
|
|
Returns:
|
|
Function result as a JSON string.
|
|
"""
|
|
# Coerce string arguments to their schema-declared types (e.g. "42"→42)
|
|
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 [])
|
|
|
|
# ── Tool Search bridge dispatch ──────────────────────────────────
|
|
# tool_search and tool_describe are pure catalog reads — handle them
|
|
# inline. tool_call is unwrapped to the underlying tool so that every
|
|
# downstream hook (pre/post, edit approval, guardrails) sees the real
|
|
# tool name, not the bridge.
|
|
_ts_mod = None
|
|
try:
|
|
from tools import tool_search as _ts_mod # noqa: F401
|
|
except Exception:
|
|
_ts_mod = None
|
|
|
|
if _ts_mod is not None and _ts_mod.is_bridge_tool(function_name):
|
|
try:
|
|
# Use skip_tool_search_assembly=True so we see the real catalog,
|
|
# not the already-collapsed bridge-only list (the bridge would
|
|
# otherwise be searching only itself).
|
|
#
|
|
# Scope the catalog to the session's toolsets so the bridge can
|
|
# only surface and invoke tools the session was actually granted.
|
|
# Without this, a restricted-toolset session (subagent, kanban
|
|
# worker, curated gateway session) would see and be able to call
|
|
# the entire process registry via the bridge. Passing the same
|
|
# enabled/disabled toolsets the session was assembled with keeps
|
|
# the deferred catalog identical to the deferrable subset of the
|
|
# session's own tool list, and avoids polluting the process-global
|
|
# _last_resolved_tool_names with out-of-scope tools.
|
|
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 = []
|
|
if function_name == _ts_mod.TOOL_SEARCH_NAME:
|
|
return _ts_mod.dispatch_tool_search(function_args or {},
|
|
current_tool_defs=current_defs)
|
|
if function_name == _ts_mod.TOOL_DESCRIBE_NAME:
|
|
return _ts_mod.dispatch_tool_describe(function_args or {},
|
|
current_tool_defs=current_defs)
|
|
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)
|
|
# 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
|
|
# additionally rejects any tool the session was not granted, so a
|
|
# restricted session can never invoke an out-of-scope tool through
|
|
# 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)
|
|
# Recurse with the underlying tool. All hooks fire against the
|
|
# real tool name. The bridge is invisible to hooks by design.
|
|
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,
|
|
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,
|
|
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 json.dumps({"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
|
|
# avoid double-firing the hook).
|
|
#
|
|
# Single-fire contract: pre_tool_call fires exactly once per tool
|
|
# execution. resolve_pre_tool_block() internally calls
|
|
# invoke_hook("pre_tool_call", ...) once and returns the block message
|
|
# for a `block` directive OR for an `approve` directive whose human
|
|
# gate denied/timed-out/errored (fail-closed). Observer plugins see
|
|
# the hook on that same pass. When skip=True, the caller already
|
|
# fired it — do nothing here.
|
|
if not skip_pre_tool_call_hook:
|
|
block_message: Optional[str] = None
|
|
try:
|
|
from hermes_cli.plugins import resolve_pre_tool_block
|
|
block_message = resolve_pre_tool_block(
|
|
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),
|
|
)
|
|
except Exception as _hook_err:
|
|
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)
|
|
_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,
|
|
status="blocked",
|
|
error_type="plugin_block",
|
|
error_message=block_message,
|
|
middleware_trace=list(_tool_middleware_trace),
|
|
)
|
|
return result
|
|
|
|
# ACP/Zed edit approval runs before any file mutation. The requester
|
|
# is bound via ContextVar only for ACP sessions, so CLI/gateway paths
|
|
# are unaffected when it is unset.
|
|
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 edit_block_message
|
|
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)
|
|
|
|
# Notify the read-loop tracker when a non-read/search tool runs,
|
|
# so the *consecutive* counter resets (reads after other work are fine).
|
|
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
|
|
|
|
# Measure tool dispatch latency so post_tool_call and
|
|
# transform_tool_result hooks can observe per-tool duration.
|
|
# Inspired by Claude Code 2.1.119, which added ``duration_ms`` to
|
|
# PostToolUse hook inputs so plugin authors can build latency
|
|
# dashboards, budget alerts, and regression canaries without having
|
|
# to wrap every tool manually. We use monotonic() so the value is
|
|
# unaffected by wall-clock adjustments during the call.
|
|
_dispatch_start = time.monotonic()
|
|
_approval_tokens = None
|
|
try:
|
|
from tools.approval import (
|
|
reset_current_observability_context,
|
|
set_current_observability_context,
|
|
)
|
|
_approval_tokens = set_current_observability_context(
|
|
turn_id=turn_id or "",
|
|
tool_call_id=tool_call_id or "",
|
|
)
|
|
except Exception:
|
|
reset_current_observability_context = None
|
|
try:
|
|
if function_name == "execute_code":
|
|
# Prefer the caller-provided list so subagents can't overwrite
|
|
# the parent's tool set via the process-global.
|
|
sandbox_enabled = enabled_tools if enabled_tools is not None else _last_resolved_tool_names
|
|
def _dispatch(next_args: Dict[str, Any]) -> Any:
|
|
return registry.dispatch(
|
|
function_name, next_args,
|
|
task_id=task_id,
|
|
session_id=session_id,
|
|
enabled_tools=sandbox_enabled,
|
|
)
|
|
else:
|
|
def _dispatch(next_args: Dict[str, Any]) -> Any:
|
|
return registry.dispatch(
|
|
function_name, next_args,
|
|
task_id=task_id,
|
|
session_id=session_id,
|
|
user_task=user_task,
|
|
)
|
|
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_current_observability_context is not None:
|
|
try:
|
|
reset_current_observability_context(_approval_tokens)
|
|
except Exception:
|
|
pass
|
|
duration_ms = int((time.monotonic() - _dispatch_start) * 1000)
|
|
|
|
_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,
|
|
duration_ms=duration_ms,
|
|
middleware_trace=list(_tool_middleware_trace),
|
|
)
|
|
|
|
# Generic tool-result canonicalization seam: plugins receive the
|
|
# final result string (JSON, usually) and may replace it by
|
|
# returning a string from transform_tool_result. Runs after
|
|
# post_tool_call (which stays observational) and before the result
|
|
# is appended back into conversation context. Fail-open; the first
|
|
# valid string return wins; non-string returns are ignored.
|
|
# Gated on has_hook so the no-listener path skips both the result
|
|
# field derivation and the payload dispatch.
|
|
try:
|
|
from hermes_cli.plugins import has_hook, invoke_hook
|
|
if has_hook("transform_tool_result"):
|
|
status, error_type, error_message = _tool_result_observer_fields(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 json.dumps({"error": _sanitize_tool_error(error_msg)}, ensure_ascii=False)
|
|
|
|
|
|
# =============================================================================
|
|
# 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)
|