diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c648c3f05fd2..b0d9b3007c1e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2088,7 +2088,8 @@ def _ensure_hermes_home_managed(home: Path): # tool_search / tool_describe / tool_call — and surfaced on demand. # # Core Hermes tools (terminal, read_file, write_file, patch, - # search_files, todo, memory, browser_*, etc.) are NEVER deferred. + # search_files, todo, memory, browser_*, etc.) are never deferred by + # default; ``include_builtin`` opts them in, minus ``always_include``. # See tools/tool_search.py for full design notes and the # openclaw-tool-search-report PDF in this PR for the rationale. "tools": { @@ -2109,6 +2110,18 @@ def _ensure_hermes_home_managed(home: Path): "search_default_limit": 5, # Hard upper bound the model can request via ``limit``. Range 1..50. "max_search_limit": 20, + # Opt-in (hermes-agent#6839): make builtin (core) tools outside + # ``always_include`` deferrable too. On installs with few or no + # MCP servers the builtin schemas ARE the per-turn overhead. + "include_builtin": False, + # Tool names that never defer. Omit for the lean default hot set + # (terminal/process, file tools, web tools, execute_code, skill + # tools + the agent-loop floor). A user-provided list replaces + # the default hot set but is always unioned with the agent-loop + # floor (todo, memory, session_search, delegate_task, clarify). + # Accepts MCP/plugin names too — pinning never adds tools the + # session wasn't granted. + # "always_include": ["terminal", "read_file", "web_search"], }, }, diff --git a/model_tools.py b/model_tools.py index 22719a5daef0..f55a0af9b52e 100644 --- a/model_tools.py +++ b/model_tools.py @@ -507,8 +507,10 @@ def _compute_tool_definitions( # Conditionally replace MCP + plugin (non-core) tools with three bridge # tools (tool_search / tool_describe / tool_call) when the deferrable # surface exceeds the configured threshold (default 10% of context - # window). Core Hermes tools (toolsets._HERMES_CORE_TOOLS) are NEVER - # deferred. See tools/tool_search.py for full design notes. + # window). Core Hermes tools (toolsets._HERMES_CORE_TOOLS) are never + # deferred by default; tools.tool_search.include_builtin opts them in, + # minus the always_include set. See tools/tool_search.py for full + # design notes. # # This is deliberately the last step before returning — sanitization # has already normalized schemas, and the assembly is idempotent in @@ -525,7 +527,7 @@ def _compute_tool_definitions( ) if assembly.activated and not quiet_mode: print( - f"🔎 Tool Search: {assembly.deferred_count} MCP/plugin tools deferred " + f"🔎 Tool Search: {assembly.deferred_count} tools deferred " f"(~{assembly.deferred_tokens} tokens) behind tool_search/describe/call. " f"Threshold ~{assembly.threshold_tokens} tokens." ) diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 9c8c8a33c178..342a2e5ef186 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -536,3 +536,179 @@ def test_scoped_deferrable_names_helper(self): # core tools are never deferrable assert "terminal" not in names + +# --------------------------------------------------------------------------- +# Builtin (core) tool deferral — the include_builtin opt-in (#6839). +# +# Every test passes an explicit ToolSearchConfig so results never depend on +# the developer's ~/.hermes/config.yaml. +# --------------------------------------------------------------------------- + + +def _cfg(**overrides): + from tools.tool_search import ToolSearchConfig + return ToolSearchConfig.from_raw(overrides) + + +class TestBuiltinDeferralConfig: + def test_include_builtin_defaults_off(self): + from tools.tool_search import ToolSearchConfig + assert ToolSearchConfig.from_raw(None).include_builtin is False + assert ToolSearchConfig.from_raw(True).include_builtin is False + assert ToolSearchConfig.from_raw({}).include_builtin is False + + def test_include_builtin_parses_bool_and_strings(self): + assert _cfg(include_builtin=True).include_builtin is True + assert _cfg(include_builtin="true").include_builtin is True + assert _cfg(include_builtin="off").include_builtin is False + assert _cfg(include_builtin="garbage").include_builtin is False + + def test_always_include_defaults_to_hot_set(self): + from tools.tool_search import DEFAULT_ALWAYS_INCLUDE, ALWAYS_INCLUDE_FLOOR + cfg = _cfg(include_builtin=True) + assert cfg.always_include == DEFAULT_ALWAYS_INCLUDE + assert ALWAYS_INCLUDE_FLOOR <= cfg.always_include + + def test_user_always_include_extends_floor_but_cannot_remove_it(self): + from tools.tool_search import ALWAYS_INCLUDE_FLOOR + cfg = _cfg(include_builtin=True, always_include=["terminal"]) + assert "terminal" in cfg.always_include + # Floor names survive even though the user list omitted them. + assert ALWAYS_INCLUDE_FLOOR <= cfg.always_include + # And the default hot set is replaced, not merged. + assert "web_search" not in cfg.always_include + + def test_always_include_non_list_falls_back_to_default(self): + from tools.tool_search import DEFAULT_ALWAYS_INCLUDE + cfg = _cfg(include_builtin=True, always_include="terminal") + assert cfg.always_include == DEFAULT_ALWAYS_INCLUDE + + +class TestBuiltinDeferralClassification: + def test_core_tools_never_defer_with_default_config(self): + """The original invariant holds verbatim when include_builtin is off.""" + from tools.tool_search import is_deferrable_tool_name + cfg = _cfg() + for core_name in ["terminal", "read_file", "browser_navigate", + "cronjob", "send_message", "computer_use"]: + assert not is_deferrable_tool_name(core_name, config=cfg) + + def test_opt_in_makes_cold_core_tools_deferrable(self): + from tools.tool_search import is_deferrable_tool_name + cfg = _cfg(include_builtin=True) + for name in ["browser_navigate", "browser_click", "cronjob", + "send_message", "computer_use", "kanban_show"]: + assert is_deferrable_tool_name(name, config=cfg), ( + f"'{name}' should be deferrable under include_builtin" + ) + + def test_default_hot_set_stays_direct_under_opt_in(self): + from tools.tool_search import is_deferrable_tool_name + cfg = _cfg(include_builtin=True) + for name in ["terminal", "process", "read_file", "write_file", + "patch", "search_files", "web_search", "web_extract", + "execute_code", "skills_list"]: + assert not is_deferrable_tool_name(name, config=cfg) + + def test_floor_tools_never_defer_even_with_minimal_always_include(self): + from tools.tool_search import is_deferrable_tool_name + cfg = _cfg(include_builtin=True, always_include=["terminal"]) + # Agent-loop tools are serviced by run_agent itself — deferring them + # would break the loop, so the floor wins over user config. + for name in ["todo", "memory", "session_search", "delegate_task", + "clarify"]: + assert not is_deferrable_tool_name(name, config=cfg) + # But non-floor core tools outside the user's list now defer. + assert is_deferrable_tool_name("web_search", config=cfg) + + def test_always_include_pins_non_core_names_too(self): + from tools.tool_search import is_deferrable_tool_name + cfg = _cfg(always_include=["mcp_pinned_example_op"]) + # The pin short-circuits before any registry lookup. + assert not is_deferrable_tool_name("mcp_pinned_example_op", config=cfg) + + def test_bridge_tools_still_never_defer(self): + from tools.tool_search import is_deferrable_tool_name, BRIDGE_TOOL_NAMES + cfg = _cfg(include_builtin=True, always_include=[]) + for name in BRIDGE_TOOL_NAMES: + assert not is_deferrable_tool_name(name, config=cfg) + + +class TestBuiltinDeferralAssembly: + @staticmethod + def _defs(): + return [ + _td("terminal", "Run shell commands"), + _td("read_file", "Read a file"), + _td("browser_navigate", "Navigate the browser"), + _td("browser_click", "Click an element"), + _td("cronjob", "Manage cron jobs"), + ] + + def test_classify_splits_builtin_by_always_include(self): + from tools.tool_search import classify_tools + cfg = _cfg(include_builtin=True) + visible, deferrable = classify_tools(self._defs(), config=cfg) + vnames = {(t.get("function") or {}).get("name") for t in visible} + dnames = {(t.get("function") or {}).get("name") for t in deferrable} + assert {"terminal", "read_file"} <= vnames + assert {"browser_navigate", "browser_click", "cronjob"} <= dnames + + def test_assembly_defers_builtin_and_keeps_hot_set(self): + from tools.tool_search import assemble_tool_defs, BRIDGE_TOOL_NAMES + cfg = _cfg(enabled="on", include_builtin=True) + result = assemble_tool_defs( + self._defs(), context_length=200_000, config=cfg, + ) + assert result.activated + names = {(t.get("function") or {}).get("name") for t in result.tool_defs} + assert {"terminal", "read_file"} <= names + assert BRIDGE_TOOL_NAMES <= names + assert "browser_navigate" not in names + assert result.deferred_count == 3 + + def test_assembly_unchanged_without_opt_in(self): + """Same defs, include_builtin off → pure passthrough.""" + from tools.tool_search import assemble_tool_defs + cfg = _cfg(enabled="on") + result = assemble_tool_defs( + self._defs(), context_length=200_000, config=cfg, + ) + assert not result.activated + names = {(t.get("function") or {}).get("name") for t in result.tool_defs} + assert "browser_navigate" in names + + def test_catalog_classifies_builtin_source(self): + from tools.tool_search import build_catalog + catalog = build_catalog([_td("browser_navigate", "Navigate the browser")]) + assert catalog[0].source == "builtin" + + def test_describe_serves_builtin_schema_under_opt_in(self): + from tools.tool_search import dispatch_tool_describe + out = json.loads(dispatch_tool_describe( + {"name": "browser_navigate"}, + current_tool_defs=self._defs(), + config=_cfg(include_builtin=True), + )) + assert out.get("name") == "browser_navigate" + assert "parameters" in out + + def test_describe_rejects_builtin_without_opt_in(self): + from tools.tool_search import dispatch_tool_describe + out = json.loads(dispatch_tool_describe( + {"name": "browser_navigate"}, + current_tool_defs=self._defs(), + config=_cfg(), + )) + assert "error" in out + + def test_scoped_deferrable_names_respects_config(self): + from tools.tool_search import scoped_deferrable_names + defs = self._defs() + assert "browser_navigate" not in scoped_deferrable_names( + defs, config=_cfg(), + ) + names = scoped_deferrable_names(defs, config=_cfg(include_builtin=True)) + assert "browser_navigate" in names + assert "terminal" not in names + diff --git a/tools/tool_search.py b/tools/tool_search.py index e885a5d7b88c..4a77b7d8dd8d 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -2,13 +2,18 @@ When enabled, MCP and non-core plugin tools are replaced in the model-visible tools array by three bridge tools — ``tool_search``, ``tool_describe``, -``tool_call`` — and surfaced on demand. Core Hermes tools never defer. +``tool_call`` — and surfaced on demand. Core Hermes tools never defer by +default; ``include_builtin`` extends deferral to them as an explicit opt-in. Design constraints this module is built around (see ``openclaw-tool-search-report`` for the full rationale): -* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred. - Always-load means always-load. No exceptions. +* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred + by default. ``tools.tool_search.include_builtin: true`` relaxes this as an + explicit opt-in for installs where builtin schemas dominate the per-turn + overhead (hermes-agent#6839) — and even then the ``always_include`` set + stays directly visible, with a hard floor (``ALWAYS_INCLUDE_FLOOR``) of + agent-loop tools that user config can extend but never remove. * 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. @@ -46,6 +51,28 @@ BRIDGE_TOOL_NAMES = frozenset({TOOL_SEARCH_NAME, TOOL_DESCRIBE_NAME, TOOL_CALL_NAME}) +# Hard floor under ``always_include``: tools the agent loop intercepts and +# services itself (run_agent's TodoStore / MemoryStore / delegation — see +# model_tools._AGENT_LOOP_TOOLS) plus the clarification primitive. Deferring +# these breaks the loop, so user config can extend ``always_include`` but +# never remove these names from it. +ALWAYS_INCLUDE_FLOOR = frozenset({ + "todo", "memory", "session_search", "delegate_task", "clarify", +}) + +# Default ``always_include`` when ``include_builtin`` is enabled and the user +# does not set ``tools.tool_search.always_include``: the lean "hot set" from +# the hermes-agent#6839 measurements. Files, terminal, web, code execution +# and skills stay direct; browser / kanban / Home Assistant / media tools +# become deferrable. +DEFAULT_ALWAYS_INCLUDE = ALWAYS_INCLUDE_FLOOR | frozenset({ + "terminal", "process", + "read_file", "write_file", "patch", "search_files", + "web_search", "web_extract", + "execute_code", + "skills_list", "skill_view", "skill_manage", +}) + # When estimating tokens from char count without a real tokenizer, this is # the cheap rule of thumb that's stable across providers. Roughly 4 chars # per token for English+JSON. Underestimating leads to false negatives @@ -68,6 +95,12 @@ class ToolSearchConfig: threshold_pct: float # 0..100 — only used when enabled == "auto" search_default_limit: int max_search_limit: int + # Opt-in: make builtin (core) tools deferrable too, except always_include. + include_builtin: bool = False + # Names that never defer. Only consulted as an *exclusion*: adding a name + # here never makes an unavailable tool available. Always a superset of + # ALWAYS_INCLUDE_FLOOR. + always_include: frozenset = DEFAULT_ALWAYS_INCLUDE @classmethod def from_raw(cls, raw: Any) -> "ToolSearchConfig": @@ -106,11 +139,24 @@ def from_raw(cls, raw: Any) -> "ToolSearchConfig": search_default_limit = max(1, min(max_search_limit, _safe_int(raw.get("search_default_limit"), 5))) + include_builtin = _safe_bool(raw.get("include_builtin"), False) + always_raw = raw.get("always_include") + if isinstance(always_raw, (list, tuple)): + user_always = frozenset( + str(x).strip() for x in always_raw if str(x).strip() + ) + # User config extends the floor; it can never remove floor names. + always_include = user_always | ALWAYS_INCLUDE_FLOOR + else: + always_include = DEFAULT_ALWAYS_INCLUDE + return cls( enabled=enabled, threshold_pct=threshold_pct, search_default_limit=search_default_limit, max_search_limit=max_search_limit, + include_builtin=include_builtin, + always_include=always_include, ) @@ -128,6 +174,19 @@ def _safe_float(value: Any, fallback: float) -> float: return fallback +def _safe_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if value is None: + return fallback + s = str(value).strip().lower() + if s in ("true", "1", "yes", "on"): + return True + if s in ("false", "0", "no", "off"): + return False + return fallback + + def load_config() -> ToolSearchConfig: """Load tool-search config from the user config file.""" try: @@ -160,18 +219,27 @@ def _core_tool_names() -> frozenset[str]: return frozenset() -def is_deferrable_tool_name(name: str) -> bool: +def is_deferrable_tool_name(name: str, config: Optional[ToolSearchConfig] = None) -> bool: """Return True if a tool with this name is *eligible* for deferral. A tool is deferrable iff it is registered with an MCP toolset prefix OR it is not in ``_HERMES_CORE_TOOLS``. Core tools are never deferred - even when their toolset is technically plugin-provided (this protects - against accidental shadowing). + by default, even when their toolset is technically plugin-provided + (this protects against accidental shadowing); ``include_builtin`` makes + them eligible too. Names in ``always_include`` never defer regardless + of source — pinning applies to MCP/plugin tools as well. + + ``config`` defaults to the user config; callers iterating many names + should resolve it once and pass it through (``classify_tools`` does). """ if name in BRIDGE_TOOL_NAMES: return False - if name in _core_tool_names(): + if config is None: + config = load_config() + if name in config.always_include: return False + if name in _core_tool_names(): + return config.include_builtin # Check registry toolset for MCP prefix. try: from tools.registry import registry @@ -186,13 +254,18 @@ def is_deferrable_tool_name(name: str) -> bool: return False -def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: +def classify_tools( + tool_defs: List[Dict[str, Any]], + config: Optional[ToolSearchConfig] = None, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Split a tool-defs list into (visible, deferrable). ``visible`` retains every tool that must stay in the model-facing array: - every core tool, plus any tool we can't classify. ``deferrable`` is the - candidate set for catalog entry. + every always-include/core tool, plus any tool we can't classify. + ``deferrable`` is the candidate set for catalog entry. """ + if config is None: + config = load_config() visible: List[Dict[str, Any]] = [] deferrable: List[Dict[str, Any]] = [] for td in tool_defs: @@ -202,7 +275,7 @@ def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any] # Should never happen — bridge tools are added after classification — # but be defensive. continue - if is_deferrable_tool_name(name): + if is_deferrable_tool_name(name, config=config): deferrable.append(td) else: visible.append(td) @@ -270,7 +343,7 @@ class CatalogEntry: name: str description: str schema: Dict[str, Any] # The full {"type":"function", "function": {...}} entry. - source: str # "mcp" | "plugin" | "other" + source: str # "mcp" | "plugin" | "builtin" | "other" source_name: str # Toolset name, e.g. "mcp-github" or "kanban" # Pre-tokenized fields for BM25. @@ -306,16 +379,20 @@ def _entry_search_text(td: Dict[str, Any]) -> str: def _classify_source(name: str) -> Tuple[str, str]: """Return (source_kind, source_name) for a registered tool name.""" + is_core = name in _core_tool_names() try: from tools.registry import registry entry = registry.get_entry(name) + if entry is not None and entry.toolset.startswith("mcp-"): + return ("mcp", entry.toolset) + if is_core: + # Only present in the catalog when include_builtin is enabled. + return ("builtin", entry.toolset if entry is not None else "") if entry is None: return ("other", "") - if entry.toolset.startswith("mcp-"): - return ("mcp", entry.toolset) return ("plugin", entry.toolset) except Exception: - return ("other", "") + return ("builtin", "") if is_core else ("other", "") def build_catalog(tool_defs: List[Dict[str, Any]]) -> List[CatalogEntry]: @@ -535,9 +612,10 @@ def assemble_tool_defs( """Return the tool-defs list the model should actually see. When tool search is inactive (off, no deferrable tools, or below - threshold), this is a passthrough. When active, MCP and plugin tools - are stripped from the visible list and replaced with the three bridge - tools. Core tools are *never* deferred regardless of config. + threshold), this is a passthrough. When active, MCP and plugin tools — + plus builtin tools outside ``always_include`` when ``include_builtin`` + is enabled — are stripped from the visible list and replaced with the + three bridge tools. Idempotent: calling with bridge tools already in the input is a no-op (they classify as non-core/non-deferrable but their names are reserved, @@ -551,7 +629,7 @@ def assemble_tool_defs( incoming = [td for td in tool_defs if (td.get("function") or {}).get("name") not in BRIDGE_TOOL_NAMES] - visible, deferrable = classify_tools(incoming) + visible, deferrable = classify_tools(incoming, config=config) if not deferrable: return AssemblyResult(tool_defs=incoming, activated=False) @@ -570,7 +648,7 @@ def assemble_tool_defs( threshold_tokens = int((context_length or 0) * (config.threshold_pct / 100.0)) logger.info( - "tool_search activated: %d core/visible tools kept, %d deferred (~%d tokens, threshold ~%d)", + "tool_search activated: %d visible tools kept, %d deferred (~%d tokens, threshold ~%d)", len(visible), len(deferrable), deferrable_tokens, threshold_tokens, ) @@ -619,7 +697,7 @@ def dispatch_tool_search(args: Dict[str, Any], else: limit = max(1, min(config.max_search_limit, _safe_int(raw_limit, config.search_default_limit))) - _, deferrable = classify_tools(current_tool_defs) + _, deferrable = classify_tools(current_tool_defs, config=config) catalog = build_catalog(deferrable) hits = search_catalog(catalog, query, limit=limit) return json.dumps({ @@ -631,19 +709,22 @@ def dispatch_tool_search(args: Dict[str, Any], def dispatch_tool_describe(args: Dict[str, Any], *, - current_tool_defs: List[Dict[str, Any]]) -> str: + current_tool_defs: List[Dict[str, Any]], + config: Optional[ToolSearchConfig] = None) -> str: """Execute the ``tool_describe`` bridge tool. Returns a JSON string.""" + if config is None: + config = load_config() name = str(args.get("name") or "").strip() if not name: return json.dumps({"error": "name is required"}, ensure_ascii=False) - if not is_deferrable_tool_name(name): + if not is_deferrable_tool_name(name, config=config): return json.dumps({ "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." ), }, ensure_ascii=False) - _, deferrable = classify_tools(current_tool_defs) + _, deferrable = classify_tools(current_tool_defs, config=config) for td in deferrable: fn = td.get("function") or {} if fn.get("name") == name: @@ -657,7 +738,10 @@ def dispatch_tool_describe(args: Dict[str, Any], }, ensure_ascii=False) -def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: +def scoped_deferrable_names( + tool_defs: List[Dict[str, Any]], + config: Optional[ToolSearchConfig] = None, +) -> frozenset[str]: """Return the set of deferrable tool names present in ``tool_defs``. ``tool_defs`` is expected to be the *pre-assembly* tool list for the @@ -669,10 +753,12 @@ def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: ``tool_executor`` unwrap so a restricted-toolset session can never invoke an out-of-scope tool via the bridge. """ + if config is None: + config = load_config() names: set[str] = set() for td in tool_defs: name = (td.get("function") or {}).get("name", "") - if name and is_deferrable_tool_name(name): + if name and is_deferrable_tool_name(name, config=config): names.add(name) return frozenset(names) @@ -715,6 +801,8 @@ def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[s "TOOL_DESCRIBE_NAME", "TOOL_CALL_NAME", "BRIDGE_TOOL_NAMES", + "ALWAYS_INCLUDE_FLOOR", + "DEFAULT_ALWAYS_INCLUDE", "ToolSearchConfig", "CatalogEntry", "AssemblyResult", diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md index fb65ad29be34..62c45c2bd4c5 100644 --- a/website/docs/user-guide/features/tool-search.md +++ b/website/docs/user-guide/features/tool-search.md @@ -15,13 +15,14 @@ problem. When activated, MCP and plugin tools are replaced in the model-visible tools array by three bridge tools, and the model loads each specific tool's schema on demand. -:::info Built-in Hermes tools never defer +:::info Built-in Hermes tools never defer by default The tools that make up Hermes' core capability set (`terminal`, `read_file`, `write_file`, `patch`, `search_files`, `todo`, `memory`, `browser_*`, `web_search`, `web_extract`, `clarify`, `execute_code`, `delegate_task`, `session_search`, `send_message`, and the rest of -`_HERMES_CORE_TOOLS`) are *always* loaded directly. Only MCP tools and -non-core plugin tools are eligible for deferral. +`_HERMES_CORE_TOOLS`) are loaded directly unless you explicitly opt in +with [`include_builtin`](#deferring-builtin-tools-opt-in). By default +only MCP tools and non-core plugin tools are eligible for deferral. ::: ## How it works @@ -78,6 +79,8 @@ tools: threshold_pct: 10 # percentage of context — only used in auto mode search_default_limit: 5 max_search_limit: 20 + include_builtin: false # opt-in: defer builtin tools too (see below) + # always_include: [...] # names that never defer (see below) ``` | Key | Default | Meaning | @@ -86,6 +89,8 @@ tools: | `threshold_pct` | `10` | Percentage of context length at which `auto` mode kicks in. 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. | +| `include_builtin` | `false` | Opt-in: builtin (core) tools outside `always_include` become deferrable too. | +| `always_include` | lean hot set | Tool names that never defer. Extends — can never remove — the agent-loop floor. | You can also flip the legacy boolean shape: @@ -94,6 +99,45 @@ tools: tool_search: true # equivalent to {enabled: auto} ``` +## Deferring builtin tools (opt-in) + +On installs with no or few MCP servers, the dominant per-turn schema cost +is Hermes' own builtin toolset — measurements in +[hermes-agent#6839](https://github.com/NousResearch/hermes-agent/issues/6839) +put a typical 42-tool builtin surface at roughly 15K tokens per call, and +local models pay it again in prefill time on every turn. + +`include_builtin: true` extends Tool Search to builtin tools: + +```yaml +tools: + tool_search: + enabled: auto + include_builtin: true + # Optional — override the default hot set: + # always_include: [terminal, read_file, write_file, web_search] +``` + +- Builtin tools **not** in `always_include` join the deferred catalog and + are loaded on demand exactly like MCP tools. +- When you don't set `always_include`, a lean default hot set stays + direct: `terminal`, `process`, `read_file`, `write_file`, `patch`, + `search_files`, `web_search`, `web_extract`, `execute_code`, the skill + tools, and the agent-loop floor. Browser, kanban, Home Assistant, and + media tools defer — they are the bulk of the schema bytes and the least + used in text-first sessions. +- If you set `always_include`, your list **replaces** the default hot set + but is always unioned with the agent-loop floor (`todo`, `memory`, + `session_search`, `delegate_task`, `clarify`) — those are serviced by + the agent loop itself and can never be deferred, no matter the config. +- `always_include` also accepts MCP/plugin tool names, so you can pin a + hot MCP tool (e.g. a search tool you call every turn) while everything + else defers. Pinning never *adds* tools — a name outside the session's + toolsets stays unavailable. +- The same threshold gate applies: in `auto` mode nothing changes until + the deferrable surface (now including builtin schemas) crosses + `threshold_pct` of the context window. + ## When NOT to use it Tool Search trades a fixed per-turn token cost (the three bridge tool