Files
hermes-agent/tools/tool_search_catalog.py
T
Siddharth Balyan cf4b78e91f fix(tool-search): a query no tool answers returns nothing, not five tools sharing one word (#106676)
search_catalog admitted every document with BM25 score > 0 and then padded
to `limit`. BM25 sums over the tokens a document shares with the query, so
on a 300-tool catalog "send gmail email" returned five incident tools that
shared only "email", and the discriminating word ("gmail", in no document)
had no say. The model read those as the answer.

Admission is now the query's rarest token: a document is a result only if it
contains the query token with the highest IDF, the one that names the intent.
Common verbs ("send", "read", "create") sit in dozens of documents and never
gate; vendor and object words ("gmail", "github", "incident") do. A token no
document carries admits nothing, and the existing empty-group hint tells the
model to retry without it. The name-substring fallback is deleted: it admitted
tools that matched no query token at all.

Result descriptions are clipped at 500 characters instead of 400. Over 353
vendor tool descriptions, 500 keeps 91% whole and every first sentence
(first-sentence max 329); 400 kept 82%.

Measured on the live 311-tool catalog with 25 hand-labelled queries:
precision@5 0.18 -> 0.43, wrong names returned 102 -> 66, false positives on
absent intents 17 -> 13. Live before/after: "send gmail email" went from five
betterstack tools to an empty group with the retry hint; "linear create issue"
and "betterstack incident" are unchanged.
2026-09-10 02:21:15 +05:30

256 lines
11 KiB
Python

"""Deferred-tool catalog for tool search: BM25 retrieval over deferrable tool
defs plus the budgeted, byte-stable catalog listing embedded in the bridge."""
from __future__ import annotations
import functools
import math
import re
import threading
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import snowballstemmer
# Reserved bridge names: a user/plugin/MCP tool may not take them (registry override
# protection rejects such registrations).
TOOL_SEARCH_NAME = "tool_search"
TOOL_DESCRIBE_NAME = "tool_describe"
TOOL_CALL_NAME = "tool_call"
BRIDGE_TOOL_NAMES = frozenset({TOOL_SEARCH_NAME, TOOL_DESCRIBE_NAME, TOOL_CALL_NAME})
# Chars-per-token rule of thumb; 4.0 slightly underestimates (fewer false activations).
CHARS_PER_TOKEN = 4.0
@dataclass
class CatalogEntry:
"""One deferrable tool, in a form the bridge tools can search and serve."""
name: str
description: str
schema: Dict[str, Any] # the full {"type":"function", "function": {...}} entry
source: str # "mcp" | "plugin" | "other"
source_name: str # toolset name, e.g. "mcp-github" or "kanban"
_tokens: List[str] = field(default_factory=list) # pre-tokenized for BM25
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
_thread_local = threading.local()
@functools.lru_cache(maxsize=16384)
def _stem(token: str) -> str:
"""Stem one token, memoized across stateless catalog rebuilds. Snowball stemmers carry
mutable parsing state and bridge dispatch runs on parallel tool-call threads, so the
stemmer is one-per-thread, created lazily."""
if getattr(_thread_local, "stemmer", None) is None:
_thread_local.stemmer = snowballstemmer.stemmer("english")
return _thread_local.stemmer.stemWord(token)
def _tokenize(text: str) -> List[str]:
"""Lowercase alphanumeric tokens, Snowball-stemmed (English); shared by the index and
query paths so "issues" matches ``create_issue``."""
return [_stem(token.lower()) for token in _TOKEN_RE.findall(text)] if text else []
def _fn(td: Dict[str, Any]) -> Dict[str, Any]:
"""The ``function`` block of a tool-def (``{}`` when absent/None)."""
return td.get("function") or {}
def _registry_entry(name: str) -> Any:
"""Registry entry for ``name``; None when unregistered OR the registry raises (lookup
failures must never fail a bridge call). Lazy import: tests patch the registry."""
try:
from tools.registry import registry
return registry.get_entry(name)
except Exception:
return None
def _registry_toolset(name: str) -> Optional[str]:
"""Toolset of a registered tool; None when unregistered or malformed (no str toolset)."""
toolset = getattr(_registry_entry(name), "toolset", None)
return toolset if isinstance(toolset, str) else None
def _entry_search_text(td: Dict[str, Any], source_label: str = "") -> str:
"""Search-text blob: split name words + source label + description + top-level parameter
names (schema bodies are noise with no recall gain). The ``mcp__`` prefix is dropped — it
is in every MCP document, so its IDF is ~0. The source label lets a service-name query
("linear") reach a tool whose own name omits the vendor."""
fn = _fn(td)
name = fn.get("name", "")
if name.startswith("mcp__"):
name = name[len("mcp__"):]
name_words = re.sub(r"[_.:-]", " ", name)
extra = source_label if source_label and source_label not in name_words.split() else ""
param_names = " ".join(((fn.get("parameters") or {}).get("properties") or {}).keys())
return f"{name_words} {extra} {fn.get('description', '') or ''} {param_names}"
def _classify_source(name: str) -> Tuple[str, str]:
"""Return (source_kind, source_name) for a registered tool name."""
toolset = _registry_toolset(name)
if toolset is None:
return ("other", "")
return ("mcp" if toolset.startswith("mcp-") else "plugin", toolset)
def build_catalog(tool_defs: List[Dict[str, Any]]) -> List[CatalogEntry]:
"""Build the deferred-tool catalog from the deferrable subset of tool-defs."""
catalog: List[CatalogEntry] = []
for td in tool_defs:
fn = _fn(td)
name = fn.get("name", "")
if not name:
continue
source, source_name = _classify_source(name)
# Index the human-facing label ("linear", not "mcp-linear").
source_label = _listing_group_label(source_name) if source_name else ""
catalog.append(CatalogEntry(
name=name, description=fn.get("description", "") or "", schema=td, source=source,
source_name=source_name, _tokens=_tokenize(_entry_search_text(td, source_label))))
return catalog
def _bm25_score(query_tokens: List[str], doc_tokens: List[str], doc_lengths: List[int],
avg_dl: float, doc_freq: Dict[str, int], n_docs: int, k1: float = 1.5,
b: float = 0.75) -> float:
"""Standard BM25 for one query against one document (inlined; the catalog is bounded —
typically < 500 tools — so a dependency is not worth it)."""
score = 0.0
dl = len(doc_tokens)
doc_tf = Counter(doc_tokens)
for q in query_tokens:
df, tf = doc_freq.get(q, 0), doc_tf.get(q, 0)
if df and tf:
idf = math.log(1 + (n_docs - df + 0.5) / (df + 0.5))
score += idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / max(avg_dl, 1.0)))
return score
_CorpusStats = Tuple[List[int], float, Dict[str, int], int] # doc_lengths, avg_dl, df, n_docs
def _corpus_stats(catalog: List[CatalogEntry]) -> _CorpusStats:
"""Compute the BM25 statistics shared by every query over a catalog."""
doc_lengths = [len(entry._tokens) for entry in catalog]
avg_dl = sum(doc_lengths) / max(len(doc_lengths), 1)
doc_freq = Counter(tok for entry in catalog for tok in set(entry._tokens))
return doc_lengths, avg_dl, dict(doc_freq), len(catalog)
def _gate_token(query_tokens: List[str], doc_freq: Dict[str, int], n_docs: int) -> str:
"""The query token with the highest IDF: the word that names the intent. ``send``,
``read``, ``create`` sit in dozens of tool documents and separate nothing; ``gmail``,
``github``, ``incident`` sit in a few and separate everything. A document without this
token answered a different question, however many common tokens it shares."""
def _idf(token: str) -> float:
df = doc_freq.get(token, 0)
return math.log(1 + (n_docs - df + 0.5) / (df + 0.5))
return max(query_tokens, key=_idf)
def search_catalog(catalog: List[CatalogEntry], query: str, limit: int = 5, *,
corpus_stats: Optional[_CorpusStats] = None) -> List[CatalogEntry]:
"""Top-``limit`` catalog entries for ``query`` by BM25 (exact name match ranks first).
Admission is by the query's rarest token (:func:`_gate_token`), not by ``score > 0``:
BM25 is additive over the tokens a document shares with the query, so on a large catalog
``score > 0`` admits one-token matches and fills every slot with them (measured: "send
gmail email" returned 5 incident tools that only shared ``email``). A token no document
carries admits nothing; the caller's empty-group hint tells the model to retry without it."""
query_tokens = _tokenize(query) if catalog and limit > 0 else []
if not query_tokens:
return []
corpus_stats = corpus_stats or _corpus_stats(catalog)
gate = _gate_token(query_tokens, corpus_stats[2], corpus_stats[3])
exact_name = query.strip().lower()
scored = [
(float("inf") if entry.name.lower() == exact_name
else _bm25_score(query_tokens, entry._tokens, *corpus_stats), entry)
for entry in catalog
if entry.name.lower() == exact_name or gate in entry._tokens]
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
# Sentence end: ., !, ? followed by whitespace/EOS, not inside e.g./i.e./etc.
_SENTENCE_END_RE = re.compile(r"(?<!\be\.g)(?<!\bi\.e)(?<!\betc)[.!?](?=\s|$)")
def _short_desc(description: str, max_chars: int = 60) -> str:
"""First sentence of a tool description, clipped to ``max_chars`` on a word boundary.
``e.g.``/``i.e.``/``etc.`` do not end a sentence; whitespace normalization and the regex
search stay linear-time on hostile input."""
text = " ".join((description or "").split())
m = _SENTENCE_END_RE.search(text)
text = text[:m.end()] if m else text
if len(text) <= max_chars:
return text
clipped = text[:max_chars]
clipped = clipped.rsplit(" ", 1)[0] if " " in clipped else clipped
return clipped.rstrip(",;: ") + "…"
def _listing_group_label(source_name: str) -> str:
"""Human-facing group heading for a toolset, e.g. ``mcp-github`` -> ``github``."""
label = source_name or "other"
return label[4:] if label.startswith("mcp-") else label
def build_catalog_listing_with_form(
deferrable: List[Dict[str, Any]], *, max_tokens: int = 4000) -> Tuple[Optional[str], str]:
"""Render the deferred-catalog manifest: ``- name: short desc`` lines grouped per source.
Returns ``(text, form)``; form is ``"full"``, ``"names"``, ``"mixed"`` (oversized servers
collapsed to a name + count line), ``"groups"`` (every server summarized) or ``"none"``
(over budget even summarized -> text is None). Ordering is deterministic (sorted groups
and tools) so the block is byte-stable — the request prefix stays cacheable. Degradation
is PER SERVER, largest first: one huge server must not cost a small one its listing."""
groups: Dict[str, List[Tuple[str, str]]] = {}
for td in deferrable:
fn = _fn(td)
name = fn.get("name", "")
if name:
# _classify_source gives ("other", "") when unregistered; the label of "" is "other".
label = _listing_group_label(_classify_source(name)[1])
groups.setdefault(label, []).append((name, _short_desc(fn.get("description", ""))))
if not groups:
return None, "none"
def render_group(label: str, mode: str) -> str:
"""Render one server's block. mode: 'full' | 'names' | 'summary'."""
tools = sorted(groups[label])
if mode == "summary":
return (f"{label} ({len(tools)} tools — names not listed; "
f"discover via `{TOOL_SEARCH_NAME}`)")
lines = [f"{label} tools ({len(tools)}):"]
if mode == "full":
lines.extend(f"- {name}: {desc}" if desc else f"- {name}" for name, desc in tools)
else:
lines.append(", ".join(name for name, _ in tools))
return "\n".join(lines)
header = ("Deferred tool catalog (call schemas via "
f"`{TOOL_DESCRIBE_NAME}`, invoke via `{TOOL_CALL_NAME}`):")
def assemble_if_fits(modes: Dict[str, str]) -> Optional[str]:
text = "\n".join([header] + [render_group(lbl, modes[lbl]) for lbl in sorted(groups)])
return text if math.ceil(len(text) / CHARS_PER_TOKEN) <= max_tokens else None
for mode in ("full", "names"): # 1. everything full; 2. everything names-only
modes = {lbl: mode for lbl in groups}
text = assemble_if_fits(modes)
if text is not None:
return text, mode
# 3. Collapse the LARGEST rendered groups first (deterministic: size then label).
for lbl in sorted(groups, key=lambda lbl: (-len(render_group(lbl, "names")), lbl)):
modes[lbl] = "summary"
text = assemble_if_fits(modes)
if text is not None:
return text, "groups" if all(m == "summary" for m in modes.values()) else "mixed"
return None, "none"