From 3f7c2adea4f2388db9b1ec6b76deb38022ffa6e6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:13:33 -0700 Subject: [PATCH] =?UTF-8?q?refactor(tools-layer):=20model=5Ftools/mcp=5Fse?= =?UTF-8?q?rve/toolsets=20=E2=80=94=20pack=20signatures=20&=20literals,=20?= =?UTF-8?q?merge=20baseline=20helper,=20drop=20redundant=20branches;=20-17?= =?UTF-8?q?5=20LOC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp_serve.py | 143 ++++++++++++------------------------ model_tools.py | 191 +++++++++++++++---------------------------------- toolsets.py | 97 ++++++------------------- 3 files changed, 128 insertions(+), 303 deletions(-) diff --git a/mcp_serve.py b/mcp_serve.py index 95eebded68..6b0f42f2b3 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -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): - attachments.append({"type": "media", "path": match.group(1)}) - + 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,32 +345,30 @@ 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.""" - self._state_db_mtime = _read_state_db_mtime() + db = _get_session_db() + if not db: + return try: - self._cached_sessions_index = _load_sessions_index() - except Exception: - self._cached_sessions_index = {} - for session_key, entry in self._cached_sessions_index.items(): - session_id = entry.get("session_id", "") - if not session_id: - continue + self._state_db_mtime = _read_state_db_mtime() try: - latest = _latest_ts(db.get_messages(session_id)) + self._cached_sessions_index = _load_sessions_index() except Exception: - continue - if latest > 0.0: - self._last_poll_timestamps[session_key] = latest + self._cached_sessions_index = {} + for session_key, entry in self._cached_sessions_index.items(): + session_id = entry.get("session_id", "") + if not session_id: + continue + try: + latest = _latest_ts(db.get_messages(session_id)) + except Exception: + 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, - "name": entry.get("display_name") or origin.get("chat_name", ""), - "chat_type": entry.get("chat_type", origin.get("chat_type", "")), - }) + 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", ""))}) 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(): diff --git a/model_tools.py b/model_tools.py index 345a64305e..e1ec34bd6f 100644 --- a/model_tools.py +++ b/model_tools.py @@ -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} " - 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)}." - ) + 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)}.") 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'', 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, - ids: _CallIds, middleware_trace: List[Dict[str, Any]], -) -> Tuple[Dict[str, Any], Optional[Tuple[Any, str, Optional[str]]]]: +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]]]]: """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) diff --git a/toolsets.py b/toolsets.py index a36469db95..a66c95950e 100644 --- a/toolsets.py +++ b/toolsets.py @@ -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", - "annotate_preview", "read_window_below", "focus_pane", "react_to_message", - "setup_mcp", "gui_tour", "show_tip", - ], + ["read_terminal", "close_terminal", "desktop_preview", "drive_preview", + "annotate_preview", "read_window_below", "focus_pane", "react_to_message", + "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.() 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