feat(tools): tiered tool disclosure — always defer MCP/plugin tools, scale the listing with catalog size

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)
This commit is contained in:
Teknium 2026-07-20 09:33:03 -07:00
parent d799184ede
commit c80ce5aae2
No known key found for this signature in database
5 changed files with 213 additions and 96 deletions

View file

@ -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,
},
},

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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 0100. |
| `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 0100. |
| `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 150. |
| `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 20020000. |
| `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 20060000. |
### 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