mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 23:48:00 +00:00
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.
This commit is contained in:
parent
66c0c641bf
commit
ebc09ce130
@ -354,7 +354,7 @@ See the [Sandbox Configuration Guide](backend/docs/CONFIGURATION.md#sandbox) to
|
||||
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
|
||||
For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`).
|
||||
For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`.
|
||||
MCP routing hints can also prefer a specific MCP tool for matching requests without changing tool binding; when `tool_search` defers MCP schemas, the hint directs the agent to fetch the tool first.
|
||||
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.
|
||||
See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
|
||||
|
||||
#### IM Channels
|
||||
|
||||
@ -243,14 +243,15 @@ Lead-agent middlewares are assembled in strict order across three functions: the
|
||||
20. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.
|
||||
21. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||
22. **ViewImageMiddleware** - *(optional, if the model supports vision)* Injects base64 image data before the LLM call
|
||||
23. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides deferred (MCP) tool schemas from the bound model until `tool_search` promotes them (reads per-thread promotions from `ThreadState.promoted`, hash-scoped)
|
||||
24. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives
|
||||
25. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce the `MAX_CONCURRENT_SUBAGENTS` limit
|
||||
26. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`
|
||||
27. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
|
||||
28. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the safety/clarification tail
|
||||
29. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after custom middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
30. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
|
||||
23. **McpRoutingMiddleware** - *(optional, if `tool_search.enabled` and PR1 MCP routing metadata produce a routing index)* Auto-promotes matching deferred MCP tool schemas before the model call by writing a minimal `promoted` state update. It matches only the latest real `HumanMessage`, uses the global `tool_search.auto_promote_top_k` limit (default 3, clamped to 1..5), never executes tools, and must be installed before `DeferredToolFilterMiddleware`
|
||||
24. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides deferred (MCP) tool schemas from the bound model until `tool_search` or `McpRoutingMiddleware` promotes them (reads per-thread promotions from `ThreadState.promoted`, hash-scoped)
|
||||
25. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives
|
||||
26. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce the `MAX_CONCURRENT_SUBAGENTS` limit
|
||||
27. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`
|
||||
28. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
|
||||
29. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the safety/clarification tail
|
||||
30. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after custom middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
31. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, writes a readable `ToolMessage.content` fallback plus structured `ToolMessage.artifact.human_input` request payload, and interrupts via `Command(goto=END)` (must be last). Because this middleware can short-circuit tool execution before LangChain emits `on_tool_end`, `RunJournal` performs a root-run final reconciliation for allowlisted clarification `ToolMessage`s whose `tool_call_id` was produced by the current run, so human-input request cards remain recoverable from `run_events` after checkpoint compaction. Human Input Card replies are submitted as `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden response sources (currently `ask_clarification`) as `llm.human.input`, which preserves answered-card state after compaction without exposing generic internal hidden context.
|
||||
|
||||
### Configuration System
|
||||
|
||||
@ -380,7 +381,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
|
||||
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
|
||||
**Guardrail caps & `stop_reason` (#3875 Phase 2)**: three independent axes can end a subagent run early, and all now surface *why* through one additive field rather than a new status enum. **Turn axis**: `recursion_limit` on the subagent `run_config` equals `max_turns`, so exhausting the turn budget raises `GraphRecursionError` from `agent.astream`; `executor.py::_aexecute` catches it specifically (before the generic `except Exception`). **Token axis**: `TokenBudgetMiddleware` is attached per-agent via `build_subagent_runtime_middlewares` from `subagents.token_budget` (default 2,000,000 tokens, warn at 0.7, hard-stop at 1.0 — a backstop against a subagent that burns tokens on trivial work). It does *not* raise: at the hard-stop threshold it strips the in-flight turn's tool calls, forces `finish_reason="stop"`, and lets the run complete naturally with a final answer. **Loop axis**: `LoopDetectionMiddleware` (attached at the same point) catches repeated identical tool-call sets — or one tool *type* called many times with varying args — and its hard-stop likewise strips `tool_calls` and forces a final answer without raising, recording `loop_capped`. Each guard exposes its cap on a per-`run_id` `consume_stop_reason(run_id)` accessor; `_aexecute` collects **every** middleware with that method (duck-typed via `hasattr`, so the executor has no import coupling to the guard classes) and surfaces the first non-`None` reason — adding a future guard needs no executor change. **Surfacing**: whichever axis fired, `_aexecute` stamps a normal status plus an additive reason — `completed` + `stop_reason=token_capped|turn_capped|loop_capped` when a usable final answer (or partial recovered from the last streamed chunk via `_extract_final_result` → `utils/messages.py::message_content_to_text`, returning a `"No response Generated"` sentinel when no text survived) was produced; `failed` + `stop_reason=turn_capped` when nothing usable survived. `SubagentResult.stop_reason` flows through `task_tool.py::_task_result_command` → `format_subagent_result_message` (renders `Task Succeeded (capped: ...)` / `Task failed (capped: ...)`) and `make_subagent_additional_kwargs`, which stamps the additive `subagent_stop_reason` key alongside the normal `subagent_status`. **Why additive, not an enum**: a new status value would break v1 consumers; an optional field is ignored by older frontends and ledger readers, so the cross-language contract (`contracts/subagent_status_contract.json` v2 + `subagents/status_contract.py` + `frontend/.../subtask-result.ts`, pinned by `test_status_values_match_contract` / `test_stop_reason_values_match_contract`) stays backward-compatible. The durable delegation ledger captures `stop_reason` onto the entry and renders model-facing guidance ("hit a guardrail cap with a partial result; reuse it, retry tighter, or raise the per-agent budget (`max_turns` / `token_budget`)") so the lead reuses a capped completion knowingly instead of mistaking it for a clean one. (Phase 1 shipped this surfacing as a `MAX_TURNS_REACHED` status enum in #3949; Phase 2 replaced that enum with the additive `stop_reason` field per the agreed design — the `max_turns_reached` status value and `SubagentStatus.MAX_TURNS_REACHED` are gone.)
|
||||
**Step capture & persistence (#3779)**: `executor.py` captures both assistant turns (`AIMessage`) **and** tool outputs (`ToolMessage`) via `subagents/step_events.py::capture_new_step_messages`, which walks the *newly-appended tail* of each `stream_mode="values"` chunk (not just `messages[-1]`) so a multi-tool-call turn — where LangGraph's `ToolNode` appends several `ToolMessage`s in one super-step — keeps every tool output instead of dropping all but the last. `runtime/runs/worker.py::_SubagentEventBuffer` additionally persists these `task_*` custom events to the `RunEventStore` as `subagent.start`/`subagent.step`/`subagent.end` (`category="subagent"`, `task_id` in `metadata`). It **batches** writes via `put_batch` (flushing on a terminal `subagent.end`, at `FLUSH_THRESHOLD` events, and in the worker's `finally`) rather than one `put()` per step, since `put()` is a documented low-frequency path (per-thread advisory lock per call) and a deep subagent (`max_turns=150`) emits hundreds of steps on the hot stream loop. `build_subagent_step` caps both the per-step `text` and each tool call's serialized `args` at `SUBAGENT_STEP_MAX_CHARS` (flagged `truncated` / `args_truncated`) so a large `write_file`/`bash` payload can't produce an unbounded row. The dedicated category keeps them out of `list_messages` (the thread feed) while `list_events` returns them for the frontend's fetch-on-expand backfill. `list_events` accepts `task_id` (filters on `metadata["task_id"]` — SQL-side in `DbRunEventStore` via `event_metadata["task_id"].as_string()`, in-memory in the JSONL/memory stores) plus an `after_seq` forward cursor, so the card pages through one subagent's steps without the run-wide `limit` truncating the tail (no schema migration: the filter rides the existing run-scoped index). `step_events.py` is a pure, unit-tested layer (`build_subagent_step` / `subagent_run_event`).
|
||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` assembles deferral after policy filtering via the shared `assemble_deferred_tools` (fail-closed), appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(deferred_setup=...)`. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
||||
**Deferred MCP tools** (if `tool_search.enabled`): `SubagentExecutor._build_initial_state` assembles deferral after policy filtering via the shared `assemble_deferred_tools` (fail-closed), appends the `tool_search` tool, injects the `<available-deferred-tools>` section into the subagent's `SystemMessage`, and threads the setup to `_create_agent`, which attaches `McpRoutingMiddleware` (when PR1 routing metadata matches deferred tools) before `DeferredToolFilterMiddleware` through `build_subagent_runtime_middlewares(...)`. Subagents thus withhold full MCP schemas until promotion, same as the lead agent; each task run gets a fresh `ThreadState` so promotion is isolated per run
|
||||
**Checkpointer isolation**: Subagent graphs are compiled with `checkpointer=False` to avoid inheriting the parent run's checkpointer, since subagents are one-shot and never resume.
|
||||
|
||||
### Tool System (`packages/harness/deerflow/tools/`)
|
||||
@ -715,7 +716,8 @@ Returns `{}` when Langfuse is not in the enabled providers — LangSmith-only de
|
||||
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens, staleness_review_enabled, staleness_age_days, staleness_min_candidates, staleness_max_removals_per_cycle, staleness_protected_categories)
|
||||
|
||||
**`extensions_config.json`**:
|
||||
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`). `routing.mode="prefer"` emits `<mcp_routing_hints>` prompt guidance; if `tool_search` defers the hinted tool, the guidance points at promotion first. It does not hard-disable other tools.
|
||||
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`). `routing.mode="prefer"` emits `<mcp_routing_hints>` prompt guidance; if `tool_search` defers the hinted tool, `McpRoutingMiddleware` can also auto-promote matching deferred schemas before the model call. It does not hard-disable other tools.
|
||||
- `tool_search.auto_promote_top_k` - Global MCP routing auto-promote breadth. Default `3`, clamped to `1..5`; applies only when `tool_search.enabled=true` and only to policy-filtered deferred MCP tools with `routing.mode="prefer"` and non-empty keywords.
|
||||
- `skills` - Map of skill name → state (enabled)
|
||||
|
||||
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
||||
|
||||
@ -346,10 +346,10 @@ MCP servers and skill states in a single file:
|
||||
```
|
||||
|
||||
`routing` adds soft MCP preference hints to the agent prompt. It helps the
|
||||
model prefer a configured MCP tool for matching requests without changing the
|
||||
bound tool schemas or forbidding other tools. When `tool_search.enabled=true`
|
||||
defers MCP schemas, the hint tells the model to fetch the deferred tool with
|
||||
`tool_search` before preferring it.
|
||||
model prefer a configured MCP tool for matching requests without forbidding
|
||||
other tools. When `tool_search.enabled=true` defers MCP schemas, matching
|
||||
routing metadata can auto-promote up to `tool_search.auto_promote_top_k`
|
||||
deferred schemas before the model call.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@ -21,8 +21,9 @@ as internal database questions that should use a PostgreSQL MCP tool before web
|
||||
search. Routing hints are soft model guidance: they add a
|
||||
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use
|
||||
agent-level allow/deny policy for hard restrictions. If `tool_search.enabled`
|
||||
defers MCP tool schemas, the hint references `tool_search` so the model fetches
|
||||
the deferred tool before preferring it.
|
||||
defers MCP tool schemas, matching routing metadata can also auto-promote the
|
||||
deferred schema before the model call. Auto-promotion is controlled by the
|
||||
top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
|
||||
|
||||
```json
|
||||
{
|
||||
@ -53,13 +54,21 @@ the deferred tool before preferring it.
|
||||
|
||||
- `routing.mode`: `off` disables hints; `prefer` emits hints.
|
||||
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first.
|
||||
When `tool_search.enabled=true`, priority also orders auto-promote matches.
|
||||
- `routing.keywords`: operator-authored terms that describe when to prefer the
|
||||
MCP tool. Empty keywords are allowed but do not emit a hint line.
|
||||
MCP tool. Empty keywords are allowed but do not emit a hint line and do not
|
||||
trigger auto-promotion. Auto-promote matching is a case-insensitive substring
|
||||
test against the latest user message (not token/word-boundary matching), so
|
||||
prefer distinctive keywords — a short term like `api` also matches `rapid`.
|
||||
Over-matching only exposes an extra tool schema (soft/additive), never
|
||||
disables other tools.
|
||||
- `tools.<original_tool_name>.routing`: overrides only the fields explicitly
|
||||
set for that tool. The key is the MCP server's original tool name, before the
|
||||
`<server>_` prefix added for model binding. If the server-level
|
||||
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
|
||||
only `priority` or `keywords` still inherits `off` and emits no hint.
|
||||
- `tool_search.auto_promote_top_k`: global limit for auto-promoted deferred MCP
|
||||
schemas per model call. Default `3`; valid range `1..5`.
|
||||
|
||||
## Per-Tool Timeout (Stdio MCP Servers)
|
||||
|
||||
|
||||
@ -223,6 +223,7 @@ def build_middlewares(
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
deferred_setup=None,
|
||||
mcp_routing_middleware: AgentMiddleware | None = None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
"""Build the lead-agent middleware chain based on runtime configuration.
|
||||
@ -240,6 +241,8 @@ def build_middlewares(
|
||||
app_config: Explicit AppConfig; falls back to ``get_app_config()`` when omitted.
|
||||
deferred_setup: Optional deferred-MCP-tool setup that attaches
|
||||
``DeferredToolFilterMiddleware`` when ``tool_search`` is enabled.
|
||||
mcp_routing_middleware: Optional PR2 middleware that auto-promotes
|
||||
deferred MCP schemas before the deferred filter runs.
|
||||
user_id: Effective user ID for user-scoped skill loading. Passed through
|
||||
to ``SkillActivationMiddleware`` so it can resolve per-user custom skills.
|
||||
|
||||
@ -302,6 +305,11 @@ def build_middlewares(
|
||||
if model_config is not None and model_config.supports_vision:
|
||||
middlewares.append(ViewImageMiddleware())
|
||||
|
||||
# Auto-promote deferred MCP schemas from PR1 routing metadata before the
|
||||
# deferred filter decides which schemas to hide for this model call.
|
||||
if mcp_routing_middleware is not None:
|
||||
middlewares.append(mcp_routing_middleware)
|
||||
|
||||
# Hide deferred tool schemas from model binding until tool_search promotes them.
|
||||
# The deferred set + catalog hash come from the build-time setup (assembled
|
||||
# after tool-policy filtering); promotion is read from graph state.
|
||||
@ -309,6 +317,9 @@ def build_middlewares(
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import assert_mcp_routing_before_deferred_filter
|
||||
|
||||
assert_mcp_routing_before_deferred_filter(middlewares)
|
||||
|
||||
# Coalesce every SystemMessage into a single leading one before the request
|
||||
# reaches the provider. Strict backends (vLLM, SGLang, Qwen, Anthropic)
|
||||
@ -386,7 +397,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# Lazy import to avoid circular dependency
|
||||
from deerflow.tools import get_available_tools
|
||||
from deerflow.tools.builtins import setup_agent, update_agent
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_mcp_routing_hints_prompt_section
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, build_mcp_routing_middleware, get_mcp_routing_hints_prompt_section
|
||||
|
||||
cfg = _get_runtime_config(config)
|
||||
resolved_app_config = app_config
|
||||
@ -490,6 +501,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
if non_interactive:
|
||||
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
setup,
|
||||
top_k=resolved_app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
if skill_setup.describe_skill_tool:
|
||||
final_tools.append(skill_setup.describe_skill_tool)
|
||||
return create_agent(
|
||||
@ -501,6 +517,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=resolved_user_id,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
@ -546,6 +563,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
if non_interactive:
|
||||
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
setup,
|
||||
top_k=resolved_app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(filtered, deferred_names=setup.deferred_names)
|
||||
if skill_setup.describe_skill_tool:
|
||||
final_tools.append(skill_setup.describe_skill_tool)
|
||||
@ -559,6 +581,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=resolved_user_id,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
"""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})")
|
||||
@ -27,7 +27,7 @@ from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_ski
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory
|
||||
from deerflow.utils.messages import get_original_user_content_text
|
||||
from deerflow.utils.messages import get_original_user_content_text, is_real_user_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@ -36,7 +36,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
|
||||
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
|
||||
_SUMMARY_MESSAGE_NAME = "summary"
|
||||
|
||||
# _SECRETS_BINDING_AUDIT_KEY: last audited binding (skill and secret names only,
|
||||
# never values) so unchanged bindings are not re-recorded each call.
|
||||
@ -73,13 +72,7 @@ def is_slash_skill_activation_reminder(message: object) -> bool:
|
||||
|
||||
|
||||
def _is_user_activation_target(message: object) -> bool:
|
||||
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
|
||||
return is_real_user_message(message)
|
||||
|
||||
|
||||
class SkillActivationMiddleware(AgentMiddleware):
|
||||
|
||||
@ -280,6 +280,7 @@ def build_subagent_runtime_middlewares(
|
||||
model_name: str | None = None,
|
||||
lazy_init: bool = True,
|
||||
deferred_setup: "DeferredToolSetup | None" = None,
|
||||
mcp_routing_middleware: AgentMiddleware | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> list[AgentMiddleware]:
|
||||
"""Middlewares shared by subagent runtime before subagent-only middlewares."""
|
||||
@ -304,6 +305,9 @@ def build_subagent_runtime_middlewares(
|
||||
|
||||
middlewares.append(ViewImageMiddleware())
|
||||
|
||||
if mcp_routing_middleware is not None:
|
||||
middlewares.append(mcp_routing_middleware)
|
||||
|
||||
# Hide deferred (MCP) tool schemas from the subagent's model binding until
|
||||
# tool_search promotes them. This is the same wiring the lead agent gets. The deferred
|
||||
# set + catalog hash come from the build-time setup (assembled after
|
||||
@ -313,6 +317,9 @@ def build_subagent_runtime_middlewares(
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash))
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import assert_mcp_routing_before_deferred_filter
|
||||
|
||||
assert_mcp_routing_before_deferred_filter(middlewares)
|
||||
|
||||
# LoopDetectionMiddleware — subagents inherit none of the lead's runaway
|
||||
# guards today (see #3875): with no loop detection a degenerate subagent tool
|
||||
|
||||
@ -46,7 +46,7 @@ from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, build_goal_sta
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.describe import build_skill_search_setup
|
||||
from deerflow.skills.storage import get_or_new_user_skill_storage
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, get_mcp_routing_hints_prompt_section
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, build_mcp_routing_middleware, get_mcp_routing_hints_prompt_section
|
||||
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, generate_trace_id, get_current_trace_id, reset_current_trace_id, set_current_trace_id
|
||||
from deerflow.tracing import build_tracing_callbacks, inject_langfuse_metadata
|
||||
from deerflow.uploads.manager import (
|
||||
@ -257,6 +257,11 @@ class DeerFlowClient:
|
||||
|
||||
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)
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
final_tools,
|
||||
deferred_setup,
|
||||
top_k=self._app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(tools, deferred_names=deferred_setup.deferred_names)
|
||||
|
||||
# Wire deferred skill discovery — mirrors agent.py so config flag works on both paths.
|
||||
@ -286,6 +291,7 @@ class DeerFlowClient:
|
||||
custom_middlewares=self._middlewares,
|
||||
app_config=self._app_config,
|
||||
deferred_setup=deferred_setup,
|
||||
mcp_routing_middleware=mcp_routing_middleware,
|
||||
user_id=get_effective_user_id(),
|
||||
),
|
||||
"system_prompt": apply_prompt_template(
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
"""Configuration for deferred tool loading via tool_search."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
AUTO_PROMOTE_TOP_K_MIN = 1
|
||||
AUTO_PROMOTE_TOP_K_MAX = 5
|
||||
|
||||
|
||||
def clamp_auto_promote_top_k(value: int) -> int:
|
||||
"""Clamp the global MCP routing auto-promote breadth to PR2's range."""
|
||||
return max(AUTO_PROMOTE_TOP_K_MIN, min(AUTO_PROMOTE_TOP_K_MAX, int(value)))
|
||||
|
||||
|
||||
class ToolSearchConfig(BaseModel):
|
||||
@ -15,6 +23,15 @@ class ToolSearchConfig(BaseModel):
|
||||
default=False,
|
||||
description="Defer tools and enable tool_search",
|
||||
)
|
||||
auto_promote_top_k: int = Field(
|
||||
default=3,
|
||||
description="Maximum number of deferred MCP tool schemas auto-promoted from routing metadata per model call",
|
||||
)
|
||||
|
||||
@field_validator("auto_promote_top_k")
|
||||
@classmethod
|
||||
def _clamp_auto_promote_top_k(cls, value: int) -> int:
|
||||
return clamp_auto_promote_top_k(value)
|
||||
|
||||
|
||||
_tool_search_config: ToolSearchConfig | None = None
|
||||
|
||||
@ -437,13 +437,25 @@ class SubagentExecutor:
|
||||
|
||||
# Reuse shared middleware composition with lead agent. ``agent_name``
|
||||
# lets the builder resolve the per-agent token_budget override.
|
||||
middlewares = build_subagent_runtime_middlewares(
|
||||
app_config=app_config,
|
||||
model_name=self.model_name,
|
||||
lazy_init=True,
|
||||
deferred_setup=deferred_setup,
|
||||
agent_name=self.config.name,
|
||||
)
|
||||
mcp_routing_middleware = None
|
||||
if deferred_setup is not None and deferred_setup.deferred_names:
|
||||
from deerflow.tools.builtins.tool_search import build_mcp_routing_middleware
|
||||
|
||||
mcp_routing_middleware = build_mcp_routing_middleware(
|
||||
tools if tools is not None else self.tools,
|
||||
deferred_setup,
|
||||
top_k=app_config.tool_search.auto_promote_top_k,
|
||||
)
|
||||
middleware_kwargs = {
|
||||
"app_config": app_config,
|
||||
"model_name": self.model_name,
|
||||
"lazy_init": True,
|
||||
"deferred_setup": deferred_setup,
|
||||
"agent_name": self.config.name,
|
||||
}
|
||||
if mcp_routing_middleware is not None:
|
||||
middleware_kwargs["mcp_routing_middleware"] = mcp_routing_middleware
|
||||
middlewares = build_subagent_runtime_middlewares(**middleware_kwargs)
|
||||
# Collect every guard middleware that exposes ``consume_stop_reason``
|
||||
# (TokenBudgetMiddleware, LoopDetectionMiddleware) so _aexecute can read
|
||||
# each after the run and surface whichever cap fired. Duck-typed
|
||||
|
||||
@ -6,6 +6,8 @@ Contains:
|
||||
catalog; it records promotions into graph state via ``Command``.
|
||||
- build_deferred_tool_setup: assembles the catalog + tool from a
|
||||
policy-filtered tool list (call AFTER tool-policy filtering).
|
||||
- build_mcp_routing_middleware: builds the PR2 auto-promote middleware from
|
||||
serialized routing metadata on policy-filtered deferred tools.
|
||||
|
||||
The agent sees deferred tool names in <available-deferred-tools> but cannot
|
||||
call them until it fetches their full schema via the tool_search tool. The
|
||||
@ -21,7 +23,7 @@ import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import Annotated
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.messages import ToolMessage
|
||||
@ -31,6 +33,9 @@ from langgraph.types import Command
|
||||
|
||||
from deerflow.tools.mcp_metadata import get_mcp_routing, is_mcp_tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_RESULTS = 5 # Max tools returned per search
|
||||
@ -202,6 +207,64 @@ def assemble_deferred_tools(filtered_tools: list[BaseTool], *, enabled: bool) ->
|
||||
return final_tools, deferred_setup
|
||||
|
||||
|
||||
def _routing_priority(value: Any) -> int:
|
||||
# Produces the typed priority stored in the routing index. McpRoutingMiddleware
|
||||
# ._normalize_index re-parses this defensively (it is built to accept arbitrary
|
||||
# serialized data), so keep the two coercion rules in sync if either changes.
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _routing_keywords(value: Any) -> list[str]:
|
||||
# See _routing_priority: McpRoutingMiddleware._normalize_index re-normalizes
|
||||
# keywords defensively; keep both coercion rules aligned.
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [keyword for keyword in (str(item).strip() for item in value) if keyword]
|
||||
|
||||
|
||||
def build_mcp_routing_middleware(
|
||||
tools: Iterable[BaseTool],
|
||||
deferred_setup: DeferredToolSetup,
|
||||
*,
|
||||
top_k: int,
|
||||
) -> "AgentMiddleware | None":
|
||||
"""Build PR2 auto-promotion middleware from policy-filtered deferred tools.
|
||||
|
||||
The builder may inspect ``BaseTool.metadata`` at construction time, but the
|
||||
returned middleware receives only a flat serializable routing index.
|
||||
"""
|
||||
if deferred_setup.catalog_hash is None or not deferred_setup.deferred_names:
|
||||
return None
|
||||
|
||||
routing_index: dict[str, dict[str, Any]] = {}
|
||||
for candidate in tools:
|
||||
tool_name = getattr(candidate, "name", "")
|
||||
if tool_name not in deferred_setup.deferred_names:
|
||||
continue
|
||||
routing = get_mcp_routing(candidate)
|
||||
if routing is None or routing.get("mode") != "prefer":
|
||||
continue
|
||||
keywords = _routing_keywords(routing.get("keywords"))
|
||||
if not keywords:
|
||||
continue
|
||||
if routing.get("auto_promote_top_k") is not None:
|
||||
logger.debug("Ignoring per-tool MCP routing auto_promote_top_k for %s in PR2", tool_name)
|
||||
routing_index[str(tool_name)] = {
|
||||
"priority": _routing_priority(routing.get("priority", 0)),
|
||||
"keywords": keywords,
|
||||
}
|
||||
|
||||
if not routing_index:
|
||||
return None
|
||||
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
|
||||
return McpRoutingMiddleware(routing_index, deferred_setup.catalog_hash, top_k)
|
||||
|
||||
|
||||
# Prompt rendering
|
||||
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@ 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:
|
||||
@ -72,3 +75,18 @@ def get_original_user_content_text(content: Any, additional_kwargs: Mapping[str,
|
||||
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
|
||||
|
||||
@ -1087,6 +1087,68 @@ class TestEnsureAgent:
|
||||
skill_names_arg = mock_apply_prompt.call_args.kwargs.get("skill_names")
|
||||
assert skill_names_arg is None, "skill_names must be None when deferred_discovery=False"
|
||||
|
||||
def test_mcp_routing_middleware_wired_when_tool_search_enabled(self, client, mock_app_config):
|
||||
"""Embedded client builds McpRoutingMiddleware from routed deferred MCP tools.
|
||||
|
||||
RFC §10.3/§12.5 requires verifying the actual embedded-client builder path
|
||||
rather than assuming it inherits lead-agent behavior. Exercises the real
|
||||
assemble_deferred_tools + build_mcp_routing_middleware wiring and asserts a
|
||||
genuine McpRoutingMiddleware reaches build_middlewares.
|
||||
"""
|
||||
from langchain_core.tools import tool as as_tool
|
||||
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
from deerflow.tools.mcp_metadata import tag_mcp_routing, tag_mcp_tool
|
||||
|
||||
@as_tool
|
||||
def postgres_query(sql: str) -> str:
|
||||
"Query Postgres."
|
||||
return sql
|
||||
|
||||
tag_mcp_tool(postgres_query)
|
||||
tag_mcp_routing(postgres_query, {"mode": "prefer", "priority": 100, "keywords": ["orders"]})
|
||||
|
||||
mock_app_config.tool_search.enabled = True
|
||||
mock_app_config.tool_search.auto_promote_top_k = 3
|
||||
mock_app_config.skills.deferred_discovery = False
|
||||
client._app_config = mock_app_config
|
||||
config = client._get_runnable_config("t1")
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", return_value=MagicMock()),
|
||||
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch.object(client, "_get_tools", return_value=[postgres_query]),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
|
||||
):
|
||||
client._ensure_agent(config)
|
||||
|
||||
routing_arg = mock_build_middlewares.call_args.kwargs.get("mcp_routing_middleware")
|
||||
assert isinstance(routing_arg, McpRoutingMiddleware)
|
||||
assert routing_arg._matched_names({"messages": [HumanMessage(content="show orders")]}) == ["postgres_query"]
|
||||
|
||||
def test_mcp_routing_middleware_absent_when_tool_search_disabled(self, client, mock_app_config):
|
||||
"""No routing middleware is built on the embedded path when tool_search is off."""
|
||||
mock_app_config.tool_search.enabled = False
|
||||
mock_app_config.skills.deferred_discovery = False
|
||||
client._app_config = mock_app_config
|
||||
config = client._get_runnable_config("t1")
|
||||
|
||||
with (
|
||||
patch("deerflow.client.create_chat_model"),
|
||||
patch("deerflow.client.create_agent", return_value=MagicMock()),
|
||||
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
|
||||
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
|
||||
patch.object(client, "_get_tools", return_value=[]),
|
||||
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
|
||||
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
|
||||
):
|
||||
client._ensure_agent(config)
|
||||
|
||||
assert mock_build_middlewares.call_args.kwargs.get("mcp_routing_middleware") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_model
|
||||
|
||||
@ -424,6 +424,33 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
|
||||
assert middlewares[0] == "base-middleware"
|
||||
|
||||
|
||||
def test_build_middlewares_places_mcp_routing_before_deferred_filter(monkeypatch):
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
||||
|
||||
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)], loop_detection=LoopDetectionConfig(enabled=False))
|
||||
routing = McpRoutingMiddleware({"mcp_thing": {"priority": 100, "keywords": ["orders"]}}, "hash123", 3)
|
||||
setup = DeferredToolSetup(object(), frozenset({"mcp_thing"}), "hash123")
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
|
||||
monkeypatch.setattr(lead_agent_module, "build_lead_runtime_middlewares", lambda *, app_config, lazy_init=True: [])
|
||||
monkeypatch.setattr(lead_agent_module, "_create_summarization_middleware", lambda *, app_config=None: None)
|
||||
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
|
||||
|
||||
middlewares = lead_agent_module.build_middlewares(
|
||||
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
|
||||
model_name="safe-model",
|
||||
app_config=app_config,
|
||||
deferred_setup=setup,
|
||||
mcp_routing_middleware=routing,
|
||||
)
|
||||
|
||||
routing_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, McpRoutingMiddleware))
|
||||
filter_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DeferredToolFilterMiddleware))
|
||||
assert routing_idx < filter_idx
|
||||
|
||||
|
||||
def test_build_middlewares_uses_loop_detection_config(monkeypatch):
|
||||
app_config = _make_app_config(
|
||||
[_make_model("safe-model", supports_thinking=False)],
|
||||
|
||||
296
backend/tests/test_mcp_routing_auto_promote.py
Normal file
296
backend/tests/test_mcp_routing_auto_promote.py
Normal file
@ -0,0 +1,296 @@
|
||||
"""Tests for PR2 MCP routing auto-promotion."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.tools import tool as as_tool
|
||||
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware, assert_mcp_routing_before_deferred_filter
|
||||
from deerflow.agents.thread_state import ThreadState, merge_promoted
|
||||
from deerflow.tools.builtins.tool_search import assemble_deferred_tools, build_mcp_routing_middleware
|
||||
from deerflow.tools.mcp_metadata import tag_mcp_routing, tag_mcp_tool
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||
|
||||
|
||||
@as_tool
|
||||
def active_tool(x: str) -> str:
|
||||
"An always-active tool."
|
||||
return x
|
||||
|
||||
|
||||
@as_tool
|
||||
def postgres_query(sql: str) -> str:
|
||||
"Query Postgres."
|
||||
return sql
|
||||
|
||||
|
||||
@as_tool
|
||||
def metrics_query(query: str) -> str:
|
||||
"Query metrics."
|
||||
return query
|
||||
|
||||
|
||||
@as_tool
|
||||
def archive_lookup(query: str) -> str:
|
||||
"Search archived records."
|
||||
return query
|
||||
|
||||
|
||||
def _routed(tool, *, keywords: list[str], priority: int = 0, mode: str = "prefer"):
|
||||
tag_mcp_tool(tool)
|
||||
tag_mcp_routing(
|
||||
tool,
|
||||
{
|
||||
"mode": mode,
|
||||
"priority": priority,
|
||||
"keywords": keywords,
|
||||
},
|
||||
)
|
||||
return tool
|
||||
|
||||
|
||||
def test_builder_indexes_only_deferred_prefer_tools():
|
||||
routed = _routed(postgres_query, keywords=["orders"], priority=100)
|
||||
off = _routed(metrics_query, keywords=["metrics"], priority=50, mode="off")
|
||||
empty_keywords = _routed(archive_lookup, keywords=[], priority=90)
|
||||
final_tools, setup = assemble_deferred_tools([active_tool, routed, off, empty_keywords], enabled=True)
|
||||
|
||||
middleware = build_mcp_routing_middleware(final_tools, setup, top_k=3)
|
||||
|
||||
assert isinstance(middleware, McpRoutingMiddleware)
|
||||
assert middleware._matched_names({"messages": [HumanMessage(content="show ORDERS")]}) == ["postgres_query"]
|
||||
assert middleware._matched_names({"messages": [HumanMessage(content="metrics archive")]}) == []
|
||||
|
||||
|
||||
def test_builder_skips_when_tool_search_disabled_or_no_index():
|
||||
routed = _routed(postgres_query, keywords=["orders"], priority=100)
|
||||
final_tools, setup = assemble_deferred_tools([routed], enabled=False)
|
||||
|
||||
assert build_mcp_routing_middleware(final_tools, setup, top_k=3) is None
|
||||
|
||||
_, setup = assemble_deferred_tools([_routed(metrics_query, keywords=[], priority=50)], enabled=True)
|
||||
assert build_mcp_routing_middleware([metrics_query], setup, top_k=3) is None
|
||||
|
||||
|
||||
def test_matching_uses_latest_real_human_message_only():
|
||||
middleware = McpRoutingMiddleware(
|
||||
{
|
||||
"postgres_query": {"priority": 100, "keywords": ["orders"]},
|
||||
"metrics_query": {"priority": 90, "keywords": ["metrics"]},
|
||||
},
|
||||
"hash1",
|
||||
3,
|
||||
)
|
||||
|
||||
assert middleware._matched_names({"messages": [HumanMessage(content="orders"), HumanMessage(content="no match now")]}) == []
|
||||
assert middleware._matched_names({"messages": [HumanMessage(content="metrics", name="summary"), HumanMessage(content="orders", additional_kwargs={"hide_from_ui": True})]}) == []
|
||||
|
||||
|
||||
def test_matching_supports_casefold_chinese_priority_tiebreak_and_top_k():
|
||||
middleware = McpRoutingMiddleware(
|
||||
{
|
||||
"z_tool": {"priority": 50, "keywords": ["订单"]},
|
||||
"a_tool": {"priority": 50, "keywords": ["orders"]},
|
||||
"top_tool": {"priority": 100, "keywords": ["ORDERS"]},
|
||||
},
|
||||
"hash1",
|
||||
2,
|
||||
)
|
||||
|
||||
assert middleware._matched_names({"messages": [HumanMessage(content="查订单 and orders")]}) == ["top_tool", "a_tool"]
|
||||
|
||||
|
||||
def test_structured_original_user_text_is_used():
|
||||
middleware = McpRoutingMiddleware(
|
||||
{"postgres_query": {"priority": 100, "keywords": ["orders"]}},
|
||||
"hash1",
|
||||
3,
|
||||
)
|
||||
message = HumanMessage(
|
||||
content=[{"type": "text", "text": "sanitized replacement"}],
|
||||
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "show orders"},
|
||||
)
|
||||
|
||||
assert middleware._matched_names({"messages": [message]}) == ["postgres_query"]
|
||||
|
||||
|
||||
def test_before_model_returns_minimal_promoted_update_and_reducer_unions():
|
||||
middleware = McpRoutingMiddleware(
|
||||
{"postgres_query": {"priority": 100, "keywords": ["orders"]}},
|
||||
"hash1",
|
||||
3,
|
||||
)
|
||||
|
||||
update = middleware.before_model(
|
||||
{"messages": [HumanMessage(content="orders")], "promoted": {"catalog_hash": "hash1", "names": ["metrics_query"]}},
|
||||
runtime=None,
|
||||
)
|
||||
|
||||
assert update == {"promoted": {"catalog_hash": "hash1", "names": ["postgres_query"]}}
|
||||
assert merge_promoted({"catalog_hash": "hash1", "names": ["metrics_query"]}, update["promoted"]) == {
|
||||
"catalog_hash": "hash1",
|
||||
"names": ["metrics_query", "postgres_query"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abefore_model_matches_sync_behavior():
|
||||
middleware = McpRoutingMiddleware(
|
||||
{"postgres_query": {"priority": 100, "keywords": ["orders"]}},
|
||||
"hash1",
|
||||
3,
|
||||
)
|
||||
|
||||
assert await middleware.abefore_model({"messages": [HumanMessage(content="orders")]}, runtime=None) == {"promoted": {"catalog_hash": "hash1", "names": ["postgres_query"]}}
|
||||
|
||||
|
||||
def test_no_match_and_missing_catalog_hash_return_no_update():
|
||||
assert McpRoutingMiddleware({"postgres_query": {"priority": 100, "keywords": ["orders"]}}, None, 3).before_model({"messages": [HumanMessage(content="orders")]}, runtime=None) is None
|
||||
assert McpRoutingMiddleware({"postgres_query": {"priority": 100, "keywords": ["orders"]}}, "hash1", 3).before_model({"messages": [HumanMessage(content="nothing")]}, runtime=None) is None
|
||||
|
||||
|
||||
def test_order_invariant_rejects_reversed_middlewares():
|
||||
routing = McpRoutingMiddleware({"postgres_query": {"priority": 100, "keywords": ["orders"]}}, "hash1", 3)
|
||||
deferred = DeferredToolFilterMiddleware(frozenset({"postgres_query"}), "hash1")
|
||||
|
||||
assert_mcp_routing_before_deferred_filter([routing, deferred])
|
||||
with pytest.raises(RuntimeError, match="McpRoutingMiddleware must be installed before DeferredToolFilterMiddleware"):
|
||||
assert_mcp_routing_before_deferred_filter([deferred, routing])
|
||||
|
||||
|
||||
def test_auto_promote_makes_schema_visible_in_same_model_cycle():
|
||||
bound: list[list[str]] = []
|
||||
|
||||
class RecordingModel(GenericFakeChatModel):
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
bound.append([getattr(t, "name", None) for t in tools])
|
||||
return self
|
||||
|
||||
routed = _routed(postgres_query, keywords=["orders"], priority=100)
|
||||
other = _routed(metrics_query, keywords=["metrics"], priority=90)
|
||||
final_tools, setup = assemble_deferred_tools([active_tool, routed, other], enabled=True)
|
||||
routing_middleware = build_mcp_routing_middleware(final_tools, setup, top_k=3)
|
||||
assert routing_middleware is not None
|
||||
|
||||
model = RecordingModel(messages=iter([AIMessage(content="done")]))
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=final_tools,
|
||||
middleware=[
|
||||
routing_middleware,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="show orders")]}))
|
||||
|
||||
assert "postgres_query" in bound[0]
|
||||
assert "metrics_query" not in bound[0]
|
||||
assert result["promoted"] == {"catalog_hash": setup.catalog_hash, "names": ["postgres_query"]}
|
||||
assert not any(isinstance(message, ToolMessage) for message in result["messages"])
|
||||
|
||||
|
||||
def test_auto_promoted_tool_can_be_called_without_tool_search():
|
||||
bound: list[list[str]] = []
|
||||
|
||||
class RecordingModel(GenericFakeChatModel):
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
bound.append([getattr(t, "name", None) for t in tools])
|
||||
return self
|
||||
|
||||
routed = _routed(postgres_query, keywords=["orders"], priority=100)
|
||||
final_tools, setup = assemble_deferred_tools([active_tool, routed], enabled=True)
|
||||
routing_middleware = build_mcp_routing_middleware(final_tools, setup, top_k=3)
|
||||
assert routing_middleware is not None
|
||||
|
||||
turn1 = AIMessage(content="", tool_calls=[{"name": "postgres_query", "args": {"sql": "select * from orders"}, "id": "c1", "type": "tool_call"}])
|
||||
turn2 = AIMessage(content="done")
|
||||
model = RecordingModel(messages=iter([turn1, turn2]))
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=final_tools,
|
||||
middleware=[
|
||||
routing_middleware,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="show orders")]}))
|
||||
|
||||
assert "postgres_query" in bound[0]
|
||||
assert result["promoted"] == {"catalog_hash": setup.catalog_hash, "names": ["postgres_query"]}
|
||||
tool_messages = [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
||||
assert tool_messages
|
||||
assert tool_messages[0].name == "postgres_query"
|
||||
assert tool_messages[0].status == "success"
|
||||
|
||||
|
||||
def test_explicit_tool_search_merges_with_auto_promoted_names():
|
||||
class RecordingModel(GenericFakeChatModel):
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
routed = _routed(postgres_query, keywords=["orders"], priority=100)
|
||||
other = _routed(metrics_query, keywords=["metrics"], priority=90)
|
||||
final_tools, setup = assemble_deferred_tools([active_tool, routed, other], enabled=True)
|
||||
routing_middleware = build_mcp_routing_middleware(final_tools, setup, top_k=3)
|
||||
assert routing_middleware is not None
|
||||
|
||||
turn1 = AIMessage(content="", tool_calls=[{"name": "tool_search", "args": {"query": "select:metrics_query"}, "id": "c1", "type": "tool_call"}])
|
||||
turn2 = AIMessage(content="done")
|
||||
model = RecordingModel(messages=iter([turn1, turn2]))
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=final_tools,
|
||||
middleware=[
|
||||
routing_middleware,
|
||||
DeferredToolFilterMiddleware(setup.deferred_names, setup.catalog_hash),
|
||||
],
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
result = asyncio.run(graph.ainvoke({"messages": [HumanMessage(content="show orders")]}))
|
||||
|
||||
assert result["promoted"] == {
|
||||
"catalog_hash": setup.catalog_hash,
|
||||
"names": ["postgres_query", "metrics_query"],
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_like_no_mcp_tools_skips_middleware():
|
||||
final_tools, setup = assemble_deferred_tools([active_tool], enabled=True)
|
||||
|
||||
assert build_mcp_routing_middleware(final_tools, setup, top_k=3) is None
|
||||
|
||||
|
||||
def test_acp_tool_without_mcp_metadata_is_not_indexed():
|
||||
final_tools, setup = assemble_deferred_tools([active_tool], enabled=True)
|
||||
|
||||
assert setup.deferred_names == frozenset()
|
||||
assert build_mcp_routing_middleware(final_tools, setup, top_k=3) is None
|
||||
|
||||
|
||||
def test_privacy_no_trace_metadata_or_info_logs(caplog):
|
||||
caplog.set_level("INFO")
|
||||
middleware = McpRoutingMiddleware(
|
||||
{"secret_tool": {"priority": 100, "keywords": ["sensitive-keyword"]}},
|
||||
"hash1",
|
||||
3,
|
||||
)
|
||||
state = {
|
||||
"messages": [HumanMessage(content="contains sensitive-keyword")],
|
||||
"metadata": {"trace": "existing"},
|
||||
}
|
||||
|
||||
update = middleware.before_model(state, runtime=None)
|
||||
|
||||
assert update == {"promoted": {"catalog_hash": "hash1", "names": ["secret_tool"]}}
|
||||
assert state["metadata"] == {"trace": "existing"}
|
||||
assert "sensitive-keyword" not in caplog.text
|
||||
assert "secret_tool" not in caplog.text
|
||||
@ -649,7 +649,7 @@ class TestAgentConstruction:
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
||||
|
||||
SubagentExecutor = classes["SubagentExecutor"]
|
||||
app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")])
|
||||
app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")], tool_search=SimpleNamespace(enabled=True, auto_promote_top_k=3))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_subagent_runtime_middlewares(**kwargs):
|
||||
|
||||
@ -548,6 +548,23 @@ def test_subagent_runtime_middlewares_attach_deferred_filter_when_setup_has_name
|
||||
assert filter_idx < safety_idx
|
||||
|
||||
|
||||
def test_subagent_runtime_middlewares_place_mcp_routing_before_deferred_filter(monkeypatch):
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
from deerflow.agents.middlewares.mcp_routing_middleware import McpRoutingMiddleware
|
||||
from deerflow.tools.builtins.tool_search import DeferredToolSetup
|
||||
|
||||
app_config = _make_app_config()
|
||||
_stub_runtime_middleware_imports(monkeypatch)
|
||||
routing = McpRoutingMiddleware({"mcp_thing": {"priority": 100, "keywords": ["orders"]}}, "hash123", 3)
|
||||
setup = DeferredToolSetup(object(), frozenset({"mcp_thing"}), "hash123")
|
||||
|
||||
middlewares = build_subagent_runtime_middlewares(app_config=app_config, deferred_setup=setup, mcp_routing_middleware=routing)
|
||||
|
||||
routing_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, McpRoutingMiddleware))
|
||||
filter_idx = next(i for i, middleware in enumerate(middlewares) if isinstance(middleware, DeferredToolFilterMiddleware))
|
||||
assert routing_idx < filter_idx
|
||||
|
||||
|
||||
def test_subagent_runtime_middlewares_skip_deferred_filter_without_names(monkeypatch):
|
||||
"""No deferred setup (disabled / no MCP tool) -> no DeferredToolFilterMiddleware."""
|
||||
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
||||
|
||||
@ -15,15 +15,60 @@ from deerflow.tools.builtins.tool_search import get_deferred_tools_prompt_sectio
|
||||
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):
|
||||
assert load_tool_search_config_from_dict({"enabled": True}).enabled is True
|
||||
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:
|
||||
|
||||
@ -872,6 +872,10 @@ tools:
|
||||
|
||||
tool_search:
|
||||
enabled: false
|
||||
# When tool_search is enabled, PR1 MCP routing metadata can auto-promote
|
||||
# matching deferred MCP tool schemas before a model call. This is the maximum
|
||||
# number of matched schemas promoted per model call. Valid range: 1..5.
|
||||
auto_promote_top_k: 3
|
||||
|
||||
# ============================================================================
|
||||
# Tool Output Budget Protection
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user