From e4a7a047197069076a97f97e9765784799367b32 Mon Sep 17 00:00:00 2001 From: Zhou Kai Date: Thu, 13 Aug 2026 23:37:46 +0800 Subject: [PATCH] feat(subagents): add isolated date-only context (#4797) * feat(subagents): inject date-only runtime context * refactor(middleware): deduplicate date reminder formatting --- .../deerflow/agents/middlewares/AGENTS.md | 2 +- .../middlewares/dynamic_context_middleware.py | 72 +++++++++++---- .../tool_error_handling_middleware.py | 31 ++++--- .../harness/deerflow/subagents/AGENTS.md | 1 + .../test_tool_error_handling_middleware.py | 92 ++++++++++++++++++- 5 files changed, 165 insertions(+), 33 deletions(-) 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