mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
feat(subagents): add isolated date-only context (#4797)
* feat(subagents): inject date-only runtime context * refactor(middleware): deduplicate date reminder formatting
This commit is contained in:
parent
ccff5f5ce7
commit
e4a7a04719
@ -38,7 +38,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
23. **ViewImageMiddleware** - *(optional, if the model supports vision)* Injects a hidden HumanMessage with base64 image data, identified by a reserved ID prefix plus a server-owned metadata marker, before the LLM call. Because `before_model`, `model`, and `after_model` are separate graph nodes, the `before_model` and `model` node checkpoints for that call still contain the payload; `after_model` / `aafter_model` then emits `RemoveMessage`, so subsequent checkpoints do not retain it
|
||||
24. **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`
|
||||
25. **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)
|
||||
26. **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. **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. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
|
||||
27. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess `task` tool calls to enforce both the per-response concurrency limit (`max_concurrent_subagents`, clamped to 1-4) and the per-run total delegation cap (`max_total_subagents` runtime override or `subagents.max_total_per_run`, default 6, clamped to 1-50). The total cap counts current-run entries in the durable delegation ledger (entries are tagged with `run_id` when captured), so repeated planning checkpoints in one run cannot keep launching legal-sized batches indefinitely, while later user turns in the same thread get a fresh run budget. If the cap is exhausted, the middleware strips remaining `task` calls, forces `finish_reason="stop"`, and appends a visible limit note so the run can synthesize existing results instead of ending with an empty tool-call response.
|
||||
28. **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`
|
||||
29. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
|
||||
|
||||
@ -66,6 +66,20 @@ _SUMMARY_MESSAGE_NAME = "summary"
|
||||
INJECTED_USER_MESSAGE_ID_SUFFIX = "__user"
|
||||
|
||||
|
||||
def _format_current_date() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d, %A")
|
||||
|
||||
|
||||
def _format_current_date_reminder(current_date: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<system-reminder>",
|
||||
f"<current_date>{current_date}</current_date>",
|
||||
"</system-reminder>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def strip_injected_user_message_id_suffix(message_id: str | None) -> str | None:
|
||||
"""Return the id *message_id* had before the reminder ID-swap.
|
||||
|
||||
@ -143,6 +157,42 @@ def _is_user_injection_target(message: object) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class SubagentDateContextMiddleware(AgentMiddleware):
|
||||
"""Inject hidden current-date context once per built-in subagent execution.
|
||||
|
||||
Built-in subagents need the same temporal anchor as the lead agent, but not
|
||||
its user-memory lookup, frozen-conversation ID swap, or midnight refresh
|
||||
lifecycle. Each subagent graph is one-shot and starts from fresh state, so a
|
||||
single ``before_agent`` update makes the date available before its first
|
||||
model call without coupling the two runtime paths.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _inject() -> dict:
|
||||
current_date = _format_current_date()
|
||||
reminder = _format_current_date_reminder(current_date)
|
||||
return {
|
||||
"messages": [
|
||||
SystemMessage(
|
||||
content=reminder,
|
||||
additional_kwargs={
|
||||
"hide_from_ui": True,
|
||||
_DYNAMIC_CONTEXT_REMINDER_KEY: True,
|
||||
_REMINDER_DATE_KEY: current_date,
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
@override
|
||||
def before_agent(self, state, runtime: Runtime) -> dict:
|
||||
return self._inject()
|
||||
|
||||
@override
|
||||
async def abefore_agent(self, state, runtime: Runtime) -> dict:
|
||||
return self._inject()
|
||||
|
||||
|
||||
class DynamicContextMiddleware(AgentMiddleware):
|
||||
"""Inject memory and current date as a SystemMessage <system-reminder>.
|
||||
|
||||
@ -186,29 +236,15 @@ class DynamicContextMiddleware(AgentMiddleware):
|
||||
if injection_enabled
|
||||
else ""
|
||||
)
|
||||
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||
|
||||
date_reminder = "\n".join(
|
||||
[
|
||||
"<system-reminder>",
|
||||
f"<current_date>{current_date}</current_date>",
|
||||
"</system-reminder>",
|
||||
]
|
||||
)
|
||||
current_date = _format_current_date()
|
||||
date_reminder = _format_current_date_reminder(current_date)
|
||||
|
||||
memory_block = memory_context.strip() if memory_context else None
|
||||
|
||||
return date_reminder, memory_block
|
||||
|
||||
def _build_date_update_reminder(self) -> str:
|
||||
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||
return "\n".join(
|
||||
[
|
||||
"<system-reminder>",
|
||||
f"<current_date>{current_date}</current_date>",
|
||||
"</system-reminder>",
|
||||
]
|
||||
)
|
||||
return _format_current_date_reminder(_format_current_date())
|
||||
|
||||
@staticmethod
|
||||
def _make_reminder_and_user_messages(
|
||||
@ -270,7 +306,7 @@ class DynamicContextMiddleware(AgentMiddleware):
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
current_date = datetime.now().strftime("%Y-%m-%d, %A")
|
||||
current_date = _format_current_date()
|
||||
last_date = _last_injected_date(messages)
|
||||
logger.debug(
|
||||
"DynamicContextMiddleware._inject: msg_count=%d last_date=%r current_date=%r",
|
||||
|
||||
@ -511,18 +511,25 @@ def build_subagent_runtime_middlewares(
|
||||
if summarization_middleware is not None:
|
||||
middlewares.append(summarization_middleware)
|
||||
|
||||
# SystemMessageCoalescingMiddleware (#4040) — DurableContextMiddleware above
|
||||
# inserts a second ``SystemMessage(authority_contract)`` after the leading
|
||||
# system prompt (subagents carry their prompt as a leading ``SystemMessage``
|
||||
# in ``messages``, not via ``create_agent(system_prompt=...)``). Two system
|
||||
# messages — or a non-leading one — are exactly what the strict backends this
|
||||
# targets (vLLM/SGLang/Qwen/Anthropic) reject, so the durable fix would trade
|
||||
# #4039's assistant-first 400 for a duplicate-system 400. Mirror the lead
|
||||
# chain: append the coalescer innermost so it merges every SystemMessage into
|
||||
# one leading ``system_message`` on the outgoing request. It only rewrites the
|
||||
# per-request payload (no ``after_model``/``consume_stop_reason``), so it is
|
||||
# inert to the Phase 2 guard-cap channel, and must sit inner of
|
||||
# DurableContextMiddleware to observe the injected system message.
|
||||
# SubagentDateContextMiddleware (#4781) — inject framework-owned temporal
|
||||
# context before the first model call without registering the lead agent's
|
||||
# DynamicContextMiddleware. The latter also reads user memory, performs a
|
||||
# persisted ID swap, and handles midnight updates; none belongs in a one-shot
|
||||
# subagent execution. This date-only reminder intentionally has no AppConfig
|
||||
# or memory dependency.
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import SubagentDateContextMiddleware
|
||||
|
||||
middlewares.append(SubagentDateContextMiddleware())
|
||||
|
||||
# SystemMessageCoalescingMiddleware (#4040, #4781) — DurableContextMiddleware
|
||||
# above can insert ``SystemMessage(authority_contract)``, and the date-only
|
||||
# middleware adds another hidden SystemMessage after the leading subagent
|
||||
# prompt. Multiple or non-leading system messages are exactly what strict
|
||||
# backends (vLLM/SGLang/Qwen/Anthropic) reject. Append the coalescer innermost
|
||||
# so every SystemMessage becomes one leading ``system_message`` on the
|
||||
# outgoing request. It only rewrites the per-request payload (no
|
||||
# ``after_model``/``consume_stop_reason``), so it is inert to the Phase 2
|
||||
# guard-cap channel and observes both durable authority and date context.
|
||||
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
|
||||
|
||||
middlewares.append(SystemMessageCoalescingMiddleware())
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
|
||||
**Benefit-based routing policy**: Enabling subagents exposes delegation as an optimization, not a default response to complexity. The lead prompt defaults to direct execution and permits `task` only when parallel latency, specialist capability, or context-isolation benefit clearly exceeds startup, duplicate-discovery, synthesis, state-conflict, and side-effect costs. Inter-agent output dependencies and overlapping mutable state are hard vetoes for parallel dispatch, while duplicate discovery and a cheap direct path remain costs rather than categorical vetoes; a bounded sequential chain may run in one subagent when specialist or context-isolation benefit clearly wins. Parallel scopes must be independent and non-overlapping, the lead uses the fewest useful subagents, and every later batch is re-evaluated while retaining any within-batch parallel benefit. When the enforced per-response limit is 1, the rendered prompt removes parallel and multi-batch benefit guidance and permits delegation only for material specialist or context-isolation benefit. Keep this policy aligned across `lead_agent/prompt.py`, the `task` tool description, and both built-in role descriptions; routing regressions are pinned in `tests/test_subagent_routing_prompt.py`, `tests/test_subagent_prompt_security.py`, and `tests/test_lead_agent_prompt.py`.
|
||||
**User-scoped Skills**: Subagents resolve their configured skills through `get_or_new_user_skill_storage(user_id)` using the parent runtime identity, with `DEFAULT_USER_ID` only when no identity is available. This keeps custom-skill shadowing and visibility aligned with the lead agent instead of reading the global-only catalog.
|
||||
**Date context (#4781)**: Every built-in subagent execution registers `SubagentDateContextMiddleware` immediately before `SystemMessageCoalescingMiddleware`. Its one-time `before_agent` hook adds a hidden framework-owned `SystemMessage` containing only `<current_date>` before the first model call; it does not read `AppConfig.memory`, call the memory manager, rewrite the task `HumanMessage`, or inherit the lead agent's frozen-conversation/midnight lifecycle. The coalescer merges that reminder with the subagent's static prompt so strict providers still receive exactly one leading `SystemMessage`. The lead-only `DynamicContextMiddleware` registration and its date, optional-memory, and midnight-update behavior remain unchanged.
|
||||
**Execution**: Dual thread pool - `_scheduler_pool` (3 workers) + `_execution_pool` (3 workers)
|
||||
**Concurrency and total delegation cap**: `MAX_CONCURRENT_SUBAGENTS = 3` is enforced by `SubagentLimitMiddleware` (truncates excess tool calls in `after_model`; runtime `max_concurrent_subagents` is clamped to 1-4). The same middleware also enforces `subagents.max_total_per_run` (default 6, config schema 1-50, runtime override `max_total_subagents` clamped to the same range) against current-run entries in the durable delegation ledger, so a long lead-agent run cannot bypass concurrency limits by launching repeated legal-sized batches at each planning checkpoint, but historical delegations from previous runs in the same thread do not consume the new run's budget. The lead-agent prompt uses the same clamped values, so model-visible limits match enforcement. Gateway `run_agent()` and embedded `DeerFlowClient.stream()` both provide a per-invocation `run_id` in runtime context; `DeerFlowClient.stream()` also tags its input `HumanMessage` with that same id so durable-context capture can identify the current request boundary. Gateway resume paths may not append a new `HumanMessage`, so the worker also exposes the pre-run checkpoint's message ids in runtime context; durable-context capture uses that as the current-run boundary and never re-tags older task calls as the resumed run. When no delegation slots remain, task calls are stripped, provider raw tool-call metadata is synced, `finish_reason` is forced to `stop`, and a visible "subagent delegation limit" note is appended so the agent can synthesize already-collected results. Default subagent timeout `subagents.timeout_seconds=1800` (30 min) and built-in `general-purpose` `max_turns=150` (raised from 100/15-min so deep-research subtasks stop hitting `GraphRecursionError` out of the box)
|
||||
**Flow**: `task()` tool → `SubagentExecutor` → background thread → poll 5s → SSE events → result. `task_started` carries the resolved effective model name. The per-subagent `SubagentTokenCollector` publishes a cumulative usage snapshot to the shared `SubagentResult` after every completed LLM response; the next `task_running` event carries that snapshot, so collapsed workspace cards can update without re-accounting parent-run totals. Terminal ToolMessage metadata (`subagent_model_name`, `subagent_token_usage`) and the persisted `subagent.end` event retain the model/usage after reload; absent provider usage stays absent rather than being estimated as zero.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@ -161,8 +163,10 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
# + 1 TokenBudgetMiddleware (subagents.token_budget enabled by default, #3875 Phase 2)
|
||||
# + 1 SkillActivationMiddleware + 1 SkillToolPolicyMiddleware
|
||||
# + 1 SafetyFinishReasonMiddleware + 1 DurableContextMiddleware
|
||||
# + 1 SubagentDateContextMiddleware
|
||||
# + 1 SystemMessageCoalescingMiddleware (all enabled by default).
|
||||
from deerflow.agents.middlewares.durable_context_middleware import DurableContextMiddleware
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import SubagentDateContextMiddleware
|
||||
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.agents.middlewares.skill_tool_policy_middleware import SkillToolPolicyMiddleware
|
||||
@ -170,7 +174,7 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
|
||||
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
||||
|
||||
assert len(middlewares) == 17
|
||||
assert len(middlewares) == 18
|
||||
assert isinstance(middlewares[0], FakeMiddleware) # InputSanitizationMiddleware stub
|
||||
assert isinstance(middlewares[1], ToolOutputBudgetMiddleware)
|
||||
assert any(isinstance(m, ToolErrorHandlingMiddleware) for m in middlewares)
|
||||
@ -187,8 +191,9 @@ def test_build_subagent_runtime_middlewares_threads_app_config_to_llm_middleware
|
||||
# middleware), so it is the last element regardless of summarization.enabled —
|
||||
# unlike DurableContextMiddleware, which is only last when summarization is off.
|
||||
durable_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, DurableContextMiddleware))
|
||||
date_idx = next(i for i, m in enumerate(middlewares) if isinstance(m, SubagentDateContextMiddleware))
|
||||
assert isinstance(middlewares[-1], SystemMessageCoalescingMiddleware)
|
||||
assert policy_idx < durable_idx < len(middlewares) - 1
|
||||
assert policy_idx < durable_idx < date_idx == len(middlewares) - 2
|
||||
|
||||
|
||||
def test_tool_progress_middleware_is_outer_relative_to_error_handling(monkeypatch: pytest.MonkeyPatch):
|
||||
@ -897,6 +902,89 @@ def test_subagent_chain_coalesces_durable_authority_system_message(monkeypatch):
|
||||
assert seen["system_indices"] == [0], f"request must have a single leading SystemMessage, got {seen['system_indices']}"
|
||||
|
||||
|
||||
def test_subagent_chain_injects_date_without_memory_and_coalesces_for_strict_provider(monkeypatch):
|
||||
"""A built-in subagent's first model request gets hidden date-only context.
|
||||
|
||||
The date must be framework-owned and independent of the lead agent's
|
||||
memory path, even when memory injection is enabled globally. Subagents
|
||||
carry their static prompt in ``messages``, so the outgoing strict-provider
|
||||
payload must also retain exactly one leading ``SystemMessage`` after the
|
||||
date reminder is added.
|
||||
"""
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
|
||||
from deerflow.agents.middlewares import dynamic_context_middleware as dynamic_context
|
||||
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
|
||||
class _FrozenDateTime:
|
||||
@classmethod
|
||||
def now(cls):
|
||||
return datetime(2026, 5, 8)
|
||||
|
||||
def _unexpected_memory_lookup(*args, **kwargs):
|
||||
raise AssertionError("subagent date context must not look up user memory")
|
||||
|
||||
seen: list[list] = []
|
||||
task = "Find releases published today."
|
||||
|
||||
class _StrictModel(BaseChatModel):
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "strict"
|
||||
|
||||
def bind_tools(self, tools, **kwargs):
|
||||
return self
|
||||
|
||||
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
||||
captured = list(messages)
|
||||
seen.append(captured)
|
||||
|
||||
system_indices = [i for i, message in enumerate(captured) if isinstance(message, SystemMessage)]
|
||||
assert system_indices == [0], f"strict provider must receive one leading SystemMessage, got {system_indices}"
|
||||
|
||||
system_message = captured[0]
|
||||
reminder_blocks = re.findall(r"<system-reminder>\s*(.*?)\s*</system-reminder>", system_message.content, re.DOTALL)
|
||||
assert reminder_blocks == ["<current_date>2026-05-08, Friday</current_date>"]
|
||||
assert "<memory>" not in system_message.content
|
||||
assert system_message.additional_kwargs.get("hide_from_ui") is True
|
||||
|
||||
human_messages = [message for message in captured if isinstance(message, HumanMessage)]
|
||||
assert len(human_messages) == 1
|
||||
assert human_messages[0].content == task
|
||||
assert human_messages[0].id == "task"
|
||||
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
||||
|
||||
app_config = _make_app_config()
|
||||
assert app_config.memory.injection_enabled is True
|
||||
monkeypatch.setattr(dynamic_context, "datetime", _FrozenDateTime)
|
||||
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_memory_context", _unexpected_memory_lookup)
|
||||
|
||||
runtime_middlewares = build_subagent_runtime_middlewares(
|
||||
app_config=app_config,
|
||||
model_name="test-model",
|
||||
agent_name="general-purpose",
|
||||
)
|
||||
# Exercise the builder-owned date/coalescing slice without unrelated
|
||||
# sandbox, tool, or skill middleware side effects.
|
||||
chain = [middleware for middleware in runtime_middlewares if type(middleware).__module__ == dynamic_context.__name__ or isinstance(middleware, SystemMessageCoalescingMiddleware)]
|
||||
agent = create_agent(model=_StrictModel(), tools=[], middleware=chain, state_schema=ThreadState)
|
||||
|
||||
agent.invoke(
|
||||
{
|
||||
"messages": [
|
||||
SystemMessage(content="subagent instructions", id="system"),
|
||||
HumanMessage(content=task, id="task"),
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_subagent_runtime_middlewares_omit_summarization_when_factory_returns_none(monkeypatch):
|
||||
"""When ``summarization.enabled`` is False the shared factory returns None and
|
||||
the subagent chain must NOT carry a summarization middleware — the default
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user