mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +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>
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""Middlewares describe their own behaviour-affecting parameters.
|
|
|
|
Two runs that used different limits are different runs. Reconstructing that
|
|
from outside means reading private attributes and guessing which ones matter;
|
|
each middleware declares it instead.
|
|
"""
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
from deerflow_extension_api import ReleasePolicyProvider, canonical_hash, canonical_json, collect_release_policies
|
|
from langchain_core.language_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
|
|
|
|
def test_canonical_json_is_key_order_independent():
|
|
assert canonical_json({"b": 1, "a": 2}) == canonical_json({"a": 2, "b": 1})
|
|
|
|
|
|
def test_canonical_json_is_stable_across_processes_for_nested_values():
|
|
assert canonical_json({"a": [1, {"d": 4, "c": 3}]}) == '{"a":[1,{"c":3,"d":4}]}'
|
|
|
|
|
|
def test_canonical_hash_differs_when_a_value_differs():
|
|
assert canonical_hash({"limit": 5}) != canonical_hash({"limit": 6})
|
|
|
|
|
|
def test_canonical_json_rejects_unserialisable_values_loudly():
|
|
with pytest.raises(TypeError):
|
|
canonical_json({"f": object()})
|
|
|
|
|
|
def test_collect_skips_middlewares_that_declare_nothing():
|
|
class Silent:
|
|
pass
|
|
|
|
class Declaring:
|
|
def release_policy_parameters(self):
|
|
return {"limit": 3}
|
|
|
|
assert collect_release_policies([Silent(), Declaring()]) == {"Declaring": {"limit": 3}}
|
|
|
|
|
|
def test_collect_survives_a_middleware_whose_declaration_raises():
|
|
class Broken:
|
|
def release_policy_parameters(self):
|
|
raise RuntimeError("boom")
|
|
|
|
class Fine:
|
|
def release_policy_parameters(self):
|
|
return {"ok": True}
|
|
|
|
result = collect_release_policies([Broken(), Fine()])
|
|
assert result["Fine"] == {"ok": True}
|
|
assert result["Broken"] == {"error": "RuntimeError"}
|
|
|
|
|
|
def test_collect_survives_two_middlewares_of_the_same_class():
|
|
"""A second instance of the same class must not overwrite the first."""
|
|
|
|
class Declaring:
|
|
def __init__(self, limit):
|
|
self._limit = limit
|
|
|
|
def release_policy_parameters(self):
|
|
return {"limit": self._limit}
|
|
|
|
result = collect_release_policies([Declaring(1), Declaring(2)])
|
|
assert result == {"Declaring": {"limit": 1}, "Declaring#2": {"limit": 2}}
|
|
|
|
|
|
def test_collect_unwraps_an_isolation_style_wrapper():
|
|
"""A contributed middleware reaches the stack behind a duck-typed ``.inner``
|
|
wrapper; describing the wrapper instead of the real middleware would
|
|
collapse every extension contribution into one shared, empty entry."""
|
|
|
|
class Wrapped:
|
|
def release_policy_parameters(self):
|
|
return {"limit": 3}
|
|
|
|
class Wrapper:
|
|
def __init__(self, inner):
|
|
self.inner = inner
|
|
|
|
assert collect_release_policies([Wrapper(Wrapped())]) == {"Wrapped": {"limit": 3}}
|
|
|
|
|
|
def test_protocol_is_runtime_checkable():
|
|
class Declaring:
|
|
def release_policy_parameters(self):
|
|
return {}
|
|
|
|
assert isinstance(Declaring(), ReleasePolicyProvider)
|
|
|
|
|
|
class _StaticChatModel(BaseChatModel):
|
|
"""Minimal real ``BaseChatModel`` that never calls a provider.
|
|
|
|
Mirrors the construction-time stand-in already used by
|
|
``test_summarization_middleware.py``'s ``_StaticChatModel``: summarization
|
|
middleware construction needs a model object, but no API key or network
|
|
access, so a real (non-string) ``BaseChatModel`` subclass sidesteps
|
|
``langchain``'s ``init_chat_model`` entirely.
|
|
"""
|
|
|
|
text: str = "ok"
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "static-test-chat-model"
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=self.text))])
|
|
|
|
|
|
def _make_loop_detection_middleware():
|
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
|
|
|
return LoopDetectionMiddleware()
|
|
|
|
|
|
def _make_subagent_limit_middleware():
|
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
|
|
|
return SubagentLimitMiddleware(max_concurrent=2, max_total=6)
|
|
|
|
|
|
def _make_terminal_response_middleware():
|
|
from deerflow.agents.middlewares.terminal_response_middleware import TerminalResponseMiddleware
|
|
|
|
return TerminalResponseMiddleware()
|
|
|
|
|
|
def _make_todo_middleware():
|
|
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
|
|
|
return TodoMiddleware()
|
|
|
|
|
|
def _make_token_budget_middleware():
|
|
from deerflow.agents.middlewares.token_budget_middleware import TokenBudgetMiddleware
|
|
from deerflow.config.token_budget_config import TokenBudgetConfig
|
|
|
|
return TokenBudgetMiddleware(config=TokenBudgetConfig())
|
|
|
|
|
|
def _make_deferred_tool_filter_middleware():
|
|
from deerflow.agents.middlewares.deferred_tool_filter_middleware import DeferredToolFilterMiddleware
|
|
|
|
return DeferredToolFilterMiddleware(deferred_names=frozenset({"tool_b", "tool_a"}), catalog_hash="catalog-1")
|
|
|
|
|
|
def _make_safety_finish_reason_middleware():
|
|
from deerflow.agents.middlewares.safety_finish_reason_middleware import SafetyFinishReasonMiddleware
|
|
|
|
return SafetyFinishReasonMiddleware()
|
|
|
|
|
|
def _make_summarization_middleware():
|
|
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
|
|
|
|
return DeerFlowSummarizationMiddleware(
|
|
model=_StaticChatModel(),
|
|
trigger=("messages", 4),
|
|
keep=("messages", 2),
|
|
token_counter=len,
|
|
)
|
|
|
|
|
|
def _make_tool_output_budget_middleware():
|
|
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware
|
|
|
|
return ToolOutputBudgetMiddleware()
|
|
|
|
|
|
def _make_skill_activation_middleware():
|
|
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
|
|
|
return SkillActivationMiddleware(available_skills={"skill-b", "skill-a"}, slash_source_owner_token="test-owner-token")
|
|
|
|
|
|
def _make_system_message_coalescing_middleware():
|
|
from deerflow.agents.middlewares.system_message_coalescing_middleware import SystemMessageCoalescingMiddleware
|
|
|
|
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
|
|
# constructible with the minimum arguments needed for a valid instance; if a
|
|
# future addition genuinely cannot be constructed in a unit test, keep its
|
|
# entry and mark it with `pytest.param(..., marks=pytest.mark.skip(reason=...))`
|
|
# instead of dropping it — a documented gap beats an invisible one.
|
|
_MIDDLEWARE_DECLARATIONS = [
|
|
("deerflow.agents.middlewares.loop_detection_middleware", "LoopDetectionMiddleware", _make_loop_detection_middleware),
|
|
("deerflow.agents.middlewares.subagent_limit_middleware", "SubagentLimitMiddleware", _make_subagent_limit_middleware),
|
|
("deerflow.agents.middlewares.terminal_response_middleware", "TerminalResponseMiddleware", _make_terminal_response_middleware),
|
|
# DeerFlow's own subclass, not the LangChain base class re-exported into
|
|
# this module under the same import path (TodoListMiddleware).
|
|
("deerflow.agents.middlewares.todo_middleware", "TodoMiddleware", _make_todo_middleware),
|
|
("deerflow.agents.middlewares.token_budget_middleware", "TokenBudgetMiddleware", _make_token_budget_middleware),
|
|
("deerflow.agents.middlewares.deferred_tool_filter_middleware", "DeferredToolFilterMiddleware", _make_deferred_tool_filter_middleware),
|
|
("deerflow.agents.middlewares.safety_finish_reason_middleware", "SafetyFinishReasonMiddleware", _make_safety_finish_reason_middleware),
|
|
("deerflow.agents.middlewares.summarization_middleware", "DeerFlowSummarizationMiddleware", _make_summarization_middleware),
|
|
("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),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("import_path,class_name,make_instance", _MIDDLEWARE_DECLARATIONS)
|
|
def test_middleware_declares_release_policy_parameters(import_path, class_name, make_instance):
|
|
cls = getattr(importlib.import_module(import_path), class_name)
|
|
assert hasattr(cls, "release_policy_parameters"), f"{class_name} must declare its behaviour policy"
|
|
|
|
|
|
@pytest.mark.parametrize("import_path,class_name,make_instance", _MIDDLEWARE_DECLARATIONS)
|
|
def test_middleware_release_policy_parameters_are_canonically_serialisable(import_path, class_name, make_instance):
|
|
"""A declaration that cannot be hashed is not usable as release identity.
|
|
|
|
Unlike ``test_middleware_declares_release_policy_parameters`` above (which
|
|
only checks the method exists), this constructs a real instance and calls
|
|
it for real. A set-typed or model-typed field added to any declaration
|
|
later would raise ``TypeError`` here — a bare ``hasattr`` check would stay
|
|
green while the identity mechanism this slice exists to provide breaks
|
|
silently.
|
|
"""
|
|
cls = getattr(importlib.import_module(import_path), class_name)
|
|
middleware = make_instance()
|
|
assert isinstance(middleware, cls)
|
|
params = middleware.release_policy_parameters()
|
|
assert isinstance(params, dict)
|
|
canonical_hash(params)
|