feat(agents): allow custom agents to disable memory (#5167)

* feat(agents): allow custom agents to disable memory

Signed-off-by: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>

* fix(agents): honor memory opt-out during compaction

Signed-off-by: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>

* fix(runtime): preserve agent binding across state rewrites

* fix(client): apply named-agent memory policy

* fix(agents): address memory policy review feedback

Signed-off-by: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>

* fix(agents): address remaining memory opt-out reviews

Signed-off-by: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>

---------

Signed-off-by: Beautyl0ve <74452755+Beautyl0ve@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Beautyl0ve 2026-09-14 18:17:54 +08:00 committed by GitHub
parent ba998a92ac
commit 80f13935c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1232 additions and 89 deletions

View File

@ -1538,6 +1538,8 @@ File-backed memory now separates global user context from agent facts. Each user
Memory injection follows the configured operation mode. In `middleware` mode, DeerMem injects the user-global summaries and the selected agent's facts. Custom-agent bootstrap conversations use that agent's fact bucket as well, so setup details do not leak into the default agent's memory. In `tool` mode, the automatic `<memory>` block contains only the global `user` and `history` summaries; agent facts are retrieved explicitly through `memory_search`, avoiding duplicate automatic and tool-returned fact context. Setting `memory.injection_enabled: false` still disables the entire block in either mode.
An individual Custom Agent can opt out of memory without changing the global setting. Add `memory_enabled: false` to that agent's `users/{user_id}/agents/{name}/config.yaml`. The agent still receives the current-date reminder, but DeerFlow does not inject recalled memory, queue passive or summarization-driven memory updates (including manual `/compact`), expose memory tools, or add memory-tool instructions for that agent. If an existing agent is switched off, its previously injected memory block is removed from checkpoint state before the next model call while its date reminder and conversation remain. Omitting the field (or setting it to `true`) preserves the existing global `memory` behavior.
Single-fact repository operations are genuinely incremental: an upsert/delete reads, journals, writes, and re-indexes only the addressed fact files, and returns an explicit incomplete delta rather than a cache-dependent fake full document. Summary change sets merge the supplied `user`/`history` child keys over the persisted sections so a partial update cannot erase omitted siblings; full imports normalize both sections to the complete compatibility schema before applying replacement values. Manager/API compatibility methods materialize a fresh full document only when their public response contract requires one. Fact-level point operations use separate expected user-memory and fact revisions and may explicitly rebase when every addressed fact precondition still holds. Snapshot-derived operations such as scoped clear, capped create, consolidation, and trimming never replay stale delete/trim sets: a manifest conflict reloads the complete document and recomputes the operation, with a bounded retry. Fact paths use the first two hexadecimal characters of `SHA-256(fact_id)` so generated `fact_*` IDs distribute across shards. The cache token combines the shared JSON's nanosecond mtime, size, and persisted revision; this prevents coarse-mtime same-size writes from returning stale data without scanning fact files. Direct out-of-band Markdown edits require an explicit reload. Storage-specific conflicts and corruption are translated at the MemoryManager boundary; the Gateway returns conflict as HTTP 409 and a stable, non-sensitive corruption error as HTTP 500. Full-document `save()` remains a compatibility API and computes a diff before writing; malformed or missing `facts` can no longer silently erase an agent's Markdown files. Legacy migration preserves non-empty `user`/`history` before deleting an agent `memory.json`; conflicting summaries keep the legacy file and fail loudly instead of choosing a winner.
Legacy facts in `memory.json` migrate automatically into the reserved `__default__` Markdown bucket on the user's first normal memory read. Operators who prefer to audit or complete the migration before serving traffic can run the optional idempotent CLI from `backend/`:

View File

@ -74,7 +74,7 @@ owner-scoped assistant version selection remains enabled.
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); non-mounted sandbox sync uses a non-releasing request lease; `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped channels (`sandbox`, `thread_data`, `task_history`) are excluded from branches: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - summarize older active context, deriving memory policy and bucket from the state-producing checkpoint rather than request `agent_name`, and block while a run is in flight; unexpected failures return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content (`text/html`, `text/xml`, `application/xml`, `text/xsl`, any `+xml` type such as XHTML/SVG; `.skill` members too) is always forced as a download attachment to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update under a request lease. The outputs-only rule is `path_utils.resolve_outputs_confined_path`, shared with IM-channel attachment delivery: it collapses `..` before the prefix check and re-checks the resolved host path against the resolved outputs root, since `resolve_thread_virtual_path` only confines to `user-data/`; a percent-encoded `..` or a symlink planted in `outputs/` must not reach a sibling `uploads/` file. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
@ -146,6 +146,7 @@ startup gate rejects process-local memory and JSONL event stores when
- Startup/orphan reconciliation must claim stale active rows with `RunStore.claim_for_takeover()`, not a plain `update_status()`. The final claim re-checks `status` and lease expiry atomically, so a heartbeat renewal between the candidate scan and the recovery write keeps the run active.
- Run admission and independent writes are first-class thread operations. `runs.operation_kind` distinguishes `run` from `checkpoint_write`, `artifact_write`, `artifact_archive`, `branch`, and `delete`; every active kind shares the durable active-thread uniqueness constraint. New operation kinds must go through `RunStore.create_thread_operation_atomic()` and `RunManager.reserve_thread_operation()` rather than adding another lock or metadata marker. Live and lease-less reservations are non-interruptible; an expired leased reservation can be reclaimed immediately by interrupt/rollback admission without waiting for orphan reconciliation. Lease-less rows stay fail-closed because the store cannot distinguish a stale row from a live writer in another heartbeat-disabled worker; a rare failed delete therefore requires startup reconciliation, and heartbeat-disabled multi-worker deployment remains unsupported. Reservation bodies are attached to their caller task so loss detected by lease renewal cancels the writer before it can continue after takeover; the context manager translates that lease-loss cancellation to `ConflictError` after cleanup so Gateway mutation routes return a retryable 409 instead of dropping the HTTP request. The cleanup scope begins immediately after durable admission, including the await that attaches the caller task, so cancellation cannot strand a locally renewed pending reservation. A failed renewal is revalidated under the manager lock before cancellation; if the reservation completed and unregistered while the store update was in flight, its request task must not be cancelled after the write. Reservations are excluded from run history/reporting and from run-only helpers such as `list_by_thread()` and `has_inflight()`, release uses the captured owner rather than ambient user context, and local cleanup still runs when the best-effort store delete fails. `RunStore.create_run_atomic()` remains a deprecated compatibility shim for external stores that only admit normal runs; new stores must implement `create_thread_operation_atomic()` to support internal operation kinds.
- Gateway checkpoint mutations outside run execution must use `services.reserve_checkpoint_write()`, which composes the process-local thread lock with the durable `checkpoint_write` reservation. Manual compaction, `POST /threads/{id}/state`, and both goal mutation routes (`PUT` / `DELETE /threads/{id}/goal`, including creation of a missing goal checkpoint) use this boundary, so an existing run blocks the write and the reservation blocks new reject/interrupt/rollback runs across workers.
- Branch/state-update checkpoints copy only the source checkpoint's persisted `deerflow_agent_name`, never request metadata; missing or malformed bindings stay unbound so compaction fails closed.
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
- Memory and Redis `StreamBridge` implementations retain only `stream_bridge.queue_maxsize` data events. A syntactically valid `Last-Event-ID` older than the retained watermark, or a live subscriber that falls behind it, yields `StreamGap` before any partial replay. `sse_consumer` maps that control item to an id-less SSE `gap` payload (`stream_replay_gap`) and intentionally leaves the run active; internal `/wait` consumers resume from its latest retained ID because they only need terminal completion. Redis checks bounds plus the non-blocking read in one transaction, using blocking `XREAD` only as a wake-up before repeating the atomic snapshot. For a no-cursor subscriber that established a wait on an empty stream, the first wake response remains provisional until that next snapshot verifies its tail is still retained; this closes the pre-first-delivery trimming window without changing malformed-cursor live tailing. The correctness tradeoff is one three-command snapshot pipeline per poll plus the blocking wake round trip while idle. Malformed cursor behavior remains backend-specific. Memory treats a syntactically numeric cursor below its watermark conservatively as a gap even when the evicted timestamp can no longer be verified; unknown ids at or above the watermark retain the legacy replay-from-earliest policy.
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup and lease-driven periodic orphan recovery share one Gateway stream-terminalization path: after `RunManager` durably marks a run `error` with `stop_reason=orphan_recovered`, Gateway publishes `END_SENTINEL` and schedules stream cleanup. The periodic store scan, per-row status writes, and Gateway callback run as one supervised single-flight task, so a slow pass is skipped at the next interval instead of piling up or pausing the sole lease-renewal loop. Store retries have bounded attempts/backoff; an individual operation still relies on the database driver/pool timeout. `RunManager.shutdown()` gives active user runs priority within its shared deadline, then drains or cancels orphan recovery. Gateway tracks delayed recovered-stream cleanups and converts unfinished delays to immediate deletes before closing the bridge; the Redis TTL remains the outage safety net. Only startup recovery, before the runtime yields to requests, projects the latest affected thread to `error`; periodic recovery deliberately avoids that non-atomic projection because `ThreadMetaStore` has no `latest_run_id` conditional-update contract. Store-only SSE and `/wait` consumers wait for the bridge's real END marker after an ordinary durable terminal status, because status persistence can precede tail events. The explicit `orphan_recovered` signal is the only heartbeat fallback: its publisher is known to be gone, so it supplies the liveness boundary if END publication fails or the retained key expires. Malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Keep cross-component recovery orchestration in Gateway through the generic `RunManager.on_orphans_recovered` callback; do not introduce a harness-to-app dependency. Callback failure warnings include every recovered `run_id` so operators can identify rows whose Gateway-side terminalization needs inspection.

View File

@ -56,6 +56,7 @@ from deerflow.runtime.context_compaction import (
ThreadCompactionResult,
compact_thread_context,
)
from deerflow.runtime.context_keys import checkpoint_agent_binding_metadata
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
@ -548,7 +549,7 @@ class ThreadCompactRequest(BaseModel):
force: bool = Field(default=True, description="Run compaction even if automatic summarization thresholds are not met")
keep: ContextSize | None = Field(default=None, description="Optional retention policy for this compaction only")
agent_name: str | None = Field(default=None, max_length=128, description="Optional custom agent name for memory attribution")
agent_name: str | None = Field(default=None, max_length=128, description="Optional legacy agent hint for model selection; memory policy is bound to checkpoint metadata")
model_name: str | None = Field(default=None, max_length=128, description="Optional model to summarize with; resolved request override -> custom-agent model -> default, mirroring run model selection")
@ -1023,6 +1024,7 @@ async def _branch_thread_with_reservation(
# Stamp both synthetic checkpoints with the branch-creation time because
# serializers fall back to metadata when snapshot.created_at is absent.
checkpoint_metadata_updates = {
**checkpoint_agent_binding_metadata(getattr(snapshot, "metadata", None)),
**branch_metadata,
"source": "branch",
"updated_at": now,
@ -1487,8 +1489,14 @@ async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateReques
updates = {key: Overwrite(value) if key in reducer_fields else value for key, value in values.items()}
try:
async with reserve_checkpoint_write(request, thread_id, user_id=get_effective_user_id()):
source_metadata = await accessor.aget_metadata(read_config)
update_config = {
**read_config,
"configurable": dict(read_config.get("configurable", {})),
"metadata": checkpoint_agent_binding_metadata(source_metadata),
}
updated_config = await accessor.aupdate(
read_config,
update_config,
updates,
as_node=mutation_node,
)

View File

@ -16,8 +16,9 @@
- Dynamic model selection via `create_chat_model()` with thinking/vision support
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
- **Prompt-layer trust boundaries**: every string that enters a model context has a source, and the source's trust level decides its channel. Framework-owned authority text (report contracts, pointer notes, workflow rules) rides the system channel; anything model-supplied or user-influenceable (delegated task text, acceptance criteria, tool results) rides the untrusted channel — the `HumanMessage` that `InputSanitizationMiddleware` escapes and boundary-frames. Before adding prompt text, ask of every data source in it: what is its trust level, and which channel should it ride? Never interpolate untrusted values into framework-owned system text, even neutralized — natural-language injection survives tag escaping (PR #5090 review).
- Each assembly renders the system prompt and composes middleware exactly once; the same prompt and middleware objects must be passed to both `create_agent()` and the assembly descriptor so extension observations match the running graph, including Custom Agent `allowed_subagents` scope.
- Custom Agent `memory_enabled: false` disables memory reads, writes, tools, and compaction flushes while retaining date context; manual compaction trusts the state-producing checkpoint's agent binding, and global disable stays authoritative. Embedded clients cache the named policy by agent/user until `reset_agent()`; unreadable configs keep the legacy enabled default and log a warning.
- **Prompt trust**: framework authority uses the system channel; user/model-influenced text uses the sanitized `HumanMessage` data channel. Never interpolate untrusted values into system text—even escaped tags do not neutralize natural-language injection (PR #5090).
- Pass the same rendered prompt and middleware objects to the graph and assembly descriptor so observers describe the live graph, including Custom Agent `allowed_subagents` scope.
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`

