Files
hermes-agent/toolsets.py
Siddharth Balyan b4d04eb8fd Connector tools (Gmail, Linear, Notion, ...) are searchable and callable through tool_search for signed-in Nous users (#106842)
* feat: add session-scoped connector access for onboarding

* fix(connectors): availability is the config flag AND the portal entitlement — no free-tier leg

The port carried a third availability leg from hermes-magic: a stored guest
(free-tier) identity short-circuits the managed-tool entitlement check. That
leg reads hermes_cli.anon_auth, which does not exist on hermes-agent main, so
connectors_available() raised ImportError inside its fail-closed try and the
whole connector surface was silently dark on a plain upstream checkout.

On this tree availability is the two-leg AND the design started with:
tools.connectors.enabled AND managed_nous_tools_enabled(). The free-tier leg
is a hermes-magic concern and belongs in hermes-magic's own delta over this
branch, next to the identity it depends on. Its integration test goes with it.

* docs(tool-search): connectors section — remote tools through the bridge

The squashed port carried the code but not the user-facing docs. Restores the
Connectors section of the Tool Search page and the connector-gateway host /
CONNECTOR_GATEWAY_URL override on the Tool Gateway page, updated for the
manage_connections tool and the pure-connector batch rule.

* fix(tool-search): connector tools rank with local tools in one pass instead of taking leftover slots

dispatch_tool_search ran BM25 over the local catalog, filled `limit` slots,
then appended connector hits only into slots left empty. On a 300-tool
catalog no slot was ever empty, so with Gmail and Google Calendar connected
"send gmail email" returned five betterstack tools and zero connector tools.

The gateway's hits for a query now become catalog entries (connector name,
slug words, description as the search text) and join the local catalog for
that query's BM25 pass. One ranking, one rarest-token admission rule for both
sources, `limit` as the total per query. The merge loop and the separate
record builder for connector hits are gone; `_shared_tool_record` serves both
sources.

The gateway search timeout rises from 8 s to 30 s. One request with six
use_cases measured 7 s, so 8 s sat on the edge and cut real answers off; the
failure path is unchanged (local-only results, no error to the model).

Live, 311 local tools + gateway, before -> after:
  "send gmail email":           5 betterstack tools -> gmail SEND_EMAIL, CREATE_EMAIL_DRAFT
  "read google calendar events": 5 betterstack tools -> googlecalendar EVENTS_LIST_ALL_CALENDARS
  "linear create issue", "betterstack incident": unchanged
Benchmark (25 labelled queries): connector recall 0.09 -> 0.82, precision@5
0.18 -> 0.59, false positives on absent intents 17 -> 2.

* refactor(tool-search): connector leg into tools/connector_search.py

tools/tool_search.py is a facade. The connector leg (gateway hits as catalog
entries for tool_search, remote schemas for tool_describe, the
connections_in_scope gate) was appended to it by the port. It now lives in
its own sibling, tools/connector_search.py, and the facade imports the three
entry points: connections_in_scope, connector_entries_by_group,
remote_schemas_for.

No behaviour change. The tool_describe remote block became
remote_schemas_for(names, current_tool_defs, connector_describe) with the
same inputs, the same silent-degradation contract and the same injection
seam the tests already use.

* fix(tool-search): at most 7 queries per call, the gateway's search limit

One tool_search call sends all its queries to the connector gateway as one
search request. The gateway answers 7 use_cases per request and returns
HTTP 502 for 8 or more (measured 2026-09-09, re-measured with one-word
use_cases: it is a count limit, not a size limit). With the client cap at
10, a model sending 8 to 10 queries lost every connector hit for that call
and saw local-only results with no error.

The shared constant splits: _MAX_QUERIES_PER_CALL = 7 for search,
_MAX_DESCRIBE_NAMES_PER_CALL = 10 for describe, which has no remote count
limit. Eight or more queries now get the existing "too many queries" retry
hint before any request is made. No chunking: one call, one request.

