mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +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.
93 lines
3.5 KiB
Python
93 lines
3.5 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
ORIGINAL_USER_CONTENT_KEY = "original_user_content"
|
|
SUMMARY_MESSAGE_NAME = "summary"
|
|
|
|
|
|
def message_content_to_text(content: Any) -> str:
|
|
"""Extract text from LangChain message content shapes."""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for item in content:
|
|
if isinstance(item, str):
|
|
parts.append(item)
|
|
elif isinstance(item, dict):
|
|
text = item.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return "\n".join(part for part in parts if part)
|
|
return str(content)
|
|
|
|
|
|
def message_to_text(message: Any, *, text_attribute_fallback: bool = False) -> str:
|
|
"""Extract display text from a whole message (``BaseMessage`` or dict-shaped).
|
|
|
|
Reads ``content`` from either an attribute (``BaseMessage``) or a mapping key
|
|
(``run_events`` rows are dicts), then walks the mixed ``content`` shapes:
|
|
plain string; a list of string / ``{"text": ...}`` / nested ``{"content": ...}``
|
|
blocks joined without a separator; or a mapping with a ``text``/``content`` key.
|
|
Set ``text_attribute_fallback=True`` to fall back to ``message.text`` when
|
|
content yields nothing (matches ``RunJournal._message_text``).
|
|
|
|
Unlike :func:`message_content_to_text` (which takes raw ``content`` and joins
|
|
list blocks with newlines), this keeps the no-separator join and the broader
|
|
shape handling that several call sites had each reimplemented.
|
|
"""
|
|
content = message.get("content") if isinstance(message, Mapping) else getattr(message, "content", None)
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
parts.append(block)
|
|
elif isinstance(block, Mapping):
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
else:
|
|
nested = block.get("content")
|
|
if isinstance(nested, str):
|
|
parts.append(nested)
|
|
return "".join(parts)
|
|
if isinstance(content, Mapping):
|
|
for key in ("text", "content"):
|
|
value = content.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
if text_attribute_fallback:
|
|
text = getattr(message, "text", None)
|
|
if isinstance(text, str):
|
|
return text
|
|
return ""
|
|
|
|
|
|
def get_original_user_content_text(content: Any, additional_kwargs: Mapping[str, Any] | None) -> str:
|
|
"""Return pre-middleware user text when available, otherwise content text."""
|
|
original_content = (additional_kwargs or {}).get(ORIGINAL_USER_CONTENT_KEY)
|
|
if isinstance(original_content, str):
|
|
return original_content
|
|
return message_content_to_text(content)
|
|
|
|
|
|
def is_real_user_message(message: object) -> bool:
|
|
"""Return whether ``message`` is a real user-authored HumanMessage.
|
|
|
|
Middleware-injected hidden HumanMessages and summarization markers should not
|
|
drive user-intent features such as slash-skill activation or MCP routing.
|
|
"""
|
|
if not isinstance(message, HumanMessage):
|
|
return False
|
|
if message.name == SUMMARY_MESSAGE_NAME:
|
|
return False
|
|
if message.additional_kwargs.get("hide_from_ui"):
|
|
return False
|
|
return True
|