mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
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:
parent
ba998a92ac
commit
80f13935c2
@ -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/`:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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``
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user