mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-30 08:26:19 +00:00
* fix(agents): make the injected current-date timezone configurable ## Why The date reminder injected into the lead and subagent prompts (DynamicContextMiddleware / SubagentDateContextMiddleware) was formatted with the server's local wall clock. DeerFlow containers default to UTC, so a user in Asia/Shanghai chatting in the 00:00-08:00 window was told that 'today' is the previous day - the model then reasons, plans, and date-stamps against the wrong day. ## What changed - _format_current_date() now reads the optional DEER_FLOW_DATE_TIMEZONE env var (IANA name, e.g. Asia/Shanghai) and renders the date in that zone. - Unset = unchanged server-local behavior; invalid names log a warning and fall back to server-local. - Documented the knob in config.example.yaml, the module docstring, and the DynamicContext entry in agents/middlewares/AGENTS.md. ## Surface area - [x] Agents / LangGraph - prompt-layer date context only; message shape and midnight-update behavior unchanged - [ ] Frontend UI / Backend API / Sandbox / Skills / Dependencies - [x] Default behavior change (opt-in via env var - no behavior change unless set) ## Bug fix verification - New tests: test_format_current_date_honors_configured_timezone (UTC 20:30 -> 2026-09-03 in Asia/Shanghai), test_format_current_date_defaults_to_server_local_without_env, test_format_current_date_invalid_timezone_falls_back. - Existing mocked-datetime tests pass unchanged (no env -> datetime.now() path). ## Validation - cd backend && python -m pytest tests/test_dynamic_context_middleware.py: 31 passed. - blocking_io/test_dynamic_context_middleware.py: 2 pre-existing abefore_agent failures reproduce identically on clean main (blockbuster os.listdir detection on this host); the other 2 pass. - ruff format + ruff check clean. ## AI assistance **Tool(s) used:** Codex (coding agent) **How you used it:** analysis, implementation, and regression tests produced with AI assistance; reviewed before commit. - [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. * fix(agents): avoid passing tz to datetime.now when no timezone is configured CI (backend-unit-tests shard 2) failed in test_tool_error_handling_middleware.py::test_subagent_chain_injects_date_without_memory_and_coalesces_for_strict_provider because its _FrozenDateTime.now() subclass override accepts no arguments, while _format_current_date() called datetime.now(None) even when DEER_FLOW_DATE_TIMEZONE was unset. - _format_current_date() now calls datetime.now() with no arguments unless a timezone is actually configured, preserving the exact legacy call shape for every datetime-subclass test fake. - The configured-zone path still calls datetime.now(tz) and converts via astimezone(tz). - Updated the no-env unit test to assert datetime.now() is called without arguments. Validation: python -m pytest tests/test_dynamic_context_middleware.py + the previously failing strict-provider test: 32 passed. ruff clean. * fix(agents): declare the effective current-date timezone in the assembly descriptor ## Why Maintainer review on the DEER_FLOW_DATE_TIMEZONE change (#5154): the knob is prompt-affecting, yet both DynamicContextMiddleware and SubagentDateContextMiddleware were invisible to the agent assembly descriptor - describe_middleware() fell back to {"probed": true} for unset, UTC, and Asia/Shanghai alike, so deployments that inject different dates shared one assembly fingerprint and release observers could not distinguish or audit the behavior change. ## What changed - Both middlewares now implement release_policy_parameters() -> dict[str, object], declaring {"current_date_timezone": <name>} as required by the module's middleware self-description contract. - The declared value is the normalized effective zone: a configured, valid DEER_FLOW_DATE_TIMEZONE is reported by its IANA key (ZoneInfo.key); otherwise the server-local zone is resolved to its IANA key when the platform exposes one and to its tzname label otherwise (fixed-offset hosts), with "UTC" as the final fallback. - Added both middlewares to _MIDDLEWARE_DECLARATIONS in backend/tests/test_middleware_release_policy.py so the existence check and the construct-and-canonical-hash check cover them. ## Verification - New tests: test_date_middlewares_declare_configured_timezone (Asia/Shanghai), test_date_middlewares_declare_utc_timezone, plus resolved-server-local assertions for the unset and invalid-env paths; both middlewares agree in every case. - cd backend && python -m pytest tests/test_dynamic_context_middleware.py tests/test_middleware_release_policy.py: 70 passed. - Regression spot-check: tests/test_agent_assembly_descriptor.py, tests/test_tool_error_handling_middleware.py, tests/test_system_message_coalescing_middleware.py: 102 passed. - ruff check + ruff format clean. ## AI assistance **Tool(s) used:** Codex (coding agent) **How you used it:** analysis, implementation, and regression tests produced with AI assistance; reviewed before commit. - [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. * fix(agents): stabilize the declared date timezone and simplify the formatting path ## Why Follow-up review on #5154 (willem-bd). The release-policy declaration added in 884cec4b resolved the observability gap but pinned far less identity than its docstrings claimed, and the formatting path carried a production no-op. ## What changed - The declared label is now stable and unambiguous: a configured, valid DEER_FLOW_DATE_TIMEZONE is reported by its IANA key; without one, the server-local zone is resolved to a real IANA key from the TZ env var or the /etc/localtime symlink (Linux/macOS); when no key is recoverable (Windows, stripped containers) the declaration falls back to a stable `server-local(+-HH:MM)` sentinel carrying the current UTC offset. It never reports a bare abbreviation - datetime.now().astimezone() yields only a fixed-offset timezone whose tzname (e.g. CST, EST/EDT, CET/CEST) is ambiguous or DST-churns, which the assembly descriptor docstring says must not happen. - Dropped the redundant astimezone(tz) in _format_current_date(): datetime.now(tz) already returns the instant expressed in tz. The configured-zone test now fakes datetime.now(tz) semantics (the fixed instant converted into the requested zone) instead of relying on that conversion. - Documented why the knob is an env var, not a config-schema field: it is read at runtime by both date-context middlewares so an operator can point a container at another zone without mounting a config.yaml (module docstring + config.example.yaml note). - AGENTS.md: fixed the glued DynamicContext sentence (missing separator). - Added tzdata>=2025.1 to the harness runtime dependencies (with uv.lock) so ZoneInfo works on stripped containers / Windows without an OS zone database. ## Verification - New tests: test_server_local_timezone_name_reads_tz_env, test_effective_timezone_sentinel_uses_offset_when_local_zone_is_not_resolvable; reworked test_format_current_date_honors_configured_timezone to exercise the real datetime.now(tz) path. - cd backend && python -m pytest tests/test_dynamic_context_middleware.py tests/test_middleware_release_policy.py tests/test_agent_assembly_descriptor.py tests/test_tool_error_handling_middleware.py: 140 passed. - ruff check + ruff format clean. ## AI assistance **Tool(s) used:** Codex (coding agent) **How you used it:** analysis, implementation, and regression tests produced with AI assistance; reviewed before commit. - [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. * fix(agents): offload subagent date injection off the event loop ## Why Follow-up review on #5154 (willem-bd, P2): SubagentDateContextMiddleware.abefore_agent() called _inject() directly, so enabling DEER_FLOW_DATE_TIMEZONE could synchronously read the OS timezone database (or the tzdata wheel) on a cold cache - filesystem work on the async subagent execution path whenever no assembly observer resolved the zone first. ## What changed - SubagentDateContextMiddleware.abefore_agent() now offloads the injection via asyncio.to_thread with the same bounded timeout DynamicContextMiddleware uses (issue #3402); on timeout it logs and skips the date update for that run instead of blocking the loop. - Narrowed the exception handling in _date_timezone() and the TZ-env branch of _server_local_timezone_name() to configuration-shaped failures (ZoneInfoNotFoundError / ValueError / OSError). Previously a blanket `except Exception` also swallowed BlockingError raised by the blocking-I/O regression gate, mislabeling a loop-blocking call as an invalid timezone and silently degrading to server-local - which made the new regression anchor useless. Other exceptions now propagate. ## Verification - New blocking-I/O regression anchor (backend/tests/blocking_io/test_subagent_date_context_middleware.py): drives a real create_agent graph under the strict Blockbuster gate with the knob enabled and asserts the date reminder is injected. Verified it fails (BlockingError) when the offload is reverted and passes with it in place. - python -m pytest tests/blocking_io/test_subagent_date_context_middleware.py: 1 passed. The two pre-existing os.listdir failures in tests/blocking_io/test_dynamic_context_middleware.py reproduce unchanged on this host (same as clean main). - python -m pytest tests/test_dynamic_context_middleware.py tests/test_middleware_release_policy.py tests/test_tool_error_handling_middleware.py tests/test_agent_assembly_descriptor.py: 139 passed; the single ToolReceiptMiddleware-ordering failure reproduces with the change stashed (local extensions registry, unrelated to this PR). - ruff check + ruff format clean. ## AI assistance **Tool(s) used:** Codex (coding agent) **How you used it:** analysis, implementation, and regression tests produced with AI assistance; reviewed before commit. - [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. * fix(agents): read the direct /etc/localtime symlink target for the zone key ## Why Follow-up review on #5154 (willem-bd, P2): on macOS, /etc/localtime commonly points to /var/db/timezone/zoneinfo/<zone>, but Path.resolve() follows that directory's own symlink and yields a versioned path such as /private/var/db/timezone/tz/2026c.1.0/zoneinfo/Asia/Shanghai, which matched no configured prefix. The server-local resolution then returned None and the assembly descriptor fell back to a server-local(+HH:MM) sentinel even though the IANA key was available - conflating zones that share an offset and making DST-based fingerprints unstable. ## What changed - _server_local_timezone_name() now reads the direct symlink target via os.readlink("/etc/localtime") instead of Path.resolve(), so macOS' unversioned zoneinfo path is seen as-is and its IANA key is preserved. - The zone key is taken from whatever follows the last "/zoneinfo/" segment, which also handles Apple's canonical versioned path when a direct target already carries it, and relative targets are normalized against /etc. - Removed the now-unused Path import and the fixed zoneinfo prefix tuple. ## Verification - New tests: test_server_local_timezone_name_reads_direct_macos_symlink_target, test_server_local_timezone_name_reads_apple_versioned_symlink_target, and test_server_local_timezone_name_normalizes_relative_symlink_target. - python -m pytest tests/test_dynamic_context_middleware.py tests/test_middleware_release_policy.py tests/test_agent_assembly_descriptor.py: 105 passed (75 after re-running the first two on the merged main). The blocking subagent anchor still passes; the two pre-existing os.listdir blocking failures on this host are unchanged. - ruff check + ruff format clean. ## AI assistance **Tool(s) used:** Codex (coding agent) **How you used it:** analysis, implementation, and regression tests produced with AI assistance; reviewed before commit. - [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
584 lines
25 KiB
Python
584 lines
25 KiB
Python
"""Middleware to inject dynamic context (memory, current date) as a system-reminder.
|
|
|
|
The system prompt is kept fully static for maximum prefix-cache reuse across users
|
|
and sessions. The current date is always injected. Per-user memory is also injected
|
|
when ``memory.injection_enabled`` is True in the app config. Both are delivered once
|
|
per conversation as a dedicated <system-reminder> SystemMessage inserted before the
|
|
first user message (frozen-snapshot pattern).
|
|
|
|
When a conversation spans midnight the middleware detects the date change and injects
|
|
a lightweight date-update reminder as a separate SystemMessage before the current turn.
|
|
This correction is persisted so subsequent turns on the new day see a consistent history
|
|
and do not re-inject.
|
|
|
|
Reminder format:
|
|
|
|
<system-reminder>
|
|
<memory>...</memory>
|
|
|
|
<current_date>2026-05-08, Friday</current_date>
|
|
</system-reminder>
|
|
|
|
Date-update format:
|
|
|
|
<system-reminder>
|
|
<current_date>2026-05-09, Saturday</current_date>
|
|
</system-reminder>
|
|
|
|
By default the injected date follows the server's local timezone. Set the
|
|
``DEER_FLOW_DATE_TIMEZONE`` environment variable to an IANA timezone name (for
|
|
example ``Asia/Shanghai``) when the host clock runs UTC but the conversation
|
|
date should follow another zone. Invalid values log a warning and fall back to
|
|
the server-local timezone.
|
|
|
|
The knob is deliberately an environment variable rather than a config field:
|
|
it is read directly by both date-context middlewares at injection time, so an
|
|
operator can point a container at another zone without mounting a config.yaml,
|
|
and the lead and built-in-subagent paths can never drift apart on which zone
|
|
they render.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, tzinfo
|
|
from typing import TYPE_CHECKING, override
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from deerflow_extension_api import ContentKind, provenance_kwargs
|
|
from langchain.agents.middleware import AgentMiddleware
|
|
from langchain_core.messages import HumanMessage, SystemMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
|
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
|
from deerflow.utils.messages import INJECTED_USER_MESSAGE_ID_SUFFIX, strip_injected_user_message_id_suffix
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Upper bound (seconds) for a single _inject() offload. If the warm-up at
|
|
# gateway startup failed silently, the first request may still hit a cold
|
|
# tiktoken BPE download that blocks until the OS TCP timeout (~26 min).
|
|
# This cap ensures the request degrades gracefully instead of hanging.
|
|
_INJECT_TIMEOUT_SECONDS = 5.0
|
|
|
|
_DATE_RE = re.compile(r"<current_date>([^<]+)</current_date>")
|
|
_DYNAMIC_CONTEXT_REMINDER_KEY = "dynamic_context_reminder"
|
|
# Authoritative injected date, carried in additional_kwargs of the date
|
|
# SystemMessage. Detection reads this instead of regex-parsing message content,
|
|
# so it is never exposed to user-influenceable memory content.
|
|
_REMINDER_DATE_KEY = "reminder_date"
|
|
_SUMMARY_MESSAGE_NAME = "summary"
|
|
|
|
# ``INJECTED_USER_MESSAGE_ID_SUFFIX`` / ``strip_injected_user_message_id_suffix``
|
|
# are defined in ``deerflow.utils.messages`` and re-exported here, where the
|
|
# ID-swap they describe actually happens. Existing importers keep working.
|
|
__all__ = [
|
|
"INJECTED_USER_MESSAGE_ID_SUFFIX",
|
|
"DynamicContextMiddleware",
|
|
"SubagentDateContextMiddleware",
|
|
"is_dynamic_context_reminder",
|
|
"strip_injected_user_message_id_suffix",
|
|
]
|
|
|
|
|
|
_DATE_TIMEZONE_ENV = "DEER_FLOW_DATE_TIMEZONE"
|
|
|
|
|
|
def _date_timezone() -> tzinfo | None:
|
|
"""Resolve the configured IANA timezone for injected dates, or None for server-local."""
|
|
raw = os.environ.get(_DATE_TIMEZONE_ENV, "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return ZoneInfo(raw)
|
|
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
# Only configuration-shaped failures degrade to server-local. A
|
|
# BlockingError-style guard (blocking-I/O regression suite) or any
|
|
# unrelated exception must propagate instead of being misread as an
|
|
# invalid timezone name.
|
|
logger.warning("Invalid %s=%r; falling back to the server-local timezone", _DATE_TIMEZONE_ENV, raw)
|
|
return None
|
|
|
|
|
|
def _server_local_timezone_name() -> str | None:
|
|
"""IANA key of the server's local zone, or ``None`` when not resolvable.
|
|
|
|
``datetime.now().astimezone().tzinfo`` is always a plain fixed-offset
|
|
``datetime.timezone`` (an abbreviation such as ``CST`` is ambiguous and
|
|
DST-churns), never a ``zoneinfo.ZoneInfo`` carrying an IANA key. The key is
|
|
instead read from the platform: the ``TZ`` environment variable when it
|
|
names a real zone, or the ``/etc/localtime`` symlink target on
|
|
Linux/macOS. Only the symlink's *direct* target is read (``os.readlink``),
|
|
not a fully resolved path: on macOS ``/etc/localtime`` points into
|
|
``/var/db/timezone/zoneinfo/`` whose own directory symlink resolves to a
|
|
versioned path (``.../tz/<version>/zoneinfo/...``) that would defeat any
|
|
fixed prefix list. The zone key is whatever follows the last ``/zoneinfo/``
|
|
segment. Hosts with no symlink (Windows, stripped containers) return
|
|
``None``.
|
|
"""
|
|
tz_env = os.environ.get("TZ", "").strip()
|
|
if tz_env:
|
|
try:
|
|
return ZoneInfo(tz_env).key
|
|
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
pass
|
|
try:
|
|
target = os.readlink("/etc/localtime")
|
|
except OSError:
|
|
return None
|
|
if not target.startswith("/"):
|
|
target = posixpath.normpath(posixpath.join("/etc", target))
|
|
zoneinfo_marker = "/zoneinfo/"
|
|
marker_index = target.rfind(zoneinfo_marker)
|
|
if marker_index == -1:
|
|
return None
|
|
key = target[marker_index + len(zoneinfo_marker) :]
|
|
if not key or key.startswith("/") or ".." in key:
|
|
return None
|
|
return key
|
|
|
|
|
|
def _server_local_utc_offset_minutes() -> int:
|
|
"""Current UTC offset of the server's local zone, in minutes."""
|
|
offset = datetime.now().astimezone().utcoffset()
|
|
return int(offset.total_seconds() // 60) if offset is not None else 0
|
|
|
|
|
|
def _effective_date_timezone_name() -> str:
|
|
"""Stable label of the timezone the injected date actually follows.
|
|
|
|
A configured, valid ``DEER_FLOW_DATE_TIMEZONE`` is reported by its IANA
|
|
key; without one, the server-local zone is reported by its resolved IANA
|
|
key when the platform exposes it. When no IANA key is recoverable the
|
|
declaration falls back to a ``server-local(±HH:MM)`` sentinel carrying the
|
|
current UTC offset - never a bare abbreviation, which would be ambiguous
|
|
(``CST`` is shared by China, US Central, and Cuba) and would churn across
|
|
DST. Declaring the effective zone (never a bare ``probed``) lets the
|
|
assembly descriptor tell deployments that anchor the injected date
|
|
differently apart.
|
|
"""
|
|
tz = _date_timezone()
|
|
if tz is not None:
|
|
key = getattr(tz, "key", None)
|
|
if isinstance(key, str) and key:
|
|
return key
|
|
return "UTC"
|
|
local_key = _server_local_timezone_name()
|
|
if local_key is not None:
|
|
return local_key
|
|
offset_minutes = _server_local_utc_offset_minutes()
|
|
sign = "+" if offset_minutes >= 0 else "-"
|
|
offset_minutes = abs(offset_minutes)
|
|
return f"server-local({sign}{offset_minutes // 60:02d}:{offset_minutes % 60:02d})"
|
|
|
|
|
|
def _format_current_date() -> str:
|
|
tz = _date_timezone()
|
|
now = datetime.now(tz) if tz is not None else datetime.now()
|
|
return 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 _extract_date(content: str) -> str | None:
|
|
"""Return the first <current_date> value found in *content*, or None."""
|
|
m = _DATE_RE.search(content)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def is_dynamic_context_reminder(message: object) -> bool:
|
|
"""Return whether *message* is a hidden dynamic-context reminder."""
|
|
# DEPRECATED: HumanMessage reminders only exist in pre-PR checkpoints.
|
|
# Once all active checkpoints are migrated, the HumanMessage branch can be
|
|
# removed and this function can check SystemMessage exclusively.
|
|
return isinstance(message, (HumanMessage, SystemMessage)) and bool(message.additional_kwargs.get(_DYNAMIC_CONTEXT_REMINDER_KEY))
|
|
|
|
|
|
def _last_injected_date(messages: list) -> str | None:
|
|
"""Scan messages in reverse and return the most recently injected date.
|
|
|
|
Detection uses the ``dynamic_context_reminder`` additional_kwargs flag rather
|
|
than content substring matching, so user messages containing ``<system-reminder>``
|
|
are not mistakenly treated as injected reminders.
|
|
|
|
The authoritative date is the ``reminder_date`` value in additional_kwargs of
|
|
the date SystemMessage. Reminders without it (the separate ``<memory>``
|
|
HumanMessage, or any future dateless reminder) carry no date and are skipped,
|
|
so they cannot shadow the real date reminder.
|
|
"""
|
|
for msg in reversed(messages):
|
|
if not is_dynamic_context_reminder(msg):
|
|
continue
|
|
structured = msg.additional_kwargs.get(_REMINDER_DATE_KEY)
|
|
if isinstance(structured, str) and structured:
|
|
return structured
|
|
# Backward-compat for checkpoints written before reminder_date existed:
|
|
# the date lived in content. Scope the regex to SystemMessage so it never
|
|
# runs on the user-influenceable memory HumanMessage (preserves the OWASP
|
|
# role separation from #3630 and closes the memory date-spoofing hole).
|
|
if isinstance(msg, SystemMessage):
|
|
content_str = msg.content if isinstance(msg.content, str) else str(msg.content)
|
|
date = _extract_date(content_str)
|
|
if date is not None:
|
|
return date
|
|
return None
|
|
|
|
|
|
def _is_user_injection_target(message: object) -> bool:
|
|
"""Return whether *message* can receive a dynamic-context reminder."""
|
|
if not isinstance(message, HumanMessage):
|
|
return False
|
|
if is_dynamic_context_reminder(message):
|
|
return False
|
|
if message.name == _SUMMARY_MESSAGE_NAME:
|
|
return False
|
|
# Prevent recursive ID-swap: a message whose ID ends with "__user" was
|
|
# produced by a prior _make_reminder_and_user_messages call and must not
|
|
# be processed again — doing so causes unbounded suffix growth
|
|
# (id__user__user__user...) and ghost-message re-execution.
|
|
# Using endswith (not substring "in") avoids false positives on IDs that
|
|
# happen to contain "__user" in the middle.
|
|
if message.id and str(message.id).endswith(INJECTED_USER_MESSAGE_ID_SUFFIX):
|
|
return False
|
|
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.
|
|
"""
|
|
|
|
def release_policy_parameters(self) -> dict[str, object]:
|
|
"""The injected date's effective timezone is this middleware's behaviour identity."""
|
|
return {"current_date_timezone": _effective_date_timezone_name()}
|
|
|
|
@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 | None:
|
|
# _inject() can resolve DEER_FLOW_DATE_TIMEZONE through ZoneInfo,
|
|
# which reads the OS zone database (or the tzdata wheel) on a cold
|
|
# cache. SubagentDateContextMiddleware runs on the async subagent path,
|
|
# where no assembly observer necessarily warmed that resolution first,
|
|
# so the injection is offloaded like DynamicContextMiddleware does (see
|
|
# #3402) to keep filesystem work off the event loop.
|
|
try:
|
|
return await asyncio.wait_for(
|
|
asyncio.to_thread(self._inject),
|
|
timeout=_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"SubagentDateContextMiddleware: date injection timed out (%.1fs); skipping for this run",
|
|
_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
return None
|
|
|
|
|
|
class DynamicContextMiddleware(AgentMiddleware):
|
|
"""Inject memory and current date as a SystemMessage <system-reminder>.
|
|
|
|
First turn
|
|
----------
|
|
Prepends a full system-reminder (memory + date) to the first HumanMessage and
|
|
persists it (same message ID). The first message is then frozen for the whole
|
|
session — its content never changes again, so the prefix cache can hit on every
|
|
subsequent turn.
|
|
|
|
Fallback (missed earlier injection)
|
|
-----------------------------------
|
|
If an earlier turn ended without any reminder (e.g. the async ``abefore_agent``
|
|
degraded path skipped injection on a timeout), the first-injection branch runs
|
|
on a history that already holds several turns. The reminder then attaches to
|
|
the **last** user message instead: the ID-swap's ``{id}__user`` copy is
|
|
appended by ``add_messages``, so attaching to an earlier message would move
|
|
that stale prompt ahead of the current question and the model would answer
|
|
the old prompt as the current turn.
|
|
|
|
Midnight crossing
|
|
-----------------
|
|
If the conversation spans midnight, the current date differs from the date that
|
|
was injected earlier. In that case a lightweight date-update reminder is prepended
|
|
to the **current** (last) HumanMessage and persisted. Subsequent turns on the new
|
|
day see the corrected date in history and skip re-injection.
|
|
"""
|
|
|
|
def __init__(self, agent_name: str | None = None, *, app_config: AppConfig | None = None):
|
|
super().__init__()
|
|
self._agent_name = agent_name
|
|
self._app_config = app_config
|
|
|
|
def release_policy_parameters(self) -> dict[str, object]:
|
|
"""Declare the injected date's effective timezone for assembly identity."""
|
|
return {"current_date_timezone": _effective_date_timezone_name()}
|
|
|
|
def _build_full_reminder(self, runtime: Runtime | None = None) -> tuple[str, str | None]:
|
|
"""Return (date_reminder, memory_block | None).
|
|
|
|
Framework-owned data (date) is separated from user-owned data (memory)
|
|
so the downstream SystemMessage carries only framework authority and
|
|
memory stays at role:user — preventing untrusted content from gaining
|
|
system privilege (OWASP LLM01).
|
|
"""
|
|
from deerflow.agents.lead_agent.prompt import _get_memory_context
|
|
|
|
injection_enabled = self._app_config.memory.injection_enabled if self._app_config else True
|
|
memory_context = (
|
|
_get_memory_context(
|
|
self._agent_name,
|
|
app_config=self._app_config,
|
|
user_id=resolve_runtime_user_id(runtime),
|
|
)
|
|
if injection_enabled
|
|
else ""
|
|
)
|
|
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:
|
|
return _format_current_date_reminder(_format_current_date())
|
|
|
|
@staticmethod
|
|
def _make_reminder_and_user_messages(
|
|
original: HumanMessage,
|
|
reminder_content: str,
|
|
memory_content: str | None = None,
|
|
*,
|
|
reminder_date: str | None = None,
|
|
) -> list[SystemMessage | HumanMessage]:
|
|
"""Return messages using the ID-swap technique.
|
|
|
|
SystemMessage carries framework-owned data (date, metadata) — takes
|
|
the original ID so add_messages replaces it in-place. *reminder_date*
|
|
is recorded in its additional_kwargs as the authoritative injected date
|
|
(``_last_injected_date`` reads it instead of parsing content). Optional
|
|
HumanMessage carries user-owned memory content with ``{id}__memory``.
|
|
The actual user message gets ``{id}__user``.
|
|
|
|
SystemMessage is used — system context must not masquerade as user
|
|
input (#3630). Memory is deliberately kept as HumanMessage so
|
|
user-influenceable content does not gain system authority (OWASP LLM01)
|
|
— and it deliberately never carries ``reminder_date``.
|
|
"""
|
|
stable_id = original.id or str(uuid.uuid4())
|
|
messages: list[SystemMessage | HumanMessage] = []
|
|
|
|
reminder_kwargs = {
|
|
"hide_from_ui": True,
|
|
_DYNAMIC_CONTEXT_REMINDER_KEY: True,
|
|
**provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "dynamic_context"),
|
|
}
|
|
if reminder_date is not None:
|
|
reminder_kwargs[_REMINDER_DATE_KEY] = reminder_date
|
|
messages.append(
|
|
SystemMessage(
|
|
content=reminder_content,
|
|
id=stable_id,
|
|
additional_kwargs=reminder_kwargs,
|
|
)
|
|
)
|
|
|
|
if memory_content:
|
|
messages.append(
|
|
HumanMessage(
|
|
content=memory_content,
|
|
id=f"{stable_id}__memory",
|
|
additional_kwargs={
|
|
"hide_from_ui": True,
|
|
_DYNAMIC_CONTEXT_REMINDER_KEY: True,
|
|
**provenance_kwargs(ContentKind.MEMORY, "dynamic_context_memory"),
|
|
},
|
|
)
|
|
)
|
|
|
|
messages.append(
|
|
HumanMessage(
|
|
content=original.content,
|
|
id=f"{stable_id}{INJECTED_USER_MESSAGE_ID_SUFFIX}",
|
|
name=original.name,
|
|
additional_kwargs=original.additional_kwargs,
|
|
)
|
|
)
|
|
return messages
|
|
|
|
def _inject(self, state, runtime: Runtime | None = None) -> dict | None:
|
|
messages = list(state.get("messages", []))
|
|
if not messages:
|
|
return None
|
|
|
|
current_date = _format_current_date()
|
|
last_date = _last_injected_date(messages)
|
|
logger.debug(
|
|
"DynamicContextMiddleware._inject: msg_count=%d last_date=%r current_date=%r",
|
|
len(messages),
|
|
last_date,
|
|
current_date,
|
|
)
|
|
|
|
if last_date is None:
|
|
# ── First turn: inject full reminder as a SystemMessage ─────
|
|
#
|
|
# Scan from the end so the reminder attaches to the LAST user
|
|
# injection target. Normally that is also the only message. But
|
|
# when an earlier turn ended without any reminder — e.g. the async
|
|
# ``abefore_agent`` degraded path skipped injection on a timeout —
|
|
# history already holds multiple turns and the ID-swap's
|
|
# ``{id}__user`` copy is APPENDED by ``add_messages``; choosing an
|
|
# earlier message here would move the old first user prompt to the
|
|
# tail, ahead of the latest question, and the model would answer
|
|
# the stale first message as if it were the current turn.
|
|
target_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None)
|
|
if target_idx is None:
|
|
return None
|
|
date_reminder, memory_block = self._build_full_reminder(runtime)
|
|
logger.info(
|
|
"DynamicContextMiddleware: injecting full reminder (has_memory=%s) into last HumanMessage id=%r",
|
|
memory_block is not None,
|
|
messages[target_idx].id,
|
|
)
|
|
result_msgs = self._make_reminder_and_user_messages(messages[target_idx], date_reminder, memory_block, reminder_date=current_date)
|
|
return {"messages": result_msgs}
|
|
|
|
if last_date == current_date:
|
|
# ── Same day: nothing to do ──────────────────────────────────────────
|
|
return None
|
|
|
|
# ── Midnight crossed: inject date-update reminder as a SystemMessage ──
|
|
last_human_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None)
|
|
if last_human_idx is None:
|
|
return None
|
|
|
|
result_msgs = self._make_reminder_and_user_messages(messages[last_human_idx], self._build_date_update_reminder(), reminder_date=current_date)
|
|
logger.info("DynamicContextMiddleware: midnight crossing detected — injected date update before current turn")
|
|
return {"messages": result_msgs}
|
|
|
|
@override
|
|
def before_agent(self, state, runtime: Runtime) -> dict | None:
|
|
result = self._inject(state, runtime)
|
|
self._record_effective_memory(state, result, runtime)
|
|
return result
|
|
|
|
@override
|
|
async def abefore_agent(self, state, runtime: Runtime) -> dict | None:
|
|
# _inject() performs synchronous file I/O (memory JSON loading) and
|
|
# potentially blocking network calls (tiktoken encoding download on
|
|
# first use). Offload to a thread so the event loop is never blocked
|
|
# — a blocking call here starves all concurrent HTTP handlers (auth,
|
|
# SSE heartbeats, etc.). See issue #3402.
|
|
#
|
|
# Bounded timeout: if startup warm-up failed silently (e.g. network
|
|
# blip during deploy), the first request's cold tiktoken download can
|
|
# block for tens of minutes (OS TCP timeout). Time-box injection so
|
|
# the request degrades gracefully (no new dynamic-context update)
|
|
# rather than hanging. Frozen context already in state remains active.
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
asyncio.to_thread(self._inject, state, runtime),
|
|
timeout=_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"DynamicContextMiddleware: injection timed out (%.1fs); skipping new memory/date injection for this turn",
|
|
_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
self._record_effective_memory(state, None, runtime)
|
|
return None
|
|
self._record_effective_memory(state, result, runtime)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _effective_memory_message(state, update: dict | None, runtime: Runtime) -> HumanMessage | None:
|
|
"""Find server-created memory that is effective for this run.
|
|
|
|
A first-run block must come from this middleware's update. A reused
|
|
block must have existed in the checkpoint before the run; the Gateway
|
|
strips the reminder marker from untrusted input so a caller cannot
|
|
replace a known checkpoint ID with forged provenance.
|
|
"""
|
|
if isinstance(update, dict):
|
|
update_messages = update.get("messages")
|
|
if isinstance(update_messages, list):
|
|
for message in update_messages:
|
|
if not isinstance(message, HumanMessage):
|
|
continue
|
|
message_id = str(message.id or "")
|
|
if message_id.endswith("__memory") and is_dynamic_context_reminder(message) and isinstance(message.content, str):
|
|
return message
|
|
|
|
context = getattr(runtime, "context", None)
|
|
raw_pre_existing_ids = context.get(CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY) if isinstance(context, dict) else None
|
|
if not isinstance(raw_pre_existing_ids, (frozenset, set, list, tuple)):
|
|
return None
|
|
pre_existing_ids = {str(message_id) for message_id in raw_pre_existing_ids if message_id}
|
|
for message in state.get("messages", []):
|
|
if not isinstance(message, HumanMessage):
|
|
continue
|
|
message_id = str(message.id or "")
|
|
if message_id in pre_existing_ids and message_id.endswith("__memory") and is_dynamic_context_reminder(message) and isinstance(message.content, str):
|
|
return message
|
|
return None
|
|
|
|
def _record_effective_memory(self, state, update: dict | None, runtime: Runtime) -> None:
|
|
"""Attach the effective hidden memory block to the current run ledger."""
|
|
context = getattr(runtime, "context", None)
|
|
journal = context.get("__run_journal") if isinstance(context, dict) else None
|
|
if journal is None:
|
|
return
|
|
|
|
message = self._effective_memory_message(state, update, runtime)
|
|
if message is None:
|
|
return
|
|
|
|
try:
|
|
journal.record_memory_context(
|
|
content_sha256=hashlib.sha256(message.content.encode("utf-8")).hexdigest(),
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to record effective memory context", exc_info=True)
|