deer-flow/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py
wd_pan cc27730348
feat(memory): add opt-in relevance-aware retrieval ranking (#5251)
* feat(memory): add opt-in relevance-aware retrieval ranking

Add a deterministic, network-free lexical relevance strategy for DeerMem
(issue #4495): memory_search ranks every fact in scope by idf-weighted
token overlap combined with confidence, with optional greedy-MMR diversity
against near-duplicate facts; prompt injection ranks facts against the
current-turn query threaded from DynamicContextMiddleware through the new
optional `query` keyword on MemoryManager.get_context/aget_context.

Defaults preserve the legacy confidence-only behavior exactly; no prompt,
storage-format, or vector/embedding-dependency changes.

Refs #4495
Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): bound relevance retrieval and apply review feedback

Bound tokenization and index shared stems, preserve mixed CJK tokens, warm jieba, and align missing confidence with legacy injection. Cache MMR token sets and stop selection at result or injection budgets. Document retrieval-adapter precedence and add regression coverage. Refs #4495.

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): preserve backend compatibility and normalize relevance

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): omit absent query hints and share injection IDF

Signed-off-by: pwd11 <fvdsrc@163.com>

* test(memory): retain timeout mock until injection worker exits

Signed-off-by: pwd11 <fvdsrc@163.com>

* docs(agents): drop root guidance compaction

Signed-off-by: pwd11 <fvdsrc@163.com>

* fix(memory): validate token prefixes and preserve upload queries

---------

Signed-off-by: pwd11 <fvdsrc@163.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-18 11:32:04 +08:00

807 lines
38 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, Mapping
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, ORIGINAL_USER_CONTENT_KEY, 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
#: Hard bound on the current-turn query forwarded to the memory backend for
#: query-aware fact ranking. Keeps ranking cost deterministic regardless of
#: message length.
_INJECTION_QUERY_MAX_CHARS = 1000
_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
def _derive_injection_query(message: object) -> str | None:
"""Extract a bounded text query from the user message being injected on.
Prefer the original user text preserved by UploadsMiddleware so file
descriptions cannot consume the query budget. Otherwise handle plain
text and multimodal lists. An empty original request remains query-less.
"""
content = getattr(message, "content", None)
additional_kwargs = getattr(message, "additional_kwargs", None)
if isinstance(additional_kwargs, Mapping):
original_content = additional_kwargs.get(ORIGINAL_USER_CONTENT_KEY)
if isinstance(original_content, str):
content = original_content
if isinstance(content, str):
text = content
elif isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and isinstance(item.get("text"), str):
part = item["text"].strip()
if part:
parts.append(part)
text = " ".join(parts)
else:
return None
text = text.strip()
if not text:
return None
return text[:_INJECTION_QUERY_MAX_CHARS]
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, *, query: str | 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).
``query`` is the optional current-turn text forwarded to the memory
backend for query-aware fact ranking (issue #4495); ``None`` keeps the
legacy confidence-only ordering.
"""
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),
query=query,
)
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, query=_derive_injection_query(messages[target_idx]))
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