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:
Zhou Kai 2026-08-13 23:37:46 +08:00 committed by GitHub
parent ccff5f5ce7
commit e4a7a04719
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 165 additions and 33 deletions

View File

@ -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

View File

@ -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",

View File

@ -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())

View File

@ -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.

View File

@ -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