From c80ce5aae28e1fd893662bb0c41ea08e5e8690d9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:33:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(tools):=20tiered=20tool=20disclosure=20?= =?UTF-8?q?=E2=80=94=20always=20defer=20MCP/plugin=20tools,=20scale=20the?= =?UTF-8?q?=20listing=20with=20catalog=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 0: no MCP/plugin tools -> everything eager (pass-through). Tier 1: deferred tools whose catalog listing fits min(threshold_pct% of context, listing_max_tokens) -> bridge + skills-style listing, degrading to names-only over budget. Tier 2: listing over budget even names-only (Cloudflare's flat API surface: 3,320 tools, names alone ~32K tokens) -> bare bridge, discovery through tool_search only. The old activation threshold (defer only when schemas > threshold_pct of context) let mid-size catalogs ride eager and pay full schema cost; with servers like Cloudflare (~597K tokens of schema, would not even fit a 200K window) the binary gate is the wrong shape. Activation is now driven purely by deferrable-tool presence; threshold_pct is repurposed as the listing budget's context-relative leg. - AssemblyResult gains tier + listing_form for observability - listing_max_tokens default 4000 -> 20000 (cap 60000) so an 830-tool catalog keeps a names-only listing while Cloudflare-scale drops to bare bridge - E2E verified against real captures: Linear 24 tools -> tier 1 full, Epic UE 5.8 830 tools -> tier 1 names-only, Cloudflare 3,320 tools -> tier 2 (both 200K and 1M context) --- hermes_cli/config.py | 32 +++-- model_tools.py | 9 +- tests/tools/test_tool_search.py | 94 +++++++++---- tools/tool_search.py | 125 +++++++++++++----- .../docs/user-guide/features/tool-search.md | 49 +++---- 5 files changed, 213 insertions(+), 96 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index debbb58ac2b1..751f98472f60 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2942,16 +2942,20 @@ DEFAULT_CONFIG = { # openclaw-tool-search-report PDF in this PR for the rationale. "tools": { "tool_search": { - # "auto" (default) — activate only when deferrable tool schemas - # exceed ``threshold_pct`` of the active model's context length, - # so small toolsets pay no overhead. - # "on" — always activate when there is at least one deferrable - # tool. Use when you have many MCP servers and want maximum - # token reduction unconditionally. + # Tiered disclosure: any deferrable (MCP/plugin) tool activates + # the bridge; the listing then scales with catalog size. + # Tier 0 — no MCP/plugin tools: everything stays eager. + # Tier 1 — catalog listing fits the budget: bridge + skills-style + # name+description manifest (degrades to names-only). + # Tier 2 — listing over budget even names-only (e.g. Cloudflare's + # ~3,300-tool flat API surface): bare bridge, discovery through + # tool_search only. + # "auto"/"on" — activate when at least one deferrable tool exists. # "off" — disable entirely. Tools-array assembly is a pass-through. "enabled": "auto", - # Percentage of context length at which "auto" mode kicks in. - # 10 matches the Claude Code default. Range 0..100. + # Listing budget as a percentage of the active model's context + # length. Effective budget = min(this % of context, + # listing_max_tokens). 10 matches the Claude Code default. Range 0..100. "threshold_pct": 10, # When the model calls tool_search without a ``limit`` argument, # how many hits to return. Range 1..max_search_limit. @@ -2962,14 +2966,14 @@ DEFAULT_CONFIG = { # description: every deferred tool's name + first sentence of its # description (≤60 chars), grouped by MCP server / toolset. Keeps # capabilities discoverable while schemas stay deferred. - # "auto" (default) — include when the listing fits listing_max_tokens - # (falls back to names-only, then to the bare count). + # "auto" (default) — include when the listing fits the budget + # (falls back to names-only, then to the bare tier-2 bridge). # "on" — same rendering, but explicit intent to always list. - # "off" — legacy bare-count bridge description. + # "off" — always the bare bridge (tier 2 for every catalog). "listing": "auto", - # Token budget for the embedded listing (chars/4 estimate). - # Range 200..20000. - "listing_max_tokens": 4000, + # Absolute cap on the embedded listing in tokens (chars/4 + # estimate), regardless of context size. Range 200..60000. + "listing_max_tokens": 20000, }, }, diff --git a/model_tools.py b/model_tools.py index dd27fb342cb5..2ac445f8ff5b 100644 --- a/model_tools.py +++ b/model_tools.py @@ -555,10 +555,13 @@ def _compute_tool_definitions( config=ts_cfg, ) if assembly.activated and not quiet_mode: + _forms = {"full": "catalog listing embedded", + "names": "names-only listing embedded", + "none": "no listing (search-only)"} print( - f"🔎 Tool Search: {assembly.deferred_count} MCP/plugin tools deferred " - f"(~{assembly.deferred_tokens} tokens) behind tool_search/describe/call. " - f"Threshold ~{assembly.threshold_tokens} tokens." + f"🔎 Tool Search (tier {assembly.tier}): {assembly.deferred_count} " + f"MCP/plugin tools deferred (~{assembly.deferred_tokens} tokens) behind " + f"tool_search/describe/call — {_forms.get(assembly.listing_form, assembly.listing_form)}." ) filtered_tools = assembly.tool_defs except Exception as e: # pragma: no cover — never break tool loading diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index f4dee25b7c48..a0ad19c0527f 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -149,24 +149,27 @@ class TestThresholdGate: cfg = ToolSearchConfig.from_raw({"enabled": "on"}) assert should_activate(cfg, deferrable_tokens=100, context_length=200_000) - def test_auto_below_threshold_does_not_activate(self): + def test_auto_activates_with_any_deferrable(self): + """Tiered disclosure: ANY deferrable tool activates the bridge — + the threshold now bounds the listing, not activation.""" from tools.tool_search import ToolSearchConfig, should_activate cfg = ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}) - # 5% of 200K = below 10% threshold - assert not should_activate(cfg, deferrable_tokens=10_000, context_length=200_000) - - def test_auto_at_or_above_threshold_activates(self): - from tools.tool_search import ToolSearchConfig, should_activate - cfg = ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}) - assert should_activate(cfg, deferrable_tokens=20_000, context_length=200_000) + assert should_activate(cfg, deferrable_tokens=100, context_length=200_000) assert should_activate(cfg, deferrable_tokens=50_000, context_length=200_000) + # unknown context length: still activates + assert should_activate(cfg, deferrable_tokens=100, context_length=0) - def test_auto_without_context_length_uses_20k_cutoff(self): - """Fallback cutoff used when the active model is unknown.""" - from tools.tool_search import ToolSearchConfig, should_activate - cfg = ToolSearchConfig.from_raw({"enabled": "auto"}) - assert not should_activate(cfg, deferrable_tokens=10_000, context_length=0) - assert should_activate(cfg, deferrable_tokens=25_000, context_length=0) + def test_listing_budget_min_of_pct_and_cap(self): + from tools.tool_search import ToolSearchConfig, listing_token_budget + cfg = ToolSearchConfig.from_raw( + {"threshold_pct": 10, "listing_max_tokens": 8000}) + # 10% of 200K = 20K > cap 8K → cap wins + assert listing_token_budget(cfg, 200_000) == 8000 + # 10% of 50K = 5K < cap 8K → pct leg wins + assert listing_token_budget(cfg, 50_000) == 5000 + # unknown context → 20K fallback for the pct leg, still capped + assert listing_token_budget(cfg, 0) == 8000 + assert listing_token_budget(cfg, None) == 8000 def test_token_estimate_proportional_to_schema_size(self): from tools.tool_search import estimate_tokens_from_schemas @@ -250,20 +253,65 @@ class TestAssembly: assert not result.activated assert {t["function"]["name"] for t in result.tool_defs} == {"terminal", "read_file"} - def test_below_threshold_returns_unchanged(self): - """Tiny deferrable surface: don't bother.""" + @staticmethod + def _register_mcp(name): + from tools.registry import registry + + def _handler(args, task_id=None, **kw): + return json.dumps({"ok": True}) + + registry.register( + name=name, + handler=_handler, + schema=_td(name, "Deferred capability description.")["function"], + toolset="mcp-tiertest", + ) + + def test_small_deferrable_surface_defers_with_full_listing(self): + """Tiered disclosure: even a tiny MCP/plugin surface defers (tier 1), + with the full name+description listing embedded.""" from tools.tool_search import assemble_tool_defs, ToolSearchConfig - # _td renders to ~80 chars / 20 tokens. 3 of them = ~60 tokens. - # 10% of 200K = 20K. Way below. - defs = [_td("unknown_tool_a"), _td("unknown_tool_b"), _td("unknown_tool_c")] + for n in ("tier_small_a", "tier_small_b", "tier_small_c"): + self._register_mcp(n) + defs = [_td("terminal", "Run shell")] + [ + _td(n, "Deferred capability description.") + for n in ("tier_small_a", "tier_small_b", "tier_small_c")] result = assemble_tool_defs( defs, context_length=200_000, config=ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}), ) - assert not result.activated + assert result.activated + assert result.tier == 1 + assert result.listing_form == "full" names = {(t.get("function") or {}).get("name") for t in result.tool_defs} - assert "tool_search" not in names + assert "tool_search" in names + assert "terminal" in names # core stays eager + search = next(t for t in result.tool_defs + if t["function"]["name"] == "tool_search") + assert "tier_small_a" in search["function"]["description"] + + def test_oversized_catalog_degrades_to_bare_bridge_tier2(self): + """When even the names-only listing exceeds the budget, tier 2: + bare bridge, no listing.""" + from tools.tool_search import assemble_tool_defs, ToolSearchConfig + names = [f"tier2_very_long_tool_name_number_{i:04d}_extra" for i in range(400)] + for n in names: + self._register_mcp(n) + defs = [_td(n, "A description that will not matter at this size.") + for n in names] + result = assemble_tool_defs( + defs, + context_length=200_000, + config=ToolSearchConfig.from_raw( + {"enabled": "auto", "threshold_pct": 10, "listing_max_tokens": 200}), + ) + assert result.activated + assert result.tier == 2 + assert result.listing_form == "none" + search = next(t for t in result.tool_defs + if t["function"]["name"] == "tool_search") + assert "tier2_very_long_tool_name_number_0000" not in search["function"]["description"] def test_idempotent_when_bridge_already_present(self): from tools.tool_search import assemble_tool_defs, ToolSearchConfig, BRIDGE_TOOL_NAMES @@ -548,7 +596,7 @@ class TestCatalogListing: from tools.tool_search import ToolSearchConfig cfg = ToolSearchConfig.from_raw(None) assert cfg.listing == "auto" - assert cfg.listing_max_tokens == 4000 + assert cfg.listing_max_tokens == 20000 # legacy bool shapes keep defaults too assert ToolSearchConfig.from_raw(True).listing == "auto" @@ -556,7 +604,7 @@ class TestCatalogListing: from tools.tool_search import ToolSearchConfig cfg = ToolSearchConfig.from_raw({"listing": "off", "listing_max_tokens": 999999}) assert cfg.listing == "off" - assert cfg.listing_max_tokens == 20000 + assert cfg.listing_max_tokens == 60000 cfg2 = ToolSearchConfig.from_raw({"listing": "garbage", "listing_max_tokens": -5}) assert cfg2.listing == "auto" assert cfg2.listing_max_tokens == 200 diff --git a/tools/tool_search.py b/tools/tool_search.py index 463571851547..cdab8279ba93 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -9,9 +9,17 @@ for the full rationale): * Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred. Always-load means always-load. No exceptions. -* The threshold gate runs every assembly: when deferrable tools would consume - less than ``threshold_pct`` of the model's context window (default 10%), - tool search is a no-op and the tools array passes through unchanged. +* Tiered disclosure (July 2026 plan): the moment ANY deferrable (MCP/plugin) + tools are present, they hide behind the bridge. What scales with catalog + size is the *listing*, not the activation decision: + - Tier 0 — no MCP/plugin tools: pure passthrough, everything eager. + - Tier 1 — deferred tools whose catalog listing fits the listing budget + (``min(threshold_pct`` of context, ``listing_max_tokens)``): bridge + + skills-style listing (name + short description per tool), degrading to + a names-only listing when the full form is over budget. + - Tier 2 — listing over budget even names-only (e.g. Cloudflare's flat + API surface, ~3,300 tools whose names alone are ~32K tokens): bare + bridge; tools are discoverable only through ``tool_search``. * The catalog is stateless across turns and tools-array assemblies. It is rebuilt from the current tool-defs list every time. This is the lesson from OpenClaw's cron regression (openclaw/openclaw#84141): a session-keyed @@ -65,17 +73,25 @@ class ToolSearchConfig: """Resolved, validated tool-search configuration for a single assembly.""" enabled: str # "auto" | "on" | "off" - threshold_pct: float # 0..100 — only used when enabled == "auto" + # Listing budget as a percentage of the model's context window. Under + # tiered disclosure this no longer gates *activation* (any deferrable + # tool activates the bridge) — it bounds how much context the embedded + # catalog listing may consume before disclosure degrades: + # full listing -> names-only -> bare bridge (tier 2). + threshold_pct: float # 0..100 search_default_limit: int max_search_limit: int # 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 # DISCOVERABLE (like the skills listing in the system prompt) while full - # schemas stay deferred. "auto" = include when it fits listing_max_tokens; - # "on" = always include; "off" = legacy bare-count behavior. + # schemas stay deferred. "auto" = include when it fits the listing + # budget (falls back to names-only, then to none = bare bridge); + # "on" = same rendering, explicit intent; "off" = always bare bridge. listing: str = "auto" # "auto" | "on" | "off" - listing_max_tokens: int = 4000 + # 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 @classmethod def from_raw(cls, raw: Any) -> "ToolSearchConfig": @@ -123,7 +139,7 @@ class ToolSearchConfig: listing = listing_raw else: listing = "auto" - listing_max_tokens = max(200, min(20000, _safe_int(raw.get("listing_max_tokens"), 4000))) + listing_max_tokens = max(200, min(60000, _safe_int(raw.get("listing_max_tokens"), 20000))) return cls( enabled=enabled, @@ -259,24 +275,37 @@ def should_activate( ) -> bool: """Decide whether tool search should activate for the current assembly. - ``"off"`` skips unconditionally. ``"on"`` activates unconditionally - (as long as there is at least one deferrable tool — there's no point - swapping a no-op). ``"auto"`` activates when the deferrable schemas - would consume ``threshold_pct`` of context or more. + ``"off"`` skips unconditionally. ``"on"`` and ``"auto"`` activate whenever + at least one deferrable tool exists (there's no point swapping a no-op). + + Tiered-disclosure semantics (July 2026): the presence of ANY MCP/plugin + tool activates the bridge — schemas always defer. What the threshold now + controls is the *listing budget* (see :func:`listing_token_budget`), not + activation. ``context_length`` is retained in the signature for + backward compatibility with existing callers. """ if config.enabled == "off": return False if deferrable_tokens <= 0: return False - if config.enabled == "on": - return True - # auto - if not context_length or context_length <= 0: - # Without a known context size, fall back to a fixed 20K-token cutoff - # — the cliff above which Anthropic and OpenAI both saw quality drops. - return deferrable_tokens >= 20_000 - threshold_tokens = int(context_length * (config.threshold_pct / 100.0)) - return deferrable_tokens >= threshold_tokens + return True + + +def listing_token_budget( + config: ToolSearchConfig, + context_length: Optional[int], +) -> int: + """Effective token budget for the embedded catalog listing. + + ``min(listing_max_tokens, threshold_pct% of context)``. Without a known + context size, the percentage leg falls back to the fixed 20K cutoff the + activation gate historically used (10% of a typical 200K window). + """ + if context_length and context_length > 0: + pct_leg = int(context_length * (config.threshold_pct / 100.0)) + else: + pct_leg = 20_000 + return max(0, min(config.listing_max_tokens, pct_leg)) # --------------------------------------------------------------------------- @@ -479,7 +508,7 @@ def _listing_group_label(source_name: str) -> str: def build_catalog_listing( deferrable: List[Dict[str, Any]], *, - max_tokens: int = 4000, + max_tokens: int = 20000, ) -> Optional[str]: """Render a skills-style manifest of the deferred catalog. @@ -502,8 +531,23 @@ def build_catalog_listing( 2. names-only listing, still grouped 3. ``None`` — caller falls back to the legacy bare-count description """ + text, _form = build_catalog_listing_with_form(deferrable, max_tokens=max_tokens) + return text + + +def build_catalog_listing_with_form( + deferrable: List[Dict[str, Any]], + *, + max_tokens: int = 20000, +) -> Tuple[Optional[str], str]: + """Like :func:`build_catalog_listing` but also reports the form used. + + Returns ``(text, form)`` where ``form`` is ``"full"`` (names + short + descriptions), ``"names"`` (names-only fallback), or ``"none"`` (over + budget in every form — tier-2 bare bridge). + """ if not deferrable: - return None + return None, "none" groups: Dict[str, List[Tuple[str, str]]] = {} for td in deferrable: @@ -516,7 +560,7 @@ def build_catalog_listing( groups.setdefault(label, []).append((name, _short_desc(fn.get("description", "")))) if not groups: - return None + return None, "none" def render(with_descriptions: bool) -> str: lines: List[str] = ["Deferred tool catalog (call schemas via " @@ -531,11 +575,11 @@ def build_catalog_listing( lines.append(", ".join(name for name, _ in tools)) return "\n".join(lines) - for with_desc in (True, False): + for with_desc, form in ((True, "full"), (False, "names")): text = render(with_desc) if math.ceil(len(text) / CHARS_PER_TOKEN) <= max_tokens: - return text - return None + return text, form + return None, "none" def bridge_tool_schemas( @@ -655,6 +699,12 @@ class AssemblyResult: deferred_count: int = 0 deferred_tokens: int = 0 threshold_tokens: int = 0 + # Disclosure tier actually applied: + # 0 = passthrough (no deferrable tools, or tool_search off) + # 1 = bridge + catalog listing (full or names-only) + # 2 = bare bridge — catalog too large for any listing form + tier: int = 0 + listing_form: str = "none" # "full" | "names" | "none" def assemble_tool_defs( @@ -694,19 +744,24 @@ def assemble_tool_defs( deferred_count=len(deferrable), deferred_tokens=deferrable_tokens, threshold_tokens=int((context_length or 0) * (config.threshold_pct / 100.0)), + tier=0, ) listing = None + listing_form = "none" + listing_budget = listing_token_budget(config, context_length) if config.listing != "off": - listing = build_catalog_listing(deferrable, max_tokens=config.listing_max_tokens) + listing, listing_form = build_catalog_listing_with_form( + deferrable, max_tokens=listing_budget) bridge = bridge_tool_schemas(len(deferrable), listing=listing) result = visible + bridge - threshold_tokens = int((context_length or 0) * (config.threshold_pct / 100.0)) + tier = 1 if listing else 2 logger.info( - "tool_search activated: %d core/visible tools kept, %d deferred (~%d tokens, threshold ~%d), catalog listing %s", - len(visible), len(deferrable), deferrable_tokens, threshold_tokens, - "embedded" if listing else "omitted", + "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( @@ -714,7 +769,9 @@ def assemble_tool_defs( activated=True, deferred_count=len(deferrable), deferred_tokens=deferrable_tokens, - threshold_tokens=threshold_tokens, + threshold_tokens=listing_budget, + tier=tier, + listing_form=listing_form, ) @@ -860,6 +917,8 @@ __all__ = [ "should_activate", "build_catalog", "build_catalog_listing", + "build_catalog_listing_with_form", + "listing_token_budget", "search_catalog", "bridge_tool_schemas", "assemble_tool_defs", diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md index 1e74d51a2985..6aff6075cfcb 100644 --- a/website/docs/user-guide/features/tool-search.md +++ b/website/docs/user-guide/features/tool-search.md @@ -55,19 +55,20 @@ see the underlying tool, not the bridge. ## When does it activate? -By default Tool Search runs in `auto` mode: it activates only when the -deferrable tool schemas would consume at least 10% of the active model's -context window. Below that, the tools-array assembly is a pure -pass-through and you pay no overhead. +Tool Search uses **tiered disclosure**: the presence of *any* deferrable +(MCP/plugin) tool activates the bridge; what scales with catalog size is +how much of the catalog stays visible, not whether schemas defer. -This decision is re-evaluated every time the tools array is built, so: +| Tier | Condition | What the model sees | +| --- | --- | --- | +| **0** | No MCP/plugin tools | Every tool eager, no bridge. Pass-through. | +| **1** | Deferred catalog's listing fits the budget | Bridge + a skills-style manifest of every deferred tool (name + short description, degrading to names-only when over budget). | +| **2** | Listing exceeds the budget even names-only (e.g. Cloudflare's flat API surface: ~3,300 tools whose names alone are ~32K tokens) | Bare bridge — tools are discoverable only through `tool_search`. | -- A session with just a few MCP tools and a long context model never - activates Tool Search. -- A session with many MCP servers attached (15+ tools typically) starts - activating it. -- Removing MCP servers mid-session correctly returns to direct exposure - on the next assembly. +The listing budget is `min(threshold_pct% of context, listing_max_tokens)`. +The decision is re-evaluated every time the tools array is built, so +adding or removing MCP servers mid-session moves the session between +tiers on the next assembly. ## Configuration @@ -75,21 +76,21 @@ This decision is re-evaluated every time the tools array is built, so: tools: tool_search: enabled: auto # auto (default), on, or off - threshold_pct: 10 # percentage of context — only used in auto mode + threshold_pct: 10 # listing budget as a percentage of context search_default_limit: 5 max_search_limit: 20 listing: auto # embed a grouped name+description catalog manifest - listing_max_tokens: 4000 + listing_max_tokens: 20000 ``` | Key | Default | Meaning | | --- | --- | --- | -| `enabled` | `auto` | `auto` activates above threshold; `on` always activates if there's at least one deferrable tool; `off` disables entirely. | -| `threshold_pct` | `10` | Percentage of context length at which `auto` mode kicks in. Range 0–100. | +| `enabled` | `auto` | `auto`/`on` activate whenever at least one deferrable tool exists; `off` disables entirely (everything stays eager). | +| `threshold_pct` | `10` | 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. | -| `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 a bare count); `on`/`off` force either way. | -| `listing_max_tokens` | `4000` | Token budget for the embedded listing. Range 200–20000. | +| `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 bare tier-2 bridge); `on`/`off` force either way. | +| `listing_max_tokens` | `20000` | Absolute cap on the embedded listing, regardless of context size. Range 200–60000. | ### Why the listing exists @@ -112,13 +113,15 @@ tools: ## When NOT to use it Tool Search trades a fixed per-turn token cost (the three bridge tool -schemas, ~300 tokens) and at least one extra round trip (search → -describe → call) for the savings on the deferred schemas. It's a clear -win when you have many tools and use few per turn; it's overhead when -you have few tools total. +schemas plus the catalog listing) and at least one extra round trip on +cold tools (describe → call) for the savings on the deferred schemas. +At tier 1 the listing keeps every capability visible, so the discovery +round trip usually disappears — the model goes straight to +`tool_describe`. Live benchmarking showed the listing mode matching +eager loading's task success while costing less than the bare bridge. -The `auto` default handles this for you. If you set `enabled: on` -unconditionally, expect a slight per-turn cost on small toolsets. +If you want the old always-eager behavior for a small toolset, set +`enabled: off`. ## Trade-offs that don't go away