diff --git a/PATCHES.md b/PATCHES.md index 45dee559dea..8c055bf34b9 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -71,6 +71,7 @@ half is upstreamable, the Argus behavior lives in project config). | [#43](#patch-43) | per-run allowed-tools from schedule frontmatter | argus-additive | this PR | | [#44](#patch-44) | unattended-silence: no blank-final retry, wider narration backstop, no token logging | argus-edit | this PR | | [#45](#patch-45) | delivery-report callback for scheduled playbook fires | argus-additive | this PR | +| [#46](#patch-46) | tool_search.exclude: deferral opt-out for hot MCP tools | argus-edit | this PR | Dropped / deferred / not-carried records are at the bottom, followed by the carry budget ledger. @@ -1064,3 +1065,30 @@ scope and is not directly comparable; like-for-like against v2.0.0 the pre-#40 tip was 2246 app-code (1099 in `app/channels/`). Reproduce with: `git diff --numstat v2.0.0 | while read a d f; do git cat-file -e "v2.0.0:$f" 2>/dev/null && echo "$a $d $f"; done | awk '...'`. + + +## Patch #46 + +**Patch #46 - tool_search.exclude: deferral opt-out for hot MCP tools.** + +Deferral (tool_search.enabled) is all-or-nothing over MCP-tagged tools. The +Argus capability rewrite measured the cost on the canary (goldset-wide-v2, +15 runs/arm, 2026-07-23): the promotion round-trip doubled mean wall clock +(13.0s -> 26.3s) because the HOT knowledge tools (pythia_query, kb_query, +entity_search, code_search_*) are used in most turns - their schemas cost +less context than the extra local-27B round costs latency. The placement +rubric wants hot tools bound and only the long tail (30 atlas control-plane +tools, future integration suites) deferred. + +`ToolSearchConfig` gains `exclude: list[str]` - fnmatch patterns matched +against FINAL (server-prefixed) tool names. Excluded MCP tools skip the +deferred catalog and stay always-bound; the assemble fail-closed guard +ignores excluded tools (all-excluded is a valid empty setup, not an error). +Threaded through all four assemble_deferred_tools call sites (lead x2, +embedded client, subagent executor). + +Exit condition: upstream grows per-server or per-tool deferral control in +extensions_config/tool_search config; migrate the exclude list there and +drop this patch. + +Tests: backend/tests/test_deferred_setup.py::TestExclude (4). diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 7cbf2872a96..c4a0b266cd1 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -564,7 +564,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # remains deterministic before the custom agent's own config exists. raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent] filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, extra_allowed=extra_allowed) - final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) + final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled, exclude=resolved_app_config.tool_search.exclude) return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False), tools=final_tools, @@ -591,7 +591,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Default lead agent (unchanged behavior) raw_tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config) filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills_for_tool_policy, extra_allowed=extra_allowed) - final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) + final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled, exclude=resolved_app_config.tool_search.exclude) return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False), tools=final_tools, diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index b0c0b8b1365..8727593df00 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -239,7 +239,7 @@ def _ensure_agent(self, config: RunnableConfig): max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled) - final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled) + final_tools, deferred_setup = assemble_deferred_tools(tools, enabled=self._app_config.tool_search.enabled, exclude=self._app_config.tool_search.exclude) kwargs: dict[str, Any] = { # attach_tracing=False because ``stream()`` injects tracing # callbacks at the graph invocation root so a single embedded run diff --git a/backend/packages/harness/deerflow/config/tool_search_config.py b/backend/packages/harness/deerflow/config/tool_search_config.py index cdeddabf21f..fc0fcc6f0a9 100644 --- a/backend/packages/harness/deerflow/config/tool_search_config.py +++ b/backend/packages/harness/deerflow/config/tool_search_config.py @@ -15,6 +15,15 @@ class ToolSearchConfig(BaseModel): default=False, description="Defer tools and enable tool_search", ) + exclude: list[str] = Field( + default_factory=list, + description=( + "fnmatch patterns of FINAL (server-prefixed) tool names that stay " + "always-bound instead of deferred, e.g. ['pythia_*', 'kb_query']. " + "For hot tools used in most turns, where the tool_search promotion " + "round-trip would cost more latency than their schemas cost context." + ), + ) _tool_search_config: ToolSearchConfig | None = None diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index a89df406649..a3ed61fc03a 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -449,8 +449,8 @@ async def _build_initial_state(self, task: str) -> tuple[dict[str, Any], list[Ba # subagent's name-level allow/deny (config.tools / disallowed_tools): # its catalog is built from the already-filtered list, so it can never # surface a tool the policy denied. This matches the lead agent. - enabled = (self.app_config or get_app_config()).tool_search.enabled - final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=enabled) + _ts_config = (self.app_config or get_app_config()).tool_search + final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=_ts_config.enabled, exclude=_ts_config.exclude) skill_messages = await self._load_skill_messages(skills) # Combine system_prompt and skills into a single SystemMessage. diff --git a/backend/packages/harness/deerflow/tools/builtins/tool_search.py b/backend/packages/harness/deerflow/tools/builtins/tool_search.py index c2431151054..f97ec907437 100644 --- a/backend/packages/harness/deerflow/tools/builtins/tool_search.py +++ b/backend/packages/harness/deerflow/tools/builtins/tool_search.py @@ -14,6 +14,7 @@ when it carries the ``deerflow_mcp`` metadata tag. """ +import fnmatch import hashlib import json import logging @@ -160,7 +161,16 @@ def tool_search(query: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> return tool_search -def build_deferred_tool_setup(filtered_tools: list[BaseTool], *, enabled: bool) -> DeferredToolSetup: +def _is_excluded(name: str, exclude) -> bool: + """True when a tool name matches any tool_search.exclude pattern. Excluded + MCP tools stay always-bound: for hot tools (pythia_query and friends, used + in most turns) the promotion round-trip costs more latency than their + schemas cost context (measured: canary bench 2026-07-23, +13s mean wall). + """ + return any(fnmatch.fnmatchcase(name, pat) for pat in exclude or ()) + + +def build_deferred_tool_setup(filtered_tools: list[BaseTool], *, enabled: bool, exclude=()) -> DeferredToolSetup: """Build the deferred-tool setup from a POLICY-FILTERED tool list. Must be called after skill/agent tool-policy filtering so the catalog never @@ -173,7 +183,8 @@ def build_deferred_tool_setup(filtered_tools: list[BaseTool], *, enabled: bool) if not enabled: # Deferral disabled: defer nothing; the model binds every tool as before. return DeferredToolSetup(None, frozenset(), None) - deferred = [t for t in filtered_tools if is_mcp_tool(t)] + deferred = [t for t in filtered_tools + if is_mcp_tool(t) and not _is_excluded(t.name, exclude)] if not deferred: # Enabled, but no MCP tool to defer: same empty result, different reason. return DeferredToolSetup(None, frozenset(), None) @@ -181,7 +192,7 @@ def build_deferred_tool_setup(filtered_tools: list[BaseTool], *, enabled: bool) return DeferredToolSetup(build_tool_search_tool(catalog), catalog.names, catalog.hash) -def assemble_deferred_tools(filtered_tools: list[BaseTool], *, enabled: bool) -> tuple[list[BaseTool], DeferredToolSetup]: +def assemble_deferred_tools(filtered_tools: list[BaseTool], *, enabled: bool, exclude=()) -> tuple[list[BaseTool], DeferredToolSetup]: """Build the final tool list + deferred setup from a POLICY-FILTERED list. Call AFTER tool-policy filtering so the deferred catalog never exposes a tool @@ -192,8 +203,10 @@ def assemble_deferred_tools(filtered_tools: list[BaseTool], *, enabled: bool) -> Shared by every agent-build path (lead, embedded client, subagent) so they all get the same fail-closed guarantee from one place. """ - deferred_setup = build_deferred_tool_setup(filtered_tools, enabled=enabled) - if enabled and not deferred_setup.deferred_names and any(is_mcp_tool(t) for t in filtered_tools): + deferred_setup = build_deferred_tool_setup(filtered_tools, enabled=enabled, exclude=exclude) + if enabled and not deferred_setup.deferred_names and any( + is_mcp_tool(t) and not _is_excluded(t.name, exclude) for t in filtered_tools + ): raise RuntimeError("tool_search enabled and MCP tools survived policy filtering, but no deferred set was recovered - refusing to bind MCP schemas (fail-closed).") final_tools = list(filtered_tools) if deferred_setup.tool_search_tool: diff --git a/backend/tests/test_deferred_setup.py b/backend/tests/test_deferred_setup.py index fb6099637db..7d175d344f8 100644 --- a/backend/tests/test_deferred_setup.py +++ b/backend/tests/test_deferred_setup.py @@ -60,3 +60,54 @@ def test_tool_search_no_match_empty_names(): ts = build_tool_search_tool(catalog) out = ts.invoke({"type": "tool_call", "name": "tool_search", "args": {"query": "select:nonexistent"}, "id": "tc2"}) assert out.update["promoted"]["names"] == [] + + +def _named_mcp_tool(name: str): + """A tagged MCP tool with an explicit name (mirrors the mcp_calc fixture).""" + + def _fn(text: str) -> str: + """Echo.""" + return text + + t = as_tool(name)(_fn) + tag_mcp_tool(t) + return t + + +class TestExclude: + """tool_search.exclude: hot MCP tools stay always-bound (patch #46).""" + + def test_excluded_tool_stays_bound(self): + from deerflow.tools.builtins.tool_search import assemble_deferred_tools + + hot = _named_mcp_tool("pythia_query") + cold = _named_mcp_tool("atlas_get_status") + final, setup = assemble_deferred_tools([hot, cold], enabled=True, exclude=["pythia_*"]) + assert "pythia_query" not in setup.deferred_names + assert "atlas_get_status" in setup.deferred_names + assert any(t.name == "pythia_query" for t in final) + + def test_all_excluded_is_empty_setup_not_error(self): + from deerflow.tools.builtins.tool_search import assemble_deferred_tools + + hot = _named_mcp_tool("kb_query") + final, setup = assemble_deferred_tools([hot], enabled=True, exclude=["kb_query"]) + assert setup.tool_search_tool is None + assert setup.deferred_names == frozenset() + assert [t.name for t in final] == ["kb_query"] + + def test_exact_and_glob_patterns(self): + from deerflow.tools.builtins.tool_search import _is_excluded + + assert _is_excluded("pythia_query", ["pythia_*"]) + assert _is_excluded("kb_query", ["kb_query"]) + assert not _is_excluded("atlas_get_status", ["pythia_*", "kb_query"]) + assert not _is_excluded("pythia_query", []) + assert not _is_excluded("pythia_query", None) + + def test_config_default_empty(self): + from deerflow.config.tool_search_config import ToolSearchConfig + + assert ToolSearchConfig().exclude == [] + cfg = ToolSearchConfig.model_validate({"enabled": True, "exclude": ["pythia_*"]}) + assert cfg.exclude == ["pythia_*"]