feat(tool-search): multi-query search, batched describe, Snowball stemming

tool_search now takes queries: string[] (searched independently against
the same catalog, limit applies per query, default 5 / max 25) and
returns the split shape: per-query groups carry tool names only, one
shared tools map holds each matched tool's source, description (400-char
cap) and required parameter names once. When some queries miss, a single
top-level available_sources + hint block replaces the old per-response
fallback.

tool_describe now takes names: string[] and returns a map keyed by name;
unknown names collect in not_found (with the refresh hint) and
non-deferrable names keep their per-name spelling-check error in errors,
so one bad name no longer fails the whole call. Duplicates dedupe
silently.

The shared tokenizer now applies Snowball stemming (english, exact-pinned
snowballstemmer) at both index and query time, closing the measured
plural/singular miss where 'issues' failed to return create_issue. The
inline BM25 is unchanged. Stemmer instances are thread-local (they carry
mutable parse state and bridge dispatch can run on parallel tool-call
threads).

New config knobs under tools.tool_search: max_queries / max_describe_names
(default 10 each, floor 1, no upper clamp) bound the per-call array
inputs; over-cap calls error so the model repairs in one round-trip.

No backward compatibility with the single query/name shapes, by decision.
scripts/analyze_livetest.py renders both shapes since transcripts on disk
may predate this change.
This commit is contained in:
alt-glitch
2026-08-23 12:35:31 +05:30
committed by Teknium
parent 250faa69fb
commit e455e4afd0
9 changed files with 583 additions and 77 deletions
+9 -3
View File
@@ -2843,10 +2843,16 @@ DEFAULT_CONFIG = {
# listing_max_tokens). Range 0..100.
"threshold_pct": 5,
# When the model calls tool_search without a ``limit`` argument,
# how many hits to return. Range 1..max_search_limit.
# how many hits to return PER QUERY. Range 1..max_search_limit.
"search_default_limit": 5,
# Hard upper bound the model can request via ``limit``. Range 1..50.
"max_search_limit": 20,
# Hard upper bound the model can request via ``limit`` (per
# query). Range 1..50.
"max_search_limit": 25,
# Max queries per tool_search call / names per tool_describe
# call. Over-cap calls error and the model retries with fewer.
# Floor 1, no upper clamp.
"max_queries": 10,
"max_describe_names": 10,
# Skills-style catalog listing embedded in the tool_search bridge
# description: every deferred tool's name + first sentence of its
# description (≤60 chars), grouped by MCP server / toolset. Keeps
+5
View File
@@ -57,6 +57,11 @@ dependencies = [
"prompt_toolkit==3.0.52",
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
"croniter==6.0.0",
# Snowball stemming for tool_search's BM25 (tools/tool_search.py) —
# official Snowball project package, pure Python, zero transitive deps.
# Applied at index AND query time so morphological variants match
# ("issues" finds create_issue).
"snowballstemmer==3.1.1",
# ``packaging`` is imported directly on three production paths but was never
# declared, so it only reached users transitively (pip/uv pull it for other
# tools). The slim official Docker image ships without it, where the
+12 -2
View File
@@ -38,10 +38,20 @@ def fmt_bridge_seq(calls):
inner = (c.get("args") or {}).get("name", "?")
parts.append(f"tool_call→{inner}")
elif c["name"] == "tool_search":
q = (c.get("args") or {}).get("query", "?")
args = c.get("args") or {}
qs = args.get("queries")
if isinstance(qs, list):
q = "; ".join(str(x) for x in qs)
else: # legacy single-query transcripts
q = str(args.get("query", "?"))
parts.append(f"search('{q[:30]}')")
elif c["name"] == "tool_describe":
n = (c.get("args") or {}).get("name", "?")
args = c.get("args") or {}
ns = args.get("names")
if isinstance(ns, list):
n = ", ".join(str(x) for x in ns)
else: # legacy single-name transcripts
n = str(args.get("name", "?"))
parts.append(f"describe({n})")
return " → ".join(parts)
@@ -32,10 +32,10 @@ def test_a2a_call_schema_round_trips_through_tool_describe(monkeypatch):
described = json.loads(
tool_search.dispatch_tool_describe(
{"name": "a2a_call"},
{"names": ["a2a_call"]},
current_tool_defs=definitions,
)
)
)["tools"]["a2a_call"]
assert described["description"]
assert described["parameters"]["required"] == ["agent", "message"]
+25 -10
View File
@@ -287,11 +287,24 @@ class TestAssembly:
class TestBridgeDispatch:
def test_tool_search_requires_query(self):
def test_tool_search_requires_queries(self):
from tools.tool_search import dispatch_tool_search
result = dispatch_tool_search({}, current_tool_defs=[])
assert "error" in json.loads(result)
def test_tool_search_rejects_empty_and_overcap_queries(self):
from tools.tool_search import dispatch_tool_search, ToolSearchConfig
cfg = ToolSearchConfig.from_raw({})
assert "error" in json.loads(dispatch_tool_search(
{"queries": []}, current_tool_defs=[], config=cfg))
assert "error" in json.loads(dispatch_tool_search(
{"queries": [" ", ""]}, current_tool_defs=[], config=cfg))
over = ["q"] * (cfg.max_queries + 1)
parsed = json.loads(dispatch_tool_search(
{"queries": over}, current_tool_defs=[], config=cfg))
assert "error" in parsed
assert "too many queries" in parsed["error"]
def test_empty_search_keeps_connected_sources_discoverable(self):
from tools.registry import registry
from tools.tool_search import dispatch_tool_search
@@ -306,11 +319,12 @@ class TestBridgeDispatch:
)
result = json.loads(dispatch_tool_search(
{"query": "unrelated vocabulary"},
{"queries": ["unrelated vocabulary"]},
current_tool_defs=[tool_def],
))
assert result["matches"] == []
assert result["results"] == [{"query": "unrelated vocabulary", "matches": []}]
assert result["tools"] == {}
assert result["total_available"] == 1
assert result["available_sources"] == [
{"name": "recovery-catalog", "tool_count": 1},
@@ -351,12 +365,12 @@ class TestHandleFunctionCallIntegration:
import model_tools
result = model_tools.handle_function_call(
function_name="tool_search",
function_args={"query": "nothing matches this"},
function_args={"queries": ["nothing matches this"]},
)
parsed = json.loads(result)
# Without a real registry, the matches will be empty, but the
# dispatch path completed without error.
assert "matches" in parsed or "error" in parsed
assert "results" in parsed or "error" in parsed
def test_tool_search_emits_one_terminal_hook(self, monkeypatch):
"""Inline bridge results still complete the tool lifecycle."""
@@ -378,12 +392,12 @@ class TestHandleFunctionCallIntegration:
monkeypatch.setattr(
tool_search,
"dispatch_tool_search",
lambda *args, **kwargs: json.dumps({"matches": []}),
lambda *args, **kwargs: json.dumps({"results": []}),
)
result = model_tools.handle_function_call(
function_name="tool_search",
function_args={"query": "private-query"},
function_args={"queries": ["private-query"]},
session_id="private-session",
task_id="private-task",
turn_id="private-turn",
@@ -391,7 +405,7 @@ class TestHandleFunctionCallIntegration:
tool_call_id="private-call",
)
assert json.loads(result) == {"matches": []}
assert json.loads(result) == {"results": []}
assert len(events) == 1
hook_name, payload = events[0]
assert hook_name == "post_tool_call"
@@ -494,7 +508,7 @@ class TestRegression_ToolsetScoping:
# out-of-scope plugin tool (or any of the host registry).
result = model_tools.handle_function_call(
function_name="tool_search",
function_args={"query": "mcp_scoped_gh", "limit": 5},
function_args={"queries": ["mcp_scoped_gh"], "limit": 5},
enabled_toolsets=["mcp-scoped-gh"],
)
parsed = json.loads(result)
@@ -502,7 +516,8 @@ class TestRegression_ToolsetScoping:
f"expected scoped catalog of 12, got {parsed['total_available']} "
"— catalog leaked tools outside the session's toolsets"
)
hit_names = {m["name"] for m in parsed["matches"]}
hit_names = set(parsed["tools"])
assert hit_names == {n for g in parsed["results"] for n in g["matches"]}
assert "scoped_oos_plugin" not in hit_names
+289
View File
@@ -0,0 +1,289 @@
"""Multi-query ``tool_search``, batched ``tool_describe``, and stemming.
Covers the upgrade that replaced the single ``query`` string with
``queries: [str, ...]`` (grouped, split-shape response), the single
``name`` with ``names: [str, ...]`` (map response with ``not_found``),
and added Snowball stemming to the shared tokenizer.
"""
import json
import pytest
def _td(name, desc, props=None, required=None):
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": props or {},
"required": required or [],
},
},
}
def _register(name, toolset, desc="Deferred capability.", props=None, required=None):
from tools.registry import registry
registry.register(
name=name,
handler=lambda args, **kw: json.dumps({"ok": True}),
schema=_td(name, desc, props, required),
toolset=toolset,
)
return _td(name, desc, props, required)
@pytest.fixture
def issue_defs():
"""A small deferred catalog registered under an MCP toolset."""
return [
_register("mq_linear_create_issue", "mcp-mq-linear",
"Create a new issue in a team.",
{"title": {"type": "string"}, "team": {"type": "string"}},
["title", "team"]),
_register("mq_linear_list_issues", "mcp-mq-linear",
"List issues in the workspace.",
{"query": {"type": "string"}}),
_register("mq_slack_post_message", "mcp-mq-slack",
"Post a message to a channel.",
{"channel": {"type": "string"}, "text": {"type": "string"}},
["channel", "text"]),
]
# ---------------------------------------------------------------------------
# Stemming
# ---------------------------------------------------------------------------
class TestStemming:
def test_tokenize_stems_index_and_query_identically(self):
from tools.tool_search import _tokenize
# Same stem on both sides is the whole contract.
assert _tokenize("issues") == _tokenize("issue")
assert _tokenize("creating messages") == _tokenize("create message")
def test_plural_query_finds_singular_tool_name(self, issue_defs):
"""The measured miss on the old tokenizer: 'issues' skipped create_issue."""
from tools.tool_search import build_catalog, search_catalog
catalog = build_catalog(issue_defs)
names = [h.name for h in search_catalog(catalog, "issues", limit=5)]
assert "mq_linear_create_issue" in names
assert "mq_linear_list_issues" in names
def test_substring_fallback_still_uses_raw_name(self, issue_defs):
"""Fallback matches the unstemmed tool name, unchanged by stemming."""
from tools.tool_search import build_catalog, search_catalog
catalog = build_catalog(issue_defs)
names = [h.name for h in search_catalog(catalog, "post_mess", limit=5)]
assert names == ["mq_slack_post_message"]
# ---------------------------------------------------------------------------
# Multi-query dispatch_tool_search
# ---------------------------------------------------------------------------
class TestMultiQuerySearch:
def test_grouped_names_plus_shared_tool_map(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_search
result = json.loads(dispatch_tool_search(
{"queries": ["create linear issue", "post slack message"]},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert result["queries"] == ["create linear issue", "post slack message"]
assert result["total_available"] == 3
# Groups carry NAMES only, in query order.
assert [g["query"] for g in result["results"]] == result["queries"]
for group in result["results"]:
for name in group["matches"]:
assert isinstance(name, str)
assert "mq_linear_create_issue" in result["results"][0]["matches"]
assert "mq_slack_post_message" in result["results"][1]["matches"]
# The shared map holds each matched tool exactly once, and nothing else.
matched = {n for g in result["results"] for n in g["matches"]}
assert set(result["tools"]) == matched
record = result["tools"]["mq_linear_create_issue"]
assert record["source"] == "mcp"
assert record["source_name"] == "mcp-mq-linear"
assert record["description"].startswith("Create a new issue")
assert record["required"] == ["title", "team"]
# All queries matched → no fallback block.
assert "available_sources" not in result
assert "hint" not in result
def test_limit_applies_per_query(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_search
result = json.loads(dispatch_tool_search(
{"queries": ["issues", "message"], "limit": 1},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
for group in result["results"]:
assert len(group["matches"]) <= 1
def test_partial_miss_adds_single_toplevel_fallback(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_search
result = json.loads(dispatch_tool_search(
{"queries": ["issues", "zzzz nonsense qqqq"]},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert result["results"][1]["matches"] == []
# ONE fallback block at top level — not per empty group.
assert "available_sources" in result
assert "Some queries" in result["hint"]
for group in result["results"]:
assert "available_sources" not in group
assert "hint" not in group
source_names = {s["name"] for s in result["available_sources"]}
assert {"mq-linear", "mq-slack"} <= source_names
def test_bare_string_query_coerced_to_single_query(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_search
result = json.loads(dispatch_tool_search(
{"queries": "post slack message"},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert result["queries"] == ["post slack message"]
def test_max_queries_config_respected(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_search
cfg = ToolSearchConfig.from_raw({"max_queries": 2})
ok = json.loads(dispatch_tool_search(
{"queries": ["a b", "c d"]}, current_tool_defs=issue_defs, config=cfg))
assert "error" not in ok
over = json.loads(dispatch_tool_search(
{"queries": ["a", "b", "c"]}, current_tool_defs=issue_defs, config=cfg))
assert "too many queries" in over["error"]
# ---------------------------------------------------------------------------
# Batched dispatch_tool_describe
# ---------------------------------------------------------------------------
class TestBatchedDescribe:
def test_map_response_with_not_found(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_describe
# Deferrable in the global registry, but NOT in this session's defs —
# the stale/out-of-scope case that lands in not_found.
_register("mq_out_of_scope_op", "mcp-mq-elsewhere")
result = json.loads(dispatch_tool_describe(
{"names": ["mq_linear_create_issue", "mq_slack_post_message",
"mq_out_of_scope_op", "mcp__bogus__missing"]},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert set(result["tools"]) == {"mq_linear_create_issue",
"mq_slack_post_message"}
schema = result["tools"]["mq_linear_create_issue"]
assert schema["description"] == "Create a new issue in a team."
assert schema["parameters"]["required"] == ["title", "team"]
# Deferrable-but-absent collects in not_found; found ones still resolve.
assert result["not_found"] == ["mq_out_of_scope_op"]
assert "tool_search" in result["hint"]
# A name unknown to the registry keeps the spelling-check error.
assert "not a deferrable tool" in result["errors"]["mcp__bogus__missing"]
def test_non_deferrable_name_keeps_per_name_error(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_describe
result = json.loads(dispatch_tool_describe(
{"names": ["terminal", "mq_linear_create_issue"]},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert "mq_linear_create_issue" in result["tools"]
assert "not a deferrable tool" in result["errors"]["terminal"]
def test_duplicates_deduped_silently(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_describe
result = json.loads(dispatch_tool_describe(
{"names": ["mq_linear_create_issue", "mq_linear_create_issue"]},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert list(result["tools"]) == ["mq_linear_create_issue"]
assert "not_found" not in result
def test_empty_and_overcap_names_error(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_describe
cfg = ToolSearchConfig.from_raw({})
assert "error" in json.loads(dispatch_tool_describe(
{}, current_tool_defs=issue_defs, config=cfg))
assert "error" in json.loads(dispatch_tool_describe(
{"names": []}, current_tool_defs=issue_defs, config=cfg))
over = ["n%d" % i for i in range(cfg.max_describe_names + 1)]
parsed = json.loads(dispatch_tool_describe(
{"names": over}, current_tool_defs=issue_defs, config=cfg))
assert "too many names" in parsed["error"]
def test_bare_string_name_coerced(self, issue_defs):
from tools.tool_search import ToolSearchConfig, dispatch_tool_describe
result = json.loads(dispatch_tool_describe(
{"names": "mq_linear_create_issue"},
current_tool_defs=issue_defs,
config=ToolSearchConfig.from_raw({}),
))
assert "mq_linear_create_issue" in result["tools"]
# ---------------------------------------------------------------------------
# Config + bridge schema
# ---------------------------------------------------------------------------
class TestConfigAndSchema:
def test_new_caps_default_and_parse(self):
from tools.tool_search import ToolSearchConfig
cfg = ToolSearchConfig.from_raw(None)
assert cfg.max_queries >= 1
assert cfg.max_describe_names >= 1
# Operator-tunable with no upper clamp; floored at 1.
big = ToolSearchConfig.from_raw({"max_queries": 500,
"max_describe_names": 500})
assert big.max_queries == 500
assert big.max_describe_names == 500
floored = ToolSearchConfig.from_raw({"max_queries": 0,
"max_describe_names": -3})
assert floored.max_queries == 1
assert floored.max_describe_names == 1
def test_limit_default_within_cap(self):
from tools.tool_search import ToolSearchConfig
cfg = ToolSearchConfig.from_raw({})
assert 1 <= cfg.search_default_limit <= cfg.max_search_limit <= 50
def test_bridge_schema_declares_array_inputs(self):
from tools.tool_search import bridge_tool_schemas
schemas = {s["function"]["name"]: s["function"] for s in bridge_tool_schemas(3)}
search_params = schemas["tool_search"]["parameters"]
assert search_params["required"] == ["queries"]
assert search_params["properties"]["queries"]["type"] == "array"
describe_params = schemas["tool_describe"]["parameters"]
assert describe_params["required"] == ["names"]
assert describe_params["properties"]["names"]["type"] == "array"
+194 -48
View File
@@ -46,9 +46,12 @@ import json
import logging
import math
import re
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Tuple
import snowballstemmer
from tools.registry import tool_error
logger = logging.getLogger("tools.tool_search")
@@ -90,6 +93,12 @@ class ToolSearchConfig:
threshold_pct: float # 0..100
search_default_limit: int
max_search_limit: int
# Per-call caps on the model-facing array inputs. ``max_queries`` bounds
# ``tool_search``'s ``queries`` list; ``max_describe_names`` bounds
# ``tool_describe``'s ``names`` list. Over-cap calls error (the model
# repairs in one round-trip) rather than silently truncating.
max_queries: int = 10
max_describe_names: int = 10
# Catalog listing ("skills-style" progressive disclosure): when active,
# a grouped name + short-description manifest of every deferred tool is
# embedded in the tool_search bridge description, so capabilities stay
@@ -114,13 +123,13 @@ class ToolSearchConfig:
"""
if raw is True:
return cls(enabled="auto", threshold_pct=5.0,
search_default_limit=5, max_search_limit=20)
search_default_limit=5, max_search_limit=25)
if raw is False:
return cls(enabled="off", threshold_pct=5.0,
search_default_limit=5, max_search_limit=20)
search_default_limit=5, max_search_limit=25)
if not isinstance(raw, dict):
return cls(enabled="auto", threshold_pct=5.0,
search_default_limit=5, max_search_limit=20)
search_default_limit=5, max_search_limit=25)
enabled_raw = str(raw.get("enabled", "auto")).strip().lower()
if enabled_raw in ("true", "1", "yes"):
@@ -135,10 +144,15 @@ class ToolSearchConfig:
threshold_pct = _safe_float(raw.get("threshold_pct"), 5.0)
threshold_pct = max(0.0, min(100.0, threshold_pct))
max_search_limit = max(1, min(50, _safe_int(raw.get("max_search_limit"), 20)))
max_search_limit = max(1, min(50, _safe_int(raw.get("max_search_limit"), 25)))
search_default_limit = max(1, min(max_search_limit,
_safe_int(raw.get("search_default_limit"), 5)))
# Per-call array caps. Floored at 1 (a non-positive cap would reject
# every call); no upper clamp — deliberately operator-tunable.
max_queries = max(1, _safe_int(raw.get("max_queries"), 10))
max_describe_names = max(1, _safe_int(raw.get("max_describe_names"), 10))
listing_raw = str(raw.get("listing", "auto")).strip().lower()
if listing_raw in ("true", "1", "yes"):
listing = "on"
@@ -155,6 +169,8 @@ class ToolSearchConfig:
threshold_pct=threshold_pct,
search_default_limit=search_default_limit,
max_search_limit=max_search_limit,
max_queries=max_queries,
max_describe_names=max_describe_names,
listing=listing,
listing_max_tokens=listing_max_tokens,
)
@@ -353,11 +369,32 @@ class CatalogEntry:
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
# Snowball stemmer instances keep mutable parsing state, so they are not
# safe to share across threads — and bridge dispatch can run on parallel
# tool-call threads. One stemmer per thread, created lazily.
_thread_local = threading.local()
def _stemmer() -> Any:
st = getattr(_thread_local, "stemmer", None)
if st is None:
st = snowballstemmer.stemmer("english")
_thread_local.stemmer = st
return st
def _tokenize(text: str) -> List[str]:
"""Lowercase alphanumeric tokens, Snowball-stemmed (English).
Stemming is applied here so it hits BOTH the index path
(:func:`build_catalog` via :func:`_entry_search_text`) and the query
path (:func:`search_catalog`) identically — a query for "issues"
matches a tool named ``create_issue``.
"""
if not text:
return []
return [t.lower() for t in _TOKEN_RE.findall(text)]
tokens = [t.lower() for t in _TOKEN_RE.findall(text)]
return list(_stemmer().stemWords(tokens))
def _entry_search_text(td: Dict[str, Any], source_label: str = "") -> str:
@@ -687,9 +724,12 @@ def bridge_tool_schemas(
"""
desc_search = (
f"Search {deferred_count} additional tools that are loaded on demand. "
"Returns up to ``limit`` matches with name and description. Follow "
f"with `{TOOL_DESCRIBE_NAME}` to load a tool's full parameter schema, "
f"then `{TOOL_CALL_NAME}` to invoke it. Tools listed at the top of this "
"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."
)
if listing and listing_form == "groups":
@@ -715,8 +755,9 @@ def bridge_tool_schemas(
)
desc_search += "\n\n" + listing
desc_describe = (
f"Load the full JSON schema for one tool returned by `{TOOL_SEARCH_NAME}`. "
f"Required before `{TOOL_CALL_NAME}` if the tool's parameters are unknown."
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."
)
desc_call = (
"Invoke a deferred tool by name with the given arguments. Argument shape "
@@ -733,16 +774,17 @@ def bridge_tool_schemas(
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords describing the capability you need (e.g. 'create github issue').",
"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.",
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return. Default 5.",
"description": "Maximum number of matches PER QUERY. Default 5.",
},
},
"required": ["query"],
"required": ["queries"],
},
},
},
@@ -754,12 +796,13 @@ def bridge_tool_schemas(
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact tool name (as returned by tool_search).",
"names": {
"type": "array",
"items": {"type": "string"},
"description": "Exact tool names (as returned by tool_search).",
},
},
"required": ["name"],
"required": ["names"],
},
},
},
@@ -891,13 +934,25 @@ def is_bridge_tool(name: str) -> bool:
return name in BRIDGE_TOOL_NAMES
def _format_search_hit(entry: CatalogEntry) -> Dict[str, Any]:
def _shared_tool_record(entry: CatalogEntry) -> Dict[str, Any]:
"""One record for the response's shared ``tools`` map.
Held once per tool no matter how many query groups matched it — the
per-query groups carry names only. ``required`` lists the schema's
required parameter names so the model can attempt a call without a
``tool_describe`` round-trip when the required surface is trivial.
"""
fn = (entry.schema or {}).get("function") or {}
params = fn.get("parameters") or {}
required = params.get("required")
if not isinstance(required, list):
required = []
return {
"name": entry.name,
"source": entry.source,
"source_name": entry.source_name,
# Cap description so a chatty MCP server doesn't blow up the result.
"description": (entry.description or "")[:400],
"required": [r for r in required if isinstance(r, str)],
}
@@ -925,12 +980,42 @@ def dispatch_tool_search(args: Dict[str, Any],
*,
current_tool_defs: List[Dict[str, Any]],
config: Optional[ToolSearchConfig] = None) -> str:
"""Execute the ``tool_search`` bridge tool. Returns a JSON string."""
"""Execute the ``tool_search`` bridge tool. Returns a JSON string.
Accepts ``queries: [str, ...]`` — each query is searched independently
against the same catalog. The response groups matching tool NAMES per
query and carries each matched tool's record exactly once in a shared
``tools`` map::
{
"queries": ["...", "..."],
"total_available": 215,
"results": [{"query": "...", "matches": ["<tool name>", ...]}, ...],
"tools": {"<tool name>": {"source": ..., "source_name": ...,
"description": ..., "required": [...]}}
}
``limit`` applies PER QUERY. When one or more queries return no matches,
a single top-level ``available_sources`` + ``hint`` block is added so a
lexical miss is not mistaken for a missing capability.
"""
if config is None:
config = load_config()
query = str(args.get("query") or "").strip()
if not query:
return tool_error("query is required")
raw_queries = args.get("queries")
if isinstance(raw_queries, str):
# A bare string is an understandable model slip; treat as one query.
raw_queries = [raw_queries]
if not isinstance(raw_queries, list):
return tool_error("queries is required and must be an array of strings")
queries = [str(q).strip() for q in raw_queries if str(q or "").strip()]
if not queries:
return tool_error("queries is required and must contain at least one non-empty string")
if len(queries) > config.max_queries:
return tool_error(
f"too many queries: {len(queries)} > max {config.max_queries}. "
"Retry with fewer, more targeted queries."
)
raw_limit = args.get("limit")
if raw_limit is None:
@@ -940,47 +1025,108 @@ def dispatch_tool_search(args: Dict[str, Any],
_, deferrable = classify_tools(current_tool_defs)
catalog = build_catalog(deferrable)
hits = search_catalog(catalog, query, limit=limit)
results: List[Dict[str, Any]] = []
tools_map: Dict[str, Dict[str, Any]] = {}
any_empty = False
for query in queries:
hits = search_catalog(catalog, query, limit=limit)
if not hits:
any_empty = True
for h in hits:
if h.name not in tools_map:
tools_map[h.name] = _shared_tool_record(h)
results.append({"query": query, "matches": [h.name for h in hits]})
result: Dict[str, Any] = {
"query": query,
"queries": queries,
"total_available": len(catalog),
"matches": [_format_search_hit(h) for h in hits],
"results": results,
"tools": tools_map,
}
if not hits and catalog:
if any_empty and catalog:
result["available_sources"] = _available_source_summary(catalog)
result["hint"] = (
"No lexical match was found, 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."
"Some queries 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."
)
return json.dumps(result, ensure_ascii=False)
def dispatch_tool_describe(args: Dict[str, Any],
*,
current_tool_defs: List[Dict[str, Any]]) -> str:
"""Execute the ``tool_describe`` bridge tool. Returns a JSON string."""
name = str(args.get("name") or "").strip()
if not name:
return tool_error("name is required")
if not is_deferrable_tool_name(name):
current_tool_defs: List[Dict[str, Any]],
config: Optional[ToolSearchConfig] = None) -> str:
"""Execute the ``tool_describe`` bridge tool. Returns a JSON string.
Accepts ``names: [str, ...]`` and returns a map keyed by tool name::
{
"tools": {"<name>": {"description": ..., "parameters": {...}}, ...},
"not_found": ["<name>", ...], # only when some names missed
"errors": {"<name>": "..."} # only for non-deferrable names
}
Unknown names land in ``not_found`` instead of failing the whole call;
non-deferrable names (core tools, typos of visible tools) keep their
per-name error message in ``errors``. Duplicates are deduped silently.
"""
if config is None:
config = load_config()
raw_names = args.get("names")
if isinstance(raw_names, str):
# A bare string is an understandable model slip; treat as one name.
raw_names = [raw_names]
if not isinstance(raw_names, list):
return tool_error("names is required and must be an array of strings")
names: List[str] = []
for n in raw_names:
n = str(n or "").strip()
if n and n not in names:
names.append(n)
if not names:
return tool_error("names is required and must contain at least one non-empty string")
if len(names) > config.max_describe_names:
return tool_error(
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."
f"too many names: {len(names)} > max {config.max_describe_names}. "
"Retry with fewer names per call."
)
_, deferrable = classify_tools(current_tool_defs)
by_name: Dict[str, Dict[str, Any]] = {}
for td in deferrable:
fn = td.get("function") or {}
if fn.get("name") == name:
return json.dumps({
"name": name,
if fn.get("name"):
by_name[fn["name"]] = fn
tools: Dict[str, Dict[str, Any]] = {}
not_found: List[str] = []
errors: Dict[str, str] = {}
for name in names:
fn = by_name.get(name)
if fn is not None:
tools[name] = {
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
}, ensure_ascii=False)
return tool_error(
f"'{name}' is not currently available. Re-run tool_search to refresh."
)
}
elif not is_deferrable_tool_name(name):
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]:
Generated
+11
View File
@@ -1599,6 +1599,7 @@ dependencies = [
{ name = "requests" },
{ name = "rich" },
{ name = "ruamel-yaml" },
{ name = "snowballstemmer" },
{ name = "tenacity" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "urllib3" },
@@ -1929,6 +1930,7 @@ requires-dist = [
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.30.0" },
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" },
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" },
{ name = "snowballstemmer", specifier = "==3.1.1" },
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
{ name = "sounddevice", marker = "extra == 'wake'", specifier = "==0.5.5" },
{ name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" },
@@ -4256,6 +4258,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "snowballstemmer"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
+36 -12
View File
@@ -30,22 +30,39 @@ When Tool Search activates for a turn, the model sees three new tools in
place of the deferred ones:
```
tool_search(query, limit?) — search the deferred-tool catalog
tool_describe(name) — load the full schema for one tool
tool_search(queries, limit?) — search the deferred-tool catalog (one or more queries)
tool_describe(names) — load the full schemas for one or more tools
tool_call(name, arguments) — invoke a deferred tool
```
A typical interaction looks like:
```
Model: tool_search("create a github issue")
→ { matches: [{ name: "mcp_github_create_issue", ... }, ...] }
Model: tool_describe("mcp_github_create_issue")
→ { parameters: { type: "object", properties: { ... } } }
Model: tool_search(["create a github issue", "send a slack message"])
→ { results: [ { query: "create a github issue",
matches: ["mcp_github_create_issue", ...] },
{ query: "send a slack message",
matches: ["mcp_slack_post_message", ...] } ],
tools: { mcp_github_create_issue: { description: "...",
required: ["title"], ... },
mcp_slack_post_message: { ... } } }
Model: tool_describe(["mcp_github_create_issue", "mcp_slack_post_message"])
→ { tools: { mcp_github_create_issue: { parameters: { ... } },
mcp_slack_post_message: { parameters: { ... } } } }
Model: tool_call("mcp_github_create_issue", { title: "...", body: "..." })
→ { ok: true, issue_number: 42 }
```
Each query in a `tool_search` call is searched independently against the
same catalog (`limit` applies per query); the per-query groups carry tool
names only, while the shared `tools` map holds each matched tool's
description and required parameter names once. Queries are stemmed, so
"issues" finds `create_issue`. When some queries return no matches, the
response includes a single `available_sources` summary of the connected
servers so a lexical miss is not mistaken for a missing capability.
`tool_describe` resolves every requested name in one call; unknown names
are reported in `not_found` without failing the rest of the batch.
When the model invokes `tool_call`, Hermes **unwraps the bridge** and
dispatches the underlying tool exactly as if the model had called it
directly. Pre-tool-call hooks, guardrails, approval prompts, and
@@ -78,7 +95,9 @@ tools:
enabled: auto # auto (default), on, or off
threshold_pct: 5 # listing budget as a percentage of context
search_default_limit: 5
max_search_limit: 20
max_search_limit: 25
max_queries: 10
max_describe_names: 10
listing: auto # embed a grouped name+description catalog manifest
listing_max_tokens: 4000
```
@@ -87,8 +106,10 @@ tools:
| --- | --- | --- |
| `enabled` | `auto` | `auto`/`on` activate whenever at least one deferrable tool exists; `off` disables entirely (everything stays eager). `auto` is currently an alias of `on` — it is reserved for a future mode that inlines schemas when they fit the context and defers only when they don't. Pin `on` or `off` if you want today's behavior guaranteed across upgrades. |
| `threshold_pct` | `5` | Listing budget as a percentage of the active model's context length. Range 0–100. |
| `search_default_limit` | `5` | Hits returned when the model calls `tool_search` without a `limit`. |
| `max_search_limit` | `20` | Hard upper bound the model can request via `limit`. Range 1–50. |
| `search_default_limit` | `5` | Hits returned per query when the model calls `tool_search` without a `limit`. |
| `max_search_limit` | `25` | Hard upper bound the model can request via `limit` (per query). Range 1–50. |
| `max_queries` | `10` | Maximum queries per `tool_search` call. Over-cap calls return an error and the model retries with fewer. No upper clamp. |
| `max_describe_names` | `10` | Maximum names per `tool_describe` call. Same error-and-retry semantics. No upper clamp. |
| `listing` | `auto` | Embed a skills-style manifest of every deferred tool (name + first sentence of its description, ≤60 chars, grouped by MCP server) in the `tool_search` bridge description. `auto` includes it when it fits the budget (falling back to names-only, then to the tier-2 server summary); `on`/`off` force either way. |
| `listing_max_tokens` | `4000` | Absolute cap on the embedded listing, regardless of context size. Range 200–60000. Large catalogs degrade to names-only or per-server summaries, keeping full schemas available through search. |
@@ -151,9 +172,12 @@ to any progressive-disclosure design, not specific to this implementation:
- **Retrieval:** BM25 over tokenized tool name, source name (the MCP
server or plugin toolset the tool belongs to, so searching `"linear"`
finds that server's tools even when a tool's own name doesn't carry
the service), description, and parameter names. Falls back to a
literal substring match on the tool name when no query token matches
any document (e.g. searching `"hub"` where the token is `github`).
the service), description, and parameter names, with Snowball
stemming (English) applied to both the index and the query so
morphological variants match ("issues" finds `create_issue`). Falls
back to a literal substring match on the tool name when no query
token matches any document (e.g. searching `"hub"` where the token is
`github`).
- **Parallel execution unwraps the bridge.** The batch planner decides
concurrency on the *underlying* tool of a `tool_call`, not on the
literal bridge name — so an MCP server opted in via