fix(agents): make the injected current-date timezone configurable (#5154)

* 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>
This commit is contained in:
Michael 2026-09-04 23:39:31 +08:00 committed by GitHub
parent fcb1c88e5e
commit eebe909ebd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 390 additions and 5 deletions

View File

@ -81,7 +81,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
**Lead-only middlewares** (`build_middlewares`, appended after the base):
14. **DynamicContextMiddleware** - Injects the current date (and optionally memory) as a `<system-reminder>` into the first HumanMessage, keeping the base system prompt fully static for prefix-cache reuse
14. **DynamicContextMiddleware** - Injects the current date (and optionally memory) as a `<system-reminder>` into the first HumanMessage, keeping the base system prompt fully static for prefix-cache reuse. The injected date follows the server-local timezone unless `DEER_FLOW_DATE_TIMEZONE` names an IANA zone (invalid values fall back to server-local).
15. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event
16. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking.
17. **DurableContextMiddleware** - Captures `task` delegations into `ThreadState.delegations` (including in-progress dispatches and terminal result summaries) and loaded skill-file references (name/path/description, parsed in-memory - not the body) into `ThreadState.skill_context` before summarization can compact the paired tool-call/result messages, then projects durable context into each model request. Static authority rules are injected as a `SystemMessage`; untrusted field values (`summary_text`, delegation results, skill descriptions) are injected separately as a hidden `HumanMessage` data block so compressed history, delegated work, and which skills are active stay visible without being stored as `messages` or promoted to system-role instructions. `build_subagent_runtime_middlewares` also attaches this middleware immediately before subagent summarization so a compacted `summary_text` is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request.

View File

@ -24,6 +24,18 @@ 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
@ -31,10 +43,13 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import posixpath
import re
import uuid
from datetime import datetime
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
@ -76,8 +91,101 @@ __all__ = [
]
_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:
return datetime.now().strftime("%Y-%m-%d, %A")
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:
@ -163,6 +271,10 @@ class SubagentDateContextMiddleware(AgentMiddleware):
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()
@ -185,8 +297,24 @@ class SubagentDateContextMiddleware(AgentMiddleware):
return self._inject()
@override
async def abefore_agent(self, state, runtime: Runtime) -> dict:
return self._inject()
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):
@ -222,6 +350,10 @@ class DynamicContextMiddleware(AgentMiddleware):
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).

View File

@ -50,6 +50,10 @@ dependencies = [
"alembic>=1.13",
"cryptography>=50.0.0",
"e2b-code-interpreter>=2.8.0",
# zoneinfo fallback: ZoneInfo reads the OS zone database first and this
# wheel when the image has none (stripped containers, Windows without
# tzdata), keeping the DEER_FLOW_DATE_TIMEZONE knob portable.
"tzdata>=2025.1",
]
[project.scripts]

View File

@ -0,0 +1,60 @@
"""Regression anchor: SubagentDateContextMiddleware must not block the event loop.
``_inject`` can resolve ``DEER_FLOW_DATE_TIMEZONE`` through ``ZoneInfo``, which
reads the OS timezone database (or the bundled ``tzdata`` wheel) on a cold
cache. ``abefore_agent`` runs on the async subagent path with no guarantee that
an assembly observer warmed that resolution first, so it offloads the call via
``asyncio.to_thread`` the same pattern ``DynamicContextMiddleware`` uses for
its file-I/O injection (see issue #3402).
This anchor drives the real ``create_agent`` graph via ``ainvoke`` under the
strict Blockbuster gate with the knob enabled. If the offload regresses and
``ZoneInfo`` resolution runs on the event loop, Blockbuster raises
``BlockingError`` and this test fails.
"""
from __future__ import annotations
import asyncio
import pytest
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from deerflow.agents.middlewares.dynamic_context_middleware import (
_DYNAMIC_CONTEXT_REMINDER_KEY,
SubagentDateContextMiddleware,
)
pytestmark = pytest.mark.asyncio
class _FakeModel(FakeMessagesListChatModel):
"""FakeMessagesListChatModel with a no-op ``bind_tools`` for create_agent."""
def bind_tools(self, tools, **kwargs): # type: ignore[override]
return self
async def test_subagent_abefore_agent_does_not_block_event_loop_with_timezone_enabled(monkeypatch) -> None:
"""A cold DEER_FLOW_DATE_TIMEZONE resolution must stay off the event loop."""
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Asia/Shanghai")
middleware = SubagentDateContextMiddleware()
agent = await asyncio.to_thread(
lambda: create_agent(
model=_FakeModel(responses=[AIMessage(content="ok")]),
tools=[],
middleware=[middleware],
)
)
result = await agent.ainvoke(
{"messages": [HumanMessage(content="hi")]},
{"configurable": {"thread_id": "test-thread"}},
)
reminders = [message for message in result["messages"] if isinstance(message, SystemMessage) and (message.additional_kwargs or {}).get(_DYNAMIC_CONTEXT_REMINDER_KEY)]
assert reminders, "the subagent date reminder must have been injected"
assert "<current_date>" in reminders[0].content

