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

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})")