* fix(tool-search): the model is told that connectors__ names are manage_connections accounts

tool_search results carry names like connectors__gmail__CREATE_EMAIL_DRAFT and
manage_connections is the tool that checks and connects those accounts, but
nothing told the model the two are the same thing. A model that hit
CONNECTION_REQUIRED had to infer the fix on its own.

The tool_search description gains one sentence making the link, added at
assembly only when manage_connections is in the session's tools. Signed out
or with connectors off the tool is absent and the description is unchanged,
so it never names a tool the model cannot call. This follows the existing
rule for cross-tool references (tools/AGENTS.md): they are added dynamically
from the session's actual tool set, never hardcoded in a schema.

Tool defs are fixed for the life of a conversation, so the description is
byte-stable per conversation; this is a one-time prefix change.

Live, real get_tool_definitions() against a signed-in home: sentence present.
Same home with auth.json removed: manage_connections absent, sentence absent.

* fix(connectors): /stop halts a connector batch before the next remote call

dispatch_connector_batch runs every remote entry of a tool_call batch in
sequence. The executor only checks the interrupt flag between tools, and
the whole batch is one tool to it, so a /stop landing during entry 1 of
20 still sent the other 19 to the gateway.

The loop now reads tools.interrupt.is_interrupted before each dispatch.
Once set, it stops calling handle_function_call and fills every unstarted
slot with the loop's existing error-slot shape, code INTERRUPTED and the
message "Stopped by the user before this call was made.", so the result
envelope stays valid and the counts stay honest. Entries already
dispatched keep their real results.

Test: three connector calls where the fake client sets the interrupt on
the first execute. The client sees exactly one call and slots 2 and 3
carry INTERRUPTED. Red on the base branch, green with the fix.

* test(connections): schema assertions become dispatch contracts

test_schema_documents_wait_and_its_timeout froze description fragments
("REQUIRED", "can NOT disconnect", "Nous Portal"). A wording edit fails
it while a real regression (a disconnect that reaches the gateway) does
not. That is a snapshot of prose, not a behaviour contract.

Delete it. The requirement that wait needs connectors is already covered
by test_wait_requires_connectors. The user-only disconnect boundary is
now asserted as behaviour: action disconnect with a connector returns an
error and the fake client records no call. That replaces the earlier
de-authenticate test, which only checked that the word "dashboard"
appeared in the error text.

Test count in the file goes from 26 to 25.

* docs(tool-search): connector batches are one gateway request per entry

The user guide said a connector batch travels as one gateway request. It
does not: model_tools_connectors.dispatch_connector_batch re-enters core
dispatch per entry, and each entry becomes its own execute request in
bridge._run_remote (plus at most one literal-slug retry when the gateway
reports TOOL_NOT_FOUND under the conventional slug). The docstrings in
tools/tool_gateway/bridge.py and tools/tool_gateway/__init__.py still
described the abandoned V1 plan and claimed nothing outside the package
imports it.

Rewrite those sentences to match the code: one request per entry, in
input order, dispatched from model_tools_connectors.py, with the per-entry
approval and interrupt behaviour that motivated the split. The guide also
still showed the single-call shape tool_call(name, arguments); both
places now show the `calls: [{name, arguments}]` array the schema
advertises and note that a single local call is an array of one.

Docs only, no test.

* fix(tools): the between-turns refresh never rewrites the bridge tools

The per-turn MCP refresh folds a fresh tool snapshot into the live array
with preserve_prefix: order and membership stay, but a name present in both
takes the fresh schema. That is right for ordinary tools, whose schema is a
constant. tool_search is the one tool whose description is derived from the
session: the deferred-tool count, the embedded listing, and, on this branch,
whether manage_connections was present. A late MCP server or one failed
portal lookup (manage_connections' check_fn fails closed) changed those bytes
on the next turn, and every byte after tool_search in the cached prefix was
re-prefilled. The array also contradicted itself in that case: the flapping
manage_connections was carried forward while the description lost its hint.

The bridge entries now keep the bytes they were built with for the life of
the conversation. Nothing is lost: tool_search reads the live catalog at
dispatch, so tools that arrived late are still found; connector availability
is checked at dispatch too. The compaction-boundary rebuild (content_aware,
the one sanctioned cache break) still refreshes the description.

Consequence: connector exposure in the prompt is decided once, at agent
build, by whether the user was signed in then. That is the intended
contract.

* refactor(tool-search): normalize_tool_call_entries lives with the other argument validation

The port appended the tool_call argument parser to the tool_search facade.
The family already has tools/tool_search_validation.py for exactly this
work (schema validation of deferred call arguments), so the parser moves
there and the facade imports it. No behaviour change; the one test that
imported it now imports from the defining module.

* refactor(connectors): delete the unused batch dispatcher; _run_remote becomes run_remote

bridge.dispatch_calls and its helpers (_dispatch_calls_inner, _run_pre_dispatch,
_run_local, _error_slot, _maybe_parse_json) and the LocalDispatch / PreDispatch
seams had no production caller. Connector dispatch runs through
model_tools_connectors: dispatch_connector_batch re-enters handle_function_call
once per entry, so scope, hook, approval and middleware policy fire against each
composed name inside core dispatch, and dispatch_connector_call hands the single
planned entry to the bridge's transport function. Only tests called the batch
dispatcher, and they exercised policy seams that production never wires.

The transport function is the module's real entry point, so it drops the
underscore: _run_remote becomes run_remote, body unchanged. The module
docstring now describes the two legs that exist (availability with D32 silent
degradation, and run_remote) instead of the injected seams. Imports that only
the deleted code used are gone; merge.py is untouched because every export
still has a caller.

Tests that drove dispatch_calls are deleted where they covered the removed
seams (pre_dispatch blocks and rewrites, local_dispatch classification, mixed
batches). The literal-slug fallback, the per-entry transport failure, and the
hook rewrite reaching the gateway request body are re-targeted at
handle_function_call('tool_call', ...) with the fake client swapped in at
bridge._default_client_factory, the same seam test_connector_dispatch_policy
uses. Each re-targeted test fails when the retry is disabled in run_remote.

* fix(connectors): search keeps the twin a colliding name reaches, and says so

format_connector_name strips the toolkit prefix, so GMAIL_FETCH_PROFILE and a
literal FETCH_PROFILE on gmail both compose to connectors__gmail__FETCH_PROFILE.
describe and execute decode that name to the prefixed slug first, so the
literal twin is unreachable under it. If a vendor ever shipped both, search
could describe the literal under a name that runs the prefixed tool.

Search is the one place that sees both twins in one response. It now keeps
the twin the name reaches and drops the other with a WARNING that names both
slugs, whichever the gateway listed first. Short names stay; no marker, no
per-process map, no change to describe or execute. No such pair exists in the
live catalog today; the guard turns a silent alias into a logged one.
2026-09-10 02:21:16 +05:30

483 lines
24 KiB
Python

"""Toolset helpers: get/resolve/validate named tool groups (static TOOLSETS + registry-registered)."""
from typing import Dict, List, Any, Set, Optional, Tuple
# Shared tool list for CLI and all messaging platform toolsets (edit once, all
# platforms follow). Desktop GUI affordances are deliberately NOT here: they live
# in `desktop_ui`/`project`, enabled per desktop-sourced session by the GUI gateway
# (tui_gateway/server.py::_load_enabled_toolsets). HA, kanban and computer_use
# entries are further gated by their tools' check_fns.
_HERMES_CORE_TOOLS = [
"web_search", "web_extract",
"terminal", "process_manage",
"read_file", "write_file", "patch", "search_files",
"vision_analyze", "image_generate",
"skills_list", "skill_view", "skill_manage",
"browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back",
"browser_press", "browser_get_images",
"browser_vision", "browser_console", "browser_cdp", "browser_dialog",
"browser_exec", # replaces the other browser tools when browser.backend is "browser-use"
"text_to_speech",
"todo_list", "memory",
"session_search",
"clarify",
"execute_code", "delegate_task",
"cronjob_manage",
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
"kanban_show", "kanban_list",
"kanban_complete", "kanban_block", "kanban_request_review",
"kanban_request_changes",
"kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link",
"kanban_unblock",
"kanban_attach", "kanban_attach_url", "kanban_attachments",
"computer_use",
# Service-gated connector account status and authorization links.
"manage_connections",
]
# Webhook payloads are untrusted third-party content: no file/system execution.
_HERMES_WEBHOOK_SAFE_TOOLS = ["web_search", "web_extract", "vision_analyze", "clarify"]
_HA_TOOLS = ["ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service"]
_FEISHU_TOOLS = [
"feishu_doc_read", "feishu_drive_list_comments", "feishu_drive_list_comment_replies",
"feishu_drive_reply_comment", "feishu_drive_add_comment",
]
_YUANBAO_TOOLS = ["yb_query_group_info", "yb_query_group_members", "yb_send_dm", "yb_search_sticker", "yb_send_sticker"]
def _ts(description, tools=(), includes=(), **extra):
"""One TOOLSETS entry (fresh lists per entry; extra keys such as posture pass through)."""
return {"description": description, "tools": list(tools), "includes": list(includes), **extra}
def _bundle(description, extras=()):
"""A `hermes-*` platform bundle: the shared core tools plus optional platform extras."""
return _ts(description, _HERMES_CORE_TOOLS + list(extras))
def _core_without(*excluded, kanban=True):
"""_HERMES_CORE_TOOLS minus *excluded* (and, unless kanban=True, every kanban_* tool); order preserved."""
return [t for t in _HERMES_CORE_TOOLS if t not in excluded and (kanban or not t.startswith("kanban_"))]
# Coding posture: everything you reach for while pairing on code; drops messaging,
# tts, image_gen, home-assistant, cron, kanban and computer-use.
_CODING_TOOLS = _core_without("image_generate", "text_to_speech", "cronjob_manage", "computer_use", *_HA_TOOLS, kanban=False)
# Core toolset definitions: individual tools or references to other toolsets.
TOOLSETS = {
# Basic toolsets - individual tool categories
"web": _ts("Web research and content extraction tools", ["web_search", "web_extract"]),
"search": _ts("Web search only (no content extraction/scraping)", ["web_search"]),
"x_search": _ts(
"Search X (Twitter) posts and threads via xAI's built-in x_search Responses "
"tool. Read-only public X discovery; use the xurl skill for authenticated X "
"API reads and account actions. Available when xAI credentials are configured "
"(SuperGrok OAuth or XAI_API_KEY). Off by default; enable in `hermes tools` → "
"X (Twitter) Search.",
["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"]),
"image_gen": _ts("Creative generation tools (images)", ["image_generate"]),
"video_gen": _ts(
"Video generation tools. Single ``video_generate`` tool covers text-to-video "
"(prompt only) and image-to-video (prompt + image_url), plus "
"reference-to-video. Provider-specific edit/extend workflows may appear as "
"separate tools. Configure via ``hermes tools`` → Video Generation.",
["video_generate", "xai_video_edit", "xai_video_extend"],
),
"computer_use": _ts(
"Background desktop control via cua-driver (macOS/Windows/Linux) — "
"screenshots, mouse, keyboard, scroll, drag. Does NOT steal the user's cursor "
"or keyboard focus. Works with any tool-capable model.",
["computer_use"],
),
"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",
["skills_list", "skill_view", "skill_manage"],
),
# web_search belongs to `web`/`search` only. Listing it here too let
# `disabled_toolsets: [browser]` (headless/Docker deployments) strip
# web_search from every session, because disabled toolsets are a strict
# end-of-pipeline subtraction (#17309, #64503).
"browser": _ts(
"Browser automation for web interaction (navigate, click, type, scroll, "
"iframes, hold-click)",
[t for t in _HERMES_CORE_TOOLS if t.startswith("browser_")],
),
"cronjob": _ts(
"Cronjob management tool - create, list, update, pause, resume, remove, and "
"trigger scheduled tasks",
["cronjob_manage"],
),
"file": _ts(
"File manipulation tools: read, write, patch (with fuzzy matching), and "
"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"]),
"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"]),
"connections": _ts("Remote connector discovery, execution, and account authorization", ["manage_connections"]),
"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
# gateway (tui_gateway/server.py::_load_enabled_toolsets) — never by a
# process env var, which is blind to a desktop client on a remote backend.
"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"],
),
"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"]),
"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(
"Kanban multi-agent coordination — only active when the agent is spawned by "
"the kanban dispatcher (HERMES_KANBAN_TASK env set). The dispatcher runs "
"inside the gateway by default; see `kanban.dispatch_in_gateway` in "
"config.yaml. Lets workers mark tasks done with structured handoffs, enter "
"first-class review (request_review — not a block), return review changes, "
"block for human input, heartbeat during long ops, comment on threads, attach "
"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"]),
"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"],
),
# Scenario-specific toolsets
"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).
# `desktop_ui` is folded in separately by the GUI gateway for desktop sessions.
# posture=True: per-session posture, never auto-recovered into platform tool
# config (see the non-configurable-toolset recovery loop in hermes_cli/tools_config.py).
"coding": _ts(
"Coding-focused toolset: files, terminal, search, web docs, skills, todo, "
"delegate, vision, browser",
_CODING_TOOLS,
posture=True,
),
# Full Hermes toolsets (CLI + messaging platforms). All share the core tools;
# there is deliberately no agent-callable send_message tool. hermes-acp is the
# coding posture minus the interactive clarify UI.
"hermes-acp": _ts(
"Editor integration (VS Code, Zed, JetBrains) — coding-focused tools without "
"messaging, audio, or clarify UI",
[t for t in _CODING_TOOLS if t != "clarify"],
),
"hermes-api-server": _ts(
"OpenAI-compatible API server — full agent tools accessible via HTTP (no "
"interactive UI tools like clarify or send_message)",
_core_without("text_to_speech", "clarify", "computer_use", kanban=False),
),
"hermes-cli": _bundle("Full interactive CLI toolset - all default tools plus cronjob management"),
# 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-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-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-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)"),
"hermes-matrix": _bundle("Matrix bot toolset - decentralized encrypted messaging (full access)"),
"hermes-dingtalk": _bundle("DingTalk bot toolset - enterprise messaging platform (full access)"),
"hermes-feishu": _bundle("Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)", _FEISHU_TOOLS),
"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-yuanbao": {
"description": "Yuanbao Bot 元宝消息平台工具集 - 群信息、成员查询、私聊、贴纸表情",
"tools": _HERMES_CORE_TOOLS + _YUANBAO_TOOLS,
"module": "tools.yuanbao_tools",
"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-gateway": _ts(
"Gateway toolset - union of all messaging platform tools",
[],
includes=[
"hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack",
"hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email",
"hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk",
"hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin",
"hermes-qqbot", "hermes-webhook", "hermes-yuanbao",
],
),
}
def _registry():
"""Live tool registry, or None when tools.registry can't be imported."""
try:
from tools.registry import registry
return registry
except Exception:
return None
def _registry_call(method: str, default):
"""registry.<method>() or *default* when the registry is unavailable or the call fails."""
try:
return getattr(_registry(), method)()
except Exception: # registry None (AttributeError) or the call failed
return default
def _registry_generation() -> Tuple[int, int]:
reg = _registry()
return (id(reg), getattr(reg, "_generation", 0)) if reg is not None else (0, 0)
def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict[str, Any]]:
"""Toolset definition, or None if unknown.
include_registry=True merges plugin/overlay tools registered into this toolset
and resolves registry-only (plugin/MCP) toolsets and aliases; False returns a
copy of the static TOOLSETS entry only, so platform reverse-mapping is
unaffected by registry additions.
Args: name (str): Name of the toolset include_registry (bool): When True (default), merge in tools that
plugins/overlays registered into this toolset via the registry. Platform reverse-mapping in
``_get_platform_tools`` uses False so that a tool registered into a toolset but absent from a platform's
static composite does not drop the whole toolset from inference. See issue #49622.
"""
toolset = TOOLSETS.get(name)
if not include_registry:
return {**toolset, "tools": list(toolset.get("tools", [])), "includes": list(toolset.get("includes", []))} if toolset else None
registry = _registry()
if registry is None:
return toolset if toolset else None
if toolset:
merged_tools = set(toolset.get("tools", [])) | set(registry.get_tool_names_for_toolset(name))
# An MCP server named like a built-in toolset ("homeassistant", "browser") registers a bare
# alias to its `mcp-<name>` toolset; without this union the static entry shadows it and the
# server's tools never reach the model even though discovery registered them.
alias_target = registry.get_toolset_alias_target(name)
if alias_target and alias_target != name:
merged_tools |= set(registry.get_tool_names_for_toolset(alias_target))
return {**toolset, "tools": sorted(merged_tools)}
if name in _get_plugin_toolset_names():
# Plugin toolset; shown as its MCP server alias when one exists.
registry_toolset = name
alias = _display_alias(name, _get_registry_toolset_aliases())
description = f"MCP server '{alias}' tools" if alias else f"Plugin toolset: {name}"
else:
registry_toolset = registry.get_toolset_alias_target(name)
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": []}
def bundle_non_core_tools(toolset_name: str) -> Set[str]:
"""A bundle's tools minus _HERMES_CORE_TOOLS (one level of includes).
Disabling a `core + extras` bundle must not strip the core tools every other
toolset shares. One `includes` pass suffices (only hermes-gateway nests
bundles). Unknown names: full resolution minus core.
"""
core = set(_HERMES_CORE_TOOLS)
ts_def = get_toolset(toolset_name)
if not (ts_def and "tools" in ts_def):
return set(resolve_toolset(toolset_name)) - core
to_remove = set(ts_def["tools"])
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
# Memo keyed on (name, include_registry, id(registry), registry generation);
# engages only at the public entry (visited is None).
_resolve_toolset_memo: Dict[Tuple[str, bool, int, int], List[str]] = {}
def _plugin_platform_bundle(name: str) -> List[str]:
"""Implicit `hermes-<platform>` bundle for a registered plugin platform: core
tools plus whatever the plugin registered under the platform name. [] otherwise."""
if not name.startswith("hermes-"):
return []
platform_name = name[len("hermes-"):]
try:
from gateway.platform_registry import platform_registry
if not platform_registry.is_registered(platform_name):
return []
except Exception:
return []
tools = set(_HERMES_CORE_TOOLS)
try:
tools.update(e.name for e in _registry_call("get_all_entries", ()) if e.toolset == platform_name)
except Exception:
pass
return list(tools)
def resolve_toolset(name: str, visited: Set[str] = None, *, include_registry: bool = True) -> List[str]:
"""Recursively resolve a toolset (and its includes) to a sorted tool-name list.
include_registry=False resolves the static TOOLSETS view only.
Args: name (str): Name of the toolset to resolve visited (Set[str]): Set of already visited toolsets
(for cycle detection) include_registry (bool): When True (default), include tools that plugins/overlays
registered into a toolset. Platform reverse-mapping uses False so a registry-added tool cannot drop the
whole toolset from inference (see #49622 and ``_get_platform_tools``).
"""
external_call = visited is None
if external_call:
memo_key = (name, include_registry, *_registry_generation())
cached = _resolve_toolset_memo.get(memo_key)
if cached is not None:
return list(cached)
visited = set()
# "all"/"*" span every toolset so new toolsets are included automatically.
if name in {"all", "*"}:
all_tools: Set[str] = set()
for toolset_name in get_toolset_names():
all_tools.update(resolve_toolset(toolset_name, visited.copy(), include_registry=include_registry))
return sorted(all_tools)
# Diamond include or cycle: [] silently — the tools are collected via another path.
if name in visited:
return []
visited.add(name)
toolset = get_toolset(name, include_registry=include_registry)
if not toolset:
return _plugin_platform_bundle(name) if include_registry else []
tools = set(toolset.get("tools", []))
for included_name in toolset.get("includes", []):
tools.update(resolve_toolset(included_name, visited, include_registry=include_registry))
result = sorted(tools)
if external_call:
if len(_resolve_toolset_memo) >= 256: # stale-generation entries are never hit again
_resolve_toolset_memo.clear()
_resolve_toolset_memo[memo_key] = list(result)
return result
def _get_plugin_toolset_names() -> Set[str]:
"""Registry toolset names absent from the static TOOLSETS dict."""
return {n for n in _registry_call("get_registered_toolset_names", ()) if n not in TOOLSETS}
def _get_registry_toolset_aliases() -> Dict[str, str]:
return _registry_call("get_registered_toolset_aliases", {})
def _display_alias(ts_name: str, aliases: Dict[str, str]) -> Optional[str]:
"""First non-static alias pointing at *ts_name*, or None."""
return next((a for a, canonical in aliases.items() if canonical == ts_name and a not in TOOLSETS), None)
def _plugin_display_names() -> List[str]:
"""Plugin toolset names, shown under their first non-static alias when one exists."""
aliases = _get_registry_toolset_aliases()
return [_display_alias(n, aliases) or n for n in _get_plugin_toolset_names()]
def get_all_toolsets() -> Dict[str, Dict[str, Any]]:
"""All toolset definitions: static plus plugin-registered."""
result = dict(TOOLSETS)
aliases = _get_registry_toolset_aliases()
for display_name in _plugin_display_names():
toolset = None if display_name in result else get_toolset(display_name)
if toolset:
result[display_name] = toolset
# Static names an MCP server also aliases show the merged view get_toolset() resolves.
for name in TOOLSETS.keys() & aliases.keys():
result[name] = get_toolset(name) or result[name]
return result
def get_toolset_names() -> List[str]:
"""Sorted names of all toolsets (static + plugin), excluding aliases."""
return sorted(set(TOOLSETS.keys()) | set(_plugin_display_names()))
def validate_toolset(name: str) -> bool:
return (name in {"all", "*"} or name in TOOLSETS
or name in _get_plugin_toolset_names() or name in _get_registry_toolset_aliases())
def create_custom_toolset(name: str, description: str, tools: List[str] = None, includes: List[str] = None) -> None:
"""Register a runtime toolset in TOOLSETS."""
TOOLSETS[name] = _ts(description, tools or [], includes or [])
def get_toolset_info(name: str) -> Dict[str, Any]:
"""Toolset definition plus its resolved tools, or None if unknown."""
toolset = get_toolset(name)
if not toolset:
return None
resolved_tools = resolve_toolset(name)
return {
"name": name, "description": toolset["description"],
"direct_tools": toolset["tools"], "includes": toolset["includes"],
"resolved_tools": resolved_tools, "tool_count": len(resolved_tools),
"is_composite": bool(toolset["includes"]),
}
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]:
"""
Resolve multiple toolsets and combine their tools.
Args:
toolset_names (List[str]): List of toolset names to resolve
Returns:
List[str]: Combined list of all tool names (deduplicated)
"""
all_tools = set()
for name in toolset_names:
tools = resolve_toolset(name)
all_tools.update(tools)
return sorted(all_tools)
# ---- END PLUGIN-COMPAT ----