diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md
index 481217679..bbe561a1d 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md
+++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md
@@ -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
diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py
index 4f33daf95..c31e0c717 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py
+++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py
@@ -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(
+ [
+ "",
+ f"{current_date}",
+ "",
+ ]
+ )
+
+
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 .
@@ -186,29 +236,15 @@ class DynamicContextMiddleware(AgentMiddleware):
if injection_enabled
else ""
)
- current_date = datetime.now().strftime("%Y-%m-%d, %A")
-
- date_reminder = "\n".join(
- [
- "",
- f"{current_date}",
- "",
- ]
- )
+ 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(
- [
- "",
- f"{current_date}",
- "",
- ]
- )
+ 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",
diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
index fb19d058b..31cbd6c2f 100644
--- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
+++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py
@@ -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())
diff --git a/backend/packages/harness/deerflow/subagents/AGENTS.md b/backend/packages/harness/deerflow/subagents/AGENTS.md
index f5d33f39a..daf4b0e0b 100644
--- a/backend/packages/harness/deerflow/subagents/AGENTS.md
+++ b/backend/packages/harness/deerflow/subagents/AGENTS.md
@@ -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 `` 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.
diff --git a/backend/tests/test_tool_error_handling_middleware.py b/backend/tests/test_tool_error_handling_middleware.py
index 20f65f701..325f812de 100644
--- a/backend/tests/test_tool_error_handling_middleware.py
+++ b/backend/tests/test_tool_error_handling_middleware.py
@@ -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"\s*(.*?)\s*", system_message.content, re.DOTALL)
+ assert reminder_blocks == ["2026-05-08, Friday"]
+ assert "" 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