mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-17 01:56:18 +00:00
* fix(summarization): resolve fraction triggers from declared context_window, degrade instead of crashing the agent build A fraction trigger/keep clause requires profile["max_input_tokens"], which any third-party OpenAI-compatible model lacks, so SummarizationMiddleware construction raised ValueError out of create_summarization_middleware and failed the whole agent build (#3103). - factory: translate a declared model context_window into the langchain profile (metadata-only, never reaches the provider payload); explicit caller/override profiles win - summarization factory: drop unusable fraction trigger clauses (absolute clauses survive), fall a fraction keep back to the messages default, and disable compaction with an actionable warning only when no usable trigger clause remains — the agent build never dies from summarization config - docs: config.example.yaml, ModelConfig.context_window, summarization.md * refactor(summarization): share the default keep constant with the fraction fallback The fraction-keep degradation fallback hardcoded ("messages", 20), duplicating SummarizationConfig.keep's default_factory literal. Move the value to a shared DEFAULT_KEEP constant so the two cannot drift apart. * fix(summarization): keep trigger-null + fraction-keep constructing after degradation A trigger of None with a fraction keep hit the all-clauses-dropped branch (has_usable_trigger=False) and disabled compaction, and the accompanying warning claimed configured triggers were all fraction-based when none were configured. Only report nothing-usable when trigger clauses actually existed; trigger:null keeps constructing the never-firing middleware with the degraded keep, matching its behavior outside the degradation path. * fix(summarization): address review — keep manual compaction, validate ContextSize, pin wiring Review follow-ups on #4901: - When every configured trigger is a dropped fraction clause, keep constructing the never-firing middleware (trigger=None) instead of returning None: manual /compact runs with force=True and never consults trigger clauses, so it must keep working for a profile-less model rather than reporting 'compaction is disabled'. The warning now says auto-compaction will not fire while manual compaction remains. - ContextSize gains a config-load validator: fraction values must be in (0,1] (a percent-style 80 instead of 0.8 previously produced a threshold the context could never reach — a silently inert trigger), absolute values must be positive. - New un-monkeypatched integration test pins the shipped wiring (context_window declared -> real factory attaches profile -> fraction clause survives -> middleware constructs), which the stubbed middleware-side tests and kwarg-capturing factory-side tests each stopped short of. - Docs (summarization.md + config.example.yaml) clarify that the fraction resolves against the summary/anchor model's context_window (summarization.model_name when set, else the run model), including the mismatch caveat for a larger-window summary model. * fix(summarization): reject non-finite ContextSize values at config load YAML .nan / .inf pass pydantic's float parsing, and nan <= 0 is False, so the positivity check alone let them through as dead thresholds (count >= nan is always False) — the same silent-inert-trigger class the range validator was added to close. Guard with math.isfinite first, consistent with the existing non-finite guards on mem0 timeout_seconds and poll_after_seconds. * fix(summarization): merge context_window into inferred profile, require whole message counts - construct the model first, then merge max_input_tokens into the provider-inferred langchain profile: passing profile= to the constructor replaced the whole inferred metadata (tool_calling, structured_output, io capabilities, output limits) with the single key. An explicitly configured profile is still never clobbered. - reject non-integral ContextSize values for type=messages at config load: langchain slices the message list with them, so a float index raised TypeError mid-compaction. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
116 lines
5.7 KiB
Python
116 lines
5.7 KiB
Python
"""Configuration for conversation summarization."""
|
|
|
|
import math
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
|
|
|
ContextSizeType = Literal["fraction", "tokens", "messages"]
|
|
DEFAULT_SKILL_FILE_READ_TOOL_NAMES: tuple[str, ...] = ("read_file", "read", "view", "cat")
|
|
#: Documented default retention policy after summarization. Shared with the
|
|
#: summarization middleware's fraction-keep degradation fallback so the two
|
|
#: cannot drift apart.
|
|
DEFAULT_KEEP: tuple[ContextSizeType, int] = ("messages", 20)
|
|
|
|
|
|
class ContextSize(BaseModel):
|
|
"""Context size specification for trigger or keep parameters."""
|
|
|
|
type: ContextSizeType = Field(description="Type of context size specification")
|
|
value: int | float = Field(description="Value for the context size specification")
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_value_range(self) -> "ContextSize":
|
|
"""Reject value ranges that would silently produce a dead threshold.
|
|
|
|
A fraction written percent-style (``value: 80`` instead of ``0.8``) resolves
|
|
to ``int(max_input_tokens * 80)`` — a threshold the context can never reach,
|
|
so the trigger silently never fires. Non-finite floats (YAML ``.nan`` /
|
|
``.inf`` pass pydantic's float parsing) are dead thresholds the same way
|
|
(``count >= nan`` is always False), and ``nan <= 0`` is False so the
|
|
positivity check alone would not catch them. Failing at config load turns
|
|
these foot-guns into actionable errors, consistent with how fraction
|
|
clauses degrade (loudly) elsewhere. Absolute ``tokens`` values must simply
|
|
be positive to describe a usable threshold, while ``messages`` values must
|
|
additionally be whole numbers: langchain slices the message list with them
|
|
(``messages[-keep:]``), and a float index raises ``TypeError: list indices
|
|
must be integers or slices, not float`` mid-compaction.
|
|
"""
|
|
if not math.isfinite(self.value):
|
|
raise ValueError(f"ContextSize value must be finite (got {self.value!r})")
|
|
if self.type == "fraction":
|
|
if not 0 < self.value <= 1:
|
|
raise ValueError(f"fraction ContextSize value must be in (0, 1] (got {self.value!r}) — write 0.8 for 80%, not 80")
|
|
elif self.type == "messages" and not isinstance(self.value, int):
|
|
raise ValueError(f"messages ContextSize value must be a whole number of messages (got {self.value!r}) — it slices the message list, so a float index would raise TypeError at compaction time")
|
|
elif self.value <= 0:
|
|
raise ValueError(f"{self.type} ContextSize value must be positive (got {self.value!r})")
|
|
return self
|
|
|
|
def to_tuple(self) -> tuple[ContextSizeType, int | float]:
|
|
"""Convert to tuple format expected by SummarizationMiddleware."""
|
|
return (self.type, self.value)
|
|
|
|
|
|
class SummarizationConfig(BaseModel):
|
|
"""Configuration for automatic conversation summarization."""
|
|
|
|
enabled: bool = Field(
|
|
default=False,
|
|
description="Whether to enable automatic conversation summarization",
|
|
)
|
|
model_name: str | None = Field(
|
|
default=None,
|
|
description="Model name to use for summarization. None = summarize with the model the run "
|
|
"actually executes with (the lead run's model, a subagent's own model, or a thread's "
|
|
"custom-agent model), not config.models[0]. When set, that model generates and the run's "
|
|
"own model is used as a fallback if the configured summary provider fails.",
|
|
)
|
|
trigger: ContextSize | list[ContextSize] | None = Field(
|
|
default=None,
|
|
description="One or more thresholds that trigger summarization. When any threshold is met, summarization runs. "
|
|
"Examples: {'type': 'messages', 'value': 50} triggers at 50 messages, "
|
|
"{'type': 'tokens', 'value': 4000} triggers at 4000 tokens, "
|
|
"{'type': 'fraction', 'value': 0.8} triggers at 80% of model's max input tokens",
|
|
)
|
|
keep: ContextSize = Field(
|
|
default_factory=lambda: ContextSize(type=DEFAULT_KEEP[0], value=DEFAULT_KEEP[1]),
|
|
description="Context retention policy after summarization. Specifies how much history to preserve. "
|
|
"Examples: {'type': 'messages', 'value': 20} keeps 20 messages, "
|
|
"{'type': 'tokens', 'value': 3000} keeps 3000 tokens, "
|
|
"{'type': 'fraction', 'value': 0.3} keeps 30% of model's max input tokens",
|
|
)
|
|
trim_tokens_to_summarize: int | None = Field(
|
|
default=4000,
|
|
description="Maximum tokens to keep when preparing messages for summarization. Pass null to skip trimming.",
|
|
)
|
|
summary_prompt: str | None = Field(
|
|
default=None,
|
|
description="Custom prompt template for generating summaries. If not provided, uses the default LangChain prompt.",
|
|
)
|
|
skill_file_read_tool_names: list[str] = Field(
|
|
default_factory=lambda: list(DEFAULT_SKILL_FILE_READ_TOOL_NAMES),
|
|
description="Tool names treated as skill-file reads when capturing loaded skills into the durable skill_context channel.",
|
|
)
|
|
|
|
|
|
# Global configuration instance
|
|
_summarization_config: SummarizationConfig = SummarizationConfig()
|
|
|
|
|
|
def get_summarization_config() -> SummarizationConfig:
|
|
"""Get the current summarization configuration."""
|
|
return _summarization_config
|
|
|
|
|
|
def set_summarization_config(config: SummarizationConfig) -> None:
|
|
"""Set the summarization configuration."""
|
|
global _summarization_config
|
|
_summarization_config = config
|
|
|
|
|
|
def load_summarization_config_from_dict(config_dict: dict) -> None:
|
|
"""Load summarization configuration from a dictionary."""
|
|
global _summarization_config
|
|
_summarization_config = SummarizationConfig(**config_dict)
|