deer-flow/backend/tests/test_tool_search.py
Ryker_Feng ebc09ce130
feat(mcp): auto-promote deferred MCP tools from routing hints (#4019)
* feat(mcp): auto-promote deferred MCP tools from routing hints

When tool_search.enabled=true defers MCP tool schemas, PR1 routing hints
still require the model to spend a tool_search discovery round trip before
it can call the tool the routing metadata already points at. This adds a
McpRoutingMiddleware that matches the latest user message against PR1
routing keywords and promotes the matching deferred schemas before the
model call, removing that round trip.

Design (soft routing, opt-in, additive):
- Matches only the latest real HumanMessage (shared is_real_user_message
  helper, reused by SkillActivationMiddleware so the two cannot drift);
  case-insensitive substring match, no tokenizer dependency.
- Ordering: priority desc, then tool name asc; capped by the new global
  tool_search.auto_promote_top_k (default 3, clamped 1..5). Does not add or
  consume a per-tool auto_promote_top_k (PR1 schema unchanged); a per-tool
  value is ignored with a DEBUG note.
- Returns a plain {"promoted": ...} state update (not a Command) and relies
  on ThreadState.merge_promoted for union/dedupe, so auto-promote and a
  model-triggered tool_search converge on the same catalog hash.
- Installed before DeferredToolFilterMiddleware on every deferred-tool path
  (lead agent, subagent, embedded client, webhook via shared builders);
  a construction-time assert rejects the reversed order. catalog_hash is
  None / no routing index is a complete no-op, so bootstrap and ACP skip it.
- Privacy: never executes tools, never promotes policy-filtered tools, adds
  no routing keywords or matched tool names to trace metadata or INFO/WARN
  logs.

No behavior change when tool_search.enabled=false.

Tests: index construction, matching semantics, middleware state updates,
same-cycle deferred-filter interaction, lead/subagent/embedded-client
builder wiring + order invariant, config clamping, config.example.yaml
parseability, and privacy assertions.

* refactor(mcp): address auto-promote review nits

- executor: access app_config.tool_search.auto_promote_top_k directly to match
  the lead-agent and embedded-client paths (drop the over-defensive getattr that
  masked missing config); update the subagent test mock to carry tool_search.
- tool_search / mcp_routing_middleware: cross-reference the duplicated routing
  priority/keyword normalization between the builder and the middleware's
  defensive _normalize_index so they cannot silently drift.
- MCP_SERVER.md: document that auto-promote keyword matching is a case-insensitive
  substring test (not word-boundary), advising distinctive keywords.
2026-07-10 07:54:36 +08:00

84 lines
3.2 KiB
Python

"""Tests for the tool_search (deferred tool loading) config + prompt section.
Catalog search, setup assembly, the Command-writing tool_search tool, and the
filter middleware are covered by:
- tests/test_deferred_catalog.py
- tests/test_deferred_setup.py
- tests/test_deferred_filter_middleware.py
- tests/test_thread_state_promoted.py
"""
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
from deerflow.tools.builtins.tool_search import get_deferred_tools_prompt_section
class TestToolSearchConfig:
def test_default_disabled(self):
assert ToolSearchConfig().enabled is False
assert ToolSearchConfig().auto_promote_top_k == 3
def test_enabled(self):
assert ToolSearchConfig(enabled=True).enabled is True
def test_auto_promote_top_k_is_clamped(self):
assert ToolSearchConfig(auto_promote_top_k=0).auto_promote_top_k == 1
assert ToolSearchConfig(auto_promote_top_k=99).auto_promote_top_k == 5
def test_load_from_dict(self):
loaded = load_tool_search_config_from_dict({"enabled": True, "auto_promote_top_k": 4})
assert loaded.enabled is True
assert loaded.auto_promote_top_k == 4
def test_load_from_empty_dict(self):
assert load_tool_search_config_from_dict({}).enabled is False
assert load_tool_search_config_from_dict({}).auto_promote_top_k == 3
class TestConfigExampleToolSearchSection:
"""Guard the documented ``tool_search`` block in config.example.yaml.
The example file is the first-run template (``cp config.example.yaml
config.yaml``); a malformed indentation there breaks the whole file for
every downstream consumer, so pin that it parses and carries the PR2 field.
"""
def _load_example(self):
import os
import yaml
example_path = os.path.join(os.path.dirname(__file__), "..", "..", "config.example.yaml")
if not os.path.exists(example_path):
return None
with open(example_path, encoding="utf-8") as f:
return yaml.safe_load(f)
def test_config_example_parses(self):
# A raw yaml.safe_load raises on malformed indentation; asserting a
# dict result pins that the whole template stays parseable.
data = self._load_example()
if data is None:
return
assert isinstance(data, dict)
def test_config_example_tool_search_block(self):
data = self._load_example()
if data is None:
return
tool_search = data.get("tool_search")
assert isinstance(tool_search, dict)
assert tool_search.get("enabled") is False
assert tool_search.get("auto_promote_top_k") == 3
class TestDeferredToolsPromptSection:
def test_empty_without_names(self):
assert get_deferred_tools_prompt_section() == ""
def test_empty_with_empty_frozenset(self):
assert get_deferred_tools_prompt_section(deferred_names=frozenset()) == ""
def test_lists_sorted_names(self):
out = get_deferred_tools_prompt_section(deferred_names=frozenset({"b_tool", "a_tool"}))
assert out == "<available-deferred-tools>\na_tool\nb_tool\n</available-deferred-tools>"