mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-17 18:28:36 +00:00
* 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.
138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
"""Auto-promote deferred MCP tools from routing metadata before model calls."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any, TypedDict, override
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain_core.messages import HumanMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.config.tool_search_config import clamp_auto_promote_top_k
|
|
from deerflow.utils.messages import get_original_user_content_text, is_real_user_message
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class McpRoutingIndexEntry(TypedDict):
|
|
priority: int
|
|
keywords: list[str]
|
|
|
|
|
|
McpRoutingIndex = Mapping[str, McpRoutingIndexEntry]
|
|
|
|
|
|
class McpRoutingMiddleware(AgentMiddleware[AgentState]):
|
|
"""Write minimal deferred-tool promotion state from latest user text.
|
|
|
|
The middleware intentionally receives only serialized routing data. It does
|
|
not hold ``BaseTool`` objects, does not execute tools, and does not filter
|
|
tool calls. ``DeferredToolFilterMiddleware`` remains responsible for hiding
|
|
unpromoted schemas and blocking unpromoted deferred tool calls.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
routing_index: McpRoutingIndex,
|
|
catalog_hash: str | None,
|
|
top_k: int,
|
|
) -> None:
|
|
super().__init__()
|
|
self._catalog_hash = catalog_hash
|
|
self._top_k = clamp_auto_promote_top_k(top_k)
|
|
self._routing_index = self._normalize_index(routing_index)
|
|
|
|
@staticmethod
|
|
def _normalize_index(routing_index: McpRoutingIndex) -> dict[str, tuple[int, tuple[str, ...]]]:
|
|
# Defensive re-normalization: this middleware is built to accept arbitrary
|
|
# serialized routing data, not only the output of
|
|
# tool_search._routing_priority / _routing_keywords. In practice it is a
|
|
# no-op over the builder's output; keep the coercion rules aligned with
|
|
# those two helpers if either side changes.
|
|
normalized: dict[str, tuple[int, tuple[str, ...]]] = {}
|
|
for raw_name, raw_entry in routing_index.items():
|
|
name = str(raw_name)
|
|
if not name:
|
|
continue
|
|
try:
|
|
priority = int(raw_entry.get("priority", 0))
|
|
except (TypeError, ValueError):
|
|
priority = 0
|
|
raw_keywords = raw_entry.get("keywords") or []
|
|
if not isinstance(raw_keywords, Sequence) or isinstance(raw_keywords, (str, bytes)):
|
|
raw_keywords = []
|
|
keywords = tuple(keyword for keyword in (str(item).strip() for item in raw_keywords) if keyword)
|
|
if not keywords:
|
|
continue
|
|
normalized[name] = (priority, keywords)
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _latest_user_message(messages: list[Any]) -> HumanMessage | None:
|
|
for message in reversed(messages):
|
|
if is_real_user_message(message):
|
|
return message
|
|
return None
|
|
|
|
def _matched_names(self, state: Mapping[str, Any] | None) -> list[str]:
|
|
if not self._catalog_hash or not self._routing_index:
|
|
return []
|
|
messages = list((state or {}).get("messages") or [])
|
|
target = self._latest_user_message(messages)
|
|
if target is None:
|
|
return []
|
|
|
|
text = get_original_user_content_text(target.content, target.additional_kwargs)
|
|
if not text:
|
|
return []
|
|
|
|
haystack = text.casefold()
|
|
matched: list[tuple[int, str]] = []
|
|
for name, (priority, keywords) in self._routing_index.items():
|
|
if any(keyword.casefold() in haystack for keyword in keywords):
|
|
matched.append((priority, name))
|
|
|
|
if not matched:
|
|
return []
|
|
|
|
matched.sort(key=lambda item: (-item[0], item[1]))
|
|
return [name for _, name in matched[: self._top_k]]
|
|
|
|
def _state_update(self, state: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
names = self._matched_names(state)
|
|
if not names:
|
|
return None
|
|
logger.debug(
|
|
"McpRoutingMiddleware auto-promoted %d deferred tool schema(s) catalog=%s names=%s",
|
|
len(names),
|
|
(self._catalog_hash or "")[:8],
|
|
names,
|
|
)
|
|
return {
|
|
"promoted": {
|
|
"catalog_hash": self._catalog_hash,
|
|
"names": names,
|
|
}
|
|
}
|
|
|
|
@override
|
|
def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
|
return self._state_update(state)
|
|
|
|
@override
|
|
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
|
|
return self._state_update(state)
|
|
|
|
|
|
def assert_mcp_routing_before_deferred_filter(middlewares: Sequence[AgentMiddleware]) -> None:
|
|
"""Fail fast if auto-promote would run after deferred schema filtering."""
|
|
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
|
|
|
routing_idx = next((idx for idx, middleware in enumerate(middlewares) if isinstance(middleware, McpRoutingMiddleware)), None)
|
|
filter_idx = next((idx for idx, middleware in enumerate(middlewares) if isinstance(middleware, DeferredToolFilterMiddleware)), None)
|
|
if routing_idx is not None and filter_idx is not None and routing_idx > filter_idx:
|
|
raise RuntimeError(f"McpRoutingMiddleware must be installed before DeferredToolFilterMiddleware (routing index {routing_idx}, deferred filter index {filter_idx})")
|