View File

@ -800,3 +800,168 @@ def test_no_recursive_id_swap_in_full_middleware_flow():
msgs_v2 = result_v2["messages"]
assert msgs_v2[0].id == "msg-2" # reminder takes new message's ID
assert msgs_v2[1].id == "msg-2__user" # user content gets derived ID
# ---------------------------------------------------------------------------
# Date timezone formatting
# ---------------------------------------------------------------------------
def test_format_current_date_defaults_to_server_local_without_env(monkeypatch):
"""Without DEER_FLOW_DATE_TIMEZONE the formatter keeps the legacy server-local behavior."""
from datetime import datetime
from deerflow.agents.middlewares.dynamic_context_middleware import _format_current_date
with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.return_value = datetime(2026, 5, 8, 9, 0)
monkeypatch.delenv("DEER_FLOW_DATE_TIMEZONE", raising=False)
assert _format_current_date() == "2026-05-08, Friday"
mock_dt.now.assert_called_once_with()
def test_format_current_date_honors_configured_timezone(monkeypatch):
"""A UTC instant must be rendered in the IANA zone named by DEER_FLOW_DATE_TIMEZONE."""
from datetime import UTC, datetime
from deerflow.agents.middlewares.dynamic_context_middleware import _format_current_date
# 2026-09-02 20:30 UTC is 2026-09-03 04:30 in Asia/Shanghai: a UTC-only
# formatter would report the wrong day for a Shanghai user.
fixed_utc = datetime(2026, 9, 2, 20, 30, 0, tzinfo=UTC)
def fake_now(tz=None):
# datetime.now(tz) semantics: the fixed instant expressed in *tz*.
return fixed_utc.astimezone(tz) if tz is not None else fixed_utc.replace(tzinfo=None)
with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.side_effect = fake_now
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Asia/Shanghai")
assert _format_current_date() == "2026-09-03, Thursday"
def test_format_current_date_invalid_timezone_falls_back(monkeypatch, caplog):
"""An unparseable IANA name must warn and degrade to the server-local timezone."""
from datetime import datetime
from deerflow.agents.middlewares.dynamic_context_middleware import _format_current_date
with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.return_value = datetime(2026, 5, 8, 9, 0)
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Not/A_Zone")
assert _format_current_date() == "2026-05-08, Friday"
assert "DEER_FLOW_DATE_TIMEZONE" in caplog.text
def _declared_date_timezone_policies():
from deerflow.agents.middlewares.dynamic_context_middleware import (
DynamicContextMiddleware,
SubagentDateContextMiddleware,
)
return [
DynamicContextMiddleware().release_policy_parameters(),
SubagentDateContextMiddleware().release_policy_parameters(),
]
def test_date_middlewares_declare_configured_timezone(monkeypatch):
"""Assembly identity must reflect the zone the injected date follows."""
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Asia/Shanghai")
assert _declared_date_timezone_policies() == [
{"current_date_timezone": "Asia/Shanghai"},
{"current_date_timezone": "Asia/Shanghai"},
]
def test_date_middlewares_declare_utc_timezone(monkeypatch):
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "UTC")
assert _declared_date_timezone_policies() == [
{"current_date_timezone": "UTC"},
{"current_date_timezone": "UTC"},
]
def test_date_middlewares_declare_resolved_local_zone_without_env(monkeypatch):
"""Without the knob the declaration resolves the actual local zone, so two
hosts that render different dates still get different assembly fingerprints."""
from deerflow.agents.middlewares.dynamic_context_middleware import _effective_date_timezone_name
monkeypatch.delenv("DEER_FLOW_DATE_TIMEZONE", raising=False)
expected = {"current_date_timezone": _effective_date_timezone_name()}
assert expected["current_date_timezone"]
assert _declared_date_timezone_policies() == [expected, expected]
def test_date_middlewares_declare_resolved_local_zone_for_invalid_env(monkeypatch, caplog):
"""An invalid IANA name degrades to server-local and is declared as such."""
from deerflow.agents.middlewares.dynamic_context_middleware import _effective_date_timezone_name
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Not/A_Zone")
expected = {"current_date_timezone": _effective_date_timezone_name()}
assert _declared_date_timezone_policies() == [expected, expected]
assert "DEER_FLOW_DATE_TIMEZONE" in caplog.text
def test_server_local_timezone_name_reads_tz_env(monkeypatch):
"""A POSIX TZ env var naming a real zone resolves to its IANA key."""
from deerflow.agents.middlewares.dynamic_context_middleware import _server_local_timezone_name
monkeypatch.setenv("TZ", "Asia/Shanghai")
assert _server_local_timezone_name() == "Asia/Shanghai"
def test_server_local_timezone_name_reads_direct_macos_symlink_target(monkeypatch):
"""macOS /etc/localtime points at the unversioned zoneinfo dir; the direct
symlink target must be read instead of a fully resolved path."""
import deerflow.agents.middlewares.dynamic_context_middleware as module
monkeypatch.delenv("TZ", raising=False)
monkeypatch.setattr(module.os, "readlink", lambda _path: "/var/db/timezone/zoneinfo/Asia/Shanghai")
assert module._server_local_timezone_name() == "Asia/Shanghai"
def test_server_local_timezone_name_reads_apple_versioned_symlink_target(monkeypatch):
"""Apple's canonical versioned zoneinfo path must still yield the zone key."""
import deerflow.agents.middlewares.dynamic_context_middleware as module
monkeypatch.delenv("TZ", raising=False)
monkeypatch.setattr(
module.os,
"readlink",
lambda _path: "/private/var/db/timezone/tz/2026c.1.0/zoneinfo/America/New_York",
)
assert module._server_local_timezone_name() == "America/New_York"
def test_server_local_timezone_name_normalizes_relative_symlink_target(monkeypatch):
"""A relative /etc/localtime target is resolved against /etc."""
import deerflow.agents.middlewares.dynamic_context_middleware as module
monkeypatch.delenv("TZ", raising=False)
monkeypatch.setattr(module.os, "readlink", lambda _path: "../usr/share/zoneinfo/Etc/UTC")
assert module._server_local_timezone_name() == "Etc/UTC"
def test_effective_timezone_sentinel_uses_offset_when_local_zone_is_not_resolvable(monkeypatch):
"""Without a recoverable IANA key the declaration pins a stable sentinel."""
import deerflow.agents.middlewares.dynamic_context_middleware as module
monkeypatch.setattr(module, "_server_local_timezone_name", lambda: None)
monkeypatch.setattr(module, "_server_local_utc_offset_minutes", lambda: 8 * 60)
monkeypatch.delenv("DEER_FLOW_DATE_TIMEZONE", raising=False)
assert module._effective_date_timezone_name() == "server-local(+08:00)"
monkeypatch.setattr(module, "_server_local_utc_offset_minutes", lambda: -5 * 60 - 30)
assert module._effective_date_timezone_name() == "server-local(-05:30)"