View File

@ -316,6 +316,7 @@ def _create_summarization_middleware(
*,
app_config: AppConfig | None = None,
run_model_name: str | None = None,
skip_memory_flush: bool = False,
extensions=None,
) -> DeerFlowSummarizationMiddleware | None:
"""Create and configure the summarization middleware from config.
@ -327,6 +328,7 @@ def _create_summarization_middleware(
return create_summarization_middleware(
app_config=app_config,
run_model_name=run_model_name,
skip_memory_flush=skip_memory_flush,
extensions=extensions,
)
@ -463,6 +465,7 @@ def build_middlewares(
custom_middlewares: list[AgentMiddleware] | None = None,
*,
available_skills: set[str] | None = None,
memory_enabled: bool = True,
owns_agent_skill_projection: bool = True,
app_config: AppConfig | None = None,
deferred_setup=None,
@ -483,6 +486,8 @@ def build_middlewares(
config: Runtime configuration containing configurable options like is_plan_mode.
model_name: Resolved runtime model name; gates vision-only middleware.
agent_name: If provided, MemoryMiddleware will use per-agent memory storage.
memory_enabled: Whether this agent may read or write memory. The date-only
dynamic context remains installed when memory is disabled.
custom_middlewares: Optional list of custom middlewares to inject into the chain.
owns_agent_skill_projection: Whether this lead middleware chain owns the
thread's physical skill projection. Prompt-only bootstrap agents do
@ -526,7 +531,13 @@ def build_middlewares(
# first HumanMessage to keep the system prompt fully static for prefix-cache reuse.
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
middlewares.append(
DynamicContextMiddleware(
agent_name=agent_name,
app_config=resolved_app_config,
memory_enabled=memory_enabled,
)
)
# Deterministically load a full SKILL.md when the user starts the turn with
# /skill-name. This keeps the base system prompt metadata-only while giving
@ -581,6 +592,7 @@ def build_middlewares(
summarization_middleware = _create_summarization_middleware(
app_config=resolved_app_config,
run_model_name=model_name,
skip_memory_flush=not memory_enabled,
extensions=resolved_extensions,
)
if summarization_middleware is not None:
@ -607,15 +619,16 @@ def build_middlewares(
# Add MemoryMiddleware after TitleMiddleware. Tool mode normally skips it;
# conversation-extraction backends may explicitly retain passive writes.
if should_use_memory_tools(resolved_app_config.memory):
from deerflow.agents.memory.manager import backend_requires_passive_writes_in_tool_mode
if memory_enabled:
if should_use_memory_tools(resolved_app_config.memory):
from deerflow.agents.memory.manager import backend_requires_passive_writes_in_tool_mode
if backend_requires_passive_writes_in_tool_mode(resolved_app_config.memory.manager_class):
if backend_requires_passive_writes_in_tool_mode(resolved_app_config.memory.manager_class):
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
else:
if resolved_app_config.memory.mode == "tool" and not resolved_app_config.memory.enabled:
logger.warning("memory.mode is 'tool' but memory.enabled is false; memory tools will not be registered.")
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
else:
if resolved_app_config.memory.mode == "tool" and not resolved_app_config.memory.enabled:
logger.warning("memory.mode is 'tool' but memory.enabled is false; memory tools will not be registered.")
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
@ -913,6 +926,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
agent_name = validate_agent_name(cfg.get("agent_name"))
agent_config = load_agent_config(agent_name, user_id=resolved_user_id) if not is_bootstrap else None
memory_enabled = getattr(agent_config, "memory_enabled", True) is not False
# Keep compatibility with lightweight AgentConfig-shaped objects used by
# integrations that predate caller-level subagent restrictions.
allowed_subagents = getattr(agent_config, "allowed_subagents", None) if agent_config is not None else None
@ -981,6 +995,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
"tool_groups": agent_config.tool_groups if agent_config else None,
"available_skills": sorted(available_skills) if available_skills is not None else None,
"allowed_subagents": list(allowed_subagents) if allowed_subagents is not None else None,
"memory_enabled": memory_enabled,
}
)
@ -1023,7 +1038,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
authorization_candidates = [*configured_tools]
if skill_setup.describe_skill_tool:
authorization_candidates.append(skill_setup.describe_skill_tool)
if should_use_memory_tools(resolved_app_config.memory):
if memory_enabled and should_use_memory_tools(resolved_app_config.memory):
_append_memory_tools_without_name_conflicts(authorization_candidates)
append_task_continuity_tools(authorization_candidates, resolved_app_config)
configured_tool_ids = {id(tool) for tool in configured_tools}
@ -1046,6 +1061,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
model_name=model_name,
agent_name=agent_name,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
memory_enabled=memory_enabled,
owns_agent_skill_projection=False,
app_config=resolved_app_config,
deferred_setup=setup,
@ -1065,6 +1081,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity,
memory_enabled=memory_enabled,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
@ -1147,7 +1164,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
authorization_candidates = [*configured_tools]
if skill_setup.describe_skill_tool:
authorization_candidates.append(skill_setup.describe_skill_tool)
if should_use_memory_tools(resolved_app_config.memory):
if memory_enabled and should_use_memory_tools(resolved_app_config.memory):
_append_memory_tools_without_name_conflicts(authorization_candidates)
append_task_continuity_tools(authorization_candidates, resolved_app_config)
configured_tool_ids = {id(tool) for tool in configured_tools}
@ -1171,6 +1188,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
model_name=model_name,
agent_name=agent_name,
available_skills=available_skills,
memory_enabled=memory_enabled,
app_config=resolved_app_config,
deferred_setup=setup,
mcp_routing_middleware=mcp_routing_middleware,
@ -1191,6 +1209,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents,
subagent_execution_capacity=subagent_execution_capacity,
memory_enabled=memory_enabled,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides),

View File

@ -1033,8 +1033,11 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
def _build_memory_tool_section(*, app_config: AppConfig | None = None) -> str:
def _build_memory_tool_section(*, app_config: AppConfig | None = None, memory_enabled: bool = True) -> str:
"""Build tool-mode memory guidance for the static system prompt."""
if not memory_enabled:
return ""
try:
if app_config is None:
from deerflow.config.memory_config import get_memory_config
@ -1074,6 +1077,7 @@ def apply_prompt_template(
skill_names: frozenset[str] | None = None,
allowed_subagents: list[str] | None = None,
subagent_execution_capacity: int | None = None,
memory_enabled: bool = True,
) -> str:
# Include subagent section only if enabled (from runtime parameter)
n = (
@ -1156,7 +1160,7 @@ def apply_prompt_template(
else "- Skill First: Always load the relevant skill before starting **complex** tasks.\n"
)
memory_tool_section = _build_memory_tool_section(app_config=app_config)
memory_tool_section = _build_memory_tool_section(app_config=app_config, memory_enabled=memory_enabled)
# Build and return the fully static system prompt.
# Memory and current date are injected per-turn via DynamicContextMiddleware

View File

@ -72,6 +72,16 @@ Tool-mode injection includes only shared summaries.
Tool mode leaves agent facts behind `memory_search`.
`memory.injection_enabled: false` disables the complete injected block.
Per-user lead-agent Custom Agents may set `memory_enabled: false` in their own
`config.yaml`. This is a complete per-agent opt-out: dynamic context remains
date-only, passive capture is not installed, automatic and manual compaction do
not flush summarized messages, tool mode exposes no memory tools or tool
guidance, and the global memory configuration remains unchanged for other
agents. On the next run after an existing agent opts out, Dynamic Context emits
`RemoveMessage` updates for its server-tagged frozen `__memory` entries while
retaining date reminders and real user messages. Omission defaults to the
existing enabled behavior.
#### DeerMem storage contract
`FileMemoryStorage` owns canonical storage and the retrieval adapter.

View File

@ -88,11 +88,11 @@ Before changing a later authorization phase, read the [authorization RFC](../../
**Lead-only middlewares** (`build_middlewares`, appended after the base):
14. **DynamicContextMiddleware** - Injects the current date (and optionally memory) as a `<system-reminder>` into the first HumanMessage, keeping the base system prompt fully static for prefix-cache reuse. The injected date follows the server-local timezone unless `DEER_FLOW_DATE_TIMEZONE` names an IANA zone (invalid values fall back to server-local).
14. **DynamicContextMiddleware** - Injects date and optional memory outside the static prompt. Opt-out removes its frozen server memory but retains date/user messages. Date follows server-local time unless `DEER_FLOW_DATE_TIMEZONE` names an IANA zone (invalid values fall back locally).
15. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event
16. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking.
17. **DurableContextMiddleware** - Captures `task` delegations into `ThreadState.delegations` (including in-progress dispatches and terminal result summaries) and loaded skill-file references (name/path/description, parsed in-memory - not the body) into `ThreadState.skill_context` before summarization can compact the paired tool-call/result messages, then projects durable context into each model request. Static authority rules are injected as a `SystemMessage`; untrusted field values (`summary_text`, delegation results, skill descriptions) are injected separately as a hidden `HumanMessage` data block so compressed history, delegated work, and which skills are active stay visible without being stored as `messages` or promoted to system-role instructions. `build_subagent_runtime_middlewares` also attaches this middleware immediately before subagent summarization so a compacted `summary_text` is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request.
18. **SummarizationMiddleware** - *(optional, if enabled)* Context reduction when approaching token limits. Compaction must preserve the latest real user request by exact message ID while allowing stale DynamicContext ID-swap `__user` peers to enter the summary; tagged dynamic-context reminders remain preserved. Do not move the cutoff backward to retain the current request, because that also keeps early AI/Tool turns active and can make first-turn long-analysis compaction a no-op. `tests/test_summarization_middleware.py` pins both the multi-turn stale-peer case and the first-turn long-analysis case.
18. **SummarizationMiddleware** - *(optional, if enabled)* Compacts near token limits; memory flush follows runtime policy, while manual compaction trusts the checkpoint-bound agent, so opt-out never captures removed turns. It preserves the latest real user request by ID and tagged DynamicContext reminders while allowing stale ID-swap peers into the summary. Moving the cutoff backward can retain old AI/tool turns and make first-turn compaction a no-op.
19. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with the `write_todos` tool
20. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable.
21. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint.

View File

@ -53,7 +53,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from deerflow_extension_api import ContentKind, provenance_kwargs
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.messages import HumanMessage, RemoveMessage, SystemMessage
from langgraph.runtime import Runtime
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
@ -345,14 +345,24 @@ class DynamicContextMiddleware(AgentMiddleware):
day see the corrected date in history and skip re-injection.
"""
def __init__(self, agent_name: str | None = None, *, app_config: AppConfig | None = None):
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
def release_policy_parameters(self) -> dict[str, object]:
"""Declare the injected date's effective timezone for assembly identity."""
return {"current_date_timezone": _effective_date_timezone_name()}
"""Declare memory and date behavior for assembly identity."""
return {
"current_date_timezone": _effective_date_timezone_name(),
"memory_enabled": self._memory_enabled,
}
def _build_full_reminder(self, runtime: Runtime | None = None) -> tuple[str, str | None]:
"""Return (date_reminder, memory_block | None).
@ -364,7 +374,7 @@ class DynamicContextMiddleware(AgentMiddleware):
"""
from deerflow.agents.lead_agent.prompt import _get_memory_context
injection_enabled = self._app_config.memory.injection_enabled if self._app_config else True
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,
@ -384,10 +394,24 @@ class DynamicContextMiddleware(AgentMiddleware):
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:
@ -470,6 +494,7 @@ class DynamicContextMiddleware(AgentMiddleware):
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)
@ -494,7 +519,7 @@ class DynamicContextMiddleware(AgentMiddleware):
# the stale first message as if it were the current turn.
target_idx = next((i for i in reversed(range(len(messages))) if _is_user_injection_target(messages[i])), None)
if target_idx is None:
return None
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",
@ -502,20 +527,20 @@ class DynamicContextMiddleware(AgentMiddleware):
messages[target_idx].id,
)
result_msgs = self._make_reminder_and_user_messages(messages[target_idx], date_reminder, memory_block, reminder_date=current_date)
return {"messages": result_msgs}
return {"messages": [*memory_removals, *result_msgs]}
if last_date == current_date:
# ── Same day: nothing to do ──────────────────────────────────────────
return None
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 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": result_msgs}
return {"messages": [*memory_removals, *result_msgs]}
@override
def before_agent(self, state, runtime: Runtime) -> dict | None:
@ -525,6 +550,11 @@ class DynamicContextMiddleware(AgentMiddleware):
@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:
@ -565,7 +595,7 @@ class DynamicContextMiddleware(AgentMiddleware):
_INJECT_TIMEOUT_SECONDS,
)
self._record_effective_memory(state, None, runtime)
return None
return {"messages": memory_removals} if memory_removals else None
self._record_effective_memory(state, result, runtime)
return result
@ -603,6 +633,9 @@ class DynamicContextMiddleware(AgentMiddleware):
def _record_effective_memory(self, state, update: dict | None, runtime: Runtime) -> None:
"""Attach the effective hidden memory block to the current run ledger."""
if not self._memory_enabled:
return
context = getattr(runtime, "context", None)
journal = context.get("__run_journal") if isinstance(context, dict) else None
if journal is None:

View File

@ -931,7 +931,8 @@ def create_summarization_middleware(
``skip_memory_flush`` omits the ``memory_flush_hook`` that otherwise
flushes pre-compaction messages into the durable memory queue. The lead
chain keeps it (research should persist); the subagent chain sets it so a
chain keeps it unless its Custom Agent opted out of memory; manual
compaction follows that same policy. The subagent chain always sets it so a
subagent's INTERNAL turns (the "Task" human message + intermediate AI/tool
turns) are not written into the PARENT thread's durable memory — the hook
is keyed by ``thread_id`` and subagents share the parent's ``thread_id``

View File

@ -37,7 +37,7 @@ from deerflow.agents.lead_agent.agent import _authorize_model_name, build_middle
from deerflow.agents.lead_agent.prompt import apply_prompt_template, get_enabled_skills_for_config
from deerflow.agents.thread_state import get_thread_state_schema, normalize_middleware_state_schemas
from deerflow.authz.principal import build_principal_from_context
from deerflow.config.agents_config import AGENT_NAME_PATTERN
from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config
from deerflow.config.app_config import get_app_config, reload_app_config
from deerflow.config.extensions_config import (
ExtensionsConfig,
@ -242,6 +242,8 @@ class DeerFlowClient:
# Lazy agent — created on first call, recreated when config changes.
self._agent = None
self._agent_config_key: tuple | None = None
self._loaded_agent_config_key: tuple[str, str] | None = None
self._loaded_agent_config = None
def reset_agent(self) -> None:
"""Force the internal agent to be recreated on the next call.
@ -252,6 +254,8 @@ class DeerFlowClient:
"""
self._agent = None
self._agent_config_key = None
self._loaded_agent_config_key = None
self._loaded_agent_config = None
# ------------------------------------------------------------------
# Internal helpers
@ -301,6 +305,23 @@ class DeerFlowClient:
# authorization principal so one trusted embedded client can safely
# serve more than one caller.
effective_user_id = cfg.get("user_id") or get_effective_user_id()
agent_config = None
if self._agent_name is not None:
loaded_config_key = (self._agent_name, effective_user_id)
if getattr(self, "_loaded_agent_config_key", None) == loaded_config_key:
agent_config = self._loaded_agent_config
else:
try:
agent_config = load_agent_config(self._agent_name, user_id=effective_user_id)
except (FileNotFoundError, ValueError):
logger.warning(
"Unable to load config for named agent %s; using the memory-enabled compatibility default",
self._agent_name,
exc_info=True,
)
self._loaded_agent_config_key = loaded_config_key
self._loaded_agent_config = agent_config
memory_enabled = getattr(agent_config, "memory_enabled", True) is not False
authorization_identity = None
if self._app_config.authorization.enabled:
@ -325,6 +346,7 @@ class DeerFlowClient:
cfg.get("max_concurrent_subagents"),
cfg.get("max_total_subagents"),
self._agent_name,
memory_enabled,
frozenset(self._available_skills) if self._available_skills is not None else None,
self._checkpoint_channel_mode,
self._checkpoint_snapshot_frequency,
@ -418,6 +440,7 @@ class DeerFlowClient:
model_name=model_name,
agent_name=self._agent_name,
available_skills=self._available_skills,
memory_enabled=memory_enabled,
custom_middlewares=self._middlewares,
app_config=self._app_config,
deferred_setup=deferred_setup,
@ -441,6 +464,7 @@ class DeerFlowClient:
user_id=effective_user_id,
skill_names=skill_setup.skill_names or None,
subagent_execution_capacity=subagent_execution_capacity,
memory_enabled=memory_enabled,
),
"state_schema": get_thread_state_schema(self._checkpoint_channel_mode, self._checkpoint_snapshot_frequency),
}
@ -1316,8 +1340,7 @@ class DeerFlowClient:
self._atomic_write_json(config_path, config_data)
reloaded = reload_extensions_config()
self._agent = None
self._agent_config_key = None
self.reset_agent()
return {"mcp_servers": {name: server.model_dump() for name, server in reloaded.mcp_servers.items()}}
# ------------------------------------------------------------------
@ -1418,8 +1441,7 @@ class DeerFlowClient:
logging.getLogger(__name__).warning("Failed to invalidate skills prompt cache after update_skill: %s", exc)
self._agent = None
self._agent_config_key = None
self.reset_agent()
updated = next((s for s in storage.load_skills(enabled_only=False) if s.name == name), None)
if updated is None:

View File

@ -231,6 +231,9 @@ class AgentConfig(BaseModel):
# Per-agent reasoning-effort default for models that support it. None = do
# not override (a request-supplied reasoning_effort still wins over this).
reasoning_effort: Literal["low", "medium", "high"] | None = None
# Disable every memory path for stateless execution-oriented agents while
# preserving the global memory configuration for all other agents.
memory_enabled: bool = True
# Optional binding to GitHub repositories so this agent can respond to
# webhook events from the gateway dispatcher. None means "no GitHub
# integration", which is the case for every existing agent.

View File

@ -14,7 +14,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Compatibility is asymmetric and fail-closed.** Every checkpoint written in delta mode carries metadata marker `deerflow_checkpoint_channel_mode: "delta"` (injected via `inject_checkpoint_mode`; absence of marker = full, so pre-feature checkpoints need no migration). Before any state read/write, `ensure_checkpoint_mode_compatible` rejects a full-mode process opening a delta thread with `CheckpointModeMismatchError` (surfaced as HTTP 409 with the cause and thread id by the threads router; `CheckpointModeReconfigurationError` maps to 503) — a full-mode raw read of a delta blob would silently return empty/partial `messages`. The reverse direction is allowed: delta-mode processes read full checkpoints transparently (old full checkpoints seed the delta channel), so full → delta is the smooth migration path; delta → full requires materializing/converting the data first. Detection also honors upstream's `counters_since_delta_snapshot.messages` metadata, and an explicit config marker takes precedence over any ambient context value.
**Never bypass `CheckpointStateAccessor` (`runtime/checkpoint_state.py`) for thread-state access.** It is the single choke point binding graph + checkpointer + mode: it injects the mode marker into configs, runs the compatibility check before every `get`/`update`/`history`, and returns materialized state (delta checkpoints lack `channel_values.messages` — raw `get_tuple` reads see a sentinel). Gateway `services.py` builds and passes the accessor; thread-owned reads (state/history/regeneration) must use `build_thread_checkpoint_state_accessor` so the recorded assistant's middleware schema materializes every channel. `history(limit)` semantics: `0` means zero items (explicit empty), `None` means unlimited — do not pass `limit=0` through to `graph.get_state_history`. Assistant metadata lookup is fail-closed for mutation accessors so a store outage cannot silently select the default schema and discard extension channels. In `full` mode the read path degrades to a raw checkpointer read (`_RawCheckpointReadAccessor`) when the agent factory cannot build the graph (bad model config, MCP outage) — full checkpoints carry complete `channel_values`, so reads don't need the graph; degraded snapshots take `created_at` from the standard checkpoint `ts` field, falling back to metadata only for compatibility. The delta gate still applies on the degraded path; `next`/`tasks` degrade to empty and thread status falls back to the stored status because task presence is not derivable, while delta mode has no fallback (materialization needs the channel table).
**Never bypass `CheckpointStateAccessor` (`runtime/checkpoint_state.py`) for thread-state access.** It is the single choke point binding graph + checkpointer + mode: it injects the mode marker into configs, runs the compatibility check before every `get`/`update`/`history`, and returns materialized state (delta checkpoints lack `channel_values.messages` — raw `get_tuple` reads see a sentinel). Use `get_metadata` / `aget_metadata` when only persisted metadata is needed, avoiding delta-history materialization while retaining the mode gate. Gateway `services.py` builds and passes the accessor; thread-owned reads (state/history/regeneration) must use `build_thread_checkpoint_state_accessor` so the recorded assistant's middleware schema materializes every channel. `history(limit)` semantics: `0` means zero items (explicit empty), `None` means unlimited — do not pass `limit=0` through to `graph.get_state_history`. Assistant metadata lookup is fail-closed for mutation accessors so a store outage cannot silently select the default schema and discard extension channels. In `full` mode the read path degrades to a raw checkpointer read (`_RawCheckpointReadAccessor`) when the agent factory cannot build the graph (bad model config, MCP outage) — full checkpoints carry complete `channel_values`, so reads don't need the graph; degraded snapshots take `created_at` from the standard checkpoint `ts` field, falling back to metadata only for compatibility. The delta gate still applies on the degraded path; `next`/`tasks` degrade to empty and thread status falls back to the stored status because task presence is not derivable, while delta mode has no fallback (materialization needs the channel table).
**Replay checkpoint lookup prefers lineage and degrades only for an explicitly missing legacy parent link.** Branch and regenerate paths first walk `parent_config`, which prevents a global chronological scan from selecting a sibling created by regeneration. `CheckpointParentMissingError` alone enables the bounded newest-first history fallback in `app/gateway/checkpoint_lineage.py`; cycles, dangling/non-addressable parents, target mismatches, and depth exhaustion raise `CheckpointLineageIntegrityError` and fail closed instead of selecting a sibling. The compatibility scans request 400 raw checkpoints so up to 200 duration-only entries do not consume the effective branch-history budget; the fallback scans oldest-to-newest internally, skips duration-only checkpoints, and accepts only checkpoints with an addressable id as the replay base. A source history with no discoverable pre-user checkpoint preserves the historical single-checkpoint branch behavior instead of rejecting the branch; regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are not mutated by regenerate preparation, and no raw checkpoint tuple is copied across threads because delta state depends on ancestry and pending writes. Regenerate source-run lookup uses the current thread's exact event, then the server-stamped `run_id` on the copied human message, then verified RunManager content matching; it does not read parent-thread events. When an interrupted response was streamed but never checkpointed, regeneration accepts only the latest visible human message's server-stamped `run_id` after verifying that it belongs to the same thread and still has `interrupted` status. Storage or checkpoint-mode failures are not treated as a missing base and still fail closed.
@ -22,7 +22,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Wholesale state replacement uses a state-only mutation graph + `Overwrite`.** `update_state` values pass through channel reducers (`add_messages` merge in full, append in delta), so replacing reducer values requires `Overwrite` rather than an ordinary update. Full-mode rollback and context compaction replace `messages`; delta resume and delta rollback replace every materialized channel and reset current-head-only channels to their schema default (or `None`). These writes go through `build_state_mutation_graph(as_node, mode, state_schema)`, and `state_schema` MUST be the thread's effective schema (`graph_state_schema(assistant_graph)`), because the base-ThreadState fallback silently discards written channels contributed by custom `AgentMiddleware.state_schema`. Channels absent from a full-mode fork write inherit the parent's channel blobs, so middleware channels survive rollback/compaction (locked by `test_rollback_preserves_middleware_contributed_channels` and `test_compact_thread_context_preserves_middleware_contributed_channels`). The compiled mutation graph has one no-op node (entry = finish) whose checkpoint machinery (channels/versions/metadata) is identical to the agent graph's but schedules no pending tasks, so the restored/compacted head stays idle instead of re-triggering the agent. Never hand-write checkpoints via `checkpointer.aput` for this; raw writers elsewhere must preserve checkpoint parentage — severed ancestry breaks delta replay (see `runtime/runs/worker.py` writer parenting and `checkpoint_patches.py`).
**Run rollback flow** (`runtime/runs/worker.py`): `_capture_rollback_point` materializes the complete pre-run state via the accessor and captures raw `pending_writes` via `aget_tuple` into an immutable `RollbackPoint` before the run starts — capture failure disables rollback (fail-closed), never restores partial state. In `full` mode, cancel-with-rollback forks from the pre-run checkpoint via the mutation graph and inherits non-message channels from that parent. In `delta` mode, forking is unsafe once the cancelled path has attached sibling writes to the pre-run checkpoint, so rollback replaces every captured channel on the current head, using `Overwrite` for reducers and schema defaults for current-head-only channels. Both modes reattach only the captured pre-run pending writes to the restored checkpoint. Edit replay runs (`metadata.replay_kind="edit"`) also restore the pre-run checkpoint on failed, timed-out, or interrupted completion and publish the restored `values` snapshot to the stream before `end`, so clients do not remain on a transient edited branch when the replay did not produce a successful replacement.
**Run rollback flow** (`runtime/runs/worker.py`): `_capture_rollback_point` materializes the complete pre-run state via the accessor and captures raw `pending_writes` via `aget_tuple` into an immutable `RollbackPoint` before the run starts — capture failure disables rollback (fail-closed), never restores partial state. In `full` mode, cancel-with-rollback forks from the pre-run checkpoint via the mutation graph and inherits non-message channels from that parent. In `delta` mode, forking is unsafe once the cancelled path has attached sibling writes to the pre-run checkpoint, so rollback replaces every captured channel on the current head, using `Overwrite` for reducers and schema defaults for current-head-only channels. Resume and rollback state rewrites copy only the selected/captured checkpoint's server-authored agent binding; missing or malformed bindings remain unbound. Both modes reattach only the captured pre-run pending writes to the restored checkpoint. Edit replay runs (`metadata.replay_kind="edit"`) also restore the pre-run checkpoint on failed, timed-out, or interrupted completion and publish the restored `values` snapshot to the stream before `end`, so clients do not remain on a transient edited branch when the replay did not produce a successful replacement.
**Message feed seq stamping** (#4666): a checkpoint carries no position of its
own and loses messages to summarization, so a client merging a `values` frame
@ -179,7 +179,7 @@ the number of required IDs, whichever is larger; missing exact runs use targeted
- `runtime/checkpoint_state.py``CheckpointStateAccessor`, `build_state_mutation_graph`, `RollbackPoint`
- `checkpoint_patches.py` (package root) — checkpoint-machinery patches: delta-history folding for `InMemorySaver` (delegating to the base walk), stable message IDs across materialization, upstream first-write drop fix, and `BinaryOperatorAggregate` unwrapping an `Overwrite` first write into an empty (MISSING) channel — Union-typed reducer channels (`sandbox`/`goal`/`todos`/`promoted`) have no constructible default, so a replace-style write into a fresh branch thread or a never-written channel stored the wrapper literally and crashed the next consumer (#4380; probe-guarded, stands down if upstream fixes it)
- `agents/thread_state.py``ThreadState`/`DeltaThreadState`, `delta_messages_field` / `DELTA_MESSAGES_FIELD` (`DeltaChannel` at the configured `snapshot_frequency`, default 10), schema adaptation helpers
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer)
- `runtime/context_compaction.py` — compaction via accessor + mutation graph (reference consumer). Runs stamp their effective agent into server-owned checkpoint metadata; manual compaction uses that binding—not request `agent_name`—for memory policy and bucket. Missing/invalid legacy bindings and unreadable agent configs fail closed by skipping the optional flush while compaction may continue with the default model; a missing pre-binding checkpoint emits a warning so the skipped write is observable.
- `runtime/checkpoint_cache/` + `runtime/checkpointer/cached_saver.py` — delta-mode checkpoint history cache; checkpoint state reads MUST go through `CheckpointStateAccessor`, and the checkpointer may be a `CachedHistorySaver` wrapper — never rely on concrete saver types
- Tests: `tests/test_checkpoint_mode.py` (freeze/detect/gate), `tests/test_checkpoint_state.py` (accessor/mutation graph), `tests/test_delta_channel_checkpointers.py` (saver parity), `tests/test_threads_checkpoint_mode.py`, `tests/test_gateway_checkpoint_mode.py` (dual-mode e2e parity), `tests/test_context_compaction.py` (mutation-graph write, no scheduling), `tests/test_run_worker_rollback.py`, `tests/test_cached_history_saver.py` + `tests/test_cached_history_saver_integration.py` (history cache)

View File

@ -123,6 +123,12 @@ def raise_if_snapshot_incompatible(snapshot: Any, mode: CheckpointChannelMode) -
raise CheckpointModeMismatchError("Thread requires delta mode; materialize and convert its checkpoints before using full mode.")
def raise_if_checkpoint_tuple_incompatible(checkpoint_tuple: Any, mode: CheckpointChannelMode) -> None:
"""Fail closed before exposing raw checkpoint metadata across modes."""
if mode == "full" and checkpoint_tuple_uses_delta(checkpoint_tuple):
raise CheckpointModeMismatchError("Thread requires delta mode; materialize and convert its checkpoints before using full mode.")
def ensure_checkpoint_mode_compatible(checkpointer: Any, config: dict[str, Any], mode: CheckpointChannelMode) -> None:
"""Pre-write gate: a write cannot be un-applied, so it checks ahead of time.
@ -131,12 +137,10 @@ def ensure_checkpoint_mode_compatible(checkpointer: Any, config: dict[str, Any],
"""
if mode == "delta":
return
if checkpoint_tuple_uses_delta(checkpointer.get_tuple(config)):
raise CheckpointModeMismatchError("Thread requires delta mode; materialize and convert its checkpoints before using full mode.")
raise_if_checkpoint_tuple_incompatible(checkpointer.get_tuple(config), mode)
async def aensure_checkpoint_mode_compatible(checkpointer: Any, config: dict[str, Any], mode: CheckpointChannelMode) -> None:
if mode == "delta":
return
if checkpoint_tuple_uses_delta(await checkpointer.aget_tuple(config)):
raise CheckpointModeMismatchError("Thread requires delta mode; materialize and convert its checkpoints before using full mode.")
raise_if_checkpoint_tuple_incompatible(await checkpointer.aget_tuple(config), mode)

View File

@ -25,6 +25,7 @@ from deerflow.runtime.checkpoint_mode import (
aensure_checkpoint_mode_compatible,
ensure_checkpoint_mode_compatible,
inject_checkpoint_mode,
raise_if_checkpoint_tuple_incompatible,
raise_if_snapshot_incompatible,
)
@ -149,6 +150,18 @@ class CheckpointStateAccessor:
raise_if_snapshot_incompatible(snapshot, self.mode)
return snapshot
def get_metadata(self, config: dict[str, Any]) -> dict[str, Any]:
"""Read checkpoint metadata without materializing channel state."""
checkpoint_tuple = self.checkpointer.get_tuple(self._prepare_config(config))
raise_if_checkpoint_tuple_incompatible(checkpoint_tuple, self.mode)
return dict(getattr(checkpoint_tuple, "metadata", {}) or {})
async def aget_metadata(self, config: dict[str, Any]) -> dict[str, Any]:
"""Read checkpoint metadata without materializing channel state."""
checkpoint_tuple = await self.checkpointer.aget_tuple(self._prepare_config(config))
raise_if_checkpoint_tuple_incompatible(checkpoint_tuple, self.mode)
return dict(getattr(checkpoint_tuple, "metadata", {}) or {})
def history(self, config: dict[str, Any], *, limit: int | None = None) -> list[Any]:
prepared = self._prepare_config(config)
if limit is not None and limit <= 0:

View File

@ -4,16 +4,38 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from types import SimpleNamespace
from langgraph.types import Overwrite
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummaryGenerationError, create_summarization_middleware
from deerflow.config.agents_config import validate_agent_name
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, DEFAULT_AGENT_NAME_METADATA_VALUE
logger = logging.getLogger(__name__)
_AGENT_CONFIG_NOT_LOADED = object()
def _checkpoint_agent_binding(metadata: object) -> tuple[bool, str | None]:
"""Resolve the server-authored agent binding carried by a checkpoint."""
if not isinstance(metadata, Mapping) or CHECKPOINT_AGENT_NAME_METADATA_KEY not in metadata:
logger.warning("Skipping memory flush: checkpoint carries no agent binding (pre-binding state)")
return False, None
value = metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY]
if value == DEFAULT_AGENT_NAME_METADATA_VALUE:
return True, None
if not isinstance(value, str):
logger.warning("Ignoring non-string checkpoint agent binding; memory flush will be skipped")
return False, None
try:
return True, validate_agent_name(value)
except ValueError:
logger.warning("Ignoring invalid checkpoint agent binding; memory flush will be skipped")
return False, None
class ContextCompactionDisabled(RuntimeError):
@ -43,8 +65,14 @@ def _create_compaction_middleware(
app_config: AppConfig,
keep: tuple[str, int | float] | None,
run_model_name: str | None = None,
skip_memory_flush: bool = False,
) -> DeerFlowSummarizationMiddleware:
middleware = create_summarization_middleware(app_config=app_config, keep=keep, run_model_name=run_model_name)
middleware = create_summarization_middleware(
app_config=app_config,
keep=keep,
run_model_name=run_model_name,
skip_memory_flush=skip_memory_flush,
)
if middleware is None:
raise ContextCompactionDisabled("Context compaction is disabled.")
return middleware
@ -53,10 +81,11 @@ def _create_compaction_middleware(
def _safe_load_agent_config(agent_name: str, user_id: str | None):
"""Load a custom agent's config, returning ``None`` on any failure.
A missing / unparseable agent config must not fail compaction; the run model is a
best-effort optimization and the default is a safe fallback. The caller runs this
off the event loop via ``asyncio.to_thread``, so the strict blocking-IO detector
does not flag the filesystem read and the broad ``except`` here cannot mask a
A missing / unparseable agent config must not fail compaction. Model resolution
falls back to the default, while the optional memory flush fails closed because
an unreadable policy cannot authorize a durable write. The caller runs this off
the event loop via ``asyncio.to_thread``, so the strict blocking-IO detector does
not flag the filesystem read and the broad ``except`` here cannot mask a
``BlockingError`` raised on the loop.
"""
from deerflow.config.agents_config import load_agent_config
@ -64,7 +93,7 @@ def _safe_load_agent_config(agent_name: str, user_id: str | None):
try:
return load_agent_config(agent_name, user_id=user_id)
except Exception:
logger.warning("Could not load agent config for %r; using the default model for summarization", agent_name, exc_info=True)
logger.warning("Could not load agent config for %r; using the default model and skipping memory flush", agent_name, exc_info=True)
return None
@ -73,6 +102,8 @@ async def _aresolve_thread_model_name(
agent_name: str | None,
user_id: str | None,
app_config: AppConfig,
*,
agent_config: object = _AGENT_CONFIG_NOT_LOADED,
) -> str | None:
"""Resolve the model a thread should summarize with, mirroring lead resolution.
@ -88,9 +119,11 @@ async def _aresolve_thread_model_name(
default = app_config.models[0].name if getattr(app_config, "models", None) else None
candidate = model_name
if not candidate and agent_name:
agent_config = await asyncio.to_thread(_safe_load_agent_config, agent_name, user_id)
if agent_config and agent_config.model:
candidate = agent_config.model
if agent_config is _AGENT_CONFIG_NOT_LOADED:
agent_config = await asyncio.to_thread(_safe_load_agent_config, agent_name, user_id)
configured_model = getattr(agent_config, "model", None)
if configured_model:
candidate = configured_model
if candidate and app_config.get_model_config(candidate):
return candidate
return default
@ -109,9 +142,6 @@ async def compact_thread_context(
) -> ThreadCompactionResult:
"""Summarize old messages in a thread and write a compacted checkpoint."""
resolved_app_config = app_config or get_app_config()
run_model_name = await _aresolve_thread_model_name(model_name, agent_name, user_id, resolved_app_config)
middleware = _create_compaction_middleware(app_config=resolved_app_config, keep=keep, run_model_name=run_model_name)
read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
snapshot = await accessor.aget(read_config)
snapshot_config = snapshot.config or {}
@ -119,6 +149,26 @@ async def compact_thread_context(
if not checkpoint_id:
raise LookupError(f"Thread {thread_id} checkpoint not found")
binding_known, checkpoint_agent_name = _checkpoint_agent_binding(getattr(snapshot, "metadata", None))
# The body hint may still select a compatible summarization model for
# legacy state, but it never authorizes or attributes a memory write.
effective_agent_name = checkpoint_agent_name if binding_known else agent_name
agent_config = await asyncio.to_thread(_safe_load_agent_config, effective_agent_name, user_id) if effective_agent_name else None
run_model_name = await _aresolve_thread_model_name(
model_name,
effective_agent_name,
user_id,
resolved_app_config,
agent_config=agent_config,
)
memory_enabled = binding_known and (checkpoint_agent_name is None or (agent_config is not None and getattr(agent_config, "memory_enabled", True) is not False))
middleware = _create_compaction_middleware(
app_config=resolved_app_config,
keep=keep,
run_model_name=run_model_name,
skip_memory_flush=not memory_enabled,
)
channel_values = snapshot.values or {}
messages = channel_values.get("messages")
if not isinstance(messages, list) or not messages:
@ -131,8 +181,8 @@ async def compact_thread_context(
}
runtime_context = {"thread_id": thread_id, "user_id": user_id}
if agent_name:
runtime_context["agent_name"] = agent_name
if effective_agent_name:
runtime_context["agent_name"] = effective_agent_name
runtime = SimpleNamespace(context=runtime_context)
try:
# ``raise_on_failure`` is independent of ``force``: a manual caller always wants
@ -149,8 +199,11 @@ async def compact_thread_context(
if result is None:
return ThreadCompactionResult(thread_id=thread_id, compacted=False, reason="not_enough_messages")
update_config = dict(snapshot.config)
if binding_known:
update_config["metadata"] = {CHECKPOINT_AGENT_NAME_METADATA_KEY: (DEFAULT_AGENT_NAME_METADATA_VALUE if checkpoint_agent_name is None else checkpoint_agent_name)}
updated_config = await accessor.aupdate(
snapshot.config,
update_config,
{
"messages": Overwrite(list(result.preserved_messages)),
"summary_text": result.summary_text,

View File

@ -1,5 +1,27 @@
"""Private runtime context keys shared across DeerFlow runtime components."""
from collections.abc import Mapping
from typing import Final
CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY: Final[str] = "__deerflow_pre_run_message_ids"
# Server-authored checkpoint metadata that binds materialized state to the
# agent policy which produced it. The sentinel is intentionally not a valid
# custom-agent name, so a missing/invalid legacy value cannot be confused with
# the default agent and accidentally authorize a memory write.
CHECKPOINT_AGENT_NAME_METADATA_KEY: Final[str] = "deerflow_agent_name"
DEFAULT_AGENT_NAME_METADATA_VALUE: Final[str] = "__default__"
def checkpoint_agent_binding_metadata(metadata: object) -> dict[str, str]:
"""Copy a checkpoint's server-authored agent binding for a state rewrite.
Callers must pass persisted checkpoint metadata, never request metadata.
Missing or malformed values remain unbound so memory writes fail closed.
"""
if not isinstance(metadata, Mapping):
return {}
value = metadata.get(CHECKPOINT_AGENT_NAME_METADATA_KEY)
if not isinstance(value, str) or not value:
return {}
return {CHECKPOINT_AGENT_NAME_METADATA_KEY: value}

View File

@ -52,7 +52,12 @@ from deerflow.runtime.checkpoint_state import (
graph_state_schema,
graph_writable_channels,
)
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.runtime.context_keys import (
CHECKPOINT_AGENT_NAME_METADATA_KEY,
CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY,
DEFAULT_AGENT_NAME_METADATA_VALUE,
checkpoint_agent_binding_metadata,
)
from deerflow.runtime.events.message_identity import attach_message_seq, message_identity
from deerflow.runtime.goal import (
DEFAULT_MAX_GOAL_CONTINUATIONS,
@ -1049,6 +1054,21 @@ async def run_agent(
extensions,
ctx.conversation_reader,
)
# Bind every checkpoint produced by this run to the effective agent
# identity that produced its state. Manual compaction uses only this
# server-overwritten value for memory policy; request metadata cannot
# forge it, and an explicit default sentinel distinguishes new default
# checkpoints from unbound legacy state.
if "agent_name" in runtime_ctx:
checkpoint_agent_name = runtime_ctx["agent_name"]
else:
configurable = config.get("configurable")
checkpoint_agent_name = configurable.get("agent_name") if isinstance(configurable, dict) else None
checkpoint_metadata = config.get("metadata")
if not isinstance(checkpoint_metadata, dict):
checkpoint_metadata = {}
config["metadata"] = checkpoint_metadata
checkpoint_metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] = DEFAULT_AGENT_NAME_METADATA_VALUE if checkpoint_agent_name is None else checkpoint_agent_name
deerflow_trace_id = _bind_trace_id(config, runtime_ctx)
# Expose the run-scoped journal under a sentinel key so middleware can
# write audit events (e.g. SafetyFinishReasonMiddleware recording
@ -2230,6 +2250,7 @@ async def _linearize_delta_checkpoint_resume(
messages = values.get("messages") if isinstance(values, dict) else None
if not isinstance(messages, list):
raise RuntimeError(f"Run {run_id} could not materialize resume checkpoint {checkpoint_id}")
head_config["metadata"] = checkpoint_agent_binding_metadata(getattr(snapshot, "metadata", None))
# Write through the thread's effective schema so every application and
# middleware channel can be restored. Reducer channels need Overwrite to
@ -2319,9 +2340,14 @@ async def _rollback_to_pre_run_checkpoint(
operation="rollback",
)
else:
restore_config = rollback_point.config
restore_config = {
**rollback_point.config,
"configurable": dict(rollback_point.config.get("configurable", {})),
}
replacement_values = {"messages": Overwrite(list(rollback_point.messages))}
restore_config["metadata"] = checkpoint_agent_binding_metadata(rollback_point.metadata)
restored_config = await mutation_accessor.aupdate(
restore_config,
replacement_values,

View File

@ -29,6 +29,15 @@ def test_model_settings_default_to_none() -> None:
assert cfg.model_settings is None
assert cfg.thinking_enabled is None
assert cfg.reasoning_effort is None
assert cfg.memory_enabled is True
def test_custom_agent_can_disable_memory_without_making_it_an_api_managed_field() -> None:
cfg = AgentConfig(name="stateless-worker", memory_enabled=False)
assert cfg.memory_enabled is False
assert "memory_enabled" not in MANAGED_AGENT_CONFIG_FIELDS
assert preserve_non_managed_fields(cfg) == {"memory_enabled": False}
def test_model_settings_parse_full_shape() -> None:
@ -117,6 +126,7 @@ def test_load_agent_config_round_trips_model_settings(tmp_path: Path, monkeypatc
"model_settings": {"temperature": 0.2, "max_tokens": 12000},
"thinking_enabled": True,
"reasoning_effort": "high",
"memory_enabled": False,
}
_write_agent(tmp_path, "default", "researcher", body)
@ -127,6 +137,7 @@ def test_load_agent_config_round_trips_model_settings(tmp_path: Path, monkeypatc
assert cfg.model_settings.max_tokens == 12000
assert cfg.thinking_enabled is True
assert cfg.reasoning_effort == "high"
assert cfg.memory_enabled is False
def test_load_agent_config_without_model_settings_is_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -17,14 +17,15 @@ class FakeCheckpointer:
def __init__(self) -> None:
self.sync_configs: list[dict[str, Any]] = []
self.async_configs: list[dict[str, Any]] = []
self.checkpoint_tuple: Any | None = None
def get_tuple(self, config: dict[str, Any]) -> None:
self.sync_configs.append(config)
return None
return self.checkpoint_tuple
async def aget_tuple(self, config: dict[str, Any]) -> None:
self.async_configs.append(config)
return None
return self.checkpoint_tuple
class FakeGraph:
@ -152,6 +153,42 @@ async def test_async_accessor_binds_persistence_guards_operations_and_preserves_
assert graph.calls[-1][2:] == ({"messages": ["changed"]}, "agent")
@pytest.mark.anyio
async def test_async_accessor_reads_checkpoint_metadata_without_materializing_state() -> None:
graph = FakeGraph()
saver = FakeCheckpointer()
saver.checkpoint_tuple = SimpleNamespace(metadata={"deerflow_agent_name": "stateless-agent"})
accessor = CheckpointStateAccessor.bind(graph, saver, mode="delta")
config = {
"configurable": {"thread_id": "thread-metadata", "checkpoint_ns": ""},
"metadata": {"caller": "test"},
}
original = deepcopy(config)
metadata = await accessor.aget_metadata(config)
assert metadata == {"deerflow_agent_name": "stateless-agent"}
assert graph.calls == []
assert len(saver.async_configs) == 1
_assert_delta_config_is_copied(config, saver.async_configs[0])
assert config == original
@pytest.mark.anyio
async def test_full_accessor_metadata_read_rejects_delta_checkpoint() -> None:
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError
graph = FakeGraph()
saver = FakeCheckpointer()
saver.checkpoint_tuple = SimpleNamespace(metadata={CHECKPOINT_MODE_METADATA_KEY: "delta"})
accessor = CheckpointStateAccessor.bind(graph, saver, mode="full")
with pytest.raises(CheckpointModeMismatchError, match="requires delta mode"):
await accessor.aget_metadata({"configurable": {"thread_id": "thread-delta"}})
assert graph.calls == []
def test_sync_history_zero_limit_guards_without_consuming_a_snapshot() -> None:
graph = FakeGraph()
saver = FakeCheckpointer()

View File

@ -23,6 +23,7 @@ from app.gateway.routers.uploads import UploadResponse
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
from deerflow.agents.thread_state import DeltaThreadState, ThreadState
from deerflow.client import DeerFlowClient
from deerflow.config.agents_config import AgentConfig
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
from deerflow.config.paths import Paths
@ -1178,6 +1179,126 @@ class TestExtractText:
class TestEnsureAgent:
@pytest.mark.parametrize(
("agent_name", "agent_config", "expected_memory_enabled"),
[
("stateless-agent", AgentConfig(name="stateless-agent", memory_enabled=False), False),
("stateful-agent", AgentConfig(name="stateful-agent"), True),
(None, None, True),
],
)
def test_applies_custom_agent_memory_policy(
self,
client,
agent_name,
agent_config,
expected_memory_enabled,
):
client._agent_name = agent_name
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch("deerflow.client.load_agent_config", return_value=agent_config) as mock_load_agent_config,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(config, context={"user_id": "owner-1"})
if agent_name is None:
mock_load_agent_config.assert_not_called()
else:
mock_load_agent_config.assert_called_once_with(agent_name, user_id="owner-1")
assert mock_build_middlewares.call_args.kwargs["memory_enabled"] is expected_memory_enabled
assert mock_apply_prompt.call_args.kwargs["memory_enabled"] is expected_memory_enabled
def test_reuses_named_agent_config_on_cached_agent_fast_path(self, client):
client._agent_name = "stateful-agent"
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch(
"deerflow.client.load_agent_config",
return_value=AgentConfig(name="stateful-agent"),
) as mock_load_agent_config,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(config, context={"user_id": "owner-1"})
client._ensure_agent(config, context={"user_id": "owner-1"})
mock_load_agent_config.assert_called_once_with("stateful-agent", user_id="owner-1")
assert mock_create_agent.call_count == 1
def test_reset_agent_refreshes_named_agent_config(self, client):
client._agent_name = "custom-agent"
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", side_effect=[MagicMock(), MagicMock()]),
patch("deerflow.client.build_middlewares", return_value=[]),
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch(
"deerflow.client.load_agent_config",
side_effect=[
AgentConfig(name="custom-agent", memory_enabled=False),
AgentConfig(name="custom-agent", memory_enabled=True),
],
) as mock_load_agent_config,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(config, context={"user_id": "owner-1"})
client.reset_agent()
client._ensure_agent(config, context={"user_id": "owner-1"})
assert mock_load_agent_config.call_count == 2
@pytest.mark.parametrize(
"config_error",
[
FileNotFoundError("missing config"),
ValueError("invalid config"),
],
ids=["missing", "invalid"],
)
def test_unreadable_named_agent_config_preserves_legacy_memory_default(
self,
client,
caplog,
config_error,
):
client._agent_name = "soul-only-agent"
config = client._get_runnable_config("t1")
with (
patch("deerflow.client.create_chat_model"),
patch("deerflow.client.create_agent", return_value=MagicMock()),
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
patch("deerflow.client.apply_prompt_template", return_value="prompt"),
patch("deerflow.client.load_agent_config", side_effect=config_error) as mock_load_agent_config,
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=None),
):
client._ensure_agent(config, context={"user_id": "owner-1"})
client._ensure_agent(config, context={"user_id": "owner-1"})
mock_load_agent_config.assert_called_once_with("soul-only-agent", user_id="owner-1")
assert mock_build_middlewares.call_args.kwargs["memory_enabled"] is True
assert "using the memory-enabled compatibility default" in caplog.text
def test_authorization_filters_framework_tools_and_reuses_provider(self, client, mock_app_config):
from deerflow.authz.provider import AuthzDecision, AuthzReason
@ -1348,6 +1469,7 @@ class TestEnsureAgent:
patch("deerflow.client.create_agent", return_value=mock_agent) as mock_create_agent,
patch("deerflow.client.build_middlewares", return_value=[]) as mock_build_middlewares,
patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt,
patch("deerflow.client.load_agent_config", return_value=AgentConfig(name="custom-agent")),
patch("deerflow.client.get_enabled_skills_for_config", return_value=[]),
patch.object(client, "_get_tools", return_value=[]),
patch("deerflow.runtime.checkpointer.get_checkpointer", return_value=MagicMock()),
@ -1360,9 +1482,11 @@ class TestEnsureAgent:
# Verify agent_name propagation
mock_build_middlewares.assert_called_once()
assert mock_build_middlewares.call_args.kwargs.get("agent_name") == "custom-agent"
assert mock_build_middlewares.call_args.kwargs.get("memory_enabled") is True
mock_apply_prompt.assert_called_once()
assert mock_apply_prompt.call_args.kwargs.get("agent_name") == "custom-agent"
assert mock_apply_prompt.call_args.kwargs.get("available_skills") == {"test_skill"}
assert mock_apply_prompt.call_args.kwargs.get("memory_enabled") is True
assert mock_create_agent.call_args.kwargs["state_schema"] is ThreadState
def test_delta_mode_selects_state_and_normalizes_middleware(self, client):
@ -1454,9 +1578,25 @@ class TestEnsureAgent:
def test_reuses_agent_same_config(self, client):
"""_ensure_agent does not recreate if config key unchanged."""
from deerflow.runtime.user_context import get_effective_user_id
mock_agent = MagicMock()
client._agent = mock_agent
client._agent_config_key = (None, True, False, False, None, None, None, None, "full", 10, "test-user-autouse", None)
client._agent_config_key = (
None,
True,
False,
False,
None,
None,
None,
True,
None,
"full",
10,
get_effective_user_id(),
None,
)
config = client._get_runnable_config("t1")
client._ensure_agent(config)

View File

@ -14,10 +14,11 @@ from deerflow.agents.middlewares.summarization_middleware import SummaryGenerati
from deerflow.runtime import context_compaction
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.context_compaction import ContextCompactionFailed, compact_thread_context
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, DEFAULT_AGENT_NAME_METADATA_VALUE
class _FakeAccessor:
def __init__(self, values: dict) -> None:
def __init__(self, values: dict, *, metadata: dict | None = None) -> None:
self.snapshot = SimpleNamespace(
values=values,
config={
@ -27,7 +28,7 @@ class _FakeAccessor:
"checkpoint_ns": "",
}
},
metadata={"step": 4, "created_at": "2026-07-06T00:00:00+00:00"},
metadata=metadata if metadata is not None else {"step": 4, "created_at": "2026-07-06T00:00:00+00:00"},
)
self.update_args = None
@ -144,6 +145,9 @@ async def test_compact_thread_context_real_mutation_graph_finishes_without_sched
thread_id="thread-real-compaction",
as_node="seed",
)
seed_config["metadata"] = {
CHECKPOINT_AGENT_NAME_METADATA_KEY: DEFAULT_AGENT_NAME_METADATA_VALUE,
}
await seed_accessor.aupdate(
seed_config,
{
@ -174,6 +178,7 @@ async def test_compact_thread_context_real_mutation_graph_finishes_without_sched
assert [message.id for message in snapshot.values["messages"]] == ["h2"]
assert snapshot.values["summary_text"] == "COMPRESSED SUMMARY"
assert snapshot.next == ()
assert snapshot.metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] == DEFAULT_AGENT_NAME_METADATA_VALUE
@pytest.mark.asyncio
@ -436,3 +441,217 @@ async def test_compact_thread_context_threads_selected_model_to_factory(monkeypa
# 3. No request model, no agent → default.
await compact_thread_context(_FakeAccessor({"messages": messages}), "thread-1", app_config=app_config, user_id="user-1")
assert captured["run_model_name"] == "default-model"
@pytest.mark.asyncio
async def test_manual_compaction_skips_memory_flush_for_opted_out_agent(monkeypatch):
"""The /compact path must honor the Custom Agent's complete memory opt-out."""
import deerflow.config.agents_config as agents_config
config_reads: list[tuple[str, str | None]] = []
def _load_agent_config(name, *, user_id=None):
config_reads.append((name, user_id))
return SimpleNamespace(model="agent-model", memory_enabled=False)
monkeypatch.setattr(
agents_config,
"load_agent_config",
_load_agent_config,
)
app_config = _model_app_config("default-model", "agent-model", "requested-model")
captured: dict[str, object] = {}
def _capture(**kwargs):
captured.update(kwargs)
return _FakeCompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
messages = [HumanMessage(content="old"), AIMessage(content="answer"), HumanMessage(content="new")]
result = await compact_thread_context(
_FakeAccessor(
{"messages": messages},
metadata={CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
),
"thread-1",
app_config=app_config,
user_id="user-1",
agent_name="stateless-worker",
model_name="requested-model",
)
assert result.compacted is True
assert captured["run_model_name"] == "requested-model"
assert captured["skip_memory_flush"] is True
assert config_reads == [("stateless-worker", "user-1")]
@pytest.mark.asyncio
async def test_manual_compaction_fails_closed_when_agent_policy_cannot_load(monkeypatch):
"""An unreadable Custom Agent config must not authorize a durable write."""
import deerflow.config.agents_config as agents_config
def _raise(*_args, **_kwargs):
raise OSError("agent config unavailable")
monkeypatch.setattr(agents_config, "load_agent_config", _raise)
app_config = _model_app_config("default-model", "requested-model")
captured: dict[str, object] = {}
def _capture(**kwargs):
captured.update(kwargs)
return _FakeCompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
messages = [HumanMessage(content="old"), AIMessage(content="answer"), HumanMessage(content="new")]
result = await compact_thread_context(
_FakeAccessor(
{"messages": messages},
metadata={CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
),
"thread-1",
app_config=app_config,
user_id="user-1",
agent_name="stateless-worker",
model_name="requested-model",
)
assert result.compacted is True
assert captured["run_model_name"] == "requested-model"
assert captured["skip_memory_flush"] is True
@pytest.mark.parametrize(
"request_agent_name",
[None, "memory-enabled-impostor"],
ids=["omitted-agent", "forged-agent"],
)
@pytest.mark.asyncio
async def test_manual_compaction_uses_checkpoint_agent_for_memory_policy(monkeypatch, request_agent_name):
"""The state-producing run, not the request body, owns memory policy."""
import deerflow.config.agents_config as agents_config
config_reads: list[str] = []
def _load_agent_config(name, **_kwargs):
config_reads.append(name)
if name != "stateless-worker":
raise AssertionError(f"untrusted agent name reached policy lookup: {name}")
return SimpleNamespace(model="agent-model", memory_enabled=False)
monkeypatch.setattr(agents_config, "load_agent_config", _load_agent_config)
captured: dict[str, object] = {}
def _capture(**kwargs):
captured.update(kwargs)
return _FakeCompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
messages = [HumanMessage(content="old"), AIMessage(content="answer"), HumanMessage(content="new")]
accessor = _FakeAccessor(
{"messages": messages},
metadata={CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
)
result = await compact_thread_context(
accessor,
"thread-1",
app_config=_model_app_config("default-model", "agent-model"),
user_id="user-1",
agent_name=request_agent_name,
)
assert result.compacted is True
assert captured["skip_memory_flush"] is True
assert config_reads == ["stateless-worker"]
assert accessor.update_args[0]["metadata"][CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
@pytest.mark.asyncio
async def test_manual_compaction_uses_checkpoint_agent_for_flush_bucket(monkeypatch):
"""A trusted policy and its memory bucket must come from the same binding."""
import deerflow.config.agents_config as agents_config
config_reads: list[str] = []
def _load_agent_config(name, **_kwargs):
config_reads.append(name)
return SimpleNamespace(model=None, memory_enabled=True)
monkeypatch.setattr(agents_config, "load_agent_config", _load_agent_config)
middleware = _FakeCompactionMiddleware()
captured: dict[str, object] = {}
def _capture(**kwargs):
captured.update(kwargs)
return middleware
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
messages = [HumanMessage(content="old"), AIMessage(content="answer"), HumanMessage(content="new")]
await compact_thread_context(
_FakeAccessor(
{"messages": messages},
metadata={CHECKPOINT_AGENT_NAME_METADATA_KEY: "real-agent"},
),
"thread-1",
app_config=_model_app_config("default-model"),
user_id="user-1",
agent_name="memory-enabled-impostor",
)
assert captured["skip_memory_flush"] is False
assert config_reads == ["real-agent"]
assert middleware.runtime_contexts == [
{"thread_id": "thread-1", "user_id": "user-1", "agent_name": "real-agent"},
]
@pytest.mark.parametrize(
("checkpoint_metadata", "request_agent_name", "expected_skip"),
[
({}, "memory-enabled-impostor", True),
({CHECKPOINT_AGENT_NAME_METADATA_KEY: None}, "memory-enabled-impostor", True),
({CHECKPOINT_AGENT_NAME_METADATA_KEY: "../invalid"}, "memory-enabled-impostor", True),
({CHECKPOINT_AGENT_NAME_METADATA_KEY: DEFAULT_AGENT_NAME_METADATA_VALUE}, "memory-enabled-impostor", False),
],
ids=["legacy-missing", "non-string-binding", "invalid-binding", "bound-default-agent"],
)
@pytest.mark.asyncio
async def test_manual_compaction_fails_closed_without_valid_checkpoint_agent_binding(
monkeypatch,
caplog,
checkpoint_metadata,
request_agent_name,
expected_skip,
):
"""Only an explicit valid checkpoint binding may authorize memory flush."""
import deerflow.config.agents_config as agents_config
monkeypatch.setattr(
agents_config,
"load_agent_config",
lambda name, **_kwargs: SimpleNamespace(model=None, memory_enabled=True),
)
captured: dict[str, object] = {}
def _capture(**kwargs):
captured.update(kwargs)
return _FakeCompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _capture)
messages = [HumanMessage(content="old"), AIMessage(content="answer"), HumanMessage(content="new")]
await compact_thread_context(
_FakeAccessor({"messages": messages}, metadata=checkpoint_metadata),
"thread-1",
app_config=_model_app_config("default-model"),
user_id="user-1",
agent_name=request_agent_name,
)
assert captured["skip_memory_flush"] is expected_skip
if checkpoint_metadata == {}:
assert "checkpoint carries no agent binding" in caplog.text

View File

@ -703,8 +703,8 @@ class TestAgentsAPI:
assert response.status_code == 200
assert response.json()["description"] == "new desc"
def test_update_agent_preserves_hand_authored_github_block(self, agent_client):
"""A hand-authored ``github:`` block on disk must survive PATCH.
def test_update_agent_preserves_hand_authored_non_managed_fields(self, agent_client):
"""Hand-authored ``github:`` and ``memory_enabled`` fields must survive PATCH.
The HTTP route does not expose ``github`` as an editable field
(and rightly so the GitHub App credentials and binding triggers
@ -736,6 +736,7 @@ class TestAgentsAPI:
}
],
}
config_data["memory_enabled"] = False
config_file.write_text(yaml.safe_dump(config_data, sort_keys=False), encoding="utf-8")
# PATCH only the description.
@ -745,6 +746,7 @@ class TestAgentsAPI:
# github: block must survive verbatim.
reloaded = yaml.safe_load(config_file.read_text())
assert reloaded["description"] == "new desc"
assert reloaded["memory_enabled"] is False
assert reloaded["github"] == {
"installation_id": 99999,
"bot_login": "github-agent-bot",

View File

@ -4,11 +4,14 @@ Verifies that memory and current date are injected as a <system-reminder> into
the first HumanMessage exactly once per session (frozen-snapshot pattern).
"""
import asyncio
import hashlib
from types import SimpleNamespace
from unittest import mock
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
from langgraph.graph.message import add_messages
from deerflow.agents.middlewares.dynamic_context_middleware import (
_DYNAMIC_CONTEXT_REMINDER_KEY,
@ -118,6 +121,98 @@ def test_memory_included_when_present():
assert msgs[2].content == "Hi"
def test_memory_opt_out_keeps_date_reminder_without_reading_memory():
mw = _make_middleware(memory_enabled=False)
state = {"messages": [HumanMessage(content="Hi", id="msg-1")]}
with (
mock.patch(
"deerflow.agents.lead_agent.prompt._get_memory_context",
side_effect=AssertionError("disabled custom agent must not read memory"),
),
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
result = mw.before_agent(state, _fake_runtime())
assert result is not None
assert len(result["messages"]) == 2
assert isinstance(result["messages"][0], SystemMessage)
assert "<current_date>2026-05-08, Friday</current_date>" in result["messages"][0].content
assert result["messages"][1].content == "Hi"
def test_memory_opt_out_removes_frozen_checkpoint_memory_but_keeps_date():
"""Changing an existing agent to stateless must take effect immediately.
The date reminder and original user turn remain valid framework context, but
a server-tagged ``__memory`` message from an earlier checkpoint must not be
sent to the model after the agent opts out. An untagged client message with a
similar ID is not middleware-owned and must be preserved.
"""
date = "2026-05-08, Friday"
frozen_memory = HumanMessage(
content="<memory>User prefers Python.</memory>",
id="msg-1__memory",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
)
client_message = HumanMessage(content="client data", id="client__memory")
state = {
"messages": [
_date_reminder_msg(date, "msg-1"),
frozen_memory,
HumanMessage(content="Hello", id="msg-1__user"),
client_message,
HumanMessage(content="Continue", id="msg-2"),
]
}
with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.return_value.strftime.return_value = date
result = _make_middleware(memory_enabled=False).before_agent(state, _fake_runtime())
assert result is not None
assert result["messages"] == [RemoveMessage(id="msg-1__memory")]
updated = add_messages(state["messages"], result["messages"])
assert "msg-1__memory" not in {message.id for message in updated}
assert next(message for message in updated if message.id == "msg-1").content.endswith(f"<current_date>{date}</current_date>\n</system-reminder>")
assert next(message for message in updated if message.id == "client__memory").content == "client data"
async def _wait_forever(*_args, **_kwargs):
await asyncio.Event().wait()
@pytest.mark.asyncio
async def test_memory_opt_out_async_timeout_still_removes_frozen_memory(monkeypatch):
"""A date/injection timeout cannot delay an already-active opt-out."""
date = "2026-05-08, Friday"
state = {
"messages": [
_date_reminder_msg(date, "msg-1"),
HumanMessage(
content="<memory>User prefers Python.</memory>",
id="msg-1__memory",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
),
HumanMessage(content="Continue", id="msg-2"),
]
}
monkeypatch.setattr(
"deerflow.agents.middlewares.dynamic_context_middleware.asyncio.to_thread",
_wait_forever,
)
monkeypatch.setattr(
"deerflow.agents.middlewares.dynamic_context_middleware._INJECT_TIMEOUT_SECONDS",
0.01,
)
result = await _make_middleware(memory_enabled=False).abefore_agent(state, _fake_runtime())
assert result == {"messages": [RemoveMessage(id="msg-1__memory")]}
def test_memory_lookup_uses_runtime_user_id():
mw = _make_middleware()
state = {"messages": [HumanMessage(content="Hi", id="msg-1")]}
@ -873,7 +968,7 @@ def test_date_middlewares_declare_configured_timezone(monkeypatch):
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Asia/Shanghai")
assert _declared_date_timezone_policies() == [
{"current_date_timezone": "Asia/Shanghai"},
{"current_date_timezone": "Asia/Shanghai", "memory_enabled": True},
{"current_date_timezone": "Asia/Shanghai"},
]
@ -882,21 +977,35 @@ def test_date_middlewares_declare_utc_timezone(monkeypatch):
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "UTC")
assert _declared_date_timezone_policies() == [
{"current_date_timezone": "UTC"},
{"current_date_timezone": "UTC", "memory_enabled": True},
{"current_date_timezone": "UTC"},
]
def test_dynamic_context_release_policy_includes_memory_opt_out(monkeypatch):
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "UTC")
assert DynamicContextMiddleware(memory_enabled=False).release_policy_parameters() == {
"current_date_timezone": "UTC",
"memory_enabled": False,
}
def test_date_middlewares_declare_resolved_local_zone_without_env(monkeypatch):
"""Without the knob the declaration resolves the actual local zone, so two
hosts that render different dates still get different assembly fingerprints."""
from deerflow.agents.middlewares.dynamic_context_middleware import _effective_date_timezone_name
monkeypatch.delenv("DEER_FLOW_DATE_TIMEZONE", raising=False)
expected = {"current_date_timezone": _effective_date_timezone_name()}
assert expected["current_date_timezone"]
date_policy = {"current_date_timezone": _effective_date_timezone_name()}
assert date_policy["current_date_timezone"]
assert _declared_date_timezone_policies() == [expected, expected]
assert _declared_date_timezone_policies() == [
{**date_policy, "memory_enabled": True},
date_policy,
]
def test_date_middlewares_declare_resolved_local_zone_for_invalid_env(monkeypatch, caplog):
@ -904,9 +1013,12 @@ def test_date_middlewares_declare_resolved_local_zone_for_invalid_env(monkeypatc
from deerflow.agents.middlewares.dynamic_context_middleware import _effective_date_timezone_name
monkeypatch.setenv("DEER_FLOW_DATE_TIMEZONE", "Not/A_Zone")
expected = {"current_date_timezone": _effective_date_timezone_name()}
date_policy = {"current_date_timezone": _effective_date_timezone_name()}
assert _declared_date_timezone_policies() == [expected, expected]
assert _declared_date_timezone_policies() == [
{**date_policy, "memory_enabled": True},
date_policy,
]
assert "DEER_FLOW_DATE_TIMEZONE" in caplog.text

View File

@ -218,6 +218,51 @@ def test_make_lead_agent_uses_server_auth_identity_for_all_user_scoped_inputs(mo
}
def test_make_lead_agent_applies_custom_agent_memory_opt_out(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
app_config.memory = MemoryConfig(enabled=True, mode="tool")
captured: dict[str, object] = {"memory_tool_appends": 0}
import deerflow.tools as tools_module
monkeypatch.setattr(
lead_agent_module,
"load_agent_config",
lambda name, *, user_id=None: AgentConfig(name=name, memory_enabled=False),
)
monkeypatch.setattr(lead_agent_module, "_load_enabled_available_skills", lambda available_skills, *, app_config, user_id=None: [])
def _build_middlewares(*args, **kwargs):
captured["middleware_memory_enabled"] = kwargs.get("memory_enabled")
return []
def _apply_prompt_template(**kwargs):
captured["prompt_memory_enabled"] = kwargs.get("memory_enabled")
return "system prompt"
def _append_memory_tools(_tools):
captured["memory_tool_appends"] = int(captured["memory_tool_appends"]) + 1
monkeypatch.setattr(lead_agent_module, "build_middlewares", _build_middlewares)
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", _apply_prompt_template)
monkeypatch.setattr(lead_agent_module, "_append_memory_tools_without_name_conflicts", _append_memory_tools)
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
lead_agent_module._make_lead_agent(
{"configurable": {"agent_name": "stateless-worker"}},
app_config=app_config,
)
assert captured == {
"memory_tool_appends": 0,
"middleware_memory_enabled": False,
"prompt_memory_enabled": False,
}
def test_make_lead_agent_scopes_bootstrap_middlewares_to_custom_agent(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
middleware_calls: list[dict[str, object]] = []
@ -718,6 +763,33 @@ def test_build_middlewares_uses_resolved_model_name_for_vision(monkeypatch):
assert isinstance(middlewares[-1], ClarificationMiddleware)
def test_build_middlewares_custom_agent_memory_opt_out_keeps_dynamic_date_only(monkeypatch):
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
summarization_kwargs: dict[str, object] = {}
monkeypatch.setattr(
lead_agent_module,
"_create_summarization_middleware",
lambda **kwargs: summarization_kwargs.update(kwargs) or None,
)
monkeypatch.setattr(lead_agent_module, "_create_todo_list_middleware", lambda is_plan_mode: None)
middlewares = lead_agent_module.build_middlewares(
{"configurable": {"is_plan_mode": False, "subagent_enabled": False}},
model_name="safe-model",
agent_name="stateless-worker",
memory_enabled=False,
app_config=app_config,
)
dynamic_context = next(middleware for middleware in middlewares if isinstance(middleware, DynamicContextMiddleware))
assert dynamic_context._memory_enabled is False
assert summarization_kwargs["skip_memory_flush"] is True
assert not any(isinstance(middleware, MemoryMiddleware) for middleware in middlewares)
def test_build_middlewares_prefers_startup_execution_capacity_after_reload(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
app_config.subagent_runtime.max_running = 12

View File

@ -129,6 +129,7 @@ def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(m
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None, **kwargs: "")
tool_prompt = prompt_module.apply_prompt_template(app_config=tool_config)
stateless_agent_prompt = prompt_module.apply_prompt_template(app_config=tool_config, memory_enabled=False)
middleware_prompt = prompt_module.apply_prompt_template(app_config=middleware_config)
assert "<memory_tool_system>" in tool_prompt
@ -136,6 +137,7 @@ def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(m
assert "memory_add" in tool_prompt
assert "agent facts are not injected automatically" in tool_prompt
assert "When present, the injected <memory> block contains only global user and history summaries" in tool_prompt
assert "<memory_tool_system>" not in stateless_agent_prompt
assert "<memory_tool_system>" not in middleware_prompt

View File

@ -199,7 +199,7 @@ def _make_system_message_coalescing_middleware():
def _make_dynamic_context_middleware():
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
return DynamicContextMiddleware()
return DynamicContextMiddleware(memory_enabled=False)
def _make_subagent_date_context_middleware():

View File

@ -27,6 +27,7 @@ from langgraph.types import Overwrite
from deerflow.agents.thread_state import merge_message_writes
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.worker import RunContext, _checkpoint_thread_lock, _linearize_delta_checkpoint_resume, run_agent
@ -118,6 +119,42 @@ async def test_linearizes_a_delta_resume_onto_the_head():
assert new_head.config["configurable"]["checkpoint_id"] != head.config["configurable"]["checkpoint_id"]
async def test_linearized_resume_preserves_selected_checkpoint_agent_binding():
checkpointer = InMemorySaver()
thread_config = _run_config("thread-binding")
seeded_config = {
**thread_config,
"metadata": {CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
}
graph = _build_answer_graph(_DeltaChannelState, checkpointer, "a1")
await graph.ainvoke(
{"messages": [HumanMessage(content="q1", id="h1")]},
seeded_config,
)
selected = await CheckpointStateAccessor.bind(graph, checkpointer, mode="delta").aget(thread_config)
graph = _build_answer_graph(_DeltaChannelState, checkpointer, "a2")
await graph.ainvoke(
{"messages": [HumanMessage(content="q2", id="h2")]},
thread_config,
)
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode="delta")
await _linearize_delta_checkpoint_resume(
accessor=accessor,
checkpointer=checkpointer,
config=_run_config(
"thread-binding",
selected.config["configurable"]["checkpoint_id"],
),
thread_id="thread-binding",
run_id="run-binding",
)
rewritten = await accessor.aget(thread_config)
assert rewritten.metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
async def test_linearization_restores_all_selected_state_and_clears_newer_channels():
checkpointer = InMemorySaver()
config = _run_config("thread-state")

View File

@ -21,8 +21,10 @@ from langgraph.types import Overwrite
from deerflow.agents.thread_state import merge_artifacts, merge_message_writes
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.runtime import context_compaction
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor, build_state_mutation_graph
from deerflow.runtime.context_compaction import compact_thread_context
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
from deerflow.runtime.runs.manager import CancelOutcome, ConflictError, RunManager
@ -374,6 +376,14 @@ class _FullChannelState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
class _CompactionFullState(_FullChannelState):
summary_text: NotRequired[str | None]
class _CompactionDeltaState(_DeltaChannelState):
summary_text: NotRequired[str | None]
def _build_message_append_graph(state_schema: type, checkpointer: Any):
async def _append_message(state: dict[str, Any]) -> dict[str, Any]:
return {"messages": [HumanMessage(content=f"turn-{len(state.get('messages') or [])}")]}
@ -2199,6 +2209,97 @@ async def test_rollback_linearizes_delta_restore_onto_cancelled_head():
assert not isinstance(raw_messages, list)
@pytest.mark.anyio
@pytest.mark.parametrize(
"mode,state_schema",
[("full", _CompactionFullState), ("delta", _CompactionDeltaState)],
)
async def test_rollback_preserves_agent_binding_for_manual_compaction(monkeypatch, mode, state_schema):
"""A state-only rollback must keep the policy that produced its state."""
import deerflow.config.agents_config as agents_config
checkpointer = InMemorySaver()
async def _finish(_state: dict[str, Any]) -> dict[str, Any]:
return {}
builder = StateGraph(state_schema)
builder.add_node("finish", _finish)
builder.set_entry_point("finish")
builder.set_finish_point("finish")
graph = builder.compile(checkpointer=checkpointer)
accessor = CheckpointStateAccessor.bind(graph, checkpointer, mode=mode)
thread_config = {"configurable": {"thread_id": "thread-binding"}}
seeded_config = {
**thread_config,
"metadata": {CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
}
original_messages = [
HumanMessage(content="old question", id="h1"),
AIMessage(content="old answer", id="a1"),
HumanMessage(content="latest question", id="h2"),
]
await graph.ainvoke({"messages": original_messages}, seeded_config)
rollback_point = await _capture_rollback_point(accessor, checkpointer, thread_config)
assert rollback_point is not None
await graph.ainvoke(
{"messages": [AIMessage(content="cancelled answer", id="a2")]},
thread_config,
)
await _rollback_to_pre_run_checkpoint(
accessor=accessor,
checkpointer=checkpointer,
thread_id="thread-binding",
run_id="run-binding",
rollback_point=rollback_point,
snapshot_capture_failed=False,
)
restored = await accessor.aget(thread_config)
assert restored.metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
assert [message.id for message in restored.values["messages"]] == ["h1", "a1", "h2"]
config_reads: list[tuple[str, str | None]] = []
def _load_agent_config(name, *, user_id=None):
config_reads.append((name, user_id))
return SimpleNamespace(model=None, memory_enabled=False)
monkeypatch.setattr(agents_config, "load_agent_config", _load_agent_config)
captured: dict[str, Any] = {}
class _CompactionMiddleware:
async def acompact_state(self, state, runtime, *, force=False, raise_on_failure=False):
del runtime, force, raise_on_failure
return SimpleNamespace(
summary_text="summary",
messages_to_summarize=tuple(state["messages"][:-1]),
preserved_messages=tuple(state["messages"][-1:]),
total_tokens=42,
)
def _create_compaction_middleware(**kwargs):
captured.update(kwargs)
return _CompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", _create_compaction_middleware)
compaction_graph = build_state_mutation_graph("manual_compaction", mode, state_schema)
compaction_accessor = CheckpointStateAccessor.bind(compaction_graph, checkpointer, mode=mode)
result = await compact_thread_context(
compaction_accessor,
"thread-binding",
app_config=SimpleNamespace(models=[]),
user_id="user-1",
agent_name="memory-enabled-impostor",
)
assert result.compacted is True
assert captured["skip_memory_flush"] is True
assert config_reads == [("stateless-worker", "user-1")]
@pytest.mark.anyio
async def test_rollback_restores_pre_run_pending_writes_for_delta_checkpoints():
"""Pre-run pending writes are re-attached to the restored checkpoint; writes

View File

@ -676,6 +676,37 @@ def test_factory_skip_memory_flush_omits_hook(monkeypatch):
assert middleware._before_summarization_hooks == []
def test_memory_opt_out_compaction_never_queues_durable_memory(monkeypatch):
"""A real compaction remains memory-silent when the caller opts out."""
manager = MagicMock()
monkeypatch.setattr(
"deerflow.agents.middlewares.summarization_middleware.create_chat_model",
lambda **_kw: _StaticChatModel(),
)
monkeypatch.setattr(
"deerflow.agents.memory.summarization_hook.get_memory_config",
lambda: MemoryConfig(enabled=True),
)
monkeypatch.setattr(
"deerflow.agents.memory.summarization_hook.get_memory_manager",
lambda: manager,
)
app_config = SimpleNamespace(
summarization=SummarizationConfig(enabled=True),
memory=MemoryConfig(enabled=True),
)
middleware = create_summarization_middleware(
app_config=app_config,
keep=("messages", 2),
skip_memory_flush=True,
)
assert middleware is not None
assert middleware.compact_state({"messages": _messages()}, _runtime(agent_name="stateless-worker"), force=True) is not None
manager.add_nowait.assert_not_called()
def test_new_messages_block_escapes_breakout() -> None:
"""A user turn that closes ``</new_messages>`` and forges an authority
section must be neutralized before it lands in the summary prompt.

View File

@ -31,6 +31,7 @@ from deerflow.persistence.thread_meta import (
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
from deerflow.runtime import ConflictError, ThreadOperationKind
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY
from deerflow.runtime.user_context import reset_current_user, set_current_user
_ISO_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
@ -211,6 +212,10 @@ class _RawStateAccessor:
checkpoint_tuple = await self.checkpointer.aget_tuple(config)
return self._snapshot(checkpoint_tuple, config)
async def aget_metadata(self, config):
checkpoint_tuple = await self.checkpointer.aget_tuple(config)
return dict(getattr(checkpoint_tuple, "metadata", {}) or {})
async def ahistory(self, config, *, limit=None):
snapshots = []
async for checkpoint_tuple in self.checkpointer.alist(config, limit=limit):
@ -3076,20 +3081,23 @@ def _wire_extension_agent(monkeypatch, app, checkpointer, mode):
return custom_factory
async def _seed_extension_source(checkpointer, custom_factory, mode, source_thread_id):
async def _seed_extension_source(checkpointer, custom_factory, mode, source_thread_id, *, agent_name=None):
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
config = {"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}}
if agent_name is not None:
config["metadata"] = {CHECKPOINT_AGENT_NAME_METADATA_KEY: agent_name}
await accessor.aupdate(
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
config,
{"messages": [HumanMessage(id="h1", content="question")], "ext_list": ["merged"]},
as_node="model",
)
await accessor.aupdate(
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
config,
{"messages": [AIMessage(id="a1", content="answer")], "ext_list": ["payload"]},
as_node="model",
)
await accessor.aupdate(
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
config,
{
"messages": [
HumanMessage(
@ -3102,7 +3110,7 @@ async def _seed_extension_source(checkpointer, custom_factory, mode, source_thre
as_node="model",
)
await accessor.aupdate(
{"configurable": {"thread_id": source_thread_id, "checkpoint_ns": ""}},
config,
{"messages": [AIMessage(id="a2", content="follow-up answer")]},
as_node="model",
)
@ -3158,7 +3166,15 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
assert created.status_code == 200, created.text
# Seed after creation: create_thread writes an empty head checkpoint.
asyncio.run(_seed_extension_source(checkpointer, custom_factory, mode, source_thread_id))
asyncio.run(
_seed_extension_source(
checkpointer,
custom_factory,
mode,
source_thread_id,
agent_name="stateless-worker",
)
)
read_response = client.get(f"/api/threads/{source_thread_id}/state")
assert read_response.status_code == 200, read_response.text
@ -3192,18 +3208,19 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
async def materialize(thread_id):
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
snapshot = await accessor.aget({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
return snapshot.values
return await accessor.aget({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
branch_values = asyncio.run(materialize(branch_thread_id))
branch_snapshot = asyncio.run(materialize(branch_thread_id))
branch_values = branch_snapshot.values
assert branch_values["ext_list"] == ["replaced"]
assert [message.id for message in branch_values["messages"]] == ["h1", "a1", "h2", "a2"]
assert branch_snapshot.metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
prepared = prepare_response.json()
assert prepared["target_run_id"] == "source-run"
assert prepared["input"]["messages"][0]["id"] == "h2"
base_accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
base_values = asyncio.run(
base_snapshot = asyncio.run(
base_accessor.aget(
{
"configurable": {
@ -3213,8 +3230,10 @@ def test_state_endpoints_preserve_extension_reducer_channels(monkeypatch, mode)
}
}
)
).values
)
base_values = base_snapshot.values
assert [message.id for message in base_values["messages"]] == ["h1", "a1"]
assert base_snapshot.metadata[CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
async def _seed_branch_history_source(checkpointer, custom_factory, mode, source_thread_id):
@ -3445,6 +3464,86 @@ def test_update_thread_state_overwrite_into_never_written_channel(monkeypatch, m
assert read_response.json()["values"]["goal"] == {"objective": "finish"}
@pytest.mark.parametrize("mode", ["full", "delta"])
def test_update_thread_state_preserves_agent_binding_for_manual_compaction(monkeypatch, mode) -> None:
"""A manual state rewrite must retain the state-producing agent policy."""
import deerflow.config.agents_config as agents_config
from deerflow.runtime import context_compaction
app, _store, checkpointer = _build_thread_app()
custom_factory = _wire_extension_agent(monkeypatch, app, checkpointer, mode)
thread_id = f"state-binding-{mode}"
async def seed_bound_state() -> None:
accessor = CheckpointStateAccessor.bind(custom_factory(), checkpointer, mode=mode)
await accessor.aupdate(
{
"configurable": {"thread_id": thread_id, "checkpoint_ns": ""},
"metadata": {CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
},
{
"messages": [
HumanMessage(id="h1", content="old question"),
AIMessage(id="a1", content="old answer"),
HumanMessage(id="h2", content="latest question"),
]
},
as_node="model",
)
config_reads: list[str] = []
def load_agent_config(name, **_kwargs):
config_reads.append(name)
if name != "stateless-worker":
raise AssertionError(f"untrusted agent name reached policy lookup: {name}")
return SimpleNamespace(model=None, memory_enabled=False)
monkeypatch.setattr(agents_config, "load_agent_config", load_agent_config)
monkeypatch.setattr(context_compaction, "get_app_config", lambda: SimpleNamespace(models=[]))
captured: dict[str, object] = {}
class CompactionMiddleware:
async def acompact_state(self, state, runtime, *, force=False, raise_on_failure=False):
del runtime, force, raise_on_failure
return SimpleNamespace(
summary_text="summary",
messages_to_summarize=tuple(state["messages"][:-1]),
preserved_messages=tuple(state["messages"][-1:]),
total_tokens=42,
)
def create_compaction_middleware(**kwargs):
captured.update(kwargs)
return CompactionMiddleware()
monkeypatch.setattr(context_compaction, "_create_compaction_middleware", create_compaction_middleware)
with TestClient(app) as client:
created = client.post(
"/api/threads",
json={"thread_id": thread_id, "metadata": {}, "assistant_id": "extension-agent"},
)
assert created.status_code == 200, created.text
asyncio.run(seed_bound_state())
updated = client.post(
f"/api/threads/{thread_id}/state",
json={"values": {"title": "Renamed"}},
)
assert updated.status_code == 200, updated.text
compacted = client.post(
f"/api/threads/{thread_id}/compact",
json={"force": True, "agent_name": "memory-enabled-impostor"},
)
assert compacted.status_code == 200, compacted.text
assert compacted.json()["compacted"] is True
assert captured["skip_memory_flush"] is True
assert config_reads == ["stateless-worker"]
def test_update_thread_state_rejects_unknown_state_fields(monkeypatch) -> None:
"""Unknown fields fail 422 instead of a false-success 200."""
app, _store, checkpointer = _build_thread_app()
@ -3763,6 +3862,9 @@ def test_update_thread_state_overwrites_reducer_fields_and_writes_last_values_di
accessor = SimpleNamespace(
aupdate=aupdate,
aget_metadata=AsyncMock(
return_value={CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"},
),
aget=AsyncMock(return_value=snapshot),
)
@ -3799,12 +3901,16 @@ def test_update_thread_state_overwrites_reducer_fields_and_writes_last_values_di
assert len(update_calls) == 1
read_config, updates, as_node = update_calls[0]
assert read_config["configurable"]["thread_id"] == "state-overwrite"
assert read_config["metadata"] == {CHECKPOINT_AGENT_NAME_METADATA_KEY: "stateless-worker"}
assert isinstance(updates["messages"], Overwrite)
assert updates["messages"].value[0]["id"] == "h1"
assert isinstance(updates["artifacts"], Overwrite)
assert updates["artifacts"].value == ["artifact-1"]
assert updates["title"] == "Renamed"
assert as_node == "manual_state_update"
accessor.aget_metadata.assert_awaited_once_with(
{"configurable": {"thread_id": "state-overwrite", "checkpoint_ns": ""}},
)
accessor.aget.assert_awaited_once_with(updated_config)
assert response.json()["checkpoint_id"] == "ckpt-updated"

View File

@ -14,6 +14,7 @@ import asyncio
import pytest
from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY, DEFAULT_AGENT_NAME_METADATA_VALUE
from deerflow.runtime.runs.manager import RunRecord, RunStartOutcome
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext, _build_runtime_context, run_agent
@ -144,6 +145,56 @@ async def test_caller_supplied_context_trace_id_is_overwritten():
assert captured["context"]["agent_name"] == "kept"
@pytest.mark.asyncio
async def test_checkpoint_metadata_binds_the_effective_runtime_agent():
"""The checkpoint records the agent that actually produced its state."""
captured = await _run(
{
"configurable": {"thread_id": "thread-trace-binding"},
"context": {"agent_name": "stateless-worker"},
}
)
assert captured["metadata"][CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
@pytest.mark.asyncio
async def test_checkpoint_agent_binding_overwrites_caller_metadata():
"""Request metadata cannot forge the memory policy attached to state."""
captured = await _run(
{
"configurable": {"thread_id": "thread-trace-binding"},
"metadata": {CHECKPOINT_AGENT_NAME_METADATA_KEY: "memory-enabled-impostor"},
"context": {"agent_name": "stateless-worker"},
}
)
assert captured["metadata"][CHECKPOINT_AGENT_NAME_METADATA_KEY] == "stateless-worker"
@pytest.mark.asyncio
async def test_default_agent_checkpoint_binding_is_explicit():
"""A sentinel distinguishes the default agent from unbound legacy state."""
captured = await _run({"configurable": {"thread_id": "thread-trace-binding"}})
assert captured["metadata"][CHECKPOINT_AGENT_NAME_METADATA_KEY] == DEFAULT_AGENT_NAME_METADATA_VALUE
@pytest.mark.asyncio
async def test_checkpoint_agent_binding_falls_back_to_configurable_context():
"""Embedded callers may still carry their effective agent only in configurable."""
captured = await _run(
{
"configurable": {
"thread_id": "thread-trace-binding",
"agent_name": "embedded-agent",
}
}
)
assert captured["metadata"][CHECKPOINT_AGENT_NAME_METADATA_KEY] == "embedded-agent"
@pytest.mark.asyncio
async def test_both_forks_agree_when_the_caller_forges_both():
"""The failure this rules out is disagreement, not any single wrong value."""