Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
4 changes: 2 additions & 2 deletions backend/packages/harness/deerflow/agents/lead_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/packages/harness/deerflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/packages/harness/deerflow/subagents/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 18 additions & 5 deletions backend/packages/harness/deerflow/tools/builtins/tool_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
when it carries the ``deerflow_mcp`` metadata tag.
"""

import fnmatch
import hashlib
import json
import logging
Expand Down Expand Up @@ -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
Expand All @@ -173,15 +183,16 @@ 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)
catalog = DeferredToolCatalog(tuple(deferred))
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
Expand All @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions backend/tests/test_deferred_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_*"]
Loading