mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 20:16:18 +00:00
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md (issue #5160, tracker #5129) in the slice order of the spec's §16. Slices: - A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY admission pinning (both server-owned sets + worker hoist); latest-only request-scoped <project> block via DynamicContextMiddleware wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID prefix + marker + provenance, never persisted); journal audit fingerprints; Instructions tab. - B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with locked check-and-set; hash-qualified immutable shelf storage with Paths helpers; upload/list/content/delete-to-trash routes; project delete trashes the shelf in-transaction; request-scoped bounded <documents> index with honest count/shown + actionable overflow note; list_project_documents/read_project_document tools registered only on pinned runs; PAT allowlist + drift guards; blocking-IO anchors. - C: shared thread-upload ingestion service (uploads router refactored to parity); POST from-thread with provenance; attach-to-thread with lock-staged copy (archived source allowed); read-only thread-files view with per-group truncation reporting. - D: restore (restored/merged/not_found/no_target/content_missing; no file moves), purge (continuous row lock across unlink/delete/commit, retryable on FS errors), retention sweep (lazy + startup, 24h orphan guard, row-side reconciliation never deletes). - E: Documents tab (shelf + conversation-files browser, provenance, archived banner, content-missing rows), /workspace/trash route, sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks + specs. Review hardening folded in (10 rounds, all with tests): - force active shelf content (HTML/XML family) to download; nosniff on artifact + content responses; unified unsandboxed-iframe PDF preview (fixes the pre-existing Chromium sandbox blank in the artifact viewer) - scope document trash to the URL project under the document lock - atomic no-overwrite filename reservation for ALL ingestion (seeded claims + os.link commit with suffix retry; same-name re-upload now unique-names instead of replacing); hidden staging only, no visible placeholders; lease cleanup on setup failure - serialize conversion under the document lock with post-lock active revalidation; drain locked filesystem work on cancellation; preserve bytes when an insert's commit state is uncertain (including trashed rows) - original-integrity checks before serving text or cached conversions; content_missing surfaced in list responses (UI reads the flag, no 409-probe); downloads always serve original bytes - bounded streaming document reads with cached char counts; shelf limits declared in middleware release identity - thread-root confinement for from-thread sources; config fallback rejects fractional/infinite values; composer counts staged attachments; pending attachments persist until submission or removal; in-flight instruction/rename edits survive save refetches; shelf and trash pagination; conversation-file and thread-files pages stay subscribed to refetches Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md contracts, config.example.yaml projects block. Review follow-ups (head b4807477 → this revision): - The trash retention sweep is split so repeated lazy triggers stay bounded: the indexed expiry purge still runs on every trigger (GET /api/trash/documents, POST /api/trash/purge) while the O(all rows + all files) reconciliation is throttled to one run per user per 15 minutes (process-local, per-user window). The startup sweep now runs as a background task instead of blocking gateway readiness, and shutdown awaits it (bounded). - The export scrub (stripInternalMarkers) is fence- and indentation-aware like the render path, so a pasted, fenced <project>/<documents> snippet survives markdown export while real injected blocks (never fenced) are still removed. Fence regexes moved to a dependency-free leaf module to avoid the messages↔streamdown import cycle. - The artifact viewer's PDF iframe no longer carries an added title attribute (the upstream e2e contract locates it via :not([title])), and the upstream artifact-preview spec now pins the new contract: PDFs render unsandboxed, images keep sandbox="". * fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title - Shutdown cancelled only the shield around the background startup sweep, so an all-users reconciliation that outlived the 5s budget kept walking rows and files while the document repo and DB engine were disposed underneath it. The wait now lives in `_shutdown_startup_trash_sweep`, which cancels the task and drains it before worker exit: the shield keeps the wait bounded, the cancel makes it final (CancelledError lands at the sweep's next await, and `_run_startup_trash_sweep` only catches `Exception`, so nothing swallows it). - The browser-preview iframe lost `title={getFileName(filepath)}` in the previous fix round, leaving the PDF frame without an accessible name while its siblings keep theirs. Restore it (WCAG frame titles), assert it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]` instead of `iframe:not([title])`. * fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel `Task.cancel()` returns False when the sweep already finished inside the window between the deadline firing and the cancel, so the shutdown log claimed a cancellation that never happened. Branch on that outcome: the warning stays for a real cancel, a late finish is logged at info, and both paths still reap the task before worker exit. * fix(projects): round-4 review — make Empty trash delete what it confirms `POST /api/trash/purge` only ran the retention sweep, and the sweep's candidate selection is age-gated, so a freshly trashed document survived "Empty trash" even though the confirmation promises that every listed document is permanently deleted. With one trashed row the route answered `{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps expired rows before listing, so the visible rows were normally ineligible for the action by construction. Empty trash now drives `purge_all_trashed`: the caller's trashed rows (`list_all_trashed`, no age filter) each go through the same guarded, row-locked `purge` as the single-document delete — bytes first, then the row, in one transaction — so a row restored mid-flight is skipped instead of force-deleted, and an unlink failure rolls that row back and answers 500 with a retryable message. Retention expiry stays where it was: the sweep's `purge_candidates` is now the only age-gated selection, and the lazy retention sweep still runs on the listing and at startup. Tests: the router suite replaces the retention-gated expectation with the reviewer's repro (fresh row purged, bytes unlinked, shelf and other users' trash untouched, a failing unlink stays retryable and 500); a blocking-I/O anchor drives the new entry point through the offload; the mocked e2e covers the action end to end; a new real-backend spec performs it against the real gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE and the phase-2 design docs (en+zh) state the age-independent contract.
766 lines
36 KiB
Python
766 lines
36 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 collections.abc import Awaitable, Callable
|
|
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.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
|
from langchain_core.messages import HumanMessage, RemoveMessage, SystemMessage
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.projects.context import build_project_context_message, is_project_context_message, pinned_project_snapshot, project_context_insertion_index, render_documents_block, render_project_block
|
|
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,
|
|
memory_enabled: bool = True,
|
|
):
|
|
super().__init__()
|
|
self._agent_name = agent_name
|
|
self._app_config = app_config
|
|
self._memory_enabled = memory_enabled
|
|
# Message ID of the ``__memory`` block this instance injected during
|
|
# the current run's ``before_agent`` (assembly is per run). The
|
|
# request-time journal selection trusts a non-checkpointed ``__memory``
|
|
# message only when it carries this ID — a flagged memory message that
|
|
# is neither checkpoint-proven nor self-produced cannot forge the
|
|
# run's recorded memory identity.
|
|
self._injected_memory_message_id: str | None = None
|
|
|
|
def release_policy_parameters(self) -> dict[str, object]:
|
|
"""Declare memory/date behavior and the shelf index's rendering caps.
|
|
|
|
``memory_enabled`` gates the memory half of the injected context;
|
|
``shelf_index_max_entries`` / ``shelf_index_max_bytes`` change the
|
|
model-visible ``<documents>`` block this middleware renders. All three
|
|
are model-visible policy, so the effective (post-config-resolution)
|
|
values are part of the assembly identity — runs under different
|
|
policies must not share a fingerprint.
|
|
"""
|
|
max_entries, max_bytes = self._shelf_index_limits()
|
|
return {
|
|
"current_date_timezone": _effective_date_timezone_name(),
|
|
"memory_enabled": self._memory_enabled,
|
|
"shelf_index_max_entries": max_entries,
|
|
"shelf_index_max_bytes": max_bytes,
|
|
}
|
|
|
|
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._memory_enabled and (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())
|
|
|
|
def _disabled_memory_removals(self, messages: list) -> list[RemoveMessage]:
|
|
"""Remove only frozen memory messages owned by this middleware."""
|
|
if self._memory_enabled:
|
|
return []
|
|
|
|
removals: list[RemoveMessage] = []
|
|
for message in messages:
|
|
message_id = str(message.id or "")
|
|
if isinstance(message, HumanMessage) and message_id.endswith("__memory") and is_dynamic_context_reminder(message):
|
|
removals.append(RemoveMessage(id=message_id))
|
|
return removals
|
|
|
|
def _read_failures_are_fatal(self, *, allow_io: bool = True) -> bool | None:
|
|
from deerflow.agents.memory import memory_read_failures_are_fatal
|
|
from deerflow.config.memory_config import get_memory_config
|
|
|
|
if not self._memory_enabled:
|
|
return False
|
|
if self._app_config is None and not allow_io:
|
|
return None # get_memory_config() may reload config.yaml from disk.
|
|
try:
|
|
memory_config = self._app_config.memory if self._app_config else get_memory_config()
|
|
if not memory_config.enabled or not memory_config.injection_enabled:
|
|
return False
|
|
return memory_read_failures_are_fatal(
|
|
memory_config.manager_class,
|
|
memory_config.backend_config,
|
|
resolved_only=not allow_io,
|
|
)
|
|
except Exception:
|
|
logger.exception("DynamicContextMiddleware: could not resolve memory read failure policy; treating the injection timeout as fatal")
|
|
return True
|
|
|
|
@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
|
|
memory_removals = self._disabled_memory_removals(messages)
|
|
|
|
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 {"messages": memory_removals} if memory_removals else 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": [*memory_removals, *result_msgs]}
|
|
|
|
if last_date == current_date:
|
|
# ── Same day: nothing to do ──────────────────────────────────────────
|
|
return {"messages": memory_removals} if memory_removals else 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 {"messages": memory_removals} if memory_removals else 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": [*memory_removals, *result_msgs]}
|
|
|
|
@override
|
|
def before_agent(self, state, runtime: Runtime) -> dict | None:
|
|
result = self._inject(state, runtime)
|
|
self._track_injected_memory_message(result)
|
|
return result
|
|
|
|
@override
|
|
async def abefore_agent(self, state, runtime: Runtime) -> dict | None:
|
|
# The opt-out cleanup is an in-memory ownership check and must not be
|
|
# coupled to the time-boxed date/memory injection worker. Even if that
|
|
# worker times out, stale recalled memory must be gone before the next
|
|
# model call.
|
|
memory_removals = self._disabled_memory_removals(list(state.get("messages", [])))
|
|
# The warm path uses only this call's config and already-loaded class.
|
|
# Cold discovery/config reload shares the injection's bounded worker,
|
|
# never a second executor job after the timeout. Keep this value local:
|
|
# a late worker must not overwrite another run's timeout policy.
|
|
read_failures_are_fatal = self._read_failures_are_fatal(allow_io=False)
|
|
|
|
def inject_with_policy():
|
|
nonlocal read_failures_are_fatal
|
|
if read_failures_are_fatal is None:
|
|
read_failures_are_fatal = self._read_failures_are_fatal()
|
|
return self._inject(state, runtime)
|
|
|
|
# _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(inject_with_policy),
|
|
timeout=_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError as exc:
|
|
from deerflow.agents.memory import MemoryReadError
|
|
|
|
# A worker that never started (or is still resolving policy) leaves
|
|
# the policy unknown. Fail closed without waiting for that worker.
|
|
if read_failures_are_fatal is not False:
|
|
raise MemoryReadError("Required memory context retrieval timed out") from exc
|
|
logger.warning(
|
|
"DynamicContextMiddleware: injection timed out (%.1fs); skipping new memory/date injection for this turn",
|
|
_INJECT_TIMEOUT_SECONDS,
|
|
)
|
|
return {"messages": memory_removals} if memory_removals else None
|
|
self._track_injected_memory_message(result)
|
|
return result
|
|
|
|
def _track_injected_memory_message(self, update: dict | None) -> None:
|
|
"""Remember the ``__memory`` message ID this run's injection produced.
|
|
|
|
The journal event is emitted at model-request assembly time, where the
|
|
injection's update dict is no longer available; the ID is the proof
|
|
that a non-checkpointed ``__memory`` message in the request came from
|
|
this middleware rather than from untrusted input.
|
|
"""
|
|
if not isinstance(update, dict):
|
|
return
|
|
update_messages = update.get("messages")
|
|
if not isinstance(update_messages, list):
|
|
return
|
|
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):
|
|
self._injected_memory_message_id = message_id
|
|
return
|
|
|
|
def _effective_memory_message_for_request(self, messages: list, runtime: Runtime | None) -> HumanMessage | None:
|
|
"""Find server-created memory that is effective for this run.
|
|
|
|
A first-run block must carry the ID this middleware injected during
|
|
``before_agent``. 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. With memory disabled (``memory_enabled=False``) no block
|
|
is ever effective: the opt-out removes this middleware's frozen
|
|
memory messages, and the run must not record a memory identity for
|
|
one (upstream's ``_record_effective_memory`` gate, preserved here).
|
|
"""
|
|
if not self._memory_enabled:
|
|
return None
|
|
context = getattr(runtime, "context", None)
|
|
raw_pre_existing_ids = context.get(CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY) if isinstance(context, dict) else None
|
|
pre_existing_ids = {str(message_id) for message_id in raw_pre_existing_ids if message_id} if isinstance(raw_pre_existing_ids, (frozenset, set, list, tuple)) else set()
|
|
for message in messages:
|
|
if not isinstance(message, HumanMessage):
|
|
continue
|
|
message_id = str(message.id or "")
|
|
if not message_id.endswith("__memory") or not is_dynamic_context_reminder(message) or not isinstance(message.content, str):
|
|
continue
|
|
if message_id in pre_existing_ids or message_id == self._injected_memory_message_id:
|
|
return message
|
|
return None
|
|
|
|
def _shelf_index_limits(self) -> tuple[int, int]:
|
|
"""Shelf index caps without I/O: the assembly config, else defaults."""
|
|
if self._app_config is not None:
|
|
projects = self._app_config.projects
|
|
return projects.shelf_index_max_entries, projects.shelf_index_max_bytes
|
|
from deerflow.config.projects_config import ProjectsConfig
|
|
|
|
defaults = ProjectsConfig()
|
|
return defaults.shelf_index_max_entries, defaults.shelf_index_max_bytes
|
|
|
|
def _assemble_project_request(self, request: ModelRequest) -> tuple[ModelRequest, str | None, str | None]:
|
|
"""Insert at most one transient ``<project>`` message into the request.
|
|
|
|
Pure rendering over the admission-pinned snapshot (no I/O): this
|
|
injector's own recognized transient messages are removed from the
|
|
request copy first, so re-assembling an already decorated request
|
|
stays idempotent and instructions never accumulate across calls. The
|
|
message carries the ``<project>`` block plus, for a nonempty shelf,
|
|
the bounded ``<documents>`` index appended after ``</project>`` — both
|
|
rendered fresh from the pinned snapshot on every model call (§7.2).
|
|
The message is placed immediately before the genuine current-run user
|
|
message and is never returned as a state update, so checkpoints and
|
|
``state["messages"]`` never contain it. Returns the rendered block
|
|
texts (``None`` when absent) for the audit fingerprints.
|
|
"""
|
|
runtime = getattr(request, "runtime", None)
|
|
original = list(getattr(request, "messages", None) or [])
|
|
messages = [message for message in original if not is_project_context_message(message)]
|
|
snapshot = pinned_project_snapshot(runtime)
|
|
project_block = render_project_block(snapshot)
|
|
if project_block is None:
|
|
if len(messages) == len(original):
|
|
return request, None, None
|
|
return request.override(messages=messages), None, None
|
|
max_entries, max_bytes = self._shelf_index_limits()
|
|
documents_block = render_documents_block(snapshot, max_entries=max_entries, max_bytes=max_bytes)
|
|
block = project_block if documents_block is None else f"{project_block}\n{documents_block}"
|
|
index = project_context_insertion_index(messages, runtime)
|
|
run_id = None
|
|
context = getattr(runtime, "context", None)
|
|
if isinstance(context, dict) and isinstance(context.get("run_id"), str):
|
|
run_id = context["run_id"]
|
|
message = build_project_context_message(block, run_id)
|
|
return request.override(messages=[*messages[:index], message, *messages[index:]]), project_block, documents_block
|
|
|
|
def _record_context_event(self, messages: list, runtime: Runtime | None, project_block: str | None, documents_block: str | None) -> None:
|
|
"""Emit the run's single ``context:memory`` audit event, when due.
|
|
|
|
Fires once per run (the journal dedupes) at the first successful
|
|
model-request assembly, whenever a memory block, the project block or
|
|
the shelf index was actually supplied. ``content_sha256`` covers only
|
|
the selected persisted ``__memory`` message (``None`` when none exists
|
|
— e.g. a project-only run); ``project_context_revision`` /
|
|
``project_shelf_revision`` are the sha256 fingerprints of the rendered
|
|
``<project>`` / ``<documents>`` text (``None`` when no such block was
|
|
delivered). All are audit fingerprints: never compared, never stored
|
|
in additional_kwargs, and unable to reconstruct the underlying text.
|
|
Runs supplying no such context keep the historical no-event behavior.
|
|
"""
|
|
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_for_request(messages, runtime)
|
|
content_sha256 = hashlib.sha256(message.content.encode("utf-8")).hexdigest() if message is not None else None
|
|
project_context_revision = hashlib.sha256(project_block.encode("utf-8")).hexdigest() if project_block is not None else None
|
|
project_shelf_revision = hashlib.sha256(documents_block.encode("utf-8")).hexdigest() if documents_block is not None else None
|
|
if content_sha256 is None and project_context_revision is None and project_shelf_revision is None:
|
|
return
|
|
|
|
try:
|
|
journal.record_memory_context(
|
|
content_sha256=content_sha256,
|
|
project_context_revision=project_context_revision,
|
|
project_shelf_revision=project_shelf_revision,
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to record effective memory context", exc_info=True)
|
|
|
|
@override
|
|
def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelCallResult:
|
|
request, project_block, documents_block = self._assemble_project_request(request)
|
|
response = handler(request)
|
|
# Record only after the call succeeded: a failed assembly must not
|
|
# claim the context was delivered.
|
|
self._record_context_event(request.messages, getattr(request, "runtime", None), project_block, documents_block)
|
|
return response
|
|
|
|
@override
|
|
async def awrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]]) -> ModelCallResult:
|
|
# Pure in-memory rendering: no I/O, so it stays on the event loop.
|
|
request, project_block, documents_block = self._assemble_project_request(request)
|
|
response = await handler(request)
|
|
self._record_context_event(request.messages, getattr(request, "runtime", None), project_block, documents_block)
|
|
return response
|