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:
Ryker_Feng 2026-07-10 07:54:36 +08:00 committed by GitHub
parent 66c0c641bf
commit ebc09ce130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 778 additions and 40 deletions

View File

@ -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. 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 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`. 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. See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions.
#### IM Channels #### IM Channels

View File

@ -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. 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) 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 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) 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. **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 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. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce the `MAX_CONCURRENT_SUBAGENTS` limit 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. **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` 26. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce the `MAX_CONCURRENT_SUBAGENTS` limit
27. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits 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. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the safety/clarification tail 28. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
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 29. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before the safety/clarification tail
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. 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 ### 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` **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.) **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`). **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. **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/`) ### 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) - `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`**: **`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) - `skills` - Map of skill name → state (enabled)
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods. Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.

View File

@ -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 `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 model prefer a configured MCP tool for matching requests without forbidding
bound tool schemas or forbidding other tools. When `tool_search.enabled=true` other tools. When `tool_search.enabled=true` defers MCP schemas, matching
defers MCP schemas, the hint tells the model to fetch the deferred tool with routing metadata can auto-promote up to `tool_search.auto_promote_top_k`
`tool_search` before preferring it. deferred schemas before the model call.
### Environment Variables ### Environment Variables

View File

@ -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 search. Routing hints are soft model guidance: they add a
`<mcp_routing_hints>` prompt section, but they do not forbid other tools. Use `<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` 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 defers MCP tool schemas, matching routing metadata can also auto-promote the
the deferred tool before preferring it. deferred schema before the model call. Auto-promotion is controlled by the
top-level `config.yaml -> tool_search.auto_promote_top_k` setting.
```json ```json
{ {
@ -53,13 +54,21 @@ the deferred tool before preferring it.
- `routing.mode`: `off` disables hints; `prefer` emits hints. - `routing.mode`: `off` disables hints; `prefer` emits hints.
- `routing.priority`: `0` to `100`; higher-priority hints are rendered first. - `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 - `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 - `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 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 `<server>_` prefix added for model binding. If the server-level
`routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting `routing.mode` is `off`, a tool override must set `mode: "prefer"`; setting
only `priority` or `keywords` still inherits `off` and emits no hint. 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) ## Per-Tool Timeout (Stdio MCP Servers)

View File

@ -223,6 +223,7 @@ def build_middlewares(
available_skills: set[str] | None = None, available_skills: set[str] | None = None,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
deferred_setup=None, deferred_setup=None,
mcp_routing_middleware: AgentMiddleware | None = None,
user_id: str | None = None, user_id: str | None = None,
): ):
"""Build the lead-agent middleware chain based on runtime configuration. """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. app_config: Explicit AppConfig; falls back to ``get_app_config()`` when omitted.
deferred_setup: Optional deferred-MCP-tool setup that attaches deferred_setup: Optional deferred-MCP-tool setup that attaches
``DeferredToolFilterMiddleware`` when ``tool_search`` is enabled. ``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 user_id: Effective user ID for user-scoped skill loading. Passed through
to ``SkillActivationMiddleware`` so it can resolve per-user custom skills. 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: if model_config is not None and model_config.supports_vision:
middlewares.append(ViewImageMiddleware()) 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. # Hide deferred tool schemas from model binding until tool_search promotes them.
# The deferred set + catalog hash come from the build-time setup (assembled # The deferred set + catalog hash come from the build-time setup (assembled
# after tool-policy filtering); promotion is read from graph state. # 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 from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash)) 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 # Coalesce every SystemMessage into a single leading one before the request
# reaches the provider. Strict backends (vLLM, SGLang, Qwen, Anthropic) # 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 # Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent, update_agent 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) cfg = _get_runtime_config(config)
resolved_app_config = app_config resolved_app_config = app_config
@ -490,6 +501,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if non_interactive: if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] 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) 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: if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool) final_tools.append(skill_setup.describe_skill_tool)
return create_agent( return create_agent(
@ -501,6 +517,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=set(_BOOTSTRAP_SKILL_NAMES), available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config, app_config=resolved_app_config,
deferred_setup=setup, deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id, user_id=resolved_user_id,
), ),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
@ -546,6 +563,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if non_interactive: if non_interactive:
filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] 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) 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) mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(filtered, deferred_names=setup.deferred_names)
if skill_setup.describe_skill_tool: if skill_setup.describe_skill_tool:
final_tools.append(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, available_skills=available_skills,
app_config=resolved_app_config, app_config=resolved_app_config,
deferred_setup=setup, deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=resolved_user_id, user_id=resolved_user_id,
), ),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(

View File

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

View File

@ -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 import get_or_new_skill_storage, get_or_new_user_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.skills.types import SKILL_MD_FILE, SecretRequirement, Skill, SkillCategory 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: if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig 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_KEY = "slash_skill_activation"
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id" _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, # _SECRETS_BINDING_AUDIT_KEY: last audited binding (skill and secret names only,
# never values) so unchanged bindings are not re-recorded each call. # 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: def _is_user_activation_target(message: object) -> bool:
if not isinstance(message, HumanMessage): return is_real_user_message(message)
return False
if message.name == _SUMMARY_MESSAGE_NAME:
return False
if message.additional_kwargs.get("hide_from_ui"):
return False
return True
class SkillActivationMiddleware(AgentMiddleware): class SkillActivationMiddleware(AgentMiddleware):

View File

@ -280,6 +280,7 @@ def build_subagent_runtime_middlewares(
model_name: str | None = None, model_name: str | None = None,
lazy_init: bool = True, lazy_init: bool = True,
deferred_setup: "DeferredToolSetup | None" = None, deferred_setup: "DeferredToolSetup | None" = None,
mcp_routing_middleware: AgentMiddleware | None = None,
agent_name: str | None = None, agent_name: str | None = None,
) -> list[AgentMiddleware]: ) -> list[AgentMiddleware]:
"""Middlewares shared by subagent runtime before subagent-only middlewares.""" """Middlewares shared by subagent runtime before subagent-only middlewares."""
@ -304,6 +305,9 @@ def build_subagent_runtime_middlewares(
middlewares.append(ViewImageMiddleware()) 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 # 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 # 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 # 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 from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
middlewares.append(DeferredToolFilterMiddleware(deferred_setup.deferred_names, deferred_setup.catalog_hash)) 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 # LoopDetectionMiddleware — subagents inherit none of the lead's runaway
# guards today (see #3875): with no loop detection a degenerate subagent tool # guards today (see #3875): with no loop detection a degenerate subagent tool

View File

@ -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.runtime.user_context import get_effective_user_id
from deerflow.skills.describe import build_skill_search_setup from deerflow.skills.describe import build_skill_search_setup
from deerflow.skills.storage import get_or_new_user_skill_storage 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.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.tracing import build_tracing_callbacks, inject_langfuse_metadata
from deerflow.uploads.manager import ( from deerflow.uploads.manager import (
@ -257,6 +257,11 @@ class DeerFlowClient:
tools = self._get_tools(model_name=model_name, subagent_enabled=subagent_enabled) 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) 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) 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. # Wire deferred skill discovery — mirrors agent.py so config flag works on both paths.
@ -286,6 +291,7 @@ class DeerFlowClient:
custom_middlewares=self._middlewares, custom_middlewares=self._middlewares,
app_config=self._app_config, app_config=self._app_config,
deferred_setup=deferred_setup, deferred_setup=deferred_setup,
mcp_routing_middleware=mcp_routing_middleware,
user_id=get_effective_user_id(), user_id=get_effective_user_id(),
), ),
"system_prompt": apply_prompt_template( "system_prompt": apply_prompt_template(

View File

@ -1,6 +1,14 @@
"""Configuration for deferred tool loading via tool_search.""" """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): class ToolSearchConfig(BaseModel):
@ -15,6 +23,15 @@ class ToolSearchConfig(BaseModel):
default=False, default=False,
description="Defer tools and enable tool_search", 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 _tool_search_config: ToolSearchConfig | None = None

View File

@ -437,13 +437,25 @@ class SubagentExecutor:
# Reuse shared middleware composition with lead agent. ``agent_name`` # Reuse shared middleware composition with lead agent. ``agent_name``
# lets the builder resolve the per-agent token_budget override. # lets the builder resolve the per-agent token_budget override.
middlewares = build_subagent_runtime_middlewares( mcp_routing_middleware = None
app_config=app_config, if deferred_setup is not None and deferred_setup.deferred_names:
model_name=self.model_name, from deerflow.tools.builtins.tool_search import build_mcp_routing_middleware
lazy_init=True,
deferred_setup=deferred_setup, mcp_routing_middleware = build_mcp_routing_middleware(
agent_name=self.config.name, 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`` # Collect every guard middleware that exposes ``consume_stop_reason``
# (TokenBudgetMiddleware, LoopDetectionMiddleware) so _aexecute can read # (TokenBudgetMiddleware, LoopDetectionMiddleware) so _aexecute can read
# each after the run and surface whichever cap fired. Duck-typed # each after the run and surface whichever cap fired. Duck-typed

View File

@ -6,6 +6,8 @@ Contains:
catalog; it records promotions into graph state via ``Command``. catalog; it records promotions into graph state via ``Command``.
- build_deferred_tool_setup: assembles the catalog + tool from a - build_deferred_tool_setup: assembles the catalog + tool from a
policy-filtered tool list (call AFTER tool-policy filtering). 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 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 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 collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from typing import Annotated from typing import TYPE_CHECKING, Annotated, Any
from langchain.tools import BaseTool from langchain.tools import BaseTool
from langchain_core.messages import ToolMessage 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 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__) logger = logging.getLogger(__name__)
MAX_RESULTS = 5 # Max tools returned per search 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 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 # Prompt rendering

View File

@ -3,7 +3,10 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
from langchain_core.messages import HumanMessage
ORIGINAL_USER_CONTENT_KEY = "original_user_content" ORIGINAL_USER_CONTENT_KEY = "original_user_content"
SUMMARY_MESSAGE_NAME = "summary"
def message_content_to_text(content: Any) -> str: 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): if isinstance(original_content, str):
return original_content return original_content
return message_content_to_text(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

View File

@ -1087,6 +1087,68 @@ class TestEnsureAgent:
skill_names_arg = mock_apply_prompt.call_args.kwargs.get("skill_names") 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" 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 # get_model

View File

@ -424,6 +424,33 @@ def test_build_middlewares_passes_explicit_app_config_to_shared_factory(monkeypa
assert middlewares[0] == "base-middleware" 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): def test_build_middlewares_uses_loop_detection_config(monkeypatch):
app_config = _make_app_config( app_config = _make_app_config(
[_make_model("safe-model", supports_thinking=False)], [_make_model("safe-model", supports_thinking=False)],

View 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

View File

@ -649,7 +649,7 @@ class TestAgentConstruction:
from deerflow.tools.builtins.tool_search import DeferredToolSetup from deerflow.tools.builtins.tool_search import DeferredToolSetup
SubagentExecutor = classes["SubagentExecutor"] 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] = {} captured: dict[str, object] = {}
def fake_build_subagent_runtime_middlewares(**kwargs): def fake_build_subagent_runtime_middlewares(**kwargs):

View File

@ -548,6 +548,23 @@ def test_subagent_runtime_middlewares_attach_deferred_filter_when_setup_has_name
assert filter_idx < safety_idx 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): def test_subagent_runtime_middlewares_skip_deferred_filter_without_names(monkeypatch):
"""No deferred setup (disabled / no MCP tool) -> no DeferredToolFilterMiddleware.""" """No deferred setup (disabled / no MCP tool) -> no DeferredToolFilterMiddleware."""
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware

View File

@ -15,15 +15,60 @@ from deerflow.tools.builtins.tool_search import get_deferred_tools_prompt_sectio
class TestToolSearchConfig: class TestToolSearchConfig:
def test_default_disabled(self): def test_default_disabled(self):
assert ToolSearchConfig().enabled is False assert ToolSearchConfig().enabled is False
assert ToolSearchConfig().auto_promote_top_k == 3
def test_enabled(self): def test_enabled(self):
assert ToolSearchConfig(enabled=True).enabled is True 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): 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): def test_load_from_empty_dict(self):
assert load_tool_search_config_from_dict({}).enabled is False 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: class TestDeferredToolsPromptSection:

View File

@ -872,6 +872,10 @@ tools:
tool_search: tool_search:
enabled: false 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 # Tool Output Budget Protection