diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index b070819017413..4ff18177a4538 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2370,7 +2370,7 @@ DEFAULT_CONFIG = { "listing": "auto", # Absolute cap on the embedded listing in tokens (chars/4 # estimate), regardless of context size. Range 200..60000. - "listing_max_tokens": 20000, + "listing_max_tokens": 4000, }, }, diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 2c0b66e86a257..575fcffb21afd 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -231,6 +231,32 @@ class TestBridgeDispatch: result = dispatch_tool_search({}, current_tool_defs=[]) assert "error" in json.loads(result) + def test_empty_search_keeps_connected_sources_discoverable(self): + from tools.registry import registry + from tools.tool_search import dispatch_tool_search + + name = "recovery_catalog_create_record" + tool_def = _td(name, "Create a record in the connected catalog service.") + registry.register( + name=name, + handler=lambda args, **kwargs: "{}", + schema=tool_def, + toolset="mcp-recovery-catalog", + ) + + result = json.loads(dispatch_tool_search( + {"query": "unrelated vocabulary"}, + current_tool_defs=[tool_def], + )) + + assert result["matches"] == [] + assert result["total_available"] == 1 + assert result["available_sources"] == [ + {"name": "recovery-catalog", "tool_count": 1}, + ] + assert "remain available" in result["hint"] + assert "before concluding" in result["hint"] + def test_resolve_underlying_call_parses_object_args(self): from tools.tool_search import resolve_underlying_call @@ -403,11 +429,43 @@ class TestCatalogListing: from tools.tool_search import ToolSearchConfig cfg = ToolSearchConfig.from_raw(None) assert cfg.listing == "auto" - assert cfg.listing_max_tokens == 20000 + assert cfg.listing_max_tokens == 4000 # legacy bool shapes keep defaults too assert ToolSearchConfig.from_raw(True).listing == "auto" + def test_default_listing_cap_bounds_fixed_catalog_overhead(self): + """The default manifest must not grow back to the old 20K-token cap.""" + from tools.registry import registry + from tools.tool_search import ( + ToolSearchConfig, + assemble_tool_defs, + estimate_tokens_from_schemas, + ) + + defs = [] + for i in range(500): + name = f"lean_catalog_tool_{i:04d}" + registry.register( + name=name, + handler=lambda args, **kwargs: "{}", + schema=_td(name, "Perform a deliberately verbose connected service action."), + toolset="mcp-lean-catalog", + ) + defs.append(_td(name, "Perform a deliberately verbose connected service action.")) + + cfg = ToolSearchConfig.from_raw(None) + result = assemble_tool_defs(defs, context_length=1_000_000, config=cfg) + search = next( + td for td in result.tool_defs + if td["function"]["name"] == "tool_search" + ) + description_tokens = estimate_tokens_from_schemas([search]) + # Includes the bridge schema around the listing, so allow modest + # framing overhead above the 4K listing budget. + assert description_tokens < 4500 + assert result.listing_form in {"names", "groups", "mixed"} + def test_short_desc_first_sentence_and_clip(self): from tools.tool_search import _short_desc assert _short_desc("Open an issue. Second sentence dropped.") == "Open an issue." diff --git a/tools/tool_search.py b/tools/tool_search.py index e286fa068671e..eb4314922f434 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -95,7 +95,7 @@ class ToolSearchConfig: listing: str = "auto" # "auto" | "on" | "off" # Absolute cap on the embedded listing, regardless of context size. # Effective budget = min(listing_max_tokens, threshold_pct% of context). - listing_max_tokens: int = 20000 + listing_max_tokens: int = 4000 @classmethod def from_raw(cls, raw: Any) -> "ToolSearchConfig": @@ -143,7 +143,7 @@ class ToolSearchConfig: listing = listing_raw else: listing = "auto" - listing_max_tokens = max(200, min(60000, _safe_int(raw.get("listing_max_tokens"), 20000))) + listing_max_tokens = max(200, min(60000, _safe_int(raw.get("listing_max_tokens"), 4000))) return cls( enabled=enabled, @@ -512,7 +512,7 @@ def _listing_group_label(source_name: str) -> str: def build_catalog_listing( deferrable: List[Dict[str, Any]], *, - max_tokens: int = 20000, + max_tokens: int = 4000, ) -> Optional[str]: """Render a skills-style manifest of the deferred catalog. @@ -545,7 +545,7 @@ def build_catalog_listing( def build_catalog_listing_with_form( deferrable: List[Dict[str, Any]], *, - max_tokens: int = 20000, + max_tokens: int = 4000, ) -> Tuple[Optional[str], str]: """Like :func:`build_catalog_listing` but also reports the form used. @@ -861,6 +861,25 @@ def _format_search_hit(entry: CatalogEntry) -> Dict[str, Any]: } +def _available_source_summary(catalog: List[CatalogEntry]) -> List[Dict[str, Any]]: + """Return a compact, deterministic summary of connected deferred sources. + + Included only when search returns no matches. This gives the model enough + evidence to retry with a source/action query instead of treating a lexical + miss as proof that the capability is unavailable, without adding anything + to the fixed per-turn prompt. + """ + counts: Dict[str, int] = {} + for entry in catalog: + source_name = entry.source_name or entry.source or "other" + label = _listing_group_label(source_name) + counts[label] = counts.get(label, 0) + 1 + return [ + {"name": name, "tool_count": counts[name]} + for name in sorted(counts) + ] + + def dispatch_tool_search(args: Dict[str, Any], *, current_tool_defs: List[Dict[str, Any]], @@ -881,11 +900,20 @@ 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) - return json.dumps({ + result: Dict[str, Any] = { "query": query, "total_available": len(catalog), "matches": [_format_search_hit(h) for h in hits], - }, ensure_ascii=False) + } + if not hits 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." + ) + return json.dumps(result, ensure_ascii=False) def dispatch_tool_describe(args: Dict[str, Any], diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md index ebb9b9d876037..468989f787d07 100644 --- a/website/docs/user-guide/features/tool-search.md +++ b/website/docs/user-guide/features/tool-search.md @@ -80,7 +80,7 @@ tools: search_default_limit: 5 max_search_limit: 20 listing: auto # embed a grouped name+description catalog manifest - listing_max_tokens: 20000 + listing_max_tokens: 4000 ``` | Key | Default | Meaning | @@ -90,7 +90,7 @@ tools: | `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. | | `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` | `20000` | Absolute cap on the embedded listing, regardless of context size. Range 200–60000. | +| `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. | ### Why the listing exists