View File

@ -189,6 +189,18 @@ def _make_system_message_coalescing_middleware():
return SystemMessageCoalescingMiddleware()
def _make_dynamic_context_middleware():
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
return DynamicContextMiddleware()
def _make_subagent_date_context_middleware():
from deerflow.agents.middlewares.dynamic_context_middleware import SubagentDateContextMiddleware
return SubagentDateContextMiddleware()
# Single source of truth for "which middlewares declare a release policy" so
# the existence check and the construct-call-hash check below can never drift
# apart into two separately-maintained middleware lists. Every entry here is
@ -210,6 +222,11 @@ _MIDDLEWARE_DECLARATIONS = [
("deerflow.agents.middlewares.tool_output_budget_middleware", "ToolOutputBudgetMiddleware", _make_tool_output_budget_middleware),
("deerflow.agents.middlewares.skill_activation_middleware", "SkillActivationMiddleware", _make_skill_activation_middleware),
("deerflow.agents.middlewares.system_message_coalescing_middleware", "SystemMessageCoalescingMiddleware", _make_system_message_coalescing_middleware),
# The date middlewares declare the effective timezone the injected
# <current_date> follows, so differently-anchored deployments fingerprint
# differently.
("deerflow.agents.middlewares.dynamic_context_middleware", "DynamicContextMiddleware", _make_dynamic_context_middleware),
("deerflow.agents.middlewares.dynamic_context_middleware", "SubagentDateContextMiddleware", _make_subagent_date_context_middleware),
]

2
backend/uv.lock generated
View File

@ -960,6 +960,7 @@ dependencies = [
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "tavily-python" },
{ name = "tiktoken" },
{ name = "tzdata" },
]
[package.optional-dependencies]
@ -1054,6 +1055,7 @@ requires-dist = [
{ name = "tenki", marker = "extra == 'tenki'", specifier = ">=1.0.0" },
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" },
{ name = "tiktoken", specifier = ">=0.8.0" },
{ name = "tzdata", specifier = ">=2025.1" },
]
provides-extras = ["tui", "groundroute", "ollama", "postgres", "redis", "pymupdf", "boxlite", "tenki", "opensandbox", "monocle", "browser", "memory-zh"]

View File

@ -7,6 +7,11 @@
# `DEER_FLOW_CONFIG_PATH` to point at a specific config file.
# - Runtime state defaults to `.deer-flow` under the project root. Override it
# with `DEER_FLOW_HOME` when you need a different writable data directory.
# - Set `DEER_FLOW_DATE_TIMEZONE` to an IANA timezone name (e.g. `Asia/Shanghai`)
# when the conversation date injected into agents should follow a zone other
# than the server's local timezone. It is read at runtime by the date-context
# middlewares (not a config-schema field), so it can be set on a container
# without mounting a `config.yaml`.
# - Environment variables are available for all field values. Example: `api_key: $OPENAI_API_KEY`
# - The `use` path is a string that looks like "package_name.sub_package_name.module_name:class_name/variable_name".