mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* fix(summarization): own the run model for compaction; bound failure With summarization.model_name: null the summary model resolved to config.models[0] while the executing model is selected per run; when they differ and models[0]'s provider is broken (expired key, quota, outage) compaction silently failed every triggered turn and context grew unbounded until the main provider 400s the run (#3103's shape), even though the run's own model was healthy. Model ownership is now sourced from the builders, not re-derived at runtime: - The lead, subagent, and manual /compact builders each pass the resolved run model into create_summarization_middleware(run_model_name=...). The middleware no longer reads runtime.context / get_config(), which do not carry a custom agent's or a subagent's resolved model, so a custom-agent lead run and a distinct-model subagent now summarize with their own model, not models[0] / the parent's. Runtime re-resolution and the per-name model cache are removed. - model_name: null summarizes with the run's own model; an explicitly configured summary model generates and falls back to the run model on failure. The fallback is built lazily after the primary fails and its construction is guarded, so a broken fallback cannot skip a healthy primary or escape the automatic failure boundary. Failure is bounded and side-effect-safe: - An empty or whitespace-only response is treated as a generation failure, not a valid summary, so compaction never removes all history for an empty replacement. - compact_state/acompact_state take raise_on_failure independent of force: the manual /compact path always surfaces a generation failure (even force=false) and routes it to the existing ContextCompactionFailed path (HTTP 500 -> frontend error toast) instead of an unconsumed response reason. The automatic path leaves compaction state unchanged. - before_summarization hooks fire only after a replacement summary exists. SummarizationConfig.model_name, config.example.yaml, and docs/summarization.md document the final lead/subagent/manual ownership rules. Part of RFC #4346 (section A). Evaluating fraction/triggers against the run model's profile (profile ownership) is a separate follow-up. * fix(summarization): manual /compact model ownership + fail-open construct/parse Manual /compact carried only agent_name, so it derived the run model from the custom-agent model or config.models[0] and missed the request-selected model the run path uses (request -> custom-agent -> default). Carry model_name through ThreadCompactRequest and the frontend compact call, resolve with the same precedence, and move the custom-agent config read off the event loop (asyncio .to_thread) with user_id so the strict blocking-IO gate is not bypassed by the broad except. Make one summary attempt own its full lifecycle so the fail-open boundary covers construction and response parsing, not just invocation: build each candidate model lazily and guarded (a raising constructor falls through to the healthy run model instead of breaking agent construction), build the model_name:null primary from the run model rather than config.models[0], and run response text extraction inside the invocation try so a failing .text accessor falls back instead of escaping compaction. Adds factory-level constructor-failure, response-extraction-failure (sync/async), and route-path model-ownership tests.
757 lines
34 KiB
Python
757 lines
34 KiB
Python
"""Summarization middleware extensions for DeerFlow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol, override, runtime_checkable
|
|
|
|
from langchain.agents import AgentState
|
|
from langchain.agents.middleware import SummarizationMiddleware
|
|
from langchain_core.messages import AnyMessage, HumanMessage, RemoveMessage, 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.config.app_config import get_app_config
|
|
from deerflow.models import create_chat_model
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_SUMMARY_TRIGGER_MESSAGE_NAME = "summary"
|
|
_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
|
|
|
|
|
|
@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,
|
|
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]
|
|
**kwargs,
|
|
) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
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
|
|
# 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 _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) -> 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)
|
|
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.
|
|
"""
|
|
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) -> str | None:
|
|
"""Async counterpart of :meth:`_invoke_summary`."""
|
|
if model is None:
|
|
return None
|
|
try:
|
|
response = await model.ainvoke(prompt, config={"metadata": {"lc_source": "summarization"}})
|
|
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)
|
|
return self._bound_text(text, max_tokens)
|
|
|
|
def _build_summary_input_text(self, formatted_messages: str, previous_summary: str | None = None) -> str | None:
|
|
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="first",
|
|
)
|
|
else:
|
|
trimmed_previous_summary = ""
|
|
trimmed_new_messages = self._trim_summary_section_text(
|
|
formatted_messages,
|
|
max_tokens,
|
|
strategy="first",
|
|
)
|
|
|
|
# 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)
|
|
if not trimmed_messages:
|
|
trimmed_messages = messages_to_summarize[-1:]
|
|
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)
|
|
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
|
|
|
|
messages_to_summarize, preserved_messages = self._partition_messages(messages, cutoff_index)
|
|
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
|
|
if not messages_to_summarize:
|
|
return None
|
|
return messages_to_summarize, preserved_messages, previous_summary, total_tokens
|
|
|
|
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
|
|
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)
|
|
return ContextCompactionResult(
|
|
summary_text=summary,
|
|
messages_to_summarize=tuple(messages_to_summarize),
|
|
preserved_messages=tuple(preserved_messages),
|
|
total_tokens=total_tokens,
|
|
)
|
|
|
|
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
|
|
summary = await self._asummarize_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 (see compact_state).
|
|
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
|
|
return ContextCompactionResult(
|
|
summary_text=summary,
|
|
messages_to_summarize=tuple(messages_to_summarize),
|
|
preserved_messages=tuple(preserved_messages),
|
|
total_tokens=total_tokens,
|
|
)
|
|
|
|
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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
def _preserve_dynamic_context_reminders(
|
|
self,
|
|
messages_to_summarize: list[AnyMessage],
|
|
preserved_messages: list[AnyMessage],
|
|
) -> tuple[list[AnyMessage], list[AnyMessage]]:
|
|
"""Keep hidden dynamic-context reminders and their ID-swap peers out of summary compression.
|
|
|
|
These reminders carry the current date and optional memory. If summarization
|
|
removes them, DynamicContextMiddleware can lose the already-injected reminder
|
|
and inject a replacement into the wrong point of the conversation.
|
|
|
|
The ID-swap triplet produced by ``_make_reminder_and_user_messages`` contains
|
|
three messages: ``SystemMessage(id=X)`` and ``HumanMessage(id=X__memory)`` are
|
|
both tagged with ``dynamic_context_reminder=True``, but ``HumanMessage(id=X__user)``
|
|
carries the original user content and is **not** tagged. Without peer rescue,
|
|
``__user`` would stay in ``to_summarize`` and be compressed into prose — orphaning
|
|
the tagged messages and losing the user question from the model's direct context.
|
|
|
|
This method rescues tagged reminders and also rescues any untagged messages whose
|
|
``id`` shares the same ``stable_id`` prefix (i.e. ``X__user``, ``X__memory``).
|
|
"""
|
|
reminders = [msg for msg in messages_to_summarize if is_dynamic_context_reminder(msg)]
|
|
if not reminders:
|
|
return messages_to_summarize, preserved_messages
|
|
|
|
# Collect the base IDs (the stable_id prefix) from tagged reminders.
|
|
# For a reminder with id="ctx-001__memory", the base is "ctx-001".
|
|
# For a reminder with id="ctx-001" (SystemMessage), the base is "ctx-001".
|
|
# removesuffix is suffix-only — it won't strip a "__" that sits in the
|
|
# middle of a stable_id (e.g. "ctx__001" stays intact, unlike rsplit
|
|
# which would mis-derive "ctx"). Only known ID-swap suffixes (__memory,
|
|
# __user) are stripped; __user is not tagged so won't appear in reminders,
|
|
# but is included defensively.
|
|
reminder_base_ids: set[str] = set()
|
|
for msg in reminders:
|
|
if msg.id:
|
|
base = msg.id.removesuffix("__memory").removesuffix("__user")
|
|
reminder_base_ids.add(base)
|
|
|
|
# Single-pass partition: walk messages_to_summarize in chronological order
|
|
# and rescue both tagged reminders and untagged ID-swap peers (whose id
|
|
# starts with a known base + "__"). This preserves the original message
|
|
# order within rescued — critical when multiple triplets land in one
|
|
# summarization window — and eliminates the need for id(m)-based dedup
|
|
# that the previous reminders+peers concatenation required.
|
|
rescued: list[AnyMessage] = []
|
|
remaining: list[AnyMessage] = []
|
|
for msg in messages_to_summarize:
|
|
if is_dynamic_context_reminder(msg) or (msg.id and any(msg.id.startswith(b + "__") for b in reminder_base_ids)):
|
|
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 create_summarization_middleware(
|
|
*,
|
|
app_config: Any | None = None,
|
|
keep: tuple[str, int | float] | None = None,
|
|
skip_memory_flush: bool = False,
|
|
run_model_name: str | None = 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.
|
|
|
|
``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
|
|
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
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"model": anchor_model,
|
|
"trigger": trigger,
|
|
"keep": keep or config.keep.to_tuple(),
|
|
}
|
|
if config.trim_tokens_to_summarize is not None:
|
|
kwargs["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,
|
|
app_config=resolved_app_config,
|
|
configured_model_name=config.model_name,
|
|
run_model_name=run_model_name,
|
|
anchor_model_name=anchor_name,
|
|
)
|