Beautyl0ve 80f13935c2
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>
2026-09-14 18:17:54 +08:00

225 lines
9.6 KiB
Python

"""Manual thread-context compaction helpers."""
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):
"""Raised when manual compaction is requested while summarization is disabled."""
class ContextCompactionFailed(RuntimeError):
"""Raised when a compressible thread cannot be summarized."""
@dataclass(frozen=True)
class ThreadCompactionResult:
"""Result returned after a manual context-compaction attempt."""
thread_id: str
compacted: bool
reason: str | None = None
removed_message_count: int = 0
preserved_message_count: int = 0
summary_updated: bool = False
checkpoint_id: str | None = None
total_tokens: int = 0
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,
skip_memory_flush=skip_memory_flush,
)
if middleware is None:
raise ContextCompactionDisabled("Context compaction is disabled.")
return 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. 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
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 and skipping memory flush", agent_name, exc_info=True)
return None
async def _aresolve_thread_model_name(
model_name: str | None,
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.
Precedence matches ``lead_agent._resolve_model_name``: an explicit request model
override (validated against configured models) wins, else the thread's custom-agent
configured model, else ``config.models[0]``. Manual ``/compact`` does not execute
the agent, so there is no live runtime carrying the selected model — the caller
(route / client) supplies it as ``model_name`` the same way a normal run submits
``context.model_name``. The custom-agent config read happens only when no request
model was supplied, runs off the event loop, and passes the owning ``user_id`` so
the per-user agent directory resolves.
"""
default = app_config.models[0].name if getattr(app_config, "models", None) else None
candidate = model_name
if not candidate and agent_name:
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
async def compact_thread_context(
accessor: CheckpointStateAccessor,
thread_id: str,
*,
keep: tuple[str, int | float] | None = None,
force: bool = True,
user_id: str | None = None,
agent_name: str | None = None,
model_name: str | None = None,
app_config: AppConfig | None = None,
) -> ThreadCompactionResult:
"""Summarize old messages in a thread and write a compacted checkpoint."""
resolved_app_config = app_config or get_app_config()
read_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
snapshot = await accessor.aget(read_config)
snapshot_config = snapshot.config or {}
checkpoint_id = snapshot_config.get("configurable", {}).get("checkpoint_id")
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:
return ThreadCompactionResult(thread_id=thread_id, compacted=False, reason="not_enough_messages")
state = {
"messages": list(messages),
"summary_text": channel_values.get("summary_text"),
"task_history": channel_values.get("task_history"),
}
runtime_context = {"thread_id": thread_id, "user_id": user_id}
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
# a generation failure surfaced (even a force=False call that met the threshold),
# so it must not collapse into the force=False "nothing to compact" branch below.
result = await middleware.acompact_state(state, runtime, force=force, raise_on_failure=True) # type: ignore[arg-type]
except SummaryGenerationError as exc:
# A compressible thread whose summary LLM failed (after the run-model fallback)
# is a real failure, distinct from "nothing to compact". Route it to the
# already-consumed ContextCompactionFailed path (HTTP 500 -> frontend error
# toast) instead of a compacted=False result that reads as "does not need
# compaction".
raise ContextCompactionFailed("summary generation failed") from exc
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(
update_config,
{
"messages": Overwrite(list(result.preserved_messages)),
"summary_text": result.summary_text,
**({"task_history": result.task_history} if getattr(result, "task_history", None) is not None else {}),
},
as_node="manual_compaction",
)
new_checkpoint_id = updated_config.get("configurable", {}).get("checkpoint_id")
return ThreadCompactionResult(
thread_id=thread_id,
compacted=True,
removed_message_count=len(result.messages_to_summarize),
preserved_message_count=len(result.preserved_messages),
summary_updated=True,
checkpoint_id=new_checkpoint_id,
total_tokens=result.total_tokens,
)