mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
* fix(subagents): keep the subagent system prompt through context compaction A subagent carries its whole system prompt (role, report contract, skills, deferred tools) as the leading SystemMessage in state, because its agent is built with system_prompt=None. Summarization only rescued tagged reminders and the latest user message, so the first compaction summarized the prompt away and every later model call in that subagent ran without its instructions. Rescue system messages as well. The lead agent's prompt is not in state, and its in-state SystemMessages are the tagged reminders already rescued, so the lead chain is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(compaction): clarify preservation contract and trim agent guidance --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1026 lines
48 KiB
Python
1026 lines
48 KiB
Python
"""Summarization middleware extensions for DeerFlow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any, Literal, Protocol, override, runtime_checkable
|
|
|
|
from deerflow_extension_api import CompactionEvent, canonical_hash
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import SummarizationMiddleware
|
|
from langchain_core.messages import AnyMessage, HumanMessage, RemoveMessage, SystemMessage, get_buffer_string, trim_messages
|
|
from langgraph.config import get_config
|
|
from langgraph.constants import TAG_NOSTREAM
|
|
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
from langgraph.runtime import Runtime
|
|
|
|
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
|
from deerflow.agents.middlewares.message_utils import is_genuine_user_message
|
|
from deerflow.config.app_config import get_app_config
|
|
from deerflow.config.summarization_config import DEFAULT_KEEP
|
|
from deerflow.config.task_continuity_config import TaskContinuityConfig
|
|
from deerflow.extensions.notify import notify_context_compacted
|
|
from deerflow.models import create_chat_model
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
|
|
_COMPACTION_TRANSFORM_KIND = "summarization"
|
|
_COMPACTION_TRANSFORM_VERSION = "1"
|
|
_UNSET = object()
|
|
# Valid non-generated summaries for the empty / too-long-to-summarize edges; these
|
|
# short-circuit model invocation (and must not be treated as generation failures).
|
|
_CANNED_SUMMARIES = frozenset(
|
|
{
|
|
"No previous conversation history.",
|
|
"Previous conversation was too long to summarize.",
|
|
}
|
|
)
|
|
|
|
|
|
class SummaryGenerationError(RuntimeError):
|
|
"""Summary generation failed after exhausting the run-model fallback.
|
|
|
|
Raised only when a caller opts in via ``raise_on_failure`` (the manual
|
|
``/compact`` path) so a real failure is reported distinctly from "nothing to
|
|
compact". The automatic path leaves ``raise_on_failure`` False and swallows the
|
|
failure, leaving compaction state unchanged for the turn.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SummarizationEvent:
|
|
"""Context emitted before conversation history is summarized away."""
|
|
|
|
messages_to_summarize: tuple[AnyMessage, ...]
|
|
preserved_messages: tuple[AnyMessage, ...]
|
|
thread_id: str | None
|
|
agent_name: str | None
|
|
runtime: Runtime
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContextCompactionResult:
|
|
"""Result of summarizing old context and retaining the active tail."""
|
|
|
|
summary_text: str
|
|
messages_to_summarize: tuple[AnyMessage, ...]
|
|
preserved_messages: tuple[AnyMessage, ...]
|
|
total_tokens: int
|
|
task_history: dict | None = None
|
|
|
|
|
|
@runtime_checkable
|
|
class BeforeSummarizationHook(Protocol):
|
|
"""Hook invoked before summarization removes messages from state."""
|
|
|
|
def __call__(self, event: SummarizationEvent) -> None: ...
|
|
|
|
|
|
def _resolve_thread_id(runtime: Runtime) -> str | None:
|
|
"""Resolve the current thread ID from runtime context or LangGraph config."""
|
|
thread_id = runtime.context.get("thread_id") if runtime.context else None
|
|
if thread_id is None:
|
|
try:
|
|
config_data = get_config()
|
|
except RuntimeError:
|
|
return None
|
|
thread_id = config_data.get("configurable", {}).get("thread_id")
|
|
return thread_id
|
|
|
|
|
|
def _resolve_agent_name(runtime: Runtime) -> str | None:
|
|
"""Resolve the current agent name from runtime context or LangGraph config."""
|
|
agent_name = runtime.context.get("agent_name") if runtime.context else None
|
|
if agent_name is None:
|
|
try:
|
|
config_data = get_config()
|
|
except RuntimeError:
|
|
return None
|
|
agent_name = config_data.get("configurable", {}).get("agent_name")
|
|
return agent_name
|
|
|
|
|
|
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|
"""Summarization middleware with pre-compression hook dispatch."""
|
|
|
|
def __init__(
|
|
self,
|
|
*args,
|
|
before_summarization: list[BeforeSummarizationHook] | None = None,
|
|
task_continuity_config: TaskContinuityConfig | None = None,
|
|
app_config: Any | None = None,
|
|
configured_model_name: str | None = None,
|
|
run_model_name: str | None = None,
|
|
anchor_model_name: str | None = _UNSET, # type: ignore[assignment]
|
|
extensions=None,
|
|
**kwargs,
|
|
) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._task_continuity_config = task_continuity_config if task_continuity_config is not None and task_continuity_config.enabled is True else None
|
|
self._before_summarization_hooks = before_summarization or []
|
|
# Model-ownership state. The model that actually executes the run is selected
|
|
# per run and is the authoritative source of truth, so the caller (lead /
|
|
# subagent / manual builders) supplies it directly as ``run_model_name``
|
|
# instead of the middleware re-deriving it from ``runtime.context`` /
|
|
# ``get_config()`` — those fields do not carry a custom agent's or a subagent's
|
|
# resolved model.
|
|
#
|
|
# ``configured_model_name`` is the explicitly configured summary model
|
|
# (``None`` => summarize with the run's own model). ``run_model_name`` is the
|
|
# model the run executes with; when they differ and the summary provider is
|
|
# broken (expired key, quota, outage) the run's own working model can still
|
|
# compact.
|
|
self._app_config = app_config
|
|
self._configured_summary_model_name = configured_model_name
|
|
self._run_model_name = run_model_name
|
|
# The summary LLM call runs inside a LangGraph middleware hook, so its token
|
|
# stream would otherwise be captured by the messages-tuple stream callback and
|
|
# broadcast to the frontend as a phantom AI message. Tag a dedicated model copy
|
|
# with TAG_NOSTREAM so the streaming handler skips it.
|
|
# Keep self.model untagged so the parent's profile / ls_params inspection still works.
|
|
self._summary_model = self._tag_nostream(self.model)
|
|
# ``self.model`` is the pre-built *anchor* model: it drives the parent's token
|
|
# counter / profile inspection and is reused verbatim by generation when a
|
|
# candidate matches its name. The factory builds it guarded and passes its name
|
|
# explicitly; direct construction (tests) mirrors the old factory choice
|
|
# (configured model, else default) so the passed ``model`` is the primary.
|
|
if anchor_model_name is _UNSET:
|
|
self._anchor_model_name = configured_model_name or self._default_model_name()
|
|
else:
|
|
self._anchor_model_name = anchor_model_name
|
|
if extensions is None:
|
|
from deerflow.extensions import get_agent_build_extensions
|
|
|
|
extensions = get_agent_build_extensions()
|
|
self._extensions = extensions
|
|
# Nostream generation models built lazily by name and cached (None = a build
|
|
# that failed, so a broken candidate config is not retried every turn and does
|
|
# not escape the fail-open boundary).
|
|
self._model_cache: dict[str | None, Any] = {}
|
|
|
|
def release_policy_parameters(self) -> dict[str, object]:
|
|
"""Return the effective compaction policy used for release identity."""
|
|
|
|
def plain_size(value: object) -> object:
|
|
if isinstance(value, tuple):
|
|
return [plain_size(child) for child in value]
|
|
if isinstance(value, list):
|
|
return [plain_size(child) for child in value]
|
|
return value
|
|
|
|
return {
|
|
"trigger": plain_size(self.trigger),
|
|
"keep": plain_size(self.keep),
|
|
"trim_tokens_to_summarize": self.trim_tokens_to_summarize,
|
|
"summary_prompt_hash": canonical_hash(self.summary_prompt),
|
|
# self.model is a chat-model object and is not JSON-serialisable; the
|
|
# anchor model name is the identity that actually drives compaction
|
|
# behaviour (token counting/profile inspection and, absent an
|
|
# explicit configured summary model, generation itself).
|
|
"summary_model": self._anchor_model_name,
|
|
"task_continuity": self._task_continuity_config.model_dump(mode="json") if self._task_continuity_config is not None else None,
|
|
}
|
|
|
|
def _tag_nostream(self, model: Any) -> Any:
|
|
"""Return a copy of ``model`` carrying TAG_NOSTREAM without clobbering tags.
|
|
|
|
lead_agent/agent.py binds "middleware:summarize" for RunJournal attribution;
|
|
RunnableBinding.with_config shallow-merges config, so existing tags must be
|
|
preserved explicitly instead of being overwritten with just [TAG_NOSTREAM].
|
|
"""
|
|
existing_tags = list((getattr(model, "config", None) or {}).get("tags") or [])
|
|
merged_tags = [*existing_tags, TAG_NOSTREAM] if TAG_NOSTREAM not in existing_tags else existing_tags
|
|
return model.with_config(tags=merged_tags)
|
|
|
|
def _default_model_name(self) -> str | None:
|
|
if self._app_config is None:
|
|
return None
|
|
models = getattr(self._app_config, "models", None)
|
|
return models[0].name if models else None
|
|
|
|
def _generation_candidate_names(self) -> list[str | None]:
|
|
"""Ordered summary-generation candidates by name (deduplicated).
|
|
|
|
Explicit summary model: the configured model first, then the run's own model
|
|
as a distinct fallback. ``model_name: null``: the run's own model only — its
|
|
construction is the primary, so there is no eager dependency on
|
|
``config.models[0]`` (a bare default is used only when no run model was
|
|
resolved). A ``None`` entry means "let ``create_chat_model`` pick the default",
|
|
which only occurs when nothing resolves a name.
|
|
"""
|
|
default = self._default_model_name()
|
|
if self._configured_summary_model_name is not None:
|
|
names = [self._configured_summary_model_name, self._run_model_name or default]
|
|
else:
|
|
names = [self._run_model_name or default]
|
|
deduped: list[str | None] = []
|
|
seen: set[str | None] = set()
|
|
for name in names:
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
deduped.append(name)
|
|
return deduped
|
|
|
|
def _model_for(self, name: str | None) -> Any | None:
|
|
"""The nostream summary model for ``name``, built lazily and guarded.
|
|
|
|
Returns the pre-built anchor when ``name`` matches it (no rebuild), otherwise
|
|
constructs and caches. A construction failure is caught and cached as ``None``
|
|
so a broken candidate config never escapes the fail-open boundary, is never
|
|
retried this turn, and still lets the next candidate run.
|
|
"""
|
|
if name == self._anchor_model_name:
|
|
return self._summary_model
|
|
if name in self._model_cache:
|
|
return self._model_cache[name]
|
|
try:
|
|
model = create_chat_model(
|
|
name=name,
|
|
thinking_enabled=False,
|
|
app_config=self._app_config,
|
|
attach_tracing=False,
|
|
)
|
|
built = self._tag_nostream(model.with_config(tags=["middleware:summarize"]))
|
|
except Exception:
|
|
logger.exception("Failed to build summary model %r; trying the next candidate", name)
|
|
built = None
|
|
self._model_cache[name] = built
|
|
return built
|
|
|
|
@override
|
|
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str | None:
|
|
return self._summarize_with(messages_to_summarize)
|
|
|
|
@override
|
|
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str | None:
|
|
return await self._asummarize_with(messages_to_summarize)
|
|
|
|
def _prepare_summary_prompt(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None) -> str | None:
|
|
"""Return the formatted prompt, or a canned string for the empty/too-long edges.
|
|
|
|
A non-``None`` return that is not a real prompt (the two canned strings) is a
|
|
valid summary and short-circuits generation; ``None`` means "build a prompt".
|
|
"""
|
|
if not messages_to_summarize:
|
|
return "No previous conversation history."
|
|
prompt = self._build_summary_prompt(messages_to_summarize, previous_summary=previous_summary)
|
|
if prompt is None:
|
|
return "Previous conversation was too long to summarize."
|
|
return prompt
|
|
|
|
@staticmethod
|
|
def _nonempty_summary(text: Any) -> str | None:
|
|
"""Normalize a model response's text; a blank/whitespace-only body is a failure.
|
|
|
|
Committing ``""`` as a summary would fire the before_summarization hooks and
|
|
remove all prior history for an empty replacement, so an empty body is treated
|
|
as generation failure (try the fallback, or leave state unchanged) rather than
|
|
a valid summary.
|
|
"""
|
|
stripped = text.strip() if isinstance(text, str) else ""
|
|
return stripped or None
|
|
|
|
def _summarize_with(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None = None) -> str | None:
|
|
"""Mirror the parent ``_create_summary`` but invoke the nostream-tagged model.
|
|
|
|
We do not swap ``self.model`` at the instance level: the agent/middleware is
|
|
cached and reused across concurrent runs, so a temporary swap would leak the
|
|
``RunnableBinding`` to other coroutines during ``await`` and break parent logic
|
|
that inspects the raw model (``profile`` / ``_get_ls_params``).
|
|
|
|
Generation uses the run's own model (``model_name: null``) or the explicitly
|
|
configured summary model, falling back to the run model on failure so a broken
|
|
summary provider cannot disable compaction while a working model is available.
|
|
"""
|
|
prompt = self._prepare_summary_prompt(messages_to_summarize, previous_summary)
|
|
if prompt is None or prompt in _CANNED_SUMMARIES:
|
|
return prompt
|
|
# Walk the ordered candidates; each attempt owns its full lifecycle (lazy
|
|
# guarded construction -> invoke -> text extraction -> non-empty validation),
|
|
# and any failure at any stage falls through to the next candidate. When all
|
|
# candidates fail the caller leaves compaction state unchanged.
|
|
names = self._generation_candidate_names()
|
|
for index, name in enumerate(names):
|
|
text = self._invoke_summary(self._model_for(name), prompt, last=index == len(names) - 1)
|
|
if text is not None:
|
|
return text
|
|
return None
|
|
|
|
async def _asummarize_with(
|
|
self,
|
|
messages_to_summarize: list[AnyMessage],
|
|
previous_summary: str | None = None,
|
|
*,
|
|
task_store=None,
|
|
) -> str | None:
|
|
"""Async counterpart of :meth:`_summarize_with` using the nostream model."""
|
|
prompt = self._prepare_summary_prompt(messages_to_summarize, previous_summary)
|
|
if prompt is None or prompt in _CANNED_SUMMARIES:
|
|
return prompt
|
|
names = self._generation_candidate_names()
|
|
for index, name in enumerate(names):
|
|
text = await self._ainvoke_summary(
|
|
self._model_for(name),
|
|
prompt,
|
|
last=index == len(names) - 1,
|
|
model_name=name,
|
|
task_store=task_store,
|
|
)
|
|
if text is not None:
|
|
return text
|
|
return None
|
|
|
|
def _invoke_summary(self, model: Any | None, prompt: str, *, last: bool = False) -> str | None:
|
|
"""Invoke ``model`` for a summary; ``None`` on error or a blank response.
|
|
|
|
Text extraction / non-empty validation runs *inside* the try: reading the
|
|
response's ``.text`` is part of consuming the provider result, so a failing
|
|
accessor must convert to a candidate failure (fall through) rather than escape
|
|
the fail-open boundary.
|
|
|
|
Deliberately unobserved by system-model-call extensions, unlike
|
|
:meth:`_ainvoke_summary`. Both this method and its only host caller
|
|
(``compact_state``) are the sync half of an async-only runtime: the agent runs
|
|
through ``abefore_model``, and ``runtime/context_compaction.py`` calls
|
|
``acompact_state``. Notifying from here would have to block the calling thread
|
|
on the extension loop for a call site the host never reaches.
|
|
"""
|
|
if model is None:
|
|
return None
|
|
try:
|
|
response = model.invoke(prompt, config={"metadata": {"lc_source": "summarization"}})
|
|
return self._checked_summary(response, last)
|
|
except Exception:
|
|
self._log_summary_error(last)
|
|
return None
|
|
|
|
async def _ainvoke_summary(
|
|
self,
|
|
model: Any | None,
|
|
prompt: str,
|
|
*,
|
|
last: bool = False,
|
|
model_name: str | None = None,
|
|
task_store=None,
|
|
) -> str | None:
|
|
"""Async counterpart of :meth:`_invoke_summary`."""
|
|
if model is None:
|
|
return None
|
|
try:
|
|
invoke_config = {"metadata": {"lc_source": "summarization"}}
|
|
extensions = getattr(self, "_extensions", None)
|
|
if extensions is None:
|
|
response = await model.ainvoke(prompt, config=invoke_config)
|
|
else:
|
|
from deerflow_extension_api import SystemOperationKind
|
|
|
|
from deerflow.extensions.notify import observe_system_model_call
|
|
|
|
response = await observe_system_model_call(
|
|
extensions,
|
|
SystemOperationKind.SUMMARIZATION,
|
|
messages=prompt,
|
|
model_name=model_name,
|
|
invoke_config=invoke_config,
|
|
invoke=lambda: model.ainvoke(prompt, config=invoke_config),
|
|
task_store=task_store,
|
|
)
|
|
return self._checked_summary(response, last)
|
|
except Exception:
|
|
self._log_summary_error(last)
|
|
return None
|
|
|
|
def _checked_summary(self, response: Any, last: bool) -> str | None:
|
|
summary = self._nonempty_summary(getattr(response, "text", None))
|
|
if summary is None:
|
|
self._log_summary_empty(last)
|
|
return summary
|
|
|
|
@staticmethod
|
|
def _log_summary_error(last: bool) -> None:
|
|
if last:
|
|
logger.exception("Summary generation failed; skipping compaction this turn")
|
|
else:
|
|
logger.warning("Summary generation failed; falling back to the run model", exc_info=True)
|
|
|
|
@staticmethod
|
|
def _log_summary_empty(last: bool) -> None:
|
|
if last:
|
|
logger.warning("Summary model returned empty text; skipping compaction this turn")
|
|
else:
|
|
logger.warning("Summary model returned empty text; falling back to the run model")
|
|
|
|
@staticmethod
|
|
def _summary_count_message(summary_text: str) -> HumanMessage:
|
|
return HumanMessage(content=summary_text, name=_SUMMARY_TRIGGER_MESSAGE_NAME)
|
|
|
|
def _messages_for_trigger_count(self, messages: list[AnyMessage], summary_text: str | None) -> list[AnyMessage]:
|
|
if not summary_text:
|
|
return messages
|
|
return [*messages, self._summary_count_message(summary_text)]
|
|
|
|
@staticmethod
|
|
def _bound_text(text: str, cap: int) -> str:
|
|
if len(text) <= cap:
|
|
return text
|
|
if cap <= 0:
|
|
return ""
|
|
head = cap * 2 // 3
|
|
omitted_marker = "\n...\n"
|
|
if cap <= len(omitted_marker):
|
|
return text[:cap]
|
|
tail = max(0, cap - head - len(omitted_marker))
|
|
if tail == 0:
|
|
return text[:cap]
|
|
return f"{text[:head]}{omitted_marker}{text[-tail:]}"
|
|
|
|
def _trim_summary_section_text(self, text: str, max_tokens: int, *, strategy: str) -> str:
|
|
if not text.strip():
|
|
return ""
|
|
max_tokens = max(1, max_tokens)
|
|
try:
|
|
trimmed = trim_messages(
|
|
[HumanMessage(content=text)],
|
|
max_tokens=max_tokens,
|
|
token_counter=self.token_counter,
|
|
strategy=strategy,
|
|
allow_partial=True,
|
|
text_splitter=list,
|
|
)
|
|
if trimmed:
|
|
content = trimmed[-1].content
|
|
if isinstance(content, str) and content.strip():
|
|
return content
|
|
except Exception:
|
|
logger.debug("Failed to trim summary prompt section with token counter; falling back to deterministic text cap", exc_info=True)
|
|
if strategy == "last":
|
|
omitted_marker = "\n...\n"
|
|
if len(text) > max_tokens and max_tokens > len(omitted_marker):
|
|
return omitted_marker + text[-(max_tokens - len(omitted_marker)) :]
|
|
return text[-max_tokens:]
|
|
return self._bound_text(text, max_tokens)
|
|
|
|
def _build_summary_input_text(self, formatted_messages: str, previous_summary: str | None = None, *, new_messages_strategy: Literal["first", "last"] = "first") -> str | None:
|
|
"""Trim raw input sections before adding escaping and prompt overhead."""
|
|
if self.trim_tokens_to_summarize is None:
|
|
trimmed_new_messages = formatted_messages
|
|
trimmed_previous_summary = previous_summary.strip() if previous_summary else ""
|
|
else:
|
|
max_tokens = max(1, self.trim_tokens_to_summarize)
|
|
if previous_summary:
|
|
new_message_tokens = max(1, max_tokens // 2)
|
|
previous_summary_tokens = max(1, max_tokens - new_message_tokens)
|
|
trimmed_previous_summary = self._trim_summary_section_text(
|
|
previous_summary.strip(),
|
|
previous_summary_tokens,
|
|
strategy="last",
|
|
)
|
|
trimmed_new_messages = self._trim_summary_section_text(
|
|
formatted_messages,
|
|
new_message_tokens,
|
|
strategy=new_messages_strategy,
|
|
)
|
|
else:
|
|
trimmed_previous_summary = ""
|
|
trimmed_new_messages = self._trim_summary_section_text(
|
|
formatted_messages,
|
|
max_tokens,
|
|
strategy=new_messages_strategy,
|
|
)
|
|
|
|
# Escape < > & before embedding into the <existing_summary>/<new_messages>
|
|
# blocks. new_messages is get_buffer_string over the raw state["messages"]
|
|
# tail (InputSanitizationMiddleware only overrides the ModelRequest, never
|
|
# state, so the summarizer sees genuine user text); existing_summary is the
|
|
# prior turn's summary_text. An unescaped value like "</new_messages>..."
|
|
# would close the block and forge an authority section for the extraction
|
|
# LLM. Same block-breakout defense #4162 applied to the <conversation> block
|
|
# and #4097 to the <memory> block. Escape after trimming so a trailing "..."
|
|
# cannot split an entity; quote=False because content lands in element-text
|
|
# position (never an attribute value).
|
|
parts: list[str] = []
|
|
if trimmed_previous_summary:
|
|
parts.extend(
|
|
[
|
|
"<existing_summary>",
|
|
html.escape(trimmed_previous_summary, quote=False),
|
|
"</existing_summary>",
|
|
"",
|
|
]
|
|
)
|
|
if trimmed_new_messages:
|
|
parts.extend(
|
|
[
|
|
"<new_messages>",
|
|
html.escape(trimmed_new_messages, quote=False),
|
|
"</new_messages>",
|
|
]
|
|
)
|
|
if not parts:
|
|
return None
|
|
return "\n".join(parts)
|
|
|
|
def _build_summary_prompt(self, messages_to_summarize: list[AnyMessage], previous_summary: str | None = None) -> str | None:
|
|
"""Build the summary prompt, returning ``None`` when trimming leaves nothing."""
|
|
trimmed_messages = self._trim_messages_for_summary(messages_to_summarize)
|
|
new_messages_strategy: Literal["first", "last"] = "first"
|
|
if not trimmed_messages:
|
|
if any(isinstance(message, HumanMessage) for message in messages_to_summarize):
|
|
# The human anchor can fall outside the token-limited tail.
|
|
# Preserve the existing final-message fallback for this case.
|
|
trimmed_messages = messages_to_summarize[-1:]
|
|
else:
|
|
# Rescuing the current request can leave an AI/Tool-only window,
|
|
# which the inherited human-anchored trimmer rejects even below
|
|
# budget. Bound its raw text while favoring recent content.
|
|
trimmed_messages = messages_to_summarize
|
|
new_messages_strategy = "last"
|
|
if not trimmed_messages:
|
|
return None
|
|
# Format messages to avoid token inflation from metadata when str() is called on
|
|
# message objects.
|
|
formatted_messages = get_buffer_string(trimmed_messages)
|
|
formatted_messages = self._build_summary_input_text(formatted_messages, previous_summary=previous_summary, new_messages_strategy=new_messages_strategy)
|
|
if not formatted_messages:
|
|
return None
|
|
return self.summary_prompt.format(messages=formatted_messages).rstrip()
|
|
|
|
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
return self._maybe_summarize(state, runtime)
|
|
|
|
async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
return await self._amaybe_summarize(state, runtime)
|
|
|
|
def _prepare_compaction(
|
|
self,
|
|
state: AgentState,
|
|
*,
|
|
force: bool = False,
|
|
) -> tuple[list[AnyMessage], list[AnyMessage], str | None, int] | None:
|
|
messages = state["messages"]
|
|
self._ensure_message_ids(messages)
|
|
|
|
previous_summary = state.get("summary_text") if isinstance(state.get("summary_text"), str) else None
|
|
trigger_messages = self._messages_for_trigger_count(messages, previous_summary)
|
|
total_tokens = self.token_counter(trigger_messages)
|
|
if not force and not self._should_summarize(trigger_messages, total_tokens):
|
|
return None
|
|
|
|
cutoff_index = self._determine_cutoff_index(messages)
|
|
if cutoff_index <= 0:
|
|
return None
|
|
|
|
# The latest real user message (the current request) must survive: peer
|
|
# rescue no longer covers it (see _preserve_required_context), so
|
|
# lock its id here and rescue by exact id. This keeps the current request
|
|
# without "moving cutoff" — which would also retain early AI/Tool turns and
|
|
# never compress a first-turn long analysis. A Human Input Card reply is
|
|
# hidden from the UI but is still the user's current request.
|
|
latest_user_id: str | None = None
|
|
for msg in reversed(messages):
|
|
if is_genuine_user_message(msg):
|
|
latest_user_id = msg.id
|
|
break
|
|
|
|
messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index)
|
|
messages_to_summarize, preserved_messages = self._preserve_required_context(messages_to_summarize, preserved_messages, latest_user_id=latest_user_id)
|
|
if not messages_to_summarize:
|
|
return None
|
|
return messages_to_summarize, preserved_messages, previous_summary, total_tokens
|
|
|
|
def _freeze_compaction_sources(self, messages_to_summarize: list[AnyMessage]) -> tuple[str, ...]:
|
|
"""Hash each about-to-be-removed message's content before the summary call.
|
|
|
|
Returns empty when no ``ContextCompactionObserver`` is registered. The
|
|
hashing is an O(context-size) canonical-JSON pass, and
|
|
``notify_context_compacted`` would discard the event anyway; an install
|
|
with no observer must not pay for one, the same rule ``_complete_assembly``
|
|
follows for descriptor construction. The check cannot live only in the
|
|
notify call — by then the work is already done.
|
|
|
|
Once the summary call returns, ``messages_to_summarize`` is gone from state —
|
|
only the produced summary remains. The mapping from "these messages" to "this
|
|
summary" exists only in this stack frame, so it must be captured now rather
|
|
than reconstructed later.
|
|
|
|
Hashes ``message.content`` directly, never ``str(message.content)``:
|
|
DeerFlow messages are routinely multimodal (``list[dict]`` content, e.g.
|
|
``view_image_middleware``'s injected image payloads), and ``str()`` on a
|
|
dict renders insertion order, so pre-stringifying would make two
|
|
logically identical messages hash differently. ``canonical_hash`` exists
|
|
precisely to normalize that away (sorted keys via ``canonical_json``);
|
|
stringifying first throws the normalization away before it runs.
|
|
"""
|
|
extensions = getattr(self, "_extensions", None)
|
|
if extensions is None or not extensions.context_compaction_observers:
|
|
return ()
|
|
return tuple(canonical_hash(message.content) for message in messages_to_summarize)
|
|
|
|
def _record_compaction(
|
|
self,
|
|
source_content_hashes: tuple[str, ...],
|
|
*,
|
|
summary: str,
|
|
compacted_message_count: int,
|
|
kept_message_count: int,
|
|
) -> None:
|
|
event = CompactionEvent(
|
|
transform_kind=_COMPACTION_TRANSFORM_KIND,
|
|
transform_version=_COMPACTION_TRANSFORM_VERSION,
|
|
source_content_hashes=source_content_hashes,
|
|
output_content_hash=canonical_hash(summary),
|
|
compacted_message_count=compacted_message_count,
|
|
kept_message_count=kept_message_count,
|
|
)
|
|
notify_context_compacted(event, extensions=self._extensions)
|
|
|
|
def compact_state(
|
|
self,
|
|
state: AgentState,
|
|
runtime: Runtime,
|
|
*,
|
|
force: bool = False,
|
|
raise_on_failure: bool = False,
|
|
) -> ContextCompactionResult | None:
|
|
"""Summarize old context and retain the active tail.
|
|
|
|
``force`` bypasses the automatic trigger threshold (a manual caller always
|
|
wants to compact). ``raise_on_failure`` is a *separate* concern: when set (the
|
|
manual ``/compact`` path), a generation failure raises ``SummaryGenerationError``
|
|
so it can be reported distinctly from "nothing to compact"; the automatic path
|
|
leaves it False and swallows the failure, retrying on a later triggered turn.
|
|
"""
|
|
prepared = self._prepare_compaction(state, force=force)
|
|
if prepared is None:
|
|
return None
|
|
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
|
|
source_content_hashes = self._freeze_compaction_sources(messages_to_summarize)
|
|
summary = self._summarize_with(messages_to_summarize, previous_summary=previous_summary)
|
|
if summary is None:
|
|
if raise_on_failure:
|
|
raise SummaryGenerationError("summary generation failed")
|
|
return None
|
|
# Fire hooks only once a replacement summary exists — flushing pre-compaction
|
|
# messages into durable memory for a summary that never materializes would
|
|
# duplicate that work on the next attempt. Messages are still removed after
|
|
# this returns (in _maybe_summarize), so hooks run before they are gone.
|
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
|
self._record_compaction(
|
|
source_content_hashes,
|
|
summary=summary,
|
|
compacted_message_count=len(messages_to_summarize),
|
|
kept_message_count=len(preserved_messages),
|
|
)
|
|
task_history = None
|
|
if self._task_continuity_config is not None:
|
|
from deerflow.agents.task_continuity.archive import capture
|
|
|
|
task_history = capture(state, runtime, messages_to_summarize, self._task_continuity_config)
|
|
return ContextCompactionResult(
|
|
summary_text=summary,
|
|
messages_to_summarize=tuple(messages_to_summarize),
|
|
preserved_messages=tuple(preserved_messages),
|
|
total_tokens=total_tokens,
|
|
task_history=task_history,
|
|
)
|
|
|
|
async def acompact_state(
|
|
self,
|
|
state: AgentState,
|
|
runtime: Runtime,
|
|
*,
|
|
force: bool = False,
|
|
raise_on_failure: bool = False,
|
|
) -> ContextCompactionResult | None:
|
|
"""Async counterpart of :meth:`compact_state` (see it for ``raise_on_failure``)."""
|
|
prepared = self._prepare_compaction(state, force=force)
|
|
if prepared is None:
|
|
return None
|
|
messages_to_summarize, preserved_messages, previous_summary, total_tokens = prepared
|
|
from deerflow_extension_api import task_store_from_runtime
|
|
|
|
source_content_hashes = self._freeze_compaction_sources(messages_to_summarize)
|
|
summary = await self._asummarize_with(
|
|
messages_to_summarize,
|
|
previous_summary=previous_summary,
|
|
task_store=task_store_from_runtime(runtime),
|
|
)
|
|
if summary is None:
|
|
if raise_on_failure:
|
|
raise SummaryGenerationError("summary generation failed")
|
|
return None
|
|
# Fire hooks only once a replacement summary exists (see compact_state).
|
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
|
self._record_compaction(
|
|
source_content_hashes,
|
|
summary=summary,
|
|
compacted_message_count=len(messages_to_summarize),
|
|
kept_message_count=len(preserved_messages),
|
|
)
|
|
task_history = None
|
|
if self._task_continuity_config is not None:
|
|
from deerflow.agents.task_continuity.archive import acapture
|
|
|
|
task_history = await acapture(state, runtime, messages_to_summarize, self._task_continuity_config)
|
|
return ContextCompactionResult(
|
|
summary_text=summary,
|
|
messages_to_summarize=tuple(messages_to_summarize),
|
|
preserved_messages=tuple(preserved_messages),
|
|
total_tokens=total_tokens,
|
|
task_history=task_history,
|
|
)
|
|
|
|
def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
result = self.compact_state(state, runtime, force=False)
|
|
if result is None:
|
|
return None
|
|
return {
|
|
"messages": [
|
|
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
|
*result.preserved_messages,
|
|
],
|
|
"summary_text": result.summary_text,
|
|
**({"task_history": result.task_history} if result.task_history is not None else {}),
|
|
}
|
|
|
|
async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
|
|
result = await self.acompact_state(state, runtime, force=False)
|
|
if result is None:
|
|
return None
|
|
return {
|
|
"messages": [
|
|
RemoveMessage(id=REMOVE_ALL_MESSAGES),
|
|
*result.preserved_messages,
|
|
],
|
|
"summary_text": result.summary_text,
|
|
**({"task_history": result.task_history} if result.task_history is not None else {}),
|
|
}
|
|
|
|
def _preserve_required_context(
|
|
self,
|
|
messages_to_summarize: list[AnyMessage],
|
|
preserved_messages: list[AnyMessage],
|
|
*,
|
|
latest_user_id: str | None = None,
|
|
) -> tuple[list[AnyMessage], list[AnyMessage]]:
|
|
"""Keep system messages, tagged dynamic-context reminders and the current user request out of compression.
|
|
|
|
State-level SystemMessages are framework-owned instructions and must
|
|
survive compaction, including legacy untagged reminders and extension
|
|
instructions. Transient instructions should be injected into requests,
|
|
not state. Tagged reminders (including their ``__memory`` peers) and the
|
|
latest real user message are also rescued. A subagent keeps its whole system
|
|
prompt as the leading ``SystemMessage`` in state (``create_agent`` is built
|
|
with ``system_prompt=None``), so compressing it would leave every later
|
|
call without its instructions. The untagged ``__user`` peer is deliberately NOT
|
|
rescued by ID-swap prefix: it is a stale historical request that must be
|
|
allowed to compress — the source of cross-turn prompt contamination. The
|
|
*current* request is instead identified by ``latest_user_id``, so a
|
|
first-turn long analysis keeps its ``__user`` request while its early
|
|
AI/Tool turns still compress.
|
|
|
|
Rescuing the whole partition is legitimate: ``_prepare_compaction``
|
|
skips compaction when there is no history left to summarize.
|
|
"""
|
|
rescued: list[AnyMessage] = []
|
|
remaining: list[AnyMessage] = []
|
|
for msg in messages_to_summarize:
|
|
if isinstance(msg, SystemMessage) or is_dynamic_context_reminder(msg) or (latest_user_id is not None and msg.id == latest_user_id):
|
|
rescued.append(msg)
|
|
else:
|
|
remaining.append(msg)
|
|
return remaining, rescued + preserved_messages
|
|
|
|
def _fire_hooks(
|
|
self,
|
|
messages_to_summarize: list[AnyMessage],
|
|
preserved_messages: list[AnyMessage],
|
|
runtime: Runtime,
|
|
) -> None:
|
|
if not self._before_summarization_hooks:
|
|
return
|
|
|
|
event = SummarizationEvent(
|
|
messages_to_summarize=tuple(messages_to_summarize),
|
|
preserved_messages=tuple(preserved_messages),
|
|
thread_id=_resolve_thread_id(runtime),
|
|
agent_name=_resolve_agent_name(runtime),
|
|
runtime=runtime,
|
|
)
|
|
|
|
for hook in self._before_summarization_hooks:
|
|
try:
|
|
hook(event)
|
|
except Exception:
|
|
hook_name = getattr(hook, "__name__", None) or type(hook).__name__
|
|
logger.exception("before_summarization hook %s failed", hook_name)
|
|
|
|
|
|
def _build_summary_anchor(candidate_names: list[str | None], app_config: Any) -> tuple[Any | None, str | None]:
|
|
"""Build the first constructible model among ``candidate_names`` (guarded).
|
|
|
|
The returned model is tagged for RunJournal attribution but *not* TAG_NOSTREAM (the
|
|
middleware wraps a nostream copy). It becomes the parent's token-counter / profile
|
|
anchor and is reused for generation when a candidate matches its name. A per-name
|
|
construction failure is swallowed and the next candidate tried, so a broken primary
|
|
constructor neither breaks agent construction nor skips the healthy run model; a
|
|
trailing ``None`` name asks ``create_chat_model`` for its own default. Returns
|
|
``(None, None)`` when nothing can be constructed.
|
|
"""
|
|
tried: set[str | None] = set()
|
|
for name in candidate_names:
|
|
if name in tried:
|
|
continue
|
|
tried.add(name)
|
|
try:
|
|
model = create_chat_model(name=name, thinking_enabled=False, app_config=app_config, attach_tracing=False)
|
|
except Exception:
|
|
logger.exception("Failed to build summary anchor model %r; trying the next candidate", name)
|
|
continue
|
|
return model.with_config(tags=["middleware:summarize"]), name
|
|
return None, None
|
|
|
|
|
|
def _anchor_profile_max_input_tokens(model: Any) -> int | None:
|
|
"""Pre-construction mirror of the parent's ``_get_profile_limits`` validation.
|
|
|
|
Same rules the parent will apply moments later: ``model.profile`` must be a
|
|
``Mapping`` carrying an ``int`` ``max_input_tokens``. Anything else counts as
|
|
"no usable profile".
|
|
"""
|
|
profile = getattr(model, "profile", None)
|
|
if not isinstance(profile, Mapping):
|
|
return None
|
|
max_input_tokens = profile.get("max_input_tokens")
|
|
return max_input_tokens if isinstance(max_input_tokens, int) else None
|
|
|
|
|
|
def _drop_unusable_fraction_clauses(
|
|
anchor_model: Any,
|
|
trigger: Any,
|
|
keep: tuple[str, int | float],
|
|
) -> tuple[Any, tuple[str, int | float], bool]:
|
|
"""Drop fraction clauses the anchor model cannot resolve (no usable profile).
|
|
|
|
LangChain's parent constructor raises ``ValueError`` for a fraction clause when
|
|
``profile["max_input_tokens"]`` is unavailable, which on a third-party
|
|
OpenAI-compatible model without a declared ``context_window`` would otherwise
|
|
fail the whole agent build (#3103). Fraction trigger clauses are dropped
|
|
(absolute clauses survive), and a fraction ``keep`` falls back to the messages
|
|
default.
|
|
|
|
Returns ``(trigger, keep, has_usable_trigger)``; ``has_usable_trigger`` is
|
|
``False`` only when trigger clauses were configured and every one of them was
|
|
a dropped fraction clause. A ``trigger`` that was ``None`` to begin with passes
|
|
through unchanged with ``has_usable_trigger=True``, preserving the long-standing
|
|
"enabled but never auto-triggers" configuration.
|
|
"""
|
|
clauses = list(trigger) if isinstance(trigger, list) else ([] if trigger is None else [trigger])
|
|
has_fraction_trigger = any(isinstance(clause, tuple) and clause[0] == "fraction" for clause in clauses)
|
|
keep_is_fraction = isinstance(keep, tuple) and keep[0] == "fraction"
|
|
if not (has_fraction_trigger or keep_is_fraction):
|
|
return trigger, keep, True
|
|
if _anchor_profile_max_input_tokens(anchor_model) is not None:
|
|
return trigger, keep, True
|
|
|
|
kept = [clause for clause in clauses if not (isinstance(clause, tuple) and clause[0] == "fraction")]
|
|
dropped = [clause for clause in clauses if isinstance(clause, tuple) and clause[0] == "fraction"]
|
|
if dropped:
|
|
logger.warning(
|
|
"Dropped summarization fraction trigger clause(s) %s: the summary model exposes no context window to resolve them against. Declare `context_window` on the model in config.yaml, or use absolute token/message thresholds.",
|
|
dropped,
|
|
)
|
|
new_keep = keep
|
|
if keep_is_fraction:
|
|
# The shared constant keeps this fallback identical to SummarizationConfig's
|
|
# documented default keep.
|
|
new_keep = DEFAULT_KEEP
|
|
logger.warning(
|
|
"Summarization keep %s is unusable without a model context window; falling back to %s. Declare `context_window` on the model in config.yaml to use fraction retention.",
|
|
keep,
|
|
new_keep,
|
|
)
|
|
if not kept:
|
|
# No trigger clause survived, but only treat that as "nothing usable" when
|
|
# clauses were configured at all: a trigger of None keeps constructing the
|
|
# never-firing middleware, exactly as it does outside this degradation path.
|
|
return None, new_keep, not clauses
|
|
return (kept if isinstance(trigger, list) else kept[0]), new_keep, True
|
|
|
|
|
|
def create_summarization_middleware(
|
|
*,
|
|
app_config: Any | None = None,
|
|
keep: tuple[str, int | float] | None = None,
|
|
skip_memory_flush: bool = False,
|
|
archive_task_history: bool = True,
|
|
run_model_name: str | None = None,
|
|
extensions=None,
|
|
) -> DeerFlowSummarizationMiddleware | None:
|
|
"""Create the configured summarization middleware.
|
|
|
|
Both the lead-agent automatic path and the manual context-compaction path
|
|
use this factory so model resolution, hooks, prompt config, and retention
|
|
defaults cannot drift.
|
|
|
|
``run_model_name`` is the model the run actually executes with, resolved by the
|
|
caller (the lead / subagent / manual builders each already resolve it) and passed
|
|
in as the authoritative source of truth for ``model_name: null`` summarization and
|
|
the explicit-summary-model fallback. The middleware does not re-derive it from
|
|
``runtime.context`` / ``get_config()``, which do not carry a custom agent's or a
|
|
subagent's resolved model.
|
|
|
|
``archive_task_history=False`` keeps subagent-internal messages out of the
|
|
parent thread archive, independently of the long-term memory opt-out.
|
|
|
|
``skip_memory_flush`` omits the ``memory_flush_hook`` that otherwise
|
|
flushes pre-compaction messages into the durable memory queue. The lead
|
|
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``
|
|
(#3875 Phase 3 review).
|
|
"""
|
|
resolved_app_config = app_config or get_app_config()
|
|
config = resolved_app_config.summarization
|
|
|
|
if not config.enabled:
|
|
return None
|
|
|
|
trigger = None
|
|
if config.trigger is not None:
|
|
if isinstance(config.trigger, list):
|
|
trigger = [item.to_tuple() for item in config.trigger]
|
|
else:
|
|
trigger = config.trigger.to_tuple()
|
|
|
|
default_name = resolved_app_config.models[0].name if getattr(resolved_app_config, "models", None) else None
|
|
# Build the anchor (token-counter / profile model, reused for generation) guarded,
|
|
# rather than eagerly building the configured/default model and letting a broken
|
|
# constructor escape. Candidates in order: the primary generation model (configured
|
|
# summary model, else the run's own model), then the run model, then the default,
|
|
# then ``None`` (create_chat_model's default) as a last resort. So the null case
|
|
# builds from ``run_model_name`` — not ``config.models[0]`` — and a broken primary
|
|
# falls through to the healthy run model instead of failing agent construction.
|
|
primary_name = config.model_name or run_model_name or default_name
|
|
anchor_model, anchor_name = _build_summary_anchor(
|
|
[primary_name, run_model_name or default_name, default_name, None],
|
|
resolved_app_config,
|
|
)
|
|
if anchor_model is None:
|
|
logger.warning("Summarization is enabled but no summary model could be constructed; compaction is unavailable for this build")
|
|
return None
|
|
|
|
# LangChain's SummarizationMiddleware raises ValueError at construction when a
|
|
# fraction clause is configured but the anchor exposes no usable profile
|
|
# (``profile["max_input_tokens"]``) — the default for any third-party
|
|
# OpenAI-compatible model whose ``context_window`` was not declared in
|
|
# config.yaml (#3103: `trigger: fraction` used to fail the whole agent build).
|
|
# Degrade instead: drop the unusable fraction clauses (absolute ones survive)
|
|
# and fall the keep policy back to its messages default. When every configured
|
|
# trigger clause is dropped, construction continues with ``trigger=None`` —
|
|
# the never-firing shape — so manual compaction (``/compact``, which runs with
|
|
# ``force=True`` and never consults trigger clauses) keeps working for a
|
|
# profile-less model instead of reporting "compaction is disabled". The factory
|
|
# attaches a profile from a declared ``context_window``, so this path is
|
|
# reached only when the model's capacity is genuinely unknown.
|
|
trigger, keep_tuple, has_usable_trigger = _drop_unusable_fraction_clauses(anchor_model, trigger, keep or config.keep.to_tuple())
|
|
if not has_usable_trigger:
|
|
logger.warning(
|
|
"Every configured summarization trigger is fraction-based but anchor model %r "
|
|
"exposes no context window (no `context_window` on the model in config.yaml, no provider profile); "
|
|
"auto-compaction will not fire for this build. Declare `context_window` on the model to enable fraction "
|
|
"triggers. Manual compaction (/compact) remains available.",
|
|
anchor_name,
|
|
)
|
|
kwargs: dict[str, Any] = {
|
|
"model": anchor_model,
|
|
"trigger": trigger,
|
|
"keep": keep_tuple,
|
|
"trim_tokens_to_summarize": config.trim_tokens_to_summarize,
|
|
}
|
|
if config.summary_prompt is not None:
|
|
kwargs["summary_prompt"] = config.summary_prompt
|
|
|
|
hooks: list[BeforeSummarizationHook] = []
|
|
if resolved_app_config.memory.enabled and not skip_memory_flush:
|
|
from deerflow.agents.memory.summarization_hook import memory_flush_hook
|
|
|
|
hooks.append(memory_flush_hook)
|
|
|
|
return DeerFlowSummarizationMiddleware(
|
|
**kwargs,
|
|
before_summarization=hooks,
|
|
task_continuity_config=(resolved_app_config.task_continuity if archive_task_history and getattr(getattr(resolved_app_config, "task_continuity", None), "enabled", False) is True else None),
|
|
app_config=resolved_app_config,
|
|
configured_model_name=config.model_name,
|
|
run_model_name=run_model_name,
|
|
anchor_model_name=anchor_name,
|
|
extensions=extensions,
|
|
)
|