2025-08-09 09:52:25 -07:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Model Tools Module
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
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
|
2025-08-09 09:52:25 -07:00
|
|
|
"""
|
|
|
|
|
|
2026-05-18 20:24:31 -07:00
|
|
|
import os
|
2025-08-09 09:52:25 -07:00
|
|
|
import json
|
security: sanitize tool error strings before injecting into model context (#26823)
Adds _sanitize_tool_error() in model_tools and routes both error paths
through it: registry.dispatch's try/except (the primary path for tool
exceptions) and handle_function_call's outer except (defense in depth).
Stripping targets structural framing tokens that the model itself can
react to even though json.dumps already handles wire-layer escaping:
XML role tags (tool_call, function_call, result, response, output,
input, system, assistant, user), CDATA sections, and markdown code
fences. Caps message body at 2000 chars and wraps with [TOOL_ERROR]
prefix.
Defense-in-depth: a tool exception carrying '<tool_call>...' won't
break message framing (json escapes it), but the model still reads
those tokens and they nudge it toward role-confusion framing.
Ported from ironclaw#1639 (one piece of #3838's three-feature scout).
The truncated-tool-call (#1632) and empty-response-recovery (#1677,
#1720) pieces are skipped because main now implements both far more
thoroughly (run_agent.py L8147/L12209/L13012 for truncation retry +
length rewrite; L4500/L15090+ for empty-response scaffolding stripper,
multi-stage nudge, fallback model activation).
2026-05-16 00:57:39 -07:00
|
|
|
import re
|
2025-08-09 09:52:25 -07:00
|
|
|
import asyncio
|
2026-02-21 20:22:33 -08:00
|
|
|
import logging
|
2026-03-20 09:44:50 -07:00
|
|
|
import threading
|
2026-04-25 22:13:12 -07:00
|
|
|
import time
|
2026-02-02 19:28:27 -08:00
|
|
|
from typing import Dict, Any, List, Optional, Tuple
|
2025-08-09 09:52:25 -07:00
|
|
|
|
2026-04-14 18:02:25 -05:00
|
|
|
from tools.registry import discover_builtin_tools, registry
|
2026-02-20 23:23:32 -08:00
|
|
|
from toolsets import resolve_toolset, validate_toolset
|
2025-08-09 09:52:25 -07:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
fix(tools): preserve core tools when a platform bundle is disabled
When a platform-bundle name (e.g. `hermes-yuanbao`, or any `hermes-*`) lands
in `agent.disabled_toolsets`, the shared tool-assembly path
(`model_tools._compute_tool_definitions`, used by the gateway, cron, AND the
CLI) subtracted the WHOLE bundle from the enabled set. Because every platform
bundle is defined as `_HERMES_CORE_TOOLS + [platform extras]`, and core tools
are shared by every other enabled toolset, the subtraction emptied the tool
list entirely — the model received `tools: []` / `tool_choice: null` and
started replying "I cannot execute shell commands" with no error, no warning,
and `hermes tools list` / `hermes doctor` still green. For unattended cron
jobs this fails silently for days. (#33924)
(The original report framed this as gateway-only; it actually affects every
caller of `_compute_tool_definitions`, including the CLI — the reporter's
follow-up confirms this. Fixing the shared chokepoint covers all paths.)
Fix: for a `hermes-*` bundle in `disabled_toolsets`, subtract only its
*non-core delta* (its platform-specific tools plus those of any `includes`),
leaving `_HERMES_CORE_TOOLS` intact. Disabling a bundle now removes its
platform tools (e.g. the `yb_*` tools for `hermes-yuanbao`) while terminal,
read_file, web, etc. survive. A `logger.warning` notes that core tools are
preserved and that bundle names usually belong in `toolsets:`, not
`disabled_toolsets` — informative, not destructive (the subtraction still
behaves sensibly).
Salvaged from #33941 by @liuhao1024 (authorship preserved). Extracted the
inline bundle-resolution into a module-level `_bundle_non_core_tools` helper
(was re-importing `toolsets` inside the disable loop), and added the
informative warning folding in the UX intent of #34073 (@ousiaresearch)
without its hard "ignore the bundle name" behavior — which would have undone
this fix's sensible-subtraction.
Verified empirically: disabling `hermes-yuanbao` from a gateway-style enabled
set keeps all core tools (18→18) and would remove only the 5 `yb_*` tools;
disabling `hermes-discord` removes only `discord`/`discord_admin`.
Fixes #33924
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-21 16:22:55 +05:30
|
|
|
# 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()
|
|
|
|
|
|
2026-02-02 19:28:27 -08:00
|
|
|
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
# =============================================================================
|
2026-02-21 20:22:33 -08:00
|
|
|
# Async Bridging (single source of truth -- used by registry.dispatch too)
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
|
2026-03-20 09:44:50 -07:00
|
|
|
_tool_loop = None # persistent loop for the main (CLI) thread
|
|
|
|
|
_tool_loop_lock = threading.Lock()
|
2026-03-20 15:41:06 -04:00
|
|
|
_worker_thread_local = threading.local() # per-worker-thread persistent loops
|
2026-03-20 09:44:50 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 15:41:06 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
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.
|
|
|
|
|
|
2026-03-20 09:44:50 -07:00
|
|
|
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.
|
|
|
|
|
|
2026-03-20 15:41:06 -04:00
|
|
|
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.
|
2026-03-20 11:39:13 -07:00
|
|
|
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
This is the single source of truth for sync->async bridging in tool
|
chore: remove Atropos RL environments and tinker-atropos integration (#26106)
* chore: remove Atropos RL environments, tools, tests, skill, and tinker-atropos submodule
Delete:
- environments/ (43 files — base env, agent loop, tool call parsers, benchmarks)
- rl_cli.py (standalone RL training CLI)
- tools/rl_training_tool.py (all 10 rl_* tools)
- tests: test_rl_training_tool, test_tool_call_parsers, test_managed_server_tool_support,
test_agent_loop, test_agent_loop_vllm, test_agent_loop_tool_calling,
test_terminalbench2_env_security
- optional-skills/mlops/hermes-atropos-environments/
- tinker-atropos git submodule + .gitmodules
* chore: remove RL/Atropos references from Python source
- toolsets.py: remove rl toolset block + update comment
- model_tools.py: remove rl_tools group + update async bridging comment
- hermes_cli/tools_config.py: remove RL display entry, _DEFAULT_OFF_TOOLSETS,
setup block, and rl_training post-setup handler
- tools/budget_config.py: remove RL environment reference in docstring
- tests/test_model_tools.py: remove rl_tools from expected groups
- tests/run_agent/test_streaming_tool_call_repair.py: fix stale cross-reference
* chore: remove rl/yc-bench extras and tinker-atropos refs from pyproject.toml
- Remove rl extra (atroposlib, tinker, fastapi, uvicorn, wandb)
- Remove yc-bench extra
- Remove rl_cli from py-modules
- Remove [tool.ty.src] exclude for tinker-atropos
- Remove [tool.ruff] exclude for tinker-atropos
- Regenerate uv.lock
* chore: remove tinker-atropos from install/setup scripts
- setup-hermes.sh: remove entire tinker-atropos submodule install block
- scripts/install.sh: remove both tinker-atropos blocks (Termux + standard)
- scripts/install.ps1: remove tinker-atropos block
- nix/hermes-agent.nix: remove tinker-atropos pip install line
* chore: remove RL references from cli-config.yaml.example
* docs: remove Atropos/RL references from README, CONTRIBUTING, AGENTS.md
* docs: remove RL/Atropos references from website
- Delete: environments.md, rl-training.md, mlops-hermes-atropos-environments.md
- sidebars.ts: remove rl-training and environments sidebar entries
- optional-skills-catalog.md: remove hermes-atropos-environments row
- tools-reference.md: remove entire rl toolset section
- toolsets-reference.md: remove rl row + update example
- integrations/index.md: remove RL Training bullet
- architecture.md: remove environments/ from tree + RL section
- contributing.md: remove tinker-atropos setup
- updating.md: remove tinker-atropos install + stale submodule update
* chore: remove remaining RL/Atropos stragglers
- hermes_cli/config.py: remove TINKER_API_KEY + WANDB_API_KEY env var defs
- hermes_cli/doctor.py: remove Submodules check section (tinker-atropos)
- hermes_cli/setup.py: remove RL Training status check
- hermes_cli/status.py: remove Tinker + WandB from API key status display
- agent/display.py: remove both rl_* tool preview/activity blocks
- website/docs: remove RL references from providers.md + env-variables.md
- tests: remove TINKER_API_KEY from conftest, set_config_value, setup_script
* chore: remove RL training section from .env.example
2026-05-15 10:36:38 +05:30
|
|
|
handlers. Each handler is self-protecting via this function.
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
loop = None
|
|
|
|
|
|
|
|
|
|
if loop and loop.is_running():
|
2026-04-29 04:56:33 -07:00
|
|
|
# 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).
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
import concurrent.futures
|
2026-04-29 04:56:33 -07:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-04-22 08:02:42 +03:00
|
|
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
2026-04-29 04:56:33 -07:00
|
|
|
future = pool.submit(_run_in_worker)
|
2026-04-22 08:02:42 +03:00
|
|
|
try:
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
return future.result(timeout=300)
|
2026-04-22 08:02:42 +03:00
|
|
|
except concurrent.futures.TimeoutError:
|
2026-04-29 04:56:33 -07:00
|
|
|
# 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
|
2026-04-22 08:02:42 +03:00
|
|
|
raise
|
|
|
|
|
finally:
|
2026-04-29 04:56:33 -07:00
|
|
|
# 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)
|
2026-03-20 09:44:50 -07:00
|
|
|
|
2026-03-20 15:41:06 -04:00
|
|
|
# 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.
|
2026-03-20 11:39:13 -07:00
|
|
|
if threading.current_thread() is not threading.main_thread():
|
2026-03-20 15:41:06 -04:00
|
|
|
worker_loop = _get_worker_loop()
|
|
|
|
|
return worker_loop.run_until_complete(coro)
|
2026-03-20 11:39:13 -07:00
|
|
|
|
2026-03-20 09:44:50 -07:00
|
|
|
tool_loop = _get_tool_loop()
|
|
|
|
|
return tool_loop.run_until_complete(coro)
|
refactor: deduplicate toolsets, unify async bridging, fix approval race condition, harden security
- Replace 4 copy-pasted messaging platform toolsets with shared _HERMES_CORE_TOOLS list
- Consolidate 5 ad-hoc async-bridging patterns into single _run_async() in model_tools.py
- Removes deprecated get_event_loop()/set_event_loop() calls
- Makes all tool handlers self-protecting regardless of caller's event loop state
- RL handler refactored from if/elif chain to dispatch dict
- Fix exec approval race condition: replace module-level globals with thread-safe
per-session tools/approval.py (submit_pending, pop_pending, approve_session, is_approved)
- Session A approving "rm" no longer approves it for all other sessions
- Fix config deep merge: user overriding tts.elevenlabs.voice_id no longer clobbers
tts.elevenlabs.model_id; migration detection now recurses to arbitrary depth
- Gateway default-deny: unauthenticated users denied unless GATEWAY_ALLOW_ALL_USERS=true
- Add 10 dangerous command patterns: rm --recursive, bash -c, python -e, curl|bash,
xargs rm, find -delete
- Sanitize gateway error messages: users see generic message, full traceback goes to logs
2026-02-21 18:28:49 -08:00
|
|
|
|
|
|
|
|
|
2026-02-02 19:28:27 -08:00
|
|
|
# =============================================================================
|
2026-02-21 20:22:33 -08:00
|
|
|
# Tool Discovery (importing each module triggers its registry.register calls)
|
2026-02-02 19:28:27 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
|
2026-04-14 18:02:25 -05:00
|
|
|
discover_builtin_tools()
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
|
2026-04-28 01:17:58 -07:00
|
|
|
# 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
|
feat: add MCP (Model Context Protocol) client support
Connect to external MCP servers via stdio transport, discover their tools
at startup, and register them into the hermes-agent tool registry.
- New tools/mcp_tool.py: config loading, server connection via background
event loop, tool handler factories, discovery, and graceful shutdown
- model_tools.py: trigger MCP discovery after built-in tool imports
- cli.py: call shutdown_mcp_servers in _run_cleanup
- pyproject.toml: add mcp>=1.2.0 as optional dependency
- 27 unit tests covering config, schema conversion, handlers, registration,
SDK interaction, toolset injection, graceful fallback, and shutdown
Config format (in ~/.hermes/config.yaml):
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2026-03-02 21:03:14 +03:00
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
# 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)
|
|
|
|
|
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
# Backward-compat constants (built once after discovery)
|
|
|
|
|
# =============================================================================
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map()
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements()
|
2025-11-17 01:14:31 -05:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# 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] = []
|
2025-11-17 01:14:31 -05:00
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
# Legacy toolset name mapping (old _tools-suffixed names -> tool name lists)
|
|
|
|
|
# =============================================================================
|
2025-08-09 09:52:25 -07:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
_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",
|
refactor: remove browser_close tool — auto-cleanup handles it (#5792)
* refactor: remove browser_close tool — auto-cleanup handles it
The browser_close tool was called in only 9% of browser sessions (13/144
navigations across 66 sessions), always redundantly — cleanup_browser()
already runs via _cleanup_task_resources() at conversation end, and the
background inactivity reaper catches anything else.
Removing it saves one tool schema slot in every browser-enabled API call.
Also fixes a latent bug: cleanup_browser() now handles Camofox sessions
too (previously only Browserbase). Camofox sessions were never auto-cleaned
per-task because they live in a separate dict from _active_sessions.
Files changed (13):
- tools/browser_tool.py: remove function, schema, registry entry; add
camofox cleanup to cleanup_browser()
- toolsets.py, model_tools.py, prompt_builder.py, display.py,
acp_adapter/tools.py: remove browser_close from all tool lists
- tests/: remove browser_close test, update toolset assertion
- docs/skills: remove all browser_close references
* fix: repeat browser_scroll 5x per call for meaningful page movement
Most backends scroll ~100px per call — barely visible on a typical
viewport. Repeating 5x gives ~500px (~half a viewport), making each
scroll tool call actually useful.
Backend-agnostic approach: works across all 7+ browser backends without
needing to configure each one's scroll amount individually. Breaks
early on error for the agent-browser path.
* feat: auto-return compact snapshot from browser_navigate
Every browser session starts with navigate → snapshot. Now navigate
returns the compact accessibility tree snapshot inline, saving one
tool call per browser task.
The snapshot captures the full page DOM (not viewport-limited), so
scroll position doesn't affect it. browser_snapshot remains available
for refreshing after interactions or getting full=true content.
Both Browserbase and Camofox paths auto-snapshot. If the snapshot
fails for any reason, navigation still succeeds — the snapshot is
a bonus, not a requirement.
Schema descriptions updated to guide models: navigate mentions it
returns a snapshot, snapshot mentions it's for refresh/full content.
* refactor: slim cronjob tool schema — consolidate model/provider, drop unused params
Session data (151 calls across 67 sessions) showed several schema
properties were never used by models. Consolidated and cleaned up:
Removed from schema (still work via backend/CLI):
- skill (singular): use skills array instead
- reason: pause-only, unnecessary
- include_disabled: now defaults to true
- base_url: extreme edge case, zero usage
- provider (standalone): merged into model object
Consolidated:
- model + provider → single 'model' object with {model, provider} fields.
If provider is omitted, the current main provider is pinned at creation
time so the job stays stable even if the user changes their default.
Kept:
- script: useful data collection feature
- skills array: standard interface for skill loading
Schema shrinks from 14 to 10 properties. All backend functionality
preserved — the Python function signature and handler lambda still
accept every parameter.
* fix: remove mixture_of_agents from core toolsets — opt-in only via hermes tools
MoA was in _HERMES_CORE_TOOLS and composite toolsets (hermes-cli,
hermes-messaging, safe), which meant it appeared in every session
for anyone with OPENROUTER_API_KEY set. The _DEFAULT_OFF_TOOLSETS
gate only works after running 'hermes tools' explicitly.
Now MoA only appears when a user explicitly enables it via
'hermes tools'. The moa toolset definition and check_fn remain
unchanged — it just needs to be opted into.
2026-04-07 03:28:44 -07:00
|
|
|
"browser_press", "browser_get_images",
|
2026-03-17 02:02:49 -07:00
|
|
|
"browser_vision", "browser_console"
|
2026-02-21 20:22:33 -08:00
|
|
|
],
|
2026-03-14 12:21:50 -07:00
|
|
|
"cronjob_tools": ["cronjob"],
|
2026-02-21 20:22:33 -08:00
|
|
|
"file_tools": ["read_file", "write_file", "patch", "search_files"],
|
|
|
|
|
"tts_tools": ["text_to_speech"],
|
2026-02-08 20:19:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
# get_tool_definitions (the main schema provider)
|
|
|
|
|
# =============================================================================
|
2026-02-19 23:23:43 -08:00
|
|
|
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
# 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]]] = {}
|
|
|
|
|
|
fix(gateway): close residual memory-leak sites under heavy scheduled workload
Long-lived gateways under heavy cron/build workloads grow steadily (~18 MB/hr
post-phantom-dispatch-fix) and eventually need a restart-or-OOM. Four retention
sites, all confirmed live on current main:
1. _evict_cached_agent() (/model, /reasoning, codex-runtime, /undo, etc.) popped
the cache entry without releasing the agent's OpenAI client, httpx transport,
SSL context, or conversation history. Only /new cleaned up first. Now releases
clients on a daemon thread, matching _enforce_agent_cache_cap.
2. _release_evicted_agent_soft() now clears _session_messages after
release_clients() — tool outputs (file reads, terminal output, search results)
can be tens of MB per 100+-tool-call session; the list is rebuilt from
persisted session JSON on resume, so dropping it on soft eviction is safe.
3. The session-expiry watcher (permanent finalization) now drops the session's
per-session control dicts (_session_model_overrides, _session_reasoning_overrides,
_pending_approvals, _update_prompt_pending, _pending_model_notes). These leaked
one entry per session per gateway lifetime. NOTE: this is the session-finalize
path, NOT idle agent-cache eviction — an idle-evicted session is still alive and
rebuilds its agent from these overrides, so pruning them there would silently
reset a user's /model choice.
4. _tool_defs_cache is now bounded (_TOOL_DEFS_CACHE_MAX=8) with oldest-first
eviction instead of growing unboundedly across the distinct toolset/config
fingerprints a gateway sees over its lifetime.
Salvaged from #25318 by Michael Steuer (@mssteuer); fix 3 redirected from the
idle-sweep to the session-finalize lifecycle, magic number 8 lifted to a named
constant, test ported.
Fixes #19251
Co-authored-by: Michael Steuer <michael@make.software>
2026-06-08 02:22:34 -07:00
|
|
|
# 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
|
|
|
|
|
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
def get_tool_definitions(
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
|
|
|
disabled_toolsets: Optional[List[str]] = None,
|
2026-01-31 06:30:48 +00:00
|
|
|
quiet_mode: bool = False,
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
skip_tool_search_assembly: bool = False,
|
2025-08-09 09:52:25 -07:00
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""
|
2025-09-10 00:43:55 -07:00
|
|
|
Get tool definitions for model API calls with toolset-based filtering.
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
All tools must be part of a toolset to be accessible.
|
|
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
Args:
|
2026-02-21 20:22:33 -08:00
|
|
|
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.
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
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.
|
2026-02-21 20:22:33 -08:00
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
Returns:
|
2026-02-21 20:22:33 -08:00
|
|
|
Filtered list of OpenAI-format tool definitions.
|
2025-08-09 09:52:25 -07:00
|
|
|
"""
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
# 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,
|
2026-05-18 20:24:31 -07:00
|
|
|
bool(os.environ.get("HERMES_KANBAN_TASK")),
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
bool(skip_tool_search_assembly),
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
|
|
|
|
|
skip_tool_search_assembly=skip_tool_search_assembly)
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
if quiet_mode:
|
2026-04-29 00:50:32 -07:00
|
|
|
# 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)
|
fix(gateway): close residual memory-leak sites under heavy scheduled workload
Long-lived gateways under heavy cron/build workloads grow steadily (~18 MB/hr
post-phantom-dispatch-fix) and eventually need a restart-or-OOM. Four retention
sites, all confirmed live on current main:
1. _evict_cached_agent() (/model, /reasoning, codex-runtime, /undo, etc.) popped
the cache entry without releasing the agent's OpenAI client, httpx transport,
SSL context, or conversation history. Only /new cleaned up first. Now releases
clients on a daemon thread, matching _enforce_agent_cache_cap.
2. _release_evicted_agent_soft() now clears _session_messages after
release_clients() — tool outputs (file reads, terminal output, search results)
can be tens of MB per 100+-tool-call session; the list is rebuilt from
persisted session JSON on resume, so dropping it on soft eviction is safe.
3. The session-expiry watcher (permanent finalization) now drops the session's
per-session control dicts (_session_model_overrides, _session_reasoning_overrides,
_pending_approvals, _update_prompt_pending, _pending_model_notes). These leaked
one entry per session per gateway lifetime. NOTE: this is the session-finalize
path, NOT idle agent-cache eviction — an idle-evicted session is still alive and
rebuilds its agent from these overrides, so pruning them there would silently
reset a user's /model choice.
4. _tool_defs_cache is now bounded (_TOOL_DEFS_CACHE_MAX=8) with oldest-first
eviction instead of growing unboundedly across the distinct toolset/config
fingerprints a gateway sees over its lifetime.
Salvaged from #25318 by Michael Steuer (@mssteuer); fix 3 redirected from the
idle-sweep to the session-finalize lifecycle, magic number 8 lifted to a named
constant, test ported.
Fixes #19251
Co-authored-by: Michael Steuer <michael@make.software>
2026-06-08 02:22:34 -07:00
|
|
|
# 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
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
_tool_defs_cache[cache_key] = result
|
2026-04-29 00:50:32 -07:00
|
|
|
return list(result)
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compute_tool_definitions(
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
|
|
|
disabled_toolsets: Optional[List[str]] = None,
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
quiet_mode: bool = False,
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
skip_tool_search_assembly: bool = False,
|
perf(tools): memoize get_tool_definitions + TTL-cache check_fn results (#17098)
Two amplifying optimizations to per-turn overhead in the gateway:
1. get_tool_definitions() memoization (model_tools.py)
Keyed on (frozenset(enabled), frozenset(disabled),
registry._generation, config.yaml mtime+size). Only active when
quiet_mode=True (which is every hot-path caller — gateway,
AIAgent.__init__); quiet_mode=False keeps the existing print side
effects. Cached path returns a shallow-copy list sharing read-only
schema dicts.
Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway
constructs fresh AIAgent per message, so this saves ~7 ms/turn before
any LLM work.
2. check_fn() TTL cache (tools/registry.py)
check_fn callables like check_terminal_requirements probe external
state (Docker daemon, Modal SDK, playwright binary). For a long-lived
process, hitting them on every get_definitions() pass was pure waste
— external state changes on human timescales. 30 s TTL so env-var
flips (hermes tools enable X) propagate within a turn or two without
explicit invalidation.
Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate);
subsequent calls ~0.01ms via the upstream memoization.
Invalidation surface:
- registry._generation bumps on register/deregister/register_toolset_alias,
invalidating the memoized definitions automatically.
- config.yaml mtime in the cache key captures user-visible config edits
affecting dynamic schemas (execute_code mode, discord allowlist).
- invalidate_check_fn_cache() exposed for explicit flushes (e.g. after
hermes tools enable/disable).
- tests/conftest.py autouse fixture clears both caches before every test
so env-var monkeypatches don't see stale results.
Also fixes a regression from PR #17046 that I missed:
- tools/web_tools.py — Firecrawl was removed from module scope by the
lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'.
Applied the same _FirecrawlProxy pattern used in auxiliary_client/
run_agent for OpenAI (module-level proxy that looks like the class
but imports the SDK on first call/isinstance; patch() replaces the
attribute as usual).
Verified:
- 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main)
- 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in
the full suite due to check_fn TTL cross-test pollution; fixed by
the autouse fixture)
- 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp
dynamic discovery, 5 mcp structured content — all confirmed on main)
- 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails)
- 868/868 tests/run_agent/ (excluding test_run_agent.py which has
pre-existing suite-level issues)
- Live smoke: 2 turns + /model switch + tool calls, zero errors in
agent.log session window.
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
2026-04-28 18:20:17 -07:00
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""Uncached implementation of :func:`get_tool_definitions`."""
|
2026-02-21 20:22:33 -08:00
|
|
|
# Determine which tool names the caller wants
|
|
|
|
|
tools_to_include: set = set()
|
2025-11-17 01:14:31 -05:00
|
|
|
|
2026-03-30 21:10:05 -07:00
|
|
|
if enabled_toolsets is not None:
|
2026-05-18 20:24:31 -07:00
|
|
|
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:
|
2025-09-10 00:43:55 -07:00
|
|
|
if validate_toolset(toolset_name):
|
2026-02-21 20:22:33 -08:00
|
|
|
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)
|
2026-02-02 23:46:41 -08:00
|
|
|
if not quiet_mode:
|
2026-02-21 20:22:33 -08:00
|
|
|
print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
|
2026-05-11 11:03:29 -07:00
|
|
|
elif not quiet_mode:
|
|
|
|
|
print(f"⚠️ Unknown toolset: {toolset_name}")
|
2026-04-29 13:24:50 +05:30
|
|
|
else:
|
|
|
|
|
# Default: start with everything
|
2025-09-10 00:43:55 -07:00
|
|
|
from toolsets import get_all_toolsets
|
2026-02-21 20:22:33 -08:00
|
|
|
for ts_name in get_all_toolsets():
|
|
|
|
|
tools_to_include.update(resolve_toolset(ts_name))
|
|
|
|
|
|
2026-04-29 13:24:50 +05:30
|
|
|
# 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
|
2026-04-30 20:23:04 -07:00
|
|
|
# stripped out. See issue #17309.
|
2026-04-29 13:24:50 +05:30
|
|
|
if disabled_toolsets:
|
2025-09-10 00:43:55 -07:00
|
|
|
for toolset_name in disabled_toolsets:
|
|
|
|
|
if validate_toolset(toolset_name):
|
fix(tools): preserve core tools when a platform bundle is disabled
When a platform-bundle name (e.g. `hermes-yuanbao`, or any `hermes-*`) lands
in `agent.disabled_toolsets`, the shared tool-assembly path
(`model_tools._compute_tool_definitions`, used by the gateway, cron, AND the
CLI) subtracted the WHOLE bundle from the enabled set. Because every platform
bundle is defined as `_HERMES_CORE_TOOLS + [platform extras]`, and core tools
are shared by every other enabled toolset, the subtraction emptied the tool
list entirely — the model received `tools: []` / `tool_choice: null` and
started replying "I cannot execute shell commands" with no error, no warning,
and `hermes tools list` / `hermes doctor` still green. For unattended cron
jobs this fails silently for days. (#33924)
(The original report framed this as gateway-only; it actually affects every
caller of `_compute_tool_definitions`, including the CLI — the reporter's
follow-up confirms this. Fixing the shared chokepoint covers all paths.)
Fix: for a `hermes-*` bundle in `disabled_toolsets`, subtract only its
*non-core delta* (its platform-specific tools plus those of any `includes`),
leaving `_HERMES_CORE_TOOLS` intact. Disabling a bundle now removes its
platform tools (e.g. the `yb_*` tools for `hermes-yuanbao`) while terminal,
read_file, web, etc. survive. A `logger.warning` notes that core tools are
preserved and that bundle names usually belong in `toolsets:`, not
`disabled_toolsets` — informative, not destructive (the subtraction still
behaves sensibly).
Salvaged from #33941 by @liuhao1024 (authorship preserved). Extracted the
inline bundle-resolution into a module-level `_bundle_non_core_tools` helper
(was re-importing `toolsets` inside the disable loop), and added the
informative warning folding in the UX intent of #34073 (@ousiaresearch)
without its hard "ignore the bundle name" behavior — which would have undone
this fix's sensible-subtraction.
Verified empirically: disabling `hermes-yuanbao` from a gateway-style enabled
set keeps all core tools (18→18) and would remove only the 5 `yb_*` tools;
disabling `hermes-discord` removes only `discord`/`discord_admin`.
Fixes #33924
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-21 16:22:55 +05:30
|
|
|
if toolset_name.startswith("hermes-"):
|
|
|
|
|
# Platform bundles (hermes-*) include _HERMES_CORE_TOOLS, so
|
|
|
|
|
# subtracting the whole bundle would strip core tools shared
|
|
|
|
|
# by other enabled toolsets and empty the tool list (#33924).
|
|
|
|
|
# Subtract only the bundle's non-core delta; keep core.
|
|
|
|
|
from toolsets import bundle_non_core_tools
|
|
|
|
|
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 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)
|
2026-02-21 20:22:33 -08:00
|
|
|
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)
|
2026-02-02 23:46:41 -08:00
|
|
|
if not quiet_mode:
|
2026-02-21 20:22:33 -08:00
|
|
|
print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}")
|
2026-05-11 11:03:29 -07:00
|
|
|
elif not quiet_mode:
|
|
|
|
|
print(f"⚠️ Unknown toolset: {toolset_name}")
|
2026-02-21 20:22:33 -08:00
|
|
|
|
2026-03-22 04:55:34 -07:00
|
|
|
# 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.
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# Ask the registry for schemas (only returns tools whose check_fn passes)
|
|
|
|
|
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)
|
|
|
|
|
|
2026-03-19 10:08:14 -07:00
|
|
|
# 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}
|
|
|
|
|
|
2026-03-06 17:36:06 -08:00
|
|
|
# Rebuild execute_code schema to only list sandbox tools that are actually
|
2026-03-19 10:08:14 -07:00
|
|
|
# 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:
|
feat(execute_code): add project/strict execution modes, default to project (#11971)
Weaker models (Gemma-class) repeatedly rediscover and forget that
execute_code uses a different CWD and Python interpreter than terminal(),
causing them to flip-flop on whether user files exist and to hit import
errors on project dependencies like pandas.
Adds a new 'code_execution.mode' config key (default 'project') that
brings execute_code into line with terminal()'s filesystem/interpreter:
project (new default):
- cwd = session's TERMINAL_CWD (falls back to os.getcwd())
- python = active VIRTUAL_ENV/bin/python or CONDA_PREFIX/bin/python
with a Python 3.8+ version check; falls back cleanly to
sys.executable if no venv or the candidate fails
- result : 'import pandas' works, '.env' resolves, matches terminal()
strict (opt-in):
- cwd = staging tmpdir (today's behavior)
- python = sys.executable (today's behavior)
- result : maximum reproducibility and isolation; project deps
won't resolve
Security-critical invariants are identical across both modes and covered by
explicit regression tests:
- env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD,
*_CREDENTIAL, *_PASSWD, *_AUTH substrings)
- SANDBOX_ALLOWED_TOOLS whitelist (no execute_code recursion, no
delegate_task, no MCP from inside scripts)
- resource caps (5-min timeout, 50KB stdout, 50 tool calls)
Deliberately avoids 'sandbox'/'isolated'/'cloud' language in tool
descriptions (regression from commit 39b83f34 where agents on local
backends falsely believed they were sandboxed and refused networking).
Override via env var: HERMES_EXECUTE_CODE_MODE=strict|project
2026-04-18 01:46:25 -07:00
|
|
|
from tools.code_execution_tool import SANDBOX_ALLOWED_TOOLS, build_execute_code_schema, _get_execution_mode
|
2026-03-19 10:08:14 -07:00
|
|
|
sandbox_enabled = SANDBOX_ALLOWED_TOOLS & available_tool_names
|
feat(execute_code): add project/strict execution modes, default to project (#11971)
Weaker models (Gemma-class) repeatedly rediscover and forget that
execute_code uses a different CWD and Python interpreter than terminal(),
causing them to flip-flop on whether user files exist and to hit import
errors on project dependencies like pandas.
Adds a new 'code_execution.mode' config key (default 'project') that
brings execute_code into line with terminal()'s filesystem/interpreter:
project (new default):
- cwd = session's TERMINAL_CWD (falls back to os.getcwd())
- python = active VIRTUAL_ENV/bin/python or CONDA_PREFIX/bin/python
with a Python 3.8+ version check; falls back cleanly to
sys.executable if no venv or the candidate fails
- result : 'import pandas' works, '.env' resolves, matches terminal()
strict (opt-in):
- cwd = staging tmpdir (today's behavior)
- python = sys.executable (today's behavior)
- result : maximum reproducibility and isolation; project deps
won't resolve
Security-critical invariants are identical across both modes and covered by
explicit regression tests:
- env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD,
*_CREDENTIAL, *_PASSWD, *_AUTH substrings)
- SANDBOX_ALLOWED_TOOLS whitelist (no execute_code recursion, no
delegate_task, no MCP from inside scripts)
- resource caps (5-min timeout, 50KB stdout, 50 tool calls)
Deliberately avoids 'sandbox'/'isolated'/'cloud' language in tool
descriptions (regression from commit 39b83f34 where agents on local
backends falsely believed they were sandboxed and refused networking).
Override via env var: HERMES_EXECUTE_CODE_MODE=strict|project
2026-04-18 01:46:25 -07:00
|
|
|
dynamic_schema = build_execute_code_schema(sandbox_enabled, mode=_get_execution_mode())
|
2026-03-06 17:36:06 -08:00
|
|
|
for i, td in enumerate(filtered_tools):
|
|
|
|
|
if td.get("function", {}).get("name") == "execute_code":
|
|
|
|
|
filtered_tools[i] = {"type": "function", "function": dynamic_schema}
|
|
|
|
|
break
|
|
|
|
|
|
2026-04-24 15:47:29 +05:30
|
|
|
# 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
|
feat: add Discord server introspection and management tool (#4753)
* feat: add Discord server introspection and management tool
Add a discord_server tool that gives the agent the ability to interact
with Discord servers when running on the Discord gateway. Uses Discord
REST API directly with the bot token — no dependency on the gateway
adapter's discord.py client.
The tool is only included in the hermes-discord toolset (zero cost for
users on other platforms) and gated on DISCORD_BOT_TOKEN via check_fn.
Actions (14):
- Introspection: list_guilds, server_info, list_channels, channel_info,
list_roles, member_info, search_members
- Messages: fetch_messages, list_pins, pin_message, unpin_message
- Management: create_thread, add_role, remove_role
This addresses a gap where users on Discord could not ask Hermes to
review server structure, channels, roles, or members — a task competing
agents (OpenClaw) handle out of the box.
Files changed:
- tools/discord_tool.py (new): Tool implementation + registration
- model_tools.py: Add to discovery list
- toolsets.py: Add to hermes-discord toolset only
- tests/tools/test_discord_tool.py (new): 43 tests covering all actions,
validation, error handling, registration, and toolset scoping
* feat(discord): intent-aware schema filtering + config allowlist + schema cleanup
- _detect_capabilities() hits GET /applications/@me once per process
to read GUILD_MEMBERS / MESSAGE_CONTENT privileged intent bits.
- Schema is rebuilt per-session in model_tools.get_tool_definitions:
hides search_members / member_info when GUILD_MEMBERS intent is off,
annotates fetch_messages description when MESSAGE_CONTENT is off.
- New config key discord.server_actions (comma-separated or YAML list)
lets users restrict which actions the agent can call, intersected
with intent availability. Unknown names are warned and dropped.
- Defense-in-depth: runtime handler re-checks the allowlist so a stale
cached schema cannot bypass a tightened config.
- Schema description rewritten as an action-first manifest (signature
per action) instead of per-parameter 'required for X, Y, Z' cross-refs.
~25% shorter; model can see each action's required params at a glance.
- Added bounds: limit gets minimum=1 maximum=100, auto_archive_duration
becomes an enum of the 4 valid Discord values.
- 403 enrichment: runtime 403 errors are mapped to actionable guidance
(which permission is missing and what to do about it) instead of the
raw Discord error body.
- 36 new tests: capability detection with caching and force refresh,
config allowlist parsing (string/list/invalid/unknown), intent+allowlist
intersection, dynamic schema build, runtime allowlist enforcement,
403 enrichment, and model_tools integration wiring.
2026-04-19 11:52:19 -07:00
|
|
|
# MESSAGE_CONTENT intent is missing.
|
2026-04-24 15:47:29 +05:30
|
|
|
_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
|
feat: add Discord server introspection and management tool (#4753)
* feat: add Discord server introspection and management tool
Add a discord_server tool that gives the agent the ability to interact
with Discord servers when running on the Discord gateway. Uses Discord
REST API directly with the bot token — no dependency on the gateway
adapter's discord.py client.
The tool is only included in the hermes-discord toolset (zero cost for
users on other platforms) and gated on DISCORD_BOT_TOKEN via check_fn.
Actions (14):
- Introspection: list_guilds, server_info, list_channels, channel_info,
list_roles, member_info, search_members
- Messages: fetch_messages, list_pins, pin_message, unpin_message
- Management: create_thread, add_role, remove_role
This addresses a gap where users on Discord could not ask Hermes to
review server structure, channels, roles, or members — a task competing
agents (OpenClaw) handle out of the box.
Files changed:
- tools/discord_tool.py (new): Tool implementation + registration
- model_tools.py: Add to discovery list
- toolsets.py: Add to hermes-discord toolset only
- tests/tools/test_discord_tool.py (new): 43 tests covering all actions,
validation, error handling, registration, and toolset scoping
* feat(discord): intent-aware schema filtering + config allowlist + schema cleanup
- _detect_capabilities() hits GET /applications/@me once per process
to read GUILD_MEMBERS / MESSAGE_CONTENT privileged intent bits.
- Schema is rebuilt per-session in model_tools.get_tool_definitions:
hides search_members / member_info when GUILD_MEMBERS intent is off,
annotates fetch_messages description when MESSAGE_CONTENT is off.
- New config key discord.server_actions (comma-separated or YAML list)
lets users restrict which actions the agent can call, intersected
with intent availability. Unknown names are warned and dropped.
- Defense-in-depth: runtime handler re-checks the allowlist so a stale
cached schema cannot bypass a tightened config.
- Schema description rewritten as an action-first manifest (signature
per action) instead of per-parameter 'required for X, Y, Z' cross-refs.
~25% shorter; model can see each action's required params at a glance.
- Added bounds: limit gets minimum=1 maximum=100, auto_archive_duration
becomes an enum of the 4 valid Discord values.
- 403 enrichment: runtime 403 errors are mapped to actionable guidance
(which permission is missing and what to do about it) instead of the
raw Discord error body.
- 36 new tests: capability detection with caching and force refresh,
config allowlist parsing (string/list/invalid/unknown), intent+allowlist
intersection, dynamic schema build, runtime allowlist enforcement,
403 enrichment, and model_tools integration wiring.
2026-04-19 11:52:19 -07:00
|
|
|
|
2026-03-19 10:08:14 -07:00
|
|
|
# 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
|
|
|
|
|
|
2026-01-31 06:30:48 +00:00
|
|
|
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)")
|
2026-02-21 20:22:33 -08:00
|
|
|
|
2026-02-19 23:23:43 -08:00
|
|
|
global _last_resolved_tool_names
|
|
|
|
|
_last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools]
|
2026-01-29 06:10:24 +00:00
|
|
|
|
fix: sanitize tool schemas for llama.cpp backends; restore MCP in TUI (#15032)
Local llama.cpp servers (e.g. ggml-org/llama.cpp:full-cuda) fail the entire
request with HTTP 400 'Unable to generate parser for this template. ...
Unrecognized schema: "object"' when any tool schema contains shapes its
json-schema-to-grammar converter can't handle:
* 'type': 'object' without 'properties'
* bare string schema values ('additionalProperties: "object"')
* 'type': ['X', 'null'] arrays (nullable form)
Cloud providers accept these silently, so they ship from external MCP
servers (Atlassian, GCloud, Datadog) and from a couple of our own tools.
Changes
- tools/schema_sanitizer.py: walks the finalized tool list right before it
leaves get_tool_definitions() and repairs the hostile shapes in a deep
copy. No-op on well-formed schemas. Recurses into properties, items,
additionalProperties, anyOf/oneOf/allOf, and $defs.
- model_tools.get_tool_definitions(): invoke the sanitizer as the last
step so all paths (built-in, MCP, plugin, dynamically-rebuilt) get
covered uniformly.
- tools/browser_cdp_tool.py, tools/mcp_tool.py: fix our own bare-object
schemas so sanitization isn't load-bearing for in-repo tools.
- tui_gateway/server.py: _load_enabled_toolsets() was passing
include_default_mcp_servers=False at runtime. That's the config-editing
variant (see PR #3252) — it silently drops every default MCP server
from the TUI's enabled_toolsets, which is why the TUI didn't hit the
llama.cpp crash (no MCP tools sent at all). Switch to True so TUI
matches CLI behavior.
Tests
tests/tools/test_schema_sanitizer.py (17 tests) covers the individual
failure modes, well-formed pass-through, deep-copy isolation, and
required-field pruning.
E2E: loaded the default 'hermes-cli' toolset with MCP discovery and
confirmed all 27 resolved tool schemas pass a llama.cpp-compatibility
walk (no 'object' node missing 'properties', no bare-string schema
values).
2026-04-24 02:44:46 -07:00
|
|
|
# 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)
|
|
|
|
|
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
# ── 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)
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
return filtered_tools
|
2026-01-29 06:10:24 +00:00
|
|
|
|
2026-02-02 08:26:42 -08:00
|
|
|
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
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
|
|
|
|
|
return int(get_model_context_length(model_id) or 0)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug("Could not resolve active context length: %s", e)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
# handle_function_call (the main dispatcher)
|
|
|
|
|
# =============================================================================
|
2026-02-02 08:26:42 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# 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"}
|
2026-03-18 03:04:07 -07:00
|
|
|
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
2026-02-03 23:41:26 -08:00
|
|
|
|
|
|
|
|
|
security: sanitize tool error strings before injecting into model context (#26823)
Adds _sanitize_tool_error() in model_tools and routes both error paths
through it: registry.dispatch's try/except (the primary path for tool
exceptions) and handle_function_call's outer except (defense in depth).
Stripping targets structural framing tokens that the model itself can
react to even though json.dumps already handles wire-layer escaping:
XML role tags (tool_call, function_call, result, response, output,
input, system, assistant, user), CDATA sections, and markdown code
fences. Caps message body at 2000 chars and wraps with [TOOL_ERROR]
prefix.
Defense-in-depth: a tool exception carrying '<tool_call>...' won't
break message framing (json escapes it), but the model still reads
those tokens and they nudge it toward role-confusion framing.
Ported from ironclaw#1639 (one piece of #3838's three-feature scout).
The truncated-tool-call (#1632) and empty-response-recovery (#1677,
#1720) pieces are skipped because main now implements both far more
thoroughly (run_agent.py L8147/L12209/L13012 for truncation retry +
length rewrite; L4500/L15090+ for empty-response scaffolding stripper,
multi-stage nudge, fallback model activation).
2026-05-16 00:57:39 -07:00
|
|
|
# =========================================================================
|
|
|
|
|
# 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}"
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 10:57:34 -07:00
|
|
|
# =========================================================================
|
|
|
|
|
# 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"]``).
|
fix(tools): wrap bare scalars in single-element list for array-typed args
Open-weight models (DeepSeek, Qwen, GLM) sometimes emit tool calls like
`{"urls": "https://a.com"}` when the tool schema declares
`type: array`. The call was JSON-valid but semantically wrong, and
`coerce_tool_args` would pass the bare string through — the tool then
failed with a confusing type error.
`coerce_tool_args` now wraps non-list, non-null values in a
single-element list when the schema declares `array`. Strings still go
through `_coerce_value` first so JSON-encoded arrays
(`'["a","b"]'`) parse correctly and nullable `"null"` still
becomes `None`. `None` itself is preserved — tools with sensible
defaults already handle it, and we don't want to silently mask a
deliberate null.
Salvaged from #19652 (NikolayGusev-astra) — the broader validate-then-
repair layer had several issues (duplicated existing coercion,
mis-classified `old_string` as a path field, prepended non-JSON
prefixes to tool results that break downstream JSON parsing, hardcoded
offset/limit defaults unsuitable for non-read_file tools). The one
genuinely new capability is wrapping bare scalars, which is implemented
here directly inside the existing coercion path.
Co-authored-by: Nikolay Gusev <ngusev@astralinux.ru>
2026-05-04 04:58:35 -07:00
|
|
|
|
|
|
|
|
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.
|
2026-04-05 10:57:34 -07:00
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
fix(tools): wrap bare scalars in single-element list for array-typed args
Open-weight models (DeepSeek, Qwen, GLM) sometimes emit tool calls like
`{"urls": "https://a.com"}` when the tool schema declares
`type: array`. The call was JSON-valid but semantically wrong, and
`coerce_tool_args` would pass the bare string through — the tool then
failed with a confusing type error.
`coerce_tool_args` now wraps non-list, non-null values in a
single-element list when the schema declares `array`. Strings still go
through `_coerce_value` first so JSON-encoded arrays
(`'["a","b"]'`) parse correctly and nullable `"null"` still
becomes `None`. `None` itself is preserved — tools with sensible
defaults already handle it, and we don't want to silently mask a
deliberate null.
Salvaged from #19652 (NikolayGusev-astra) — the broader validate-then-
repair layer had several issues (duplicated existing coercion,
mis-classified `old_string` as a path field, prepended non-JSON
prefixes to tool results that break downstream JSON parsing, hardcoded
offset/limit defaults unsuitable for non-read_file tools). The one
genuinely new capability is wrapping bare scalars, which is implemented
here directly inside the existing coercion path.
Co-authored-by: Nikolay Gusev <ngusev@astralinux.ru>
2026-05-04 04:58:35 -07:00
|
|
|
for key, value in list(args.items()):
|
2026-04-05 10:57:34 -07:00
|
|
|
prop_schema = properties.get(key)
|
|
|
|
|
if not prop_schema:
|
|
|
|
|
continue
|
|
|
|
|
expected = prop_schema.get("type")
|
fix(tools): wrap bare scalars in single-element list for array-typed args
Open-weight models (DeepSeek, Qwen, GLM) sometimes emit tool calls like
`{"urls": "https://a.com"}` when the tool schema declares
`type: array`. The call was JSON-valid but semantically wrong, and
`coerce_tool_args` would pass the bare string through — the tool then
failed with a confusing type error.
`coerce_tool_args` now wraps non-list, non-null values in a
single-element list when the schema declares `array`. Strings still go
through `_coerce_value` first so JSON-encoded arrays
(`'["a","b"]'`) parse correctly and nullable `"null"` still
becomes `None`. `None` itself is preserved — tools with sensible
defaults already handle it, and we don't want to silently mask a
deliberate null.
Salvaged from #19652 (NikolayGusev-astra) — the broader validate-then-
repair layer had several issues (duplicated existing coercion,
mis-classified `old_string` as a path field, prepended non-JSON
prefixes to tool results that break downstream JSON parsing, hardcoded
offset/limit defaults unsuitable for non-read_file tools). The one
genuinely new capability is wrapping bare scalars, which is implemented
here directly inside the existing coercion path.
Co-authored-by: Nikolay Gusev <ngusev@astralinux.ru>
2026-05-04 04:58:35 -07:00
|
|
|
|
|
|
|
|
# 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
|
2026-05-09 14:43:53 +05:30
|
|
|
# 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,
|
|
|
|
|
)
|
fix(tools): wrap bare scalars in single-element list for array-typed args
Open-weight models (DeepSeek, Qwen, GLM) sometimes emit tool calls like
`{"urls": "https://a.com"}` when the tool schema declares
`type: array`. The call was JSON-valid but semantically wrong, and
`coerce_tool_args` would pass the bare string through — the tool then
failed with a confusing type error.
`coerce_tool_args` now wraps non-list, non-null values in a
single-element list when the schema declares `array`. Strings still go
through `_coerce_value` first so JSON-encoded arrays
(`'["a","b"]'`) parse correctly and nullable `"null"` still
becomes `None`. `None` itself is preserved — tools with sensible
defaults already handle it, and we don't want to silently mask a
deliberate null.
Salvaged from #19652 (NikolayGusev-astra) — the broader validate-then-
repair layer had several issues (duplicated existing coercion,
mis-classified `old_string` as a path field, prepended non-JSON
prefixes to tool results that break downstream JSON parsing, hardcoded
offset/limit defaults unsuitable for non-read_file tools). The one
genuinely new capability is wrapping bare scalars, which is implemented
here directly inside the existing coercion path.
Co-authored-by: Nikolay Gusev <ngusev@astralinux.ru>
2026-05-04 04:58:35 -07:00
|
|
|
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):
|
|
|
|
|
continue
|
2026-04-28 12:25:10 +08:00
|
|
|
if not expected and not _schema_allows_null(prop_schema):
|
2026-04-05 10:57:34 -07:00
|
|
|
continue
|
2026-04-28 12:25:10 +08:00
|
|
|
coerced = _coerce_value(value, expected, schema=prop_schema)
|
2026-04-05 10:57:34 -07:00
|
|
|
if coerced is not value:
|
|
|
|
|
args[key] = coerced
|
|
|
|
|
|
|
|
|
|
return args
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 12:25:10 +08:00
|
|
|
def _coerce_value(value: str, expected_type, schema: dict | None = None):
|
2026-04-05 10:57:34 -07:00
|
|
|
"""Attempt to coerce a string *value* to *expected_type*.
|
|
|
|
|
|
|
|
|
|
Returns the original string when coercion is not applicable or fails.
|
|
|
|
|
"""
|
2026-04-28 12:25:10 +08:00
|
|
|
if _schema_allows_null(schema) and value.strip().lower() == "null":
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-05 10:57:34 -07:00
|
|
|
if isinstance(expected_type, list):
|
|
|
|
|
# Union type — try each in order, return first successful coercion
|
|
|
|
|
for t in expected_type:
|
2026-04-28 12:25:10 +08:00
|
|
|
result = _coerce_value(value, t, schema=schema)
|
2026-04-05 10:57:34 -07:00
|
|
|
if result is not value:
|
|
|
|
|
return result
|
|
|
|
|
return value
|
|
|
|
|
|
2026-05-11 11:13:25 -07:00
|
|
|
if expected_type in {"integer", "number"}:
|
2026-04-05 10:57:34 -07:00
|
|
|
return _coerce_number(value, integer_only=(expected_type == "integer"))
|
|
|
|
|
if expected_type == "boolean":
|
|
|
|
|
return _coerce_boolean(value)
|
2026-04-19 17:36:18 +00:00
|
|
|
if expected_type == "array":
|
|
|
|
|
return _coerce_json(value, list)
|
|
|
|
|
if expected_type == "object":
|
|
|
|
|
return _coerce_json(value, dict)
|
2026-04-28 12:25:10 +08:00
|
|
|
if expected_type == "null" and value.strip().lower() == "null":
|
|
|
|
|
return None
|
2026-04-19 17:36:18 +00:00
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 12:25:10 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 17:36:18 +00:00
|
|
|
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)
|
2026-05-09 14:43:53 +05:30
|
|
|
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,
|
|
|
|
|
)
|
2026-04-19 17:36:18 +00:00
|
|
|
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
|
2026-05-09 14:43:53 +05:30
|
|
|
logger.warning(
|
|
|
|
|
"coerce_tool_args: JSON-parsed value is %s, expected %s — skipping coercion",
|
|
|
|
|
type(parsed).__name__,
|
|
|
|
|
expected_python_type.__name__,
|
|
|
|
|
)
|
2026-04-05 10:57:34 -07:00
|
|
|
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
|
2026-04-24 13:33:56 +01:00
|
|
|
# Guard against inf/nan — not JSON-serializable, keep original string
|
2026-04-05 10:57:34 -07:00
|
|
|
if f != f or f == float("inf") or f == float("-inf"):
|
2026-04-24 13:33:56 +01:00
|
|
|
return value
|
2026-04-05 10:57:34 -07:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 06:05:35 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
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,
|
2026-06-03 06:05:35 -07:00
|
|
|
status: Optional[str] = None,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
error_type: Optional[str] = None,
|
|
|
|
|
error_message: Optional[str] = None,
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
) -> None:
|
2026-06-03 06:05:35 -07:00
|
|
|
"""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).
|
|
|
|
|
"""
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
try:
|
2026-06-03 06:05:35 -07:00
|
|
|
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)
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
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,
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=list(middleware_trace or []),
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
)
|
|
|
|
|
except Exception as _hook_err:
|
|
|
|
|
logger.debug("post_tool_call hook error: %s", _hook_err)
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
def handle_function_call(
|
2026-02-05 03:49:46 -08:00
|
|
|
function_name: str,
|
|
|
|
|
function_args: Dict[str, Any],
|
2026-01-29 06:10:24 +00:00
|
|
|
task_id: Optional[str] = None,
|
2026-03-29 12:26:44 +05:30
|
|
|
tool_call_id: Optional[str] = None,
|
|
|
|
|
session_id: Optional[str] = None,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
turn_id: Optional[str] = None,
|
|
|
|
|
api_request_id: Optional[str] = None,
|
2026-02-21 20:22:33 -08:00
|
|
|
user_task: Optional[str] = None,
|
2026-03-10 06:32:08 -07:00
|
|
|
enabled_tools: Optional[List[str]] = None,
|
2026-04-13 21:15:25 -07:00
|
|
|
skip_pre_tool_call_hook: bool = False,
|
2026-06-03 11:22:06 -07:00
|
|
|
skip_tool_request_middleware: bool = False,
|
|
|
|
|
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
enabled_toolsets: Optional[List[str]] = None,
|
|
|
|
|
disabled_toolsets: Optional[List[str]] = None,
|
2026-01-29 06:10:24 +00:00
|
|
|
) -> str:
|
2025-08-09 09:52:25 -07:00
|
|
|
"""
|
2026-02-21 20:22:33 -08:00
|
|
|
Main function call dispatcher that routes calls to the tool registry.
|
2025-11-03 17:42:23 -05:00
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
Args:
|
2026-02-21 20:22:33 -08:00
|
|
|
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).
|
2026-03-10 06:32:08 -07:00
|
|
|
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.
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
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.
|
2025-11-03 17:42:23 -05:00
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
Returns:
|
2026-02-21 20:22:33 -08:00
|
|
|
Function result as a JSON string.
|
2025-08-09 09:52:25 -07:00
|
|
|
"""
|
2026-04-05 10:57:34 -07:00
|
|
|
# Coerce string arguments to their schema-declared types (e.g. "42"→42)
|
|
|
|
|
function_args = coerce_tool_args(function_name, function_args)
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
if not isinstance(function_args, dict):
|
|
|
|
|
function_args = {}
|
2026-06-03 11:22:06 -07:00
|
|
|
_tool_middleware_trace = list(tool_request_middleware_trace or [])
|
2026-04-05 10:57:34 -07:00
|
|
|
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
# ── 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).
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
#
|
|
|
|
|
# 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.
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
current_defs = get_tool_definitions(
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
enabled_toolsets=enabled_toolsets,
|
|
|
|
|
disabled_toolsets=disabled_toolsets,
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
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)
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
# 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)
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
# 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,
|
2026-06-03 11:22:06 -07:00
|
|
|
skip_tool_request_middleware=skip_tool_request_middleware,
|
|
|
|
|
tool_request_middleware_trace=list(_tool_middleware_trace),
|
fix(tool-search): scope bridge catalog + dispatch to the session's toolsets
Tool Search read its catalog from the global registry (get_tool_definitions
with no toolset scope = 'start with everything'), so a restricted-toolset
session — subagent, kanban worker, curated gateway session — could:
1. tool_search the entire process registry, not just its granted tools, and
2. tool_call any registered plugin/MCP tool it was never given, because
registry.dispatch() has no enabled_tools gate for non-execute_code tools.
A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26
and successfully invoked an out-of-scope plugin tool via tool_call.
Fix:
- handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge
dispatch scopes get_tool_definitions to them (also stops polluting the
process-global _last_resolved_tool_names with out-of-scope tools, which
leaked into execute_code's sandbox-tool fallback).
- A defense-in-depth gate rejects any tool_call'd name not in the scoped
deferrable catalog.
- tool_executor's unwrap (both concurrent + sequential paths) enforces the
same scope before dispatch, since it unwraps tool_call -> underlying name
and bypasses the bridge branch. New _tool_search_scoped_names() helper,
cached per-agent on registry generation + toolset scope.
- New scoped_deferrable_names() helper in tool_search.py shared by both sites.
Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped
catalog, out-of-scope tool_call rejection, no global pollution, helper).
2026-05-29 01:21:41 -07:00
|
|
|
enabled_toolsets=enabled_toolsets,
|
|
|
|
|
disabled_toolsets=disabled_toolsets,
|
feat(tools): progressive tool disclosure for MCP and plugin tools
Adds Tool Search, a structured-tools progressive-disclosure layer that
replaces MCP and non-core plugin tools in the model-visible tools array
with three bridge tools (tool_search / tool_describe / tool_call) when
the deferrable surface would consume more than a configurable percentage
of the active model's context window. Core Hermes tools are never deferred.
Default mode is 'auto' with a 10% context threshold, so small toolsets
pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off'
to disable.
Design carefully reflects the OpenClaw production failure modes
documented in the openclaw-tool-search-report:
- Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the
'tools silently missing from isolated cron turns' regression class
(openclaw#84141) by construction: there is no code path that can
drop a core tool.
- Catalog is stateless across turns — rebuilt from the live tool-defs
list on every assembly. No session-keyed Map that can drift out of
sync with the registry.
- tool_call unwraps the bridge call before any hook fires, so plugin
pre/post hooks, guardrails, approval flows, and the activity feed
all see the underlying tool name, not the bridge (addresses
openclaw#85588 and the verbose-mode complaint on openclaw#79823).
- The unwrap happens in both the parallel and sequential paths of
agent/tool_executor.py and also in handle_function_call, so direct
callers (sandboxed code, eval harnesses) are covered too.
- Bridge tools cannot invoke each other (recursion guard) and cannot
invoke core tools (those must be called directly).
- Tools mode only — no JS-sandbox code-mode. Keeps the surface small.
- Token estimation via cheap char/4 heuristic; precision isn't needed
for the threshold decision.
Files:
- tools/tool_search.py — new module (BM25 retrieval, classification,
threshold gate, bridge dispatch, unwrap helper).
- tests/tools/test_tool_search.py — 35 tests including the OpenClaw
#84141 regression guard.
- model_tools.py — wires assembly into _compute_tool_definitions as the
final step, adds skip_tool_search_assembly kwarg so the bridge can
see the real catalog, dispatches the three bridge tools.
- agent/tool_executor.py — unwraps tool_call in both parallel and
sequential parsing loops so checkpointing, guardrails, plugin hooks,
and tool-progress callbacks all observe the underlying tool name.
- hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block.
- website/docs/user-guide/features/tool-search.md — user docs.
Validation:
- 35/35 new tests pass.
- Existing tool/registry/model_tools/config/coercion/executor tests
(82 + 74 + small adjacents) green.
- Live E2E: 20 fake MCP tools registered, get_tool_definitions returns
3 bridges, tool_search returns top 3 hits, tool_describe returns
full schema, tool_call dispatches to the real underlying handler
and the underlying result is what the model sees.
- Reserved-name recursion guard verified live.
- Core-tool refusal via tool_call verified live.
2026-05-23 15:22:01 -07:00
|
|
|
)
|
|
|
|
|
|
2026-06-03 11:22:06 -07:00
|
|
|
_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)
|
|
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
try:
|
2026-02-21 20:22:33 -08:00
|
|
|
if function_name in _AGENT_LOOP_TOOLS:
|
|
|
|
|
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
|
2026-04-13 21:15:25 -07:00
|
|
|
# Check plugin hooks for a block directive (unless caller already
|
|
|
|
|
# checked — e.g. run_agent._invoke_tool passes skip=True to
|
|
|
|
|
# avoid double-firing the hook).
|
2026-04-29 12:43:39 -07:00
|
|
|
#
|
|
|
|
|
# Single-fire contract: pre_tool_call fires exactly once per tool
|
|
|
|
|
# execution. get_pre_tool_call_block_message() internally calls
|
|
|
|
|
# invoke_hook("pre_tool_call", ...) and returns the first block
|
|
|
|
|
# directive (if any), so observer plugins see the hook on that same
|
|
|
|
|
# pass. When skip=True, the caller already fired it — do nothing
|
|
|
|
|
# here.
|
2026-04-13 21:15:25 -07:00
|
|
|
if not skip_pre_tool_call_hook:
|
|
|
|
|
block_message: Optional[str] = None
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli.plugins import get_pre_tool_call_block_message
|
|
|
|
|
block_message = get_pre_tool_call_block_message(
|
|
|
|
|
function_name,
|
|
|
|
|
function_args,
|
|
|
|
|
task_id=task_id or "",
|
|
|
|
|
session_id=session_id or "",
|
|
|
|
|
tool_call_id=tool_call_id or "",
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
turn_id=turn_id or "",
|
|
|
|
|
api_request_id=api_request_id or "",
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=list(_tool_middleware_trace),
|
2026-04-13 21:15:25 -07:00
|
|
|
)
|
2026-05-04 21:55:01 +03:00
|
|
|
except Exception as _hook_err:
|
|
|
|
|
logger.debug("pre_tool_call hook error: %s", _hook_err)
|
2026-04-13 21:15:25 -07:00
|
|
|
|
|
|
|
|
if block_message is not None:
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
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,
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=list(_tool_middleware_trace),
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
)
|
|
|
|
|
return result
|
2026-04-13 21:15:25 -07:00
|
|
|
|
2026-05-15 23:28:44 +01:00
|
|
|
# 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)
|
|
|
|
|
|
2026-04-13 21:15:25 -07:00
|
|
|
# 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
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
2026-04-25 22:13:12 -07:00
|
|
|
# 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()
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
_approval_tokens = None
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
try:
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
from tools.approval import (
|
|
|
|
|
reset_current_observability_context,
|
|
|
|
|
set_current_observability_context,
|
|
|
|
|
)
|
|
|
|
|
_approval_tokens = set_current_observability_context(
|
|
|
|
|
turn_id=turn_id or "",
|
2026-03-29 12:26:44 +05:30
|
|
|
tool_call_id=tool_call_id or "",
|
|
|
|
|
)
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
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,
|
2026-06-13 21:27:59 -07:00
|
|
|
session_id=session_id,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
enabled_tools=sandbox_enabled,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
def _dispatch(next_args: Dict[str, Any]) -> Any:
|
|
|
|
|
return registry.dispatch(
|
|
|
|
|
function_name, next_args,
|
|
|
|
|
task_id=task_id,
|
2026-06-13 21:27:59 -07:00
|
|
|
session_id=session_id,
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
user_task=user_task,
|
|
|
|
|
)
|
2026-06-03 11:22:06 -07:00
|
|
|
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 "",
|
|
|
|
|
)
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
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,
|
2026-06-03 11:22:06 -07:00
|
|
|
middleware_trace=list(_tool_middleware_trace),
|
feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
2026-06-03 17:44:13 +05:30
|
|
|
)
|
2026-02-19 23:23:43 -08:00
|
|
|
|
feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.
Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
post_tool_call (which remains observational); first valid str return
replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
None return, non-string return, first-match wins, kwargs, hook
exception fallback, post_tool_call observation invariant, ordering
vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
to include the new hook
Design
- transform_tool_result runs AFTER post_tool_call so observers always
see the original (untransformed) result. This keeps post_tool_call's
observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
|
|
|
# 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.
|
2026-06-03 06:05:35 -07:00
|
|
|
# Gated on has_hook so the no-listener path skips both the result
|
|
|
|
|
# field derivation and the payload dispatch.
|
feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.
Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
post_tool_call (which remains observational); first valid str return
replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
None return, non-string return, first-match wins, kwargs, hook
exception fallback, post_tool_call observation invariant, ordering
vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
to include the new hook
Design
- transform_tool_result runs AFTER post_tool_call so observers always
see the original (untransformed) result. This keeps post_tool_call's
observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
|
|
|
try:
|
2026-06-03 06:05:35 -07:00
|
|
|
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
|
2026-05-04 21:55:01 +03:00
|
|
|
except Exception as _hook_err:
|
|
|
|
|
logger.debug("transform_tool_result hook error: %s", _hook_err)
|
feat(plugins): add transform_tool_result hook for generic tool-result rewriting (#12972)
Closes #8933 more fully, extending the per-tool transform_terminal_output
hook from #12929 to a generic seam that fires after every tool dispatch.
Plugins can rewrite any tool's result string (normalize formats, redact
fields, summarize verbose output) without wrapping individual tools.
Changes
- hermes_cli/plugins.py: add "transform_tool_result" to VALID_HOOKS
- model_tools.py: invoke the hook in handle_function_call after
post_tool_call (which remains observational); first valid str return
replaces the result; fail-open
- tests/test_transform_tool_result_hook.py: 9 new tests covering no-op,
None return, non-string return, first-match wins, kwargs, hook
exception fallback, post_tool_call observation invariant, ordering
vs post_tool_call, and an end-to-end real-plugin integration
- tests/hermes_cli/test_plugins.py: assert new hook in VALID_HOOKS
- tests/test_model_tools.py: extend the hook-call-sequence assertion
to include the new hook
Design
- transform_tool_result runs AFTER post_tool_call so observers always
see the original (untransformed) result. This keeps post_tool_call's
observational contract.
- transform_terminal_output (from #12929) still runs earlier, inside
terminal_tool, so plugins can canonicalize BEFORE the 50k truncation
drops middle content. Both hooks coexist; they target different layers.
2026-04-20 03:48:08 -07:00
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
return result
|
2026-02-12 10:05:08 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
error_msg = f"Error executing {function_name}: {str(e)}"
|
2026-04-29 04:56:33 -07:00
|
|
|
logger.exception(error_msg)
|
security: sanitize tool error strings before injecting into model context (#26823)
Adds _sanitize_tool_error() in model_tools and routes both error paths
through it: registry.dispatch's try/except (the primary path for tool
exceptions) and handle_function_call's outer except (defense in depth).
Stripping targets structural framing tokens that the model itself can
react to even though json.dumps already handles wire-layer escaping:
XML role tags (tool_call, function_call, result, response, output,
input, system, assistant, user), CDATA sections, and markdown code
fences. Caps message body at 2000 chars and wraps with [TOOL_ERROR]
prefix.
Defense-in-depth: a tool exception carrying '<tool_call>...' won't
break message framing (json escapes it), but the model still reads
those tokens and they nudge it toward role-confusion framing.
Ported from ironclaw#1639 (one piece of #3838's three-feature scout).
The truncated-tool-call (#1632) and empty-response-recovery (#1677,
#1720) pieces are skipped because main now implements both far more
thoroughly (run_agent.py L8147/L12209/L13012 for truncation retry +
length rewrite; L4500/L15090+ for empty-response scaffolding stripper,
multi-stage nudge, fallback model activation).
2026-05-16 00:57:39 -07:00
|
|
|
return json.dumps({"error": _sanitize_tool_error(error_msg)}, ensure_ascii=False)
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
|
2026-02-17 17:02:33 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
# =============================================================================
|
|
|
|
|
# Backward-compat wrapper functions
|
|
|
|
|
# =============================================================================
|
2026-02-19 00:57:31 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
def get_all_tool_names() -> List[str]:
|
|
|
|
|
"""Return all registered tool names."""
|
|
|
|
|
return registry.get_all_tool_names()
|
2026-02-19 00:57:31 -08:00
|
|
|
|
2026-02-20 03:15:53 -08:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
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()
|
2025-08-09 09:52:25 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_toolset_requirements() -> Dict[str, bool]:
|
2026-02-21 20:22:33 -08:00
|
|
|
"""Return {toolset: available_bool} for every registered toolset."""
|
|
|
|
|
return registry.check_toolset_requirements()
|
2025-11-17 01:14:31 -05:00
|
|
|
|
2025-08-09 09:52:25 -07:00
|
|
|
|
2026-02-21 20:22:33 -08:00
|
|
|
def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]:
|
|
|
|
|
"""Return (available_toolsets, unavailable_info)."""
|
|
|
|
|
return registry.check_tool_availability(quiet=quiet)
|