refactor(tools-layer): model_tools/mcp_serve/toolsets — pack signatures & literals, merge baseline helper, drop redundant branches; -175 LOC

This commit is contained in:
Teknium
2026-09-02 21:13:33 -07:00
parent a208c541f1
commit 3f7c2adea4
3 changed files with 128 additions and 303 deletions
+33 -82
View File
@@ -50,10 +50,9 @@ def _get_sessions_dir() -> Path:
def _read_state_db_mtime() -> float:
db_file = _hermes_home() / "state.db"
try:
return db_file.stat().st_mtime if db_file.exists() else 0.0
except OSError:
return (_hermes_home() / "state.db").stat().st_mtime
except OSError: # missing file included
return 0.0
@@ -172,9 +171,7 @@ def _load_sessions_index_from_json() -> dict:
which needs GatewayConfig). Keys starting with "_" are metadata sentinels
(e.g. "_README"), not session entries."""
data = _read_json(_get_sessions_dir() / "sessions.json")
if isinstance(data, dict):
return {k: v for k, v in data.items() if not str(k).startswith("_")}
return {}
return {k: v for k, v in data.items() if not str(k).startswith("_")} if isinstance(data, dict) else {}
def _load_channel_directory() -> dict:
@@ -195,10 +192,7 @@ def _extract_message_content(msg: dict) -> str:
"""Extract text content from a message, handling multi-part content."""
content = msg.get("content", "")
if isinstance(content, list):
return "\n".join(
p.get("text", "") for p in content
if isinstance(p, dict) and p.get("type") == "text"
)
return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
return str(content) if content else ""
@@ -206,7 +200,6 @@ def _extract_attachments(msg: dict) -> List[dict]:
"""Non-text attachments: image/file content blocks plus MEDIA: tags in the text."""
attachments = []
content = msg.get("content", "")
for part in content if isinstance(content, list) else ():
if not isinstance(part, dict):
continue
@@ -221,12 +214,8 @@ def _extract_attachments(msg: dict) -> List[dict]:
continue
if url:
attachments.append({"type": "image", "url": url})
text = _extract_message_content(msg)
if text:
for match in re.finditer(r'MEDIA:\s*(\S+)', text):
for match in re.finditer(r'MEDIA:\s*(\S+)', _extract_message_content(msg)):
attachments.append({"type": "media", "path": match.group(1)})
return attachments
@@ -308,10 +297,8 @@ class EventBridge:
def _matching(self, after_cursor: int, session_key: Optional[str], limit: int) -> List[dict]:
with self._lock:
return [
e.as_dict() for e in self._queue
if e.cursor > after_cursor and (not session_key or e.session_key == session_key)
][:limit]
return [e.as_dict() for e in self._queue
if e.cursor > after_cursor and (not session_key or e.session_key == session_key)][:limit]
def poll_events(self, after_cursor: int = 0, session_key: Optional[str] = None, limit: int = 20) -> dict:
"""Return events since after_cursor, optionally filtered by session_key."""
@@ -343,10 +330,8 @@ class EventBridge:
approval = self._pending_approvals.pop(approval_id, None)
if not approval:
return {"error": f"Approval not found: {approval_id}"}
self._enqueue(QueueEvent( # cursor is assigned by _enqueue
0, "approval_resolved", approval.get("session_key", ""),
{"approval_id": approval_id, "decision": decision},
))
self._enqueue(QueueEvent(0, "approval_resolved", approval.get("session_key", ""), # cursor set by _enqueue
{"approval_id": approval_id, "decision": decision}))
return {"resolved": True, "approval_id": approval_id, "decision": decision}
def _enqueue(self, event: QueueEvent) -> None:
@@ -360,17 +345,13 @@ class EventBridge:
self._new_event.set()
def _establish_baseline(self) -> None:
db = _get_session_db()
if db:
try:
self._establish_baseline_with_db(db)
finally:
_close_quietly(db, "baseline")
def _establish_baseline_with_db(self, db) -> None:
"""Record per-session latest timestamps and the state.db mtime WITHOUT
emitting events. Only sessions existing now are baselined; later ones
default to last_seen=0.0 in _poll_once, so their first message is delivered."""
db = _get_session_db()
if not db:
return
try:
self._state_db_mtime = _read_state_db_mtime()
try:
self._cached_sessions_index = _load_sessions_index()
@@ -386,6 +367,8 @@ class EventBridge:
continue
if latest > 0.0:
self._last_poll_timestamps[session_key] = latest
finally:
_close_quietly(db, "baseline")
def _poll_loop(self):
"""Background loop: poll SessionDB for new messages."""
@@ -429,11 +412,8 @@ class EventBridge:
continue
if not messages:
continue
for msg in messages:
if msg.get("role", "") not in {"user", "assistant"}:
continue
if _ts_float(msg.get("timestamp", 0)) <= last_seen:
if msg.get("role", "") not in {"user", "assistant"} or _ts_float(msg.get("timestamp", 0)) <= last_seen:
continue
content = _extract_message_content(msg)
if not content:
@@ -442,7 +422,6 @@ class EventBridge:
"role": msg.get("role", ""), "content": content[:500],
"timestamp": str(msg.get("timestamp", "")), "message_id": str(msg.get("id", "")),
}))
latest = _latest_ts(messages)
if latest > last_seen:
self._last_poll_timestamps[session_key] = latest
@@ -507,8 +486,7 @@ class _ToolHandlers:
"user_name": origin.get("user_name", ""), "updated_at": entry.get("updated_at", ""),
})
conversations.sort(key=lambda c: c.get("updated_at", ""), reverse=True)
conversations = conversations[:limit]
conversations = sorted(conversations, key=lambda c: c.get("updated_at", ""), reverse=True)[:limit]
return json.dumps({"count": len(conversations), "conversations": conversations}, indent=2)
def conversation_get(self, session_key: str) -> str:
@@ -522,8 +500,7 @@ class _ToolHandlers:
return json.dumps({"error": f"Conversation not found: {session_key}"})
origin = entry.get("origin", {})
return json.dumps({
"session_key": session_key,
"session_id": entry.get("session_id", ""),
"session_key": session_key, "session_id": entry.get("session_id", ""),
"platform": entry.get("platform") or origin.get("platform", ""),
"chat_type": entry.get("chat_type", origin.get("chat_type", "")),
"display_name": entry.get("display_name", ""),
@@ -553,15 +530,11 @@ class _ToolHandlers:
role = msg.get("role", "")
content = _extract_message_content(msg) if role in {"user", "assistant"} else ""
if content:
filtered.append({
"id": str(msg.get("id", "")), "role": role,
"content": content[:2000], "timestamp": msg.get("timestamp", ""),
})
filtered.append({"id": str(msg.get("id", "")), "role": role,
"content": content[:2000], "timestamp": msg.get("timestamp", "")})
messages = filtered[-limit:]
return json.dumps({
"session_key": session_key, "count": len(messages),
"total_in_session": len(filtered), "messages": messages,
}, indent=2)
return json.dumps({"session_key": session_key, "count": len(messages),
"total_in_session": len(filtered), "messages": messages}, indent=2)
def attachments_fetch(self, session_key: str, message_id: str) -> str:
"""List non-text attachments for a message in a conversation.
@@ -614,9 +587,7 @@ class _ToolHandlers:
after_cursor = _coerce_int(after_cursor, default=0, minimum=0, maximum=10**18)
timeout_ms = _coerce_int(timeout_ms, default=30000, minimum=0, maximum=300000) # cap 5 min
event = self.bridge.wait_for_event(after_cursor=after_cursor, session_key=session_key, timeout_ms=timeout_ms)
if event:
return json.dumps({"event": event}, indent=2)
return json.dumps({"event": None, "reason": "timeout"}, indent=2)
return json.dumps({"event": event} if event else {"event": None, "reason": "timeout"}, indent=2)
def messages_send(self, target: str, message: str) -> str:
"""Send a message to a platform conversation.
@@ -656,25 +627,19 @@ class _ToolHandlers:
directory = _load_channel_directory()
if not directory:
# No cached directory: derive send targets from the routing index.
targets = []
seen = set()
targets, seen = [], set()
for key, entry in _load_sessions_index().items():
origin = entry.get("origin", {})
p = entry.get("platform") or origin.get("platform", "")
chat_id = origin.get("chat_id", "")
if not p or not chat_id or not _platform_matches(platform, p):
continue
target_str = f"{p}:{chat_id}"
if target_str in seen:
if not p or not chat_id or not _platform_matches(platform, p) or target_str in seen:
continue
seen.add(target_str)
targets.append({
"target": target_str, "platform": p,
targets.append({"target": target_str, "platform": p,
"name": entry.get("display_name") or origin.get("chat_name", ""),
"chat_type": entry.get("chat_type", origin.get("chat_type", "")),
})
"chat_type": entry.get("chat_type", origin.get("chat_type", ""))})
return json.dumps({"count": len(targets), "channels": targets}, indent=2)
channels = []
for plat, entries_list in directory.get("platforms", {}).items():
if not _platform_matches(platform, plat) or not isinstance(entries_list, list):
@@ -682,10 +647,8 @@ class _ToolHandlers:
for ch in entries_list:
if isinstance(ch, dict):
chat_id = ch.get("id", ch.get("chat_id", ""))
channels.append({
"target": f"{plat}:{chat_id}" if chat_id else plat, "platform": plat,
"name": ch.get("name", ch.get("display_name", "")), "chat_type": ch.get("type", ""),
})
channels.append({"target": f"{plat}:{chat_id}" if chat_id else plat, "platform": plat,
"name": ch.get("name", ch.get("display_name", "")), "chat_type": ch.get("type", "")})
return json.dumps({"count": len(channels), "channels": channels}, indent=2)
def permissions_list_open(self) -> str:
@@ -706,10 +669,7 @@ class _ToolHandlers:
decision: One of "allow-once", "allow-always", or "deny"
"""
if decision not in {"allow-once", "allow-always", "deny"}:
return json.dumps({
"error": f"Invalid decision: {decision}. "
f"Must be allow-once, allow-always, or deny"
})
return json.dumps({"error": f"Invalid decision: {decision}. Must be allow-once, allow-always, or deny"})
return json.dumps(self.bridge.respond_to_approval(id, decision), indent=2)
@@ -724,10 +684,7 @@ _TOOL_NAMES = (
def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "MCPServer":
"""Create and return the Hermes MCP server with all tools registered."""
if not _MCP_SERVER_AVAILABLE:
raise ImportError(
"MCP server requires the 'mcp' package. "
f"Install with: {sys.executable} -m pip install 'mcp'"
)
raise ImportError(f"MCP server requires the 'mcp' package. Install with: {sys.executable} -m pip install 'mcp'")
mcp = MCPServer("hermes", instructions=(
"Hermes Agent messaging bridge. Use these tools to interact with "
"conversations across Telegram, Discord, Slack, WhatsApp, Signal, "
@@ -742,19 +699,13 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "MCPServer"
def run_mcp_server(verbose: bool = False) -> None:
"""Start the Hermes MCP server on stdio."""
if not _MCP_SERVER_AVAILABLE:
print(
"Error: MCP server requires the 'mcp' package.\n"
f"Install with: {sys.executable} -m pip install 'mcp'",
file=sys.stderr,
)
print("Error: MCP server requires the 'mcp' package.\n"
f"Install with: {sys.executable} -m pip install 'mcp'", file=sys.stderr)
sys.exit(1)
logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING, stream=sys.stderr)
bridge = EventBridge()
bridge.start()
server = create_mcp_server(event_bridge=bridge)
import asyncio
async def _run():
+55 -130
View File
@@ -18,30 +18,17 @@ import threading
import time
from typing import Dict, Any, List, Optional, Tuple
from tools.registry import (
CHECK_FN_CACHE_BYPASS,
check_fn_cache_scope,
discover_builtin_tools,
registry,
tool_error,
)
from tools.registry import CHECK_FN_CACHE_BYPASS, check_fn_cache_scope, discover_builtin_tools, registry, tool_error
from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN
from toolsets import resolve_toolset, validate_toolset
from tools.arg_coercion import ( # noqa: F401 # re-exported: tests and tools/ import these from model_tools
_coerce_boolean,
_coerce_json,
_coerce_number,
_coerce_value,
_normalize_json_strings_for_schema,
_schema_accepts_kind,
_schema_allows_null,
coerce_tool_args,
_coerce_boolean, _coerce_json, _coerce_number, _coerce_value, _normalize_json_strings_for_schema,
_schema_accepts_kind, _schema_allows_null, coerce_tool_args,
)
logger = logging.getLogger(__name__)
_post_tool_call_hook_suppressed: ContextVar[bool] = ContextVar(
"post_tool_call_hook_suppressed", default=False
)
_post_tool_call_hook_suppressed: ContextVar[bool] = ContextVar("post_tool_call_hook_suppressed", default=False)
@contextmanager
@@ -188,10 +175,8 @@ _LEGACY_TOOLSET_MAP = {
"vision_tools": ["vision_analyze"],
"image_tools": ["image_generate"],
"skills_tools": ["skills_list", "skill_view", "skill_manage"],
"browser_tools": [
"browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll",
"browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console",
],
"browser_tools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll",
"browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console"],
"cronjob_tools": ["cronjob_manage"],
"file_tools": ["read_file", "write_file", "patch", "search_files"],
"tts_tools": ["text_to_speech"],
@@ -216,12 +201,8 @@ def _clear_tool_defs_cache() -> None:
_tool_defs_cache.clear()
def get_tool_definitions(
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
) -> List[Dict[str, Any]]:
def get_tool_definitions(enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False, skip_tool_search_assembly: bool = False) -> List[Dict[str, Any]]:
"""Tool definitions for model API calls, filtered by toolset.
enabled_toolsets None = all; disabled_toolsets are subtracted after enabling.
@@ -232,10 +213,8 @@ def get_tool_definitions(
def compute():
return _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
skip_tool_search_assembly=skip_tool_search_assembly)
if not quiet_mode:
return compute()
cache_key = _tool_defs_cache_key(enabled_toolsets, disabled_toolsets, skip_tool_search_assembly)
with _tool_defs_cache_lock:
cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None
@@ -276,10 +255,8 @@ def _tool_defs_cache_key(
except (FileNotFoundError, OSError, ImportError):
cfg_fp = None
return (
registry.current_scope_key(),
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation, cfg_fp,
registry.current_scope_key(), frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None, registry._generation, cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")), bool(skip_tool_search_assembly),
_is_delegated_child_context(), _is_dispatcher_owned_worker(), profile_scope,
)
@@ -318,23 +295,15 @@ def _apply_toolset_selection(tools: set, names: List[str], quiet_mode: bool, *,
print(f"{icon} {label} '{name}': {', '.join(resolved) if resolved else 'no tools'}")
def _select_tool_names(
enabled_toolsets: Optional[List[str]],
disabled_toolsets: Optional[List[str]],
quiet_mode: bool,
) -> set:
def _select_tool_names(enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]], quiet_mode: bool) -> set:
"""Tool names requested by the toolset selection (before check_fn filtering)."""
tools: set = set()
if enabled_toolsets is not None:
enabled = list(enabled_toolsets)
# Dispatcher-spawned kanban workers always get the lifecycle handoff
# tools, even when the assignee profile restricts its chat toolsets.
if (
os.environ.get("HERMES_KANBAN_TASK")
and not _is_delegated_child_context()
and _is_dispatcher_owned_worker()
and "kanban" not in enabled
):
if (os.environ.get("HERMES_KANBAN_TASK") and not _is_delegated_child_context()
and _is_dispatcher_owned_worker() and "kanban" not in enabled):
enabled.append("kanban")
_apply_toolset_selection(tools, enabled, quiet_mode, disable=False)
else:
@@ -453,12 +422,8 @@ _TOOL_SEARCH_LISTING_FORMS = {
}
def _compute_tool_definitions(
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
) -> List[Dict[str, Any]]:
def _compute_tool_definitions(enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False, skip_tool_search_assembly: bool = False) -> List[Dict[str, Any]]:
"""Uncached implementation of :func:`get_tool_definitions`."""
tools_to_include = _select_tool_names(enabled_toolsets, disabled_toolsets, quiet_mode)
# Registry returns only tools whose check_fn passes.
@@ -467,11 +432,8 @@ def _compute_tool_definitions(
_last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools]
if not quiet_mode:
if filtered_tools:
print(f"🛠️ Final tool selection ({len(filtered_tools)} tools): {', '.join(_last_resolved_tool_names)}")
else:
print("🛠️ No tools selected (all filtered out or unavailable)")
print(f"🛠️ Final tool selection ({len(filtered_tools)} tools): {', '.join(_last_resolved_tool_names)}"
if filtered_tools else "🛠️ No tools selected (all filtered out or unavailable)")
# Normalize schema shapes llama.cpp's grammar converter rejects (bare
# "type": "object", string-valued nodes from malformed MCP servers).
try:
@@ -490,12 +452,10 @@ def _compute_tool_definitions(
if not skip_tool_search_assembly and ts_cfg.enabled != "off":
assembly = assemble_tool_defs(filtered_tools, context_length=_resolve_active_context_length(), config=ts_cfg)
if assembly.activated and not quiet_mode:
print(
f"🔎 Tool Search (tier {assembly.tier}): {assembly.deferred_count} "
print(f"🔎 Tool Search (tier {assembly.tier}): {assembly.deferred_count} "
f"MCP/plugin tools deferred (~{assembly.deferred_tokens} tokens) behind "
f"tool_search/describe/call — "
f"{_TOOL_SEARCH_LISTING_FORMS.get(assembly.listing_form, assembly.listing_form)}."
)
f"{_TOOL_SEARCH_LISTING_FORMS.get(assembly.listing_form, assembly.listing_form)}.")
filtered_tools = assembly.tool_defs
except Exception as e: # pragma: no cover — never break tool loading
logger.warning("Tool search assembly skipped: %s", e)
@@ -551,10 +511,8 @@ def _resolve_active_context_length() -> int:
return cached_ctx
except Exception:
pass
return int(get_model_context_length(
model_id, base_url=base_url, api_key=api_key,
config_context_length=config_ctx, provider=provider,
) or 0)
return int(get_model_context_length(model_id, base_url=base_url, api_key=api_key,
config_context_length=config_ctx, provider=provider) or 0)
except Exception as e:
logger.debug("Could not resolve active context length: %s", e)
return 0
@@ -586,7 +544,6 @@ _TOOL_ERROR_STRIP_RES = (
re.compile(r'\s*```\s*$', re.MULTILINE),
re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL),
)
from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN
def _sanitize_tool_error(error_msg: str) -> str:
@@ -636,9 +593,8 @@ def _tool_result_observer_fields(tool_name: str, result: Any) -> tuple[str, Opti
def _emit_post_tool_call_hook(
*, function_name: str, function_args: Dict[str, Any], result: Any,
task_id: Optional[str] = None, session_id: Optional[str] = None, tool_call_id: Optional[str] = None,
turn_id: Optional[str] = None, api_request_id: Optional[str] = None,
duration_ms: int = 0, status: Optional[str] = None,
error_type: Optional[str] = None, error_message: Optional[str] = None,
turn_id: Optional[str] = None, api_request_id: Optional[str] = None, duration_ms: int = 0,
status: Optional[str] = None, error_type: Optional[str] = None, error_message: Optional[str] = None,
middleware_trace: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Emit the ``post_tool_call`` observer hook; gated on has_hook, and ok/error
@@ -661,10 +617,8 @@ def _emit_post_tool_call_hook(
logger.debug("post_tool_call hook error: %s", _hook_err)
def _dispatch_bridge_tool(
function_name: str, function_args: Dict[str, Any],
enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]],
):
def _dispatch_bridge_tool(function_name: str, function_args: Dict[str, Any],
enabled_toolsets: Optional[List[str]], disabled_toolsets: Optional[List[str]]):
"""Handle a Tool Search bridge call (tool_search / tool_describe / tool_call).
None when *function_name* is not a bridge tool; ``(result, None)`` for a
@@ -680,10 +634,8 @@ def _dispatch_bridge_tool(
# Un-collapsed catalog scoped to the session's toolsets, so a restricted
# session (subagent, kanban worker) can't reach the whole registry via the bridge.
try:
current_defs = get_tool_definitions(
enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
quiet_mode=True, skip_tool_search_assembly=True,
) or []
current_defs = get_tool_definitions(enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
quiet_mode=True, skip_tool_search_assembly=True) or []
except Exception:
current_defs = []
args = function_args or {}
@@ -697,10 +649,8 @@ def _dispatch_bridge_tool(
# Defense in depth: resolve_underlying_call only checks the global
# registry; also require membership in the session-scoped catalog.
if underlying_name not in ts.scoped_deferrable_names(current_defs):
return tool_error(
f"'{underlying_name}' is not available in this session. "
"Use tool_search to find tools you can call."
), None
return tool_error(f"'{underlying_name}' is not available in this session. "
"Use tool_search to find tools you can call."), None
# Validate against the deferred tool's concrete schema — the generic
# ``arguments: object`` bridge schema can't enforce it.
probe_err = ts.validate_deferred_call_args(underlying_name, underlying_args)
@@ -722,10 +672,9 @@ def _apply_request_middleware(
return function_args, dict(function_args), trace
def _pre_dispatch_guards(
function_name: str, function_args: Dict[str, Any], skip_pre_tool_call_hook: bool,
def _pre_dispatch_guards(function_name: str, function_args: Dict[str, Any], skip_pre_tool_call_hook: bool,
ids: _CallIds, middleware_trace: List[Dict[str, Any]],
) -> Tuple[Dict[str, Any], Optional[Tuple[Any, str, Optional[str]]]]:
) -> Tuple[Dict[str, Any], Optional[Tuple[Any, str, Optional[str]]]]:
"""Plugin pre_tool_call hook, then ACP edit approval.
``(args, None)`` to proceed (args possibly plugin-modified), or
@@ -766,9 +715,8 @@ def _approval_observability(ids: _CallIds):
"""Bind the approval observability context (turn/tool_call/session ids) for the block."""
try:
from tools.approval import reset_current_observability_context, set_current_observability_context
tokens = set_current_observability_context(
turn_id=ids.turn_id or "", tool_call_id=ids.tool_call_id or "", session_id=ids.session_id or "",
)
tokens = set_current_observability_context(turn_id=ids.turn_id or "", tool_call_id=ids.tool_call_id or "",
session_id=ids.session_id or "")
except Exception:
yield
return
@@ -781,10 +729,8 @@ def _approval_observability(ids: _CallIds):
pass
def _execute_tool(
function_name: str, function_args: Dict[str, Any], original_args: Dict[str, Any], ids: _CallIds,
*, user_task: Optional[str], enabled_tools: Optional[List[str]], skip_tool_execution_middleware: bool,
) -> Any:
def _execute_tool(function_name: str, function_args: Dict[str, Any], original_args: Dict[str, Any], ids: _CallIds,
*, user_task: Optional[str], enabled_tools: Optional[List[str]], skip_tool_execution_middleware: bool) -> Any:
"""Run the registry handler (through tool-execution middleware unless skipped)
with the approval observability context bound for the duration."""
dispatch_kwargs: Dict[str, Any] = {"task_id": ids.task_id, "session_id": ids.session_id}
@@ -802,14 +748,12 @@ def _execute_tool(
if skip_tool_execution_middleware:
return _dispatch(function_args)
from hermes_cli.middleware import run_tool_execution_middleware
return run_tool_execution_middleware(
function_name, function_args, _dispatch, original_args=original_args, **ids.hook_kwargs(),
)
return run_tool_execution_middleware(function_name, function_args, _dispatch, original_args=original_args,
**ids.hook_kwargs())
def _apply_transform_tool_result_hook(
function_name: str, function_args: Dict[str, Any], result: Any, duration_ms: int, ids: _CallIds,
) -> Any:
def _apply_transform_tool_result_hook(function_name: str, function_args: Dict[str, Any], result: Any, duration_ms: int,
ids: _CallIds) -> Any:
"""transform_tool_result: plugins may replace the final result string.
Runs after post_tool_call and before the result enters context. Fail-open;
@@ -819,14 +763,10 @@ def _apply_transform_tool_result_hook(
from hermes_cli.lifecycle import has_hook, invoke_hook
if has_hook("transform_tool_result"):
status, error_type, error_message = _tool_result_observer_fields(function_name, result)
hook_results = invoke_hook(
"transform_tool_result", tool_name=function_name, args=function_args, result=result,
**ids.hook_kwargs(), 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):
return hook_result
hook_results = invoke_hook("transform_tool_result", tool_name=function_name, args=function_args,
result=result, **ids.hook_kwargs(), duration_ms=duration_ms,
status=status, error_type=error_type, error_message=error_message)
return next((r for r in hook_results if isinstance(r, str)), result)
except Exception as _hook_err:
logger.debug("transform_tool_result hook error: %s", _hook_err)
return result
@@ -837,21 +777,12 @@ def _elapsed_ms(start: float) -> int:
def handle_function_call(
function_name: str,
function_args: Dict[str, Any],
task_id: Optional[str] = None,
tool_call_id: Optional[str] = None,
session_id: Optional[str] = None,
turn_id: Optional[str] = None,
api_request_id: Optional[str] = None,
user_task: Optional[str] = None,
enabled_tools: Optional[List[str]] = None,
skip_pre_tool_call_hook: bool = False,
skip_tool_request_middleware: bool = False,
skip_tool_execution_middleware: bool = False,
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
function_name: str, function_args: Dict[str, Any], task_id: Optional[str] = None,
tool_call_id: Optional[str] = None, session_id: Optional[str] = None, turn_id: Optional[str] = None,
api_request_id: Optional[str] = None, user_task: Optional[str] = None, enabled_tools: Optional[List[str]] = None,
skip_pre_tool_call_hook: bool = False, skip_tool_request_middleware: bool = False,
skip_tool_execution_middleware: bool = False, tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
enabled_toolsets: Optional[List[str]] = None, disabled_toolsets: Optional[List[str]] = None,
) -> str:
"""Route a tool call through hooks/middleware to the registry; returns a JSON string.
@@ -884,12 +815,9 @@ def handle_function_call(
if underlying is None:
return _emit(result, duration_ms=_elapsed_ms(start))
return handle_function_call(
*underlying, task_id=task_id, tool_call_id=tool_call_id, session_id=session_id,
turn_id=turn_id, api_request_id=api_request_id, user_task=user_task,
enabled_tools=enabled_tools, skip_pre_tool_call_hook=skip_pre_tool_call_hook,
skip_tool_request_middleware=skip_tool_request_middleware,
skip_tool_execution_middleware=skip_tool_execution_middleware,
tool_request_middleware_trace=list(trace),
*underlying, **asdict(ids), user_task=user_task, enabled_tools=enabled_tools,
skip_pre_tool_call_hook=skip_pre_tool_call_hook, skip_tool_request_middleware=skip_tool_request_middleware,
skip_tool_execution_middleware=skip_tool_execution_middleware, tool_request_middleware_trace=list(trace),
enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
)
@@ -916,11 +844,8 @@ def handle_function_call(
# duration_ms (monotonic) is exposed to post_tool_call / transform_tool_result.
start = time.monotonic()
result = _execute_tool(
function_name, function_args, original_args, ids,
user_task=user_task, enabled_tools=enabled_tools,
skip_tool_execution_middleware=skip_tool_execution_middleware,
)
result = _execute_tool(function_name, function_args, original_args, ids, user_task=user_task,
enabled_tools=enabled_tools, skip_tool_execution_middleware=skip_tool_execution_middleware)
duration_ms = _elapsed_ms(start)
_emit(result, duration_ms=duration_ms)
return _apply_transform_tool_result_hook(function_name, function_args, result, duration_ms, ids)
+22 -73
View File
@@ -79,10 +79,7 @@ TOOLSETS = {
["x_search"],
),
"vision": _ts("Image analysis and vision tools", ["vision_analyze"]),
"video": _ts(
"Video analysis and understanding tools (opt-in, not in default toolset)",
["video_analyze"],
),
"video": _ts("Video analysis and understanding tools (opt-in, not in default toolset)", ["video_analyze"]),
"image_gen": _ts("Creative generation tools (images)", ["image_generate"]),
"video_gen": _ts(
"Video generation tools. Single ``video_generate`` tool covers text-to-video "
@@ -97,10 +94,7 @@ TOOLSETS = {
"or keyboard focus. Works with any tool-capable model.",
["computer_use"],
),
"terminal": _ts(
"Terminal/command execution and process management tools",
["terminal", "process_manage"],
),
"terminal": _ts("Terminal/command execution and process management tools", ["terminal", "process_manage"]),
"skills": _ts(
"Access, create, edit, and manage skill documents with specialized "
"instructions and knowledge",
@@ -121,18 +115,12 @@ TOOLSETS = {
"search (content + files)",
["read_file", "write_file", "patch", "search_files"],
),
"tts": _ts(
"Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI",
["text_to_speech"],
),
"tts": _ts("Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", ["text_to_speech"]),
"todo": _ts("Task planning and tracking for multi-step work", ["todo_list"]),
"memory": _ts("Persistent memory across sessions (personal notes + user profile)", ["memory"]),
"context_engine": _ts("Runtime tools exposed by the active context engine"),
"session_search": _ts("Search and recall past conversations with summarization", ["session_search"]),
"project": _ts(
"Desktop Projects — create/switch named workspaces (GUI sessions only)",
["desktop_project"],
),
"project": _ts("Desktop Projects — create/switch named workspaces (GUI sessions only)", ["desktop_project"]),
"bot_room": _ts("Verified text-only Group Chat turn capabilities"),
# GUI-renderer affordances, enabled per desktop-sourced SESSION by the GUI
@@ -141,17 +129,12 @@ TOOLSETS = {
"desktop_ui": _ts(
"Desktop GUI affordances — in-app terminal/browser panes, pane focus, "
"reactions (GUI sessions only)",
[
"read_terminal", "close_terminal", "desktop_preview", "drive_preview",
["read_terminal", "close_terminal", "desktop_preview", "drive_preview",
"annotate_preview", "read_window_below", "focus_pane", "react_to_message",
"setup_mcp", "gui_tour", "show_tip",
],
"setup_mcp", "gui_tour", "show_tip"],
),
"clarify": _ts("Ask the user clarifying questions (multiple-choice or open-ended)", ["clarify"]),
"code_execution": _ts(
"Run Python scripts that call tools programmatically (reduces LLM round trips)",
["execute_code"],
),
"code_execution": _ts("Run Python scripts that call tools programmatically (reduces LLM round trips)", ["execute_code"]),
"delegation": _ts("Spawn subagents with isolated context for complex subtasks", ["delegate_task"]),
"homeassistant": _ts("Home Assistant smart home control and monitoring", _HA_TOOLS),
"kanban": _ts(
@@ -164,31 +147,19 @@ TOOLSETS = {
"files, and (for orchestrators) list, unblock, and fan out tasks.",
[t for t in _HERMES_CORE_TOOLS if t.startswith("kanban_")],
),
"discord": _ts(
"Discord read and participate tools (fetch messages, search members, create threads)",
["discord"],
),
"discord_admin": _ts(
"Discord server management (list channels/roles, pin messages, assign roles)",
["discord_admin"],
),
"discord": _ts("Discord read and participate tools (fetch messages, search members, create threads)", ["discord"]),
"discord_admin": _ts("Discord server management (list channels/roles, pin messages, assign roles)", ["discord_admin"]),
"yuanbao": _ts("Yuanbao platform tools - group info, member queries, DM, stickers", _YUANBAO_TOOLS),
"feishu_doc": _ts("Read Feishu/Lark document content", ["feishu_doc_read"]),
"feishu_drive": _ts("Feishu/Lark document comment operations (list, reply, add)", _FEISHU_TOOLS[1:]),
"spotify": _ts(
"Native Spotify playback, search, playlist, album, and library tools",
[
"spotify_playback", "spotify_devices", "spotify_queue", "spotify_search",
"spotify_playlists", "spotify_albums", "spotify_library",
],
["spotify_playback", "spotify_devices", "spotify_queue", "spotify_search",
"spotify_playlists", "spotify_albums", "spotify_library"],
),
# Scenario-specific toolsets
"debugging": _ts(
"Debugging and troubleshooting toolkit",
["terminal", "process_manage"],
includes=["web", "file"],
),
"debugging": _ts("Debugging and troubleshooting toolkit", ["terminal", "process_manage"], includes=["web", "file"]),
"safe": _ts("Safe toolkit without terminal access", [], includes=["web", "vision", "image_gen"]),
# Coding posture, auto-selected in a code workspace (agent/coding_context.py).
@@ -220,22 +191,16 @@ TOOLSETS = {
# Mirrors hermes-cli; `hermes tools` platform config filters it down and
# _get_platform_tools() drops _DEFAULT_OFF_TOOLSETS unless user-enabled.
"hermes-cron": _bundle("Default cron toolset - same core tools as hermes-cli; gated by `hermes tools`"),
"hermes-telegram": _bundle(
"Telegram bot toolset - full access for personal use (terminal has safety checks)"
),
"hermes-telegram": _bundle("Telegram bot toolset - full access for personal use (terminal has safety checks)"),
"hermes-discord": _bundle(
"Discord bot toolset - full access (terminal has safety checks via dangerous "
"command approval)",
["discord", "discord_admin"],
),
"hermes-whatsapp": _bundle(
"WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)"
),
"hermes-whatsapp": _bundle("WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)"),
"hermes-slack": _bundle("Slack bot toolset - full access for workspace use (terminal has safety checks)"),
"hermes-signal": _bundle("Signal bot toolset - encrypted messaging platform (full access)"),
"hermes-bluebubbles": _bundle(
"BlueBubbles iMessage bot toolset - Apple iMessage via local BlueBubbles server"
),
"hermes-bluebubbles": _bundle("BlueBubbles iMessage bot toolset - Apple iMessage via local BlueBubbles server"),
"hermes-homeassistant": _bundle("Home Assistant bot toolset - smart home event monitoring and control"),
"hermes-email": _bundle("Email bot toolset - interact with Hermes via email (IMAP/SMTP)"),
"hermes-mattermost": _bundle("Mattermost bot toolset - self-hosted team messaging (full access)"),
@@ -245,9 +210,7 @@ TOOLSETS = {
"hermes-weixin": _bundle("Weixin bot toolset - personal WeChat messaging via iLink (full access)"),
"hermes-qqbot": _bundle("QQBot toolset - QQ messaging via Official Bot API v2 (full access)"),
"hermes-wecom": _bundle("WeCom bot toolset - enterprise WeChat messaging (full access)"),
"hermes-wecom-callback": _bundle(
"WeCom callback toolset - enterprise self-built app messaging (full access)"
),
"hermes-wecom-callback": _bundle("WeCom callback toolset - enterprise self-built app messaging (full access)"),
"hermes-yuanbao": {
"description": "Yuanbao Bot 元宝消息平台工具集 - 群信息、成员查询、私聊、贴纸表情",
"tools": _HERMES_CORE_TOOLS + _YUANBAO_TOOLS,
@@ -255,10 +218,7 @@ TOOLSETS = {
"includes": [],
},
"hermes-sms": _bundle("SMS bot toolset - interact with Hermes via SMS (Twilio)"),
"hermes-webhook": _ts(
"Webhook toolset - receive and process external webhook events",
_HERMES_WEBHOOK_SAFE_TOOLS,
),
"hermes-webhook": _ts("Webhook toolset - receive and process external webhook events", _HERMES_WEBHOOK_SAFE_TOOLS),
"hermes-gateway": _ts(
"Gateway toolset - union of all messaging platform tools",
[],
@@ -284,12 +244,9 @@ def _registry():
def _registry_call(method: str, default):
"""registry.<method>() or *default* when the registry is unavailable or the call fails."""
registry = _registry()
if registry is None:
return default
try:
return getattr(registry, method)()
except Exception:
return getattr(_registry(), method)()
except Exception: # registry None (AttributeError) or the call failed
return default
@@ -328,12 +285,7 @@ def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict[st
if not registry_toolset:
return None
description = f"MCP server '{name}' tools"
return {
"description": description,
"tools": registry.get_tool_names_for_toolset(registry_toolset),
"includes": [],
}
return {"description": description, "tools": registry.get_tool_names_for_toolset(registry_toolset), "includes": []}
def bundle_non_core_tools(toolset_name: str) -> Set[str]:
@@ -348,8 +300,7 @@ def bundle_non_core_tools(toolset_name: str) -> Set[str]:
if not (ts_def and "tools" in ts_def):
return set(resolve_toolset(toolset_name)) - core
to_remove = set(ts_def["tools"])
for inc in ts_def.get("includes", []):
inc_def = get_toolset(inc)
for inc_def in map(get_toolset, ts_def.get("includes", [])):
if inc_def and "tools" in inc_def:
to_remove.update(inc_def["tools"])
return to_remove - core
@@ -443,9 +394,7 @@ def get_all_toolsets() -> Dict[str, Dict[str, Any]]:
"""All toolset definitions: static plus plugin-registered."""
result = dict(TOOLSETS)
for display_name in _plugin_display_names():
if display_name in result:
continue
toolset = get_toolset(display_name)
toolset = None if display_name in result else get_toolset(display_name)
if toolset:
result[display_name] = toolset
return result