Files
hermes-agent/tools/tool_search.py
Siddharth Balyan d105376b21 Connector code lives in one package, tools/connectors/ (move only; NS-868 prep) (#110368)
* refactor(tools): discovery also scans tools/<pkg>/tool.py

A tool family that is a whole package had no way to register: discovery
globbed tools/*.py only and derived the module name from the filename.
Now the candidate list is tools/*.py plus tools/*/tool.py, merged and
sorted once so import order does not depend on depth (register() lets a
same-name duplicate overwrite silently), and the module name comes from
the path relative to tools/. Only tool.py is scanned inside a package,
so its siblings are libraries by construction. A package without an
__init__.py is skipped with a warning rather than registering from a
checkout and vanishing from the installed wheel.

The AST prefilter and the (mtime, size) disk cache are per absolute path
and work unchanged. The two hand-rolled tools/*.py enumerators in tests
now use the same candidate helper.

* refactor(connectors): one package for the connector domain, tools/connectors/

The connector code was spread across six flat files and a root-level
module that was a sibling of model_tools.py only by address:

  tools/connections_tool.py            -> tools/connectors/tool.py     (schema, register, dispatcher)
                                          tools/connectors/managed.py  (the managed leg, split out)
  tools/connections_tool_mcp.py        -> tools/connectors/mcp.py      (validation split out ->)
                                          tools/connectors/targets.py  (normalize_targets, validate_action)
  tools/connections_tool_operation.py  -> tools/connectors/operation.py
  tools/connector_search.py            -> tools/connectors/search.py
  model_tools_connectors.py            -> tools/connectors/dispatch.py
  tools/tool_gateway/                  -> tools/connectors/gateway/

Move only; every function body is unchanged. tools/connectors/__init__.py
is the door: nine names, the whole cross-package surface. model_tools and
tool_search deep-import a few helpers past it on purpose and the docstring
says so. The two split files make the import graph one-directional
(tool -> mcp -> targets, tool -> managed) where the old layout had
connections_tool importing validation out of the MCP file.

One behaviour-neutral seam change: the _connectors_available try/except
wrapper is gone. connectors_available() already fails closed, and both
the registry handler and the inline executor now read it as a module
attribute (gateway.config.connectors_available), so tests patch it in
one place instead of two. _default_client lives in managed.py, the only
module that calls it.

tools/managed_tool_gateway.py and tools/managed_gateway_auth.py stay:
they are gateway identity shared by tts, transcription, image and modal.

Test files follow their modules. No docs referenced the old paths; no
compat pointer is added (in-tree moves get none).

* ci: retrigger (zero-job dispatch on 142466de6b)
2026-09-15 00:41:13 +05:30

624 lines
31 KiB
Python

"""Progressive tool disclosure ("tool search"): MCP/plugin tools and a curated set of
event-triggered core tools are replaced in the model-visible array by three bridge tools —
tool_search / tool_describe / tool_call. Invariants: core tools (``toolsets._HERMES_CORE_TOOLS``)
and session-gated GUI toolsets never defer unless named in ``defer``; ANY deferrable tool
activates the bridge (the listing scales with budget, not activation); the catalog is
stateless — rebuilt from the live tool-defs every assembly (a session-keyed one drifts and
silently drops tools); bridge calls route through ``model_tools.handle_function_call``."""
from __future__ import annotations
import functools
import json
import logging
import math
from collections import Counter
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple
from tools.registry import tool_error
from tools.tool_search_catalog import (
BRIDGE_TOOL_NAMES, CHARS_PER_TOKEN, TOOL_CALL_NAME, TOOL_DESCRIBE_NAME, TOOL_SEARCH_NAME,
CatalogEntry, _fn, _listing_group_label, _registry_entry, _registry_toolset,
build_catalog, build_catalog_listing_with_form, search_catalog)
from tools.tool_search_validation import normalize_tool_call_entries, validate_deferred_call_args
from tools.connectors import CONNECTOR_BATCH_SENTINEL, is_connector_name
from tools.connectors.search import connections_in_scope, connector_entries_by_group, remote_schemas_for
logger = logging.getLogger("tools.tool_search")
# Bound the work one bridge call requests. Search is capped at the gateway's
# own limit: the connector search route answers 7 use_cases per request and
# returns HTTP 502 for 8 or more (measured 2026-09-09), and one local call
# maps to one gateway request. Describe has no such remote limit.
_MAX_QUERIES_PER_CALL = 7
_MAX_DESCRIBE_NAMES_PER_CALL = 10
@dataclass(frozen=True)
class ToolSearchConfig:
"""Resolved, validated tool-search configuration for a single assembly."""
enabled: str # "auto" | "on" | "off" — "auto" is an alias of "on" today
# Listing budget as % of context; does NOT gate activation, only bounds how much
# the embedded manifest may consume before it degrades (full -> names -> bare).
threshold_pct: float # 0..100
search_default_limit: int
max_search_limit: int
listing: str = "auto" # "auto"/"on" = embed the manifest when it fits; "off" = bare bridge
listing_max_tokens: int = 4000 # budget = min(this, threshold_pct% of context)
# None = curated default; an explicit list replaces it wholesale ([] = defer no core tools).
defer_tools: Optional[frozenset] = None
@property
def effective_defer_tools(self) -> frozenset:
return _DEFAULT_DEFERRED_TOOLS if self.defer_tools is None else self.defer_tools
@classmethod
def from_raw(cls, raw: Any) -> "ToolSearchConfig":
"""Build from a raw dict / legacy bool / None; every field is clamped and unknown
values fall back to safe defaults — a config typo must not break the agent."""
if not isinstance(raw, dict): # legacy bool / None
raw = {"enabled": "off" if raw is False else "auto"}
max_search_limit = _clamped_int(raw.get("max_search_limit"), 25, 1, 50)
defer_raw = raw.get("defer")
return cls(
enabled=_tri_state(raw.get("enabled", "auto")),
threshold_pct=max(0.0, min(100.0, _safe_float(raw.get("threshold_pct"), 5.0))),
search_default_limit=_clamped_int(
raw.get("search_default_limit"), 5, 1, max_search_limit),
max_search_limit=max_search_limit,
listing=_tri_state(raw.get("listing", "auto")),
listing_max_tokens=_clamped_int(raw.get("listing_max_tokens"), 4000, 200, 60000),
defer_tools=(frozenset(str(n).strip() for n in defer_raw if str(n).strip())
if isinstance(defer_raw, (list, tuple, set)) else None))
_TRI_STATE_ALIASES = {"true": "on", "1": "on", "yes": "on", "false": "off", "0": "off", "no": "off"}
def _tri_state(value: Any) -> str:
"""Normalize an ``auto``/``on``/``off`` setting (bool-ish aliases accepted)."""
text = str(value).strip().lower()
return _TRI_STATE_ALIASES.get(text, text if text in ("auto", "on", "off") else "auto")
def _clamped_int(value: Any, fallback: int, lo: int, hi: int) -> int:
"""``int(value)`` (or ``fallback`` when unparseable) clamped to ``[lo, hi]``."""
try:
value = int(value)
except (TypeError, ValueError):
value = fallback
return max(lo, min(hi, value))
def _safe_float(value: Any, fallback: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return fallback
def _config_from_loader(loader_name: str) -> ToolSearchConfig:
"""Tool-search config via ``hermes_cli.config.<loader_name>`` (defaults on any failure)."""
try:
import hermes_cli.config as _cfg_mod
tools_cfg = (getattr(_cfg_mod, loader_name)() or {}).get("tools")
tools_cfg = tools_cfg if isinstance(tools_cfg, dict) else {}
return ToolSearchConfig.from_raw(tools_cfg.get("tool_search"))
except Exception as e:
logger.debug("Failed to load tool-search config: %s", e)
return ToolSearchConfig.from_raw(None)
load_config = functools.partial(_config_from_loader, "load_config")
load_config_readonly = functools.partial(_config_from_loader, "load_config_readonly") # no copy
def _core_tool_names() -> frozenset[str]:
"""Names that never defer by default (lazy: ``toolsets`` imports ``tools.registry``)."""
try:
from toolsets import _HERMES_CORE_TOOLS
return frozenset(_HERMES_CORE_TOOLS)
except Exception:
return frozenset()
# Session-gated GUI toolsets: off ``_HERMES_CORE_TOOLS`` so non-GUI clients never pay
# their schema; once enabled they stay direct unless the deferral list names them.
_DIRECT_SURFACE_TOOLSETS = frozenset({"desktop_ui", "project"})
# Event-triggered core tools deferred BY DEFAULT (a catalog stub suffices); the ``defer``
# config replaces this wholesale ([] = everything eager). POST-rename names. ``clarify``
# is deliberately absent: A/B showed deferring it collapsed structured-clarify usage
# (18/18 -> 7/18) — the ask-the-user affordance must be ambient, a stub is not enough.
_DEFAULT_DEFERRED_TOOLS = frozenset({
"computer_use", "session_search", "image_generate",
"todo_list", "process_manage", "cronjob_manage",
# Desktop GUI surface (desktop_ui + project toolsets)
"drive_preview", "gui_tour", "desktop_preview", "annotate_preview",
"show_tip", "desktop_project", "close_terminal",
"apply_layout", "read_terminal", "read_window_below", "focus_pane"})
def is_deferrable_tool_name(name: str, defer_tools: Optional[frozenset] = None) -> bool:
"""True if a tool is *eligible* for deferral: named in ``defer_tools`` (curated set or
user override), OR an MCP tool, OR neither core nor a session-gated GUI surface (i.e. a
plugin tool). Bridge names never defer."""
if name in BRIDGE_TOOL_NAMES:
return False
if defer_tools is not None and name in defer_tools:
return True
if name in _core_tool_names():
return False
toolset = _registry_toolset(name) # None (unregistered/malformed) never defers
return toolset is not None and (
toolset.startswith("mcp-") or toolset not in _DIRECT_SURFACE_TOOLSETS)
def _tool_def_names(tool_defs: Iterable[Dict[str, Any]]) -> Iterable[str]:
"""Function names of a tool-defs list (``""`` for a nameless def)."""
return (_fn(td).get("name", "") for td in tool_defs)
def classify_tools(tool_defs: List[Dict[str, Any]], defer_tools: Optional[frozenset] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Split a tool-defs list into (visible, deferrable); bridge tools are dropped (re-added
after classification)."""
visible: List[Dict[str, Any]] = []
deferrable: List[Dict[str, Any]] = []
for td, name in zip(tool_defs, _tool_def_names(tool_defs)):
if name not in BRIDGE_TOOL_NAMES:
(deferrable if is_deferrable_tool_name(name, defer_tools) else visible).append(td)
return visible, deferrable
def _deferrable_in(tool_defs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Deferrable subset of pre-assembly ``tool_defs`` under the read-only user config."""
return classify_tools(tool_defs, load_config_readonly().effective_defer_tools)[1]
def estimate_tokens_from_schemas(tool_defs: Iterable[Dict[str, Any]]) -> int:
"""Token cost via the chars/4 rule (order-of-magnitude precision suffices)."""
def _chars(td: Dict[str, Any]) -> int:
try:
return len(json.dumps(td, ensure_ascii=False, separators=(",", ":")))
except (TypeError, ValueError):
return len(str(td))
return int(math.ceil(sum(map(_chars, tool_defs)) / CHARS_PER_TOKEN))
def should_activate(config: ToolSearchConfig, deferrable_tokens: int,
context_length: Optional[int], *, connections_granted: bool = False) -> bool:
"""``"off"`` never activates; ``"on"``/``"auto"`` activate whenever any deferrable tool
exists ("auto" is reserved for a future budget-gated mode — do not distinguish them
without that design). ``context_length`` is kept for caller compatibility."""
if config.enabled == "off":
return False
if deferrable_tokens > 0:
return True
return connections_granted
def listing_token_budget(config: ToolSearchConfig, context_length: Optional[int]) -> int:
"""``min(listing_max_tokens, threshold_pct% of context)``; unknown context uses a 10K
percentage leg (5% of a typical 200K window)."""
pct_leg = (int(context_length * (config.threshold_pct / 100.0))
if context_length and context_length > 0 else 10_000)
return max(0, min(config.listing_max_tokens, pct_leg))
def _bridge_schema(name: str, description: str, properties: Dict[str, Any],
required: List[str]) -> Dict[str, Any]:
"""One OpenAI-style function schema (key order is part of the frozen bytes)."""
return {"type": "function", "function": {
"name": name, "description": description,
"parameters": {"type": "object", "properties": properties, "required": required}}}
_CONNECTIONS_HINT = (
" Names starting with `connectors__` are tools of remote connector accounts "
"(Gmail, Linear, Notion, ...); `manage_connections` checks whether an account "
"is connected and gets the authorization link when it is not.")
def _search_description(deferred_count: int, listing: Optional[str], listing_form: str,
connections_granted: bool = False) -> str:
"""tool_search bridge description with the listing embedded (framing per ``listing_form``).
``connections_granted`` adds the one sentence that ties ``connectors__`` names to
``manage_connections``; without that tool in the session the sentence would name a tool
the model cannot call."""
desc = (
(f"Search {deferred_count} additional tools that are loaded on demand. "
if deferred_count else "Search remote connector tools (email, calendars, issue trackers, and more). ")
+ "Takes a list of queries searched in parallel against the same "
"catalog; send one query per distinct capability you need. Returns "
"matching tool names grouped per query plus a shared map with each "
"tool's description. Follow with "
f"`{TOOL_DESCRIBE_NAME}` to load full parameter schemas, "
f"then `{TOOL_CALL_NAME}` to invoke. Tools listed at the top of this "
"system prompt are already available and do not need to be searched."
+ (_CONNECTIONS_HINT if connections_granted else ""))
if not listing:
return desc
if listing_form == "groups":
return desc + (
"\n\nThe servers below are connected and their tools ARE available "
"through this bridge. For any request in these domains, search "
"here FIRST — do not claim the capability is unavailable and do "
"not substitute a generic tool (terminal/browser) without "
"searching.\n\n" + listing)
desc += (
"\n\nEvery deferred capability is listed below. If a tool name "
"appears here, do NOT claim it is unavailable — load it with "
f"`{TOOL_DESCRIBE_NAME}` (skip `{TOOL_SEARCH_NAME}` when you "
"already see the exact name).")
if listing_form == "mixed":
desc += (
" For servers marked 'names not listed', the tools exist "
f"too — find them with `{TOOL_SEARCH_NAME}` before "
"concluding anything is missing.")
return desc + "\n\n" + listing
def bridge_tool_schemas(deferred_count: int, listing: Optional[str] = None,
listing_form: str = "", connections_granted: bool = False) -> List[Dict[str, Any]]:
"""Bridge tool schemas injected in place of deferred tools; kept short — every byte is paid
every turn. ``listing`` is embedded in the tool_search description; per-tool forms say
"skip search when you see the exact name", "groups" says search is mandatory."""
return [
_bridge_schema(
TOOL_SEARCH_NAME,
_search_description(deferred_count, listing, listing_form, connections_granted),
{
"queries": {
"type": "array",
"items": {"type": "string"},
"description": "Search queries, each a few keywords describing one capability (e.g. ['create github issue', 'send slack message']). Searched in parallel; results come back grouped per query. A single string is accepted and treated as one query.",
},
"limit": {
"type": "integer",
"description": "Maximum number of matches per query. Defaults to 5 and is clamped to the configured maximum (25 by default).",
},
},
["queries"],
),
_bridge_schema(
TOOL_DESCRIBE_NAME,
f"Load the full JSON schemas for tools returned by `{TOOL_SEARCH_NAME}`. "
f"Required before `{TOOL_CALL_NAME}` if a tool's parameters are unknown. "
"Batch every schema you need into one call.",
{
"names": {
"type": "array",
"items": {"type": "string"},
"description": "Exact tool names (as returned by tool_search). A single string is accepted and treated as one name.",
},
},
["names"],
),
_bridge_schema(
TOOL_CALL_NAME,
"Invoke deferred tools. Takes `calls`, an array of {name, arguments} "
"— one entry per invocation; a single call is an array of one. "
"Local tools require one entry per tool_call. Only connectors__ names "
"may be batched together; mixed and multi-local batches are rejected. "
"Connector entries execute individually with results in input order. "
f"Argument shapes match each tool's schema (see `{TOOL_DESCRIBE_NAME}`). "
"Policy, hooks, and approvals run as for directly-listed tools.",
{
"calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Exact tool name to invoke."},
"arguments": {"type": "object", "description": "Arguments matching the tool schema."},
},
"required": ["name", "arguments"],
},
"description": "One local invocation, or one or more connector invocations. Never mix local and connector tools.",
},
},
["calls"],
),
]
@dataclass
class AssemblyResult:
"""Outcome of one assembly (tests and observability)."""
tool_defs: List[Dict[str, Any]]
activated: bool
deferred_count: int = 0
deferred_tokens: int = 0
threshold_tokens: int = 0
# 0 = passthrough; 1 = bridge + per-tool listing; 2 = bare bridge / server summary only.
tier: int = 0
listing_form: str = "none" # "full" | "names" | "mixed" | "groups" | "none"
def assemble_tool_defs(tool_defs: List[Dict[str, Any]], *, context_length: Optional[int] = None,
config: Optional[ToolSearchConfig] = None) -> AssemblyResult:
"""Tool-defs the model should see: passthrough when inactive, else deferrable tools
replaced by the three bridge tools. Idempotent — existing bridge tools are stripped first."""
config = config or load_config()
incoming = [td for td, name in zip(tool_defs, _tool_def_names(tool_defs))
if name not in BRIDGE_TOOL_NAMES]
visible, deferrable = classify_tools(incoming, config.effective_defer_tools)
connections_granted = connections_in_scope(incoming)
if not deferrable:
if should_activate(config, 0, context_length, connections_granted=connections_granted):
return AssemblyResult(tool_defs=incoming + bridge_tool_schemas(0, connections_granted=connections_granted),
activated=True, tier=2)
return AssemblyResult(tool_defs=incoming, activated=False)
deferrable_tokens = estimate_tokens_from_schemas(deferrable)
if not should_activate(config, deferrable_tokens, context_length):
return AssemblyResult(
tool_defs=incoming, activated=False, deferred_count=len(deferrable),
deferred_tokens=deferrable_tokens,
threshold_tokens=int((context_length or 0) * (config.threshold_pct / 100.0)), tier=0)
listing, listing_form = None, "none"
listing_budget = listing_token_budget(config, context_length)
if config.listing != "off":
listing, listing_form = build_catalog_listing_with_form(
deferrable, max_tokens=listing_budget)
bridge = bridge_tool_schemas(len(deferrable), listing=listing, listing_form=listing_form,
connections_granted=connections_granted)
tier = 1 if listing_form in ("full", "names", "mixed") else 2
logger.info(
"tool_search activated (tier %d): %d core/visible tools kept, %d deferred "
"(~%d tokens), listing %s (budget ~%d tokens)",
tier, len(visible), len(deferrable), deferrable_tokens, listing_form, listing_budget)
return AssemblyResult(
tool_defs=visible + bridge, activated=True, deferred_count=len(deferrable),
deferred_tokens=deferrable_tokens, threshold_tokens=listing_budget,
tier=tier, listing_form=listing_form)
def is_bridge_tool(name: str) -> bool:
return name in BRIDGE_TOOL_NAMES
def _clip_description(text: str, cap: int = 500) -> str:
"""Cap a record description, marking the cut so it reads as deliberate.
A bare slice ends mid-word ("apply exponential bac") and looks like
corruption; the ellipsis says "there is more — tool_describe has it".
500 keeps 9 in 10 vendor connector descriptions whole and every first
sentence (measured p90 575, first-sentence max 329 over 353 tools).
"""
return text if len(text) <= cap else text[:cap] + "…"
def _shared_tool_record(entry: CatalogEntry) -> Dict[str, Any]:
"""One record for the shared ``tools`` map (per-query groups carry names only);
``required`` lets the model attempt a trivial call without a ``tool_describe`` round-trip."""
try:
required = entry.schema["function"]["parameters"]["required"]
except (TypeError, KeyError, AttributeError):
required = []
return {"source": entry.source, "source_name": entry.source_name,
"description": _clip_description(entry.description or ""),
"required": [r[:64] for r in (required if isinstance(required, list) else [])
if isinstance(r, str)][:32]}
def _available_source_summary(catalog: List[CatalogEntry]) -> List[Dict[str, Any]]:
"""Deterministic ``[{name, tool_count}]`` of connected sources (attached to empty query
groups so a lexical miss is not read as a missing capability)."""
counts = Counter(_listing_group_label(entry.source_name) for entry in catalog)
return [{"name": name, "tool_count": counts[name]} for name in sorted(counts)]
def _string_list_arg(args: Dict[str, Any], key: str, *, dedupe: bool, max_items: int,
retry_hint: str) -> Tuple[Optional[List[str]], Optional[str]]:
"""Read a list-of-strings bridge argument -> ``(items, error_json)``. A bare string (a
common model slip) is a one-item list; rejects non-lists, all-blank lists, > ``max_items``."""
raw = args.get(key)
raw = [raw] if isinstance(raw, str) else raw
if not isinstance(raw, list):
return None, tool_error(f"{key} is required and must be an array of strings")
out: List[str] = []
for item in raw:
text = str(item or "").strip()
if text and (not dedupe or text not in out):
out.append(text)
if not out:
return None, tool_error(f"{key} is required and must contain at least one non-empty string")
if len(out) > max_items:
return None, tool_error(f"too many {key}: {len(out)} > max {max_items}. {retry_hint}")
return out, None
def dispatch_tool_search(args: Dict[str, Any], *, current_tool_defs: List[Dict[str, Any]],
config: Optional[ToolSearchConfig] = None,
connector_search: Optional[Any] = None) -> str:
"""Execute the ``tool_search`` bridge tool -> JSON ``{queries, total_available,
results: [{query, matches: [names]}], tools: {name: {source, source_name, description,
required}}}``. ``limit`` is the total PER QUERY across local and connector tools: the
gateway's hits for a query join the local catalog as documents and one BM25 pass ranks
them together, so a connector tool that answers the query is never starved by local
tools that share one word with it. Empty groups get ``available_sources`` + ``hint`` so
a lexical miss is not mistaken for a missing capability."""
config = config or load_config()
queries, err = _string_list_arg(args, "queries", dedupe=False, max_items=_MAX_QUERIES_PER_CALL,
retry_hint="Retry with fewer, more targeted queries.")
if err:
return err
raw_limit = args.get("limit")
limit = (config.search_default_limit if raw_limit is None
else _clamped_int(raw_limit, config.search_default_limit, 1, config.max_search_limit))
catalog = build_catalog(_deferrable_in(current_tool_defs))
remote_entries: List[List[CatalogEntry]] = [[] for _ in queries]
if connections_in_scope(current_tool_defs):
remote_entries = connector_entries_by_group(queries, connector_search=connector_search)
results: List[Dict[str, Any]] = []
tools_map: Dict[str, Dict[str, Any]] = {}
available_sources = _available_source_summary(catalog) if catalog else []
for position, query in enumerate(queries):
corpus = catalog + remote_entries[position]
hits = search_catalog(corpus, query, limit=limit)
for h in hits:
tools_map.setdefault(h.name, _shared_tool_record(h))
matches = [h.name for h in hits]
group: Dict[str, Any] = {"query": query, "matches": matches}
if not matches and catalog:
group["available_sources"] = available_sources
group["hint"] = (
"This query returned no lexical matches, but the sources above "
"are connected and their tools remain available. Retry "
"tool_search with the service name plus a concrete action or "
"object before concluding the capability is unavailable.")
results.append(group)
remote_count = sum(1 for name in tools_map if is_connector_name(name))
return json.dumps({"queries": queries, "total_available": len(catalog) + remote_count, "results": results,
"tools": tools_map}, ensure_ascii=False)
def dispatch_tool_describe(args: Dict[str, Any], *, current_tool_defs: List[Dict[str, Any]],
config: Optional[ToolSearchConfig] = None,
connector_describe: Optional[Any] = None) -> str:
"""Execute the ``tool_describe`` bridge tool -> JSON ``{tools: {name: {description,
parameters}}, not_found: [...] (unknown / not in this assembly; never fails the call),
errors: {name: msg} (registered but non-deferrable)}``. Duplicates dedupe silently."""
config = config or load_config_readonly()
names, err = _string_list_arg(
args, "names", dedupe=True, max_items=_MAX_DESCRIBE_NAMES_PER_CALL,
retry_hint="Retry with fewer names per call.")
if err:
return err
deferrable = _deferrable_in(current_tool_defs)
by_name = {name: _fn(td) for td, name in zip(deferrable, _tool_def_names(deferrable)) if name}
remote_schemas = remote_schemas_for(names, current_tool_defs, connector_describe)
tools: Dict[str, Dict[str, Any]] = {}
not_found: List[str] = []
errors: Dict[str, str] = {}
for name in names:
fn = by_name.get(name)
remote_fn = remote_schemas.get(name)
if fn is not None:
tools[name] = {"description": fn.get("description", ""),
"parameters": fn.get("parameters", {})}
elif isinstance(remote_fn, dict):
tools[name] = {"description": str(remote_fn.get("description", "")),
"parameters": remote_fn.get("parameters", {})}
elif is_connector_name(name):
not_found.append(name)
elif _registry_entry(name) is not None and not is_deferrable_tool_name(
name, load_config_readonly().effective_defer_tools):
# Registered but bridge/core/GUI-surface: a real name, wrong door.
errors[name] = (
f"'{name}' is not a deferrable tool. If you see it in the tools list "
"already, call it directly; otherwise check the spelling against tool_search.")
else:
not_found.append(name)
result: Dict[str, Any] = {"tools": tools}
if not_found:
result["not_found"] = not_found
result["hint"] = "Names in not_found are not currently available. Re-run tool_search to refresh."
if errors:
result["errors"] = errors
return json.dumps(result, ensure_ascii=False)
def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]:
"""Deferrable names in the *pre-assembly* ``tool_defs`` of the session scope — the
universe ``tool_call`` may reach. Gates bridge dispatch AND the executor unwrap so a
restricted session cannot invoke an out-of-scope tool via the bridge."""
defer_tools = load_config_readonly().effective_defer_tools
return frozenset(n for n in _tool_def_names(tool_defs)
if n and is_deferrable_tool_name(n, defer_tools))
def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]:
"""Parse a ``tool_call`` invocation into (underlying_name, args, error_msg).
Used by:
* the dispatcher in ``model_tools.handle_function_call``,
* the display layer (so the activity feed shows the underlying tool),
* the trajectory recorder.
A connector-only batch resolves
to ``(CONNECTOR_BATCH_SENTINEL, {"calls": [...]}, None)``: the batch is
one dispatch unit owned by the ``model_tools`` bridge branch, and the
sentinel is what planners/display layers see. A single local entry keeps
the historical single-tool contract unchanged.
On parse error, returns ``(None, {}, error_message)``.
"""
entries, err = normalize_tool_call_entries(args)
if err:
return None, {}, err
if len(entries) > 1 and any(not is_connector_name(e["name"]) for e in entries):
return None, {}, (
"Local tools require one entry per tool_call; mixed and multi-local batches are not supported."
)
if is_connector_name(entries[0]["name"]):
return CONNECTOR_BATCH_SENTINEL, {"calls": entries}, None
name = entries[0]["name"]
raw_args = entries[0]["arguments"]
if not is_deferrable_tool_name(name, load_config_readonly().effective_defer_tools):
return None, {}, (
f"'{name}' is not a deferrable tool. If it appears in the model-facing tools "
"list already, call it directly instead of via tool_call."
)
return name, raw_args, None
__all__ = [
"TOOL_SEARCH_NAME", "TOOL_DESCRIBE_NAME", "TOOL_CALL_NAME", "BRIDGE_TOOL_NAMES",
"ToolSearchConfig", "CatalogEntry", "AssemblyResult", "load_config", "is_deferrable_tool_name",
"classify_tools", "estimate_tokens_from_schemas", "should_activate", "build_catalog",
"build_catalog_listing_with_form", "listing_token_budget", "search_catalog",
"bridge_tool_schemas", "assemble_tool_defs", "is_bridge_tool", "dispatch_tool_search",
"dispatch_tool_describe", "resolve_underlying_call", "scoped_deferrable_names",
"validate_deferred_call_args", "normalize_tool_call_entries",
"CONNECTOR_BATCH_SENTINEL", "is_connector_name"]
# ---- 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.
from typing import Literal # noqa: F401,E402
import copy # noqa: F401,E402
from dataclasses import field # noqa: F401,E402
import re # noqa: F401,E402
import snowballstemmer # noqa: F401,E402
import threading # noqa: F401,E402
def build_catalog_listing(
deferrable: List[Dict[str, Any]],
*,
max_tokens: int = 4000,
) -> Optional[str]:
"""Render a skills-style manifest of the deferred catalog.
One line per tool — ``name: short description`` — grouped under a
heading per source (MCP server / plugin toolset), exactly like the
bundled-skills listing in the system prompt:
github tools: (44)
- create_issue: Open a new issue in a GitHub repository.
- merge_pull_request: Merge an open pull request.
...
Ordering is deterministic (groups and tools sorted by name) so the
rendered block is byte-stable across assemblies of the same catalog —
this keeps the request prefix cacheable across turns.
Token-budget fallbacks (cheap chars/4 estimate, same rule as the
activation gate):
1. full listing (names + short descriptions)
2. names-only listing, still grouped
3. server-level summary — one line per MCP server / plugin toolset
(name + tool count), so the model always knows WHICH domains are
reachable through the bridge even when per-tool names don't fit
4. ``None`` — only when the summary itself exceeds the budget
"""
text, _form = build_catalog_listing_with_form(deferrable, max_tokens=max_tokens)
return text
# ---- END PLUGIN-COMPAT ----