From 0ef405a77fb8c320fe1421e1347bb6bf06b6f27c Mon Sep 17 00:00:00 2001 From: PeaceMaker-best Date: Wed, 30 Sep 2026 10:38:19 +0800 Subject: [PATCH] fix(agent): anchor compaction to runtime model profile (#5566) * fix(agent): use runtime profile for compaction Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> * fix(summarization): honor required-thinking profile anchors Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> --------- Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: Willem Jiang --- CHANGELOG.md | 7 + CHANGELOG_zh.md | 6 + backend/docs/summarization.md | 18 +- .../deerflow/agents/middlewares/AGENTS.md | 2 +- .../middlewares/summarization_middleware.py | 131 ++++++---- .../tests/test_summarization_middleware.py | 228 +++++++++++++++++- 6 files changed, 332 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0673a0517f..eafafa558f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,12 @@ This release closes that milestone with **181 merged pull requests**. ### Fixed +- **agents:** Context-compaction fraction triggers and fraction-based retention + now use the active run model's context profile; a separate + `summarization.model_name` remains generation-only. This prevents mismatched + run and summary windows from compacting too late or too early. The middleware + release identity now records `profile_model` separately from `summary_model`, + intentionally refreshing the identity when either owner changes. ([#5566]) - **events:** Run-scoped reads no longer return 500 on the JSONL backend for a run ID it cannot use as a filename. `GET /api/threads/{thread_id}/runs/{run_id}/events`, `.../messages`, and @@ -6125,6 +6131,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5562]: https://github.com/bytedance/deer-flow/pull/5562 [#5563]: https://github.com/bytedance/deer-flow/pull/5563 [#5564]: https://github.com/bytedance/deer-flow/pull/5564 +[#5566]: https://github.com/bytedance/deer-flow/pull/5566 [#5567]: https://github.com/bytedance/deer-flow/pull/5567 [#5569]: https://github.com/bytedance/deer-flow/pull/5569 [#5570]: https://github.com/bytedance/deer-flow/pull/5570 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index bc0c552602..029b1ef0f0 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -332,6 +332,11 @@ ### 修复 +- **智能体:** 上下文压缩的 fraction 触发器与 fraction 保留量现在使用当前运行 + 模型的上下文 profile;单独配置的 `summarization.model_name` 只负责生成摘要。 + 这避免运行模型与摘要模型的窗口不一致时压缩过晚或过早。中间件发布身份现在 + 分别记录 `profile_model` 与 `summary_model`,任一归属变化都会有意刷新该身份。 + ([#5566]) - **事件:** 在 JSONL 后端上,按 run 读取时,若 run ID 无法用作文件名,不再返回 500。`GET /api/threads/{thread_id}/runs/{run_id}/events`、`.../messages` 与 `.../workspace-changes` 会把 URL 中的 run ID 原样传给事件存储;在 @@ -5171,6 +5176,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5562]: https://github.com/bytedance/deer-flow/pull/5562 [#5563]: https://github.com/bytedance/deer-flow/pull/5563 [#5564]: https://github.com/bytedance/deer-flow/pull/5564 +[#5566]: https://github.com/bytedance/deer-flow/pull/5566 [#5567]: https://github.com/bytedance/deer-flow/pull/5567 [#5569]: https://github.com/bytedance/deer-flow/pull/5569 [#5570]: https://github.com/bytedance/deer-flow/pull/5570 diff --git a/backend/docs/summarization.md b/backend/docs/summarization.md index 6e45e6fffe..39a50140f4 100644 --- a/backend/docs/summarization.md +++ b/backend/docs/summarization.md @@ -109,17 +109,17 @@ summarization: value: 0.8 # 80% of max input tokens ``` - The percentage resolves from the **summary model's** declared `context_window` - — the anchor that generates summaries: `summarization.model_name` when set, - otherwise the run's own model. Declare `context_window` on that models entry - in `config.yaml`. Third-party OpenAI-compatible models carry no built-in + The percentage resolves from the **active run model's** declared + `context_window`: the lead model selected for this run, the subagent's own + model, or the model resolved for manual `/compact`. A separately configured + `summarization.model_name` generates the replacement summary but never sizes + the run model's context budget. Declare `context_window` on the run model's + entry in `config.yaml`. Third-party OpenAI-compatible models carry no built-in capacity profile, so without a declared `context_window` the fraction clause is dropped with a warning at agent build — any remaining absolute clauses - (`tokens` / `messages`) keep working. Caveat: when a separate summary model - is configured, its window sizes the threshold — a 64k run model paired with - a 128k-window summary model resolves `fraction: 0.8` to ~102k tokens and - auto-summarization cannot fire before the run model overflows; in that setup - prefer absolute `tokens` thresholds sized for the run model. + (`tokens` / `messages`) keep working. Fraction-based `keep` uses the same run + model profile and falls back to the documented message-count retention when + that profile is unavailable. **Multiple Triggers:** ```yaml diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 333bb0c00c..aa1817688f 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -106,7 +106,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../ 18. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, injects the `SKILL.md` body as hidden current-turn context, and records a `middleware:skill_activation` audit event 19. **SkillToolPolicyMiddleware** - Applies `allowed-tools` only after real activation; passive enabled skills and a custom agent's configured skill allowlist do not clamp the lead toolset. A run-scoped slash activation is authoritative and suppresses `skill_context` as a policy source, so reading another skill cannot widen the explicit skill's tools; without slash activation, skills captured after configured `read_file` loads retain the existing union semantics. The middleware filters model-visible schemas and blocks unauthorized execution, resolving canonical paths against the live enabled/agent-allowed registry on every model call, then stores a versioned, JSON-safe, middleware-token-bound decision signed by policy source plus active paths in run context for the resulting tool calls to reuse. The next model call always refreshes it, and malformed, foreign, stale, or unmatched decisions fall back to live resolution. `tool_search` and `describe_skill` remain framework-safe discovery tools under a restrictive policy; they may reveal or promote metadata, but a deferred business tool must still be declared by the active policy before its schema or execution can survive the policy middleware. The decision's owner token is authorization-sensitive, so its reserved context key is owned by `runtime.secret_context` and included in `REDACTED_CONTEXT_KEYS` for observable and persisted context copies. Registry load failures and a non-empty active set with no authorized skill fail closed to framework-safe tools; an individual stale path is skipped only when at least one valid active skill remains. This is best-effort behavioral scoping rather than a hard security boundary: alternate loads such as `bash cat` are not captured, and bounded autonomous `skill_context` can evict old entries. `task` is not framework-exempt, so a restricted skill cannot delegate around its policy. The middleware must remain immediately after `SkillActivationMiddleware` (which publishes the slash source through `runtime.secret_context`'s public path helpers authenticated by a required token shared only within the assembled middleware chain) and immediately before `DurableContextMiddleware`; assembly and compiled-graph tests pin ordering, token sharing, schema filtering, and execution blocking. 20. **DurableContextMiddleware** - Before compaction, captures task dispatches/result summaries in `ThreadState.delegations` and loaded skill name/path/description references (never bodies) in `ThreadState.skill_context`; projects them into each model request. Static authority rules are injected as a `SystemMessage`; untrusted field values (`summary_text`, delegation results, skill descriptions) are injected separately as a hidden `HumanMessage` data block so compressed history, delegated work, and which skills are active stay visible without being stored as `messages` or promoted to system-role instructions. `build_subagent_runtime_middlewares` also attaches this middleware immediately before subagent summarization so a compacted `summary_text` is projected ahead of a preserved assistant/tool tail instead of leaving strict providers with an assistant-first request. See [tool artifacts](TOOL_ARTIFACTS.md) for projection, retry limits and delegation scope. -21. **SummarizationMiddleware** - *(optional, if enabled)* Compacts near token limits; memory flush follows runtime policy, while manual compaction trusts the checkpoint-bound agent, so opt-out never captures removed turns. It preserves the latest real user request by ID and tagged DynamicContext reminders while allowing stale ID-swap peers into the summary. Moving the cutoff backward can retain old AI/tool turns and make first-turn compaction a no-op. +21. **SummarizationMiddleware** - *(optional)* Compacts near token limits. Fraction trigger/keep profiles belong to the active run model; an explicit summary model only generates. Memory flush/manual compaction follow runtime/checkpoint agent policy. Preserve the latest user request and DynamicContext reminders; a backward cutoff may retain old AI/tool turns or make first-turn compaction a no-op. 22. **TodoListMiddleware** - *(optional, if `is_plan_mode`)* Task tracking with `write_todos`. Skips reminders on `model_length_termination` so capped turns end cleanly. 23. **TokenUsageMiddleware** - *(optional, if `token_usage.enabled`)* Records token usage metrics; subagent usage is read from terminal `ToolMessage.additional_kwargs` in the current run and merged back into the dispatching AIMessage by message position. The same state update marks the ToolMessage with `subagent_token_usage_attributed=true`, so checkpoint replay or middleware re-entry cannot add the cumulative snapshot twice; missing/malformed usage or a result with no matching dispatch remains unmarked and retryable. 24. **TitleMiddleware** - Auto-generates the thread title after the first complete exchange and normalizes structured message content before prompting the title model. If a first-turn run is interrupted before this middleware can write a title, `runtime/runs/worker.py` keeps the run in a finalizing state, persists a local fallback title from the latest checkpoint or original run input, and then syncs it to `threads_meta.display_name`. Replacement runs admitted by `multitask_strategy="interrupt"` / `"rollback"` wait for older same-thread finalization before entering the graph; the interrupted run only skips the fallback title write once a later run has started and may have advanced the checkpoint. diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index e5f29b4cd5..e3ffa8251e 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -26,6 +26,7 @@ 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 +from deerflow.models.reasoning import resolve_reasoning_contract logger = logging.getLogger(__name__) _SUMMARY_TRIGGER_MESSAGE_NAME = "summary" @@ -117,6 +118,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): configured_model_name: str | None = None, run_model_name: str | None = None, anchor_model_name: str | None = _UNSET, # type: ignore[assignment] + prebuilt_models: Mapping[str | None, Any | None] | None = None, extensions=None, **kwargs, ) -> None: @@ -142,13 +144,16 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # 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. + # 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. + # ``self.model`` is the pre-built *profile anchor*: it drives the parent's + # token counter / profile inspection and is reused verbatim by generation + # when a candidate matches its name. The factory anchors this to the active + # run model even when a separate summary-generation model is configured. + # Direct construction (tests) keeps the legacy inference from the supplied + # names so the passed ``model`` remains primary unless the caller opts in to + # an explicit anchor name. if anchor_model_name is _UNSET: self._anchor_model_name = configured_model_name or self._default_model_name() else: @@ -158,10 +163,11 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): 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] = {} + # Nostream generation models are either prebuilt by the factory or built + # lazily by name. An eagerly seeded ``None`` pins the run-model fallback for + # this middleware instance; unlike the legacy path, it does not allow one + # lazy rebuild of the explicit summary model at the first compaction. + self._model_cache: dict[str | None, Any | None] = {name: None if model is None else self._tag_nostream(model) for name, model in (prebuilt_models or {}).items()} def release_policy_parameters(self) -> dict[str, object]: """Return the effective compaction policy used for release identity.""" @@ -178,11 +184,11 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): "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, + # Model objects are not JSON-serialisable. Keep generation and context + # budget ownership distinct in the identity: changing either can alter + # when history is discarded or which model produces its replacement. + "summary_model": self._configured_summary_model_name or self._run_model_name or self._anchor_model_name, + "profile_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, } @@ -242,7 +248,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): try: model = create_chat_model( name=name, - thinking_enabled=False, + thinking_enabled=_summary_model_thinking_enabled(name, self._app_config), app_config=self._app_config, attach_tracing=False, ) @@ -834,16 +840,34 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): 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). +def _summary_model_thinking_enabled(name: str | None, app_config: Any) -> bool: + """Return a construction mode accepted by the model's reasoning contract. + + Summary generation prefers non-thinking mode, but a required-thinking model + cannot honor that preference. In particular, a declared + ``on_disable_request: reject`` contract would reject construction before its + profile could anchor fraction-based compaction. Resolve ``None`` the same way + as :func:`create_chat_model` and enable thinking only when the selected model + requires it; optional and legacy models keep the existing disabled default. + """ + resolved_app_config = app_config or get_app_config() + resolved_name = name + if resolved_name is None: + models = getattr(resolved_app_config, "models", None) + resolved_name = models[0].name if models else None + model_config = resolved_app_config.get_model_config(resolved_name) if resolved_name is not None else None + return model_config is not None and resolve_reasoning_contract(model_config).thinking_required + + +def _build_summary_model(candidate_names: list[str | None], app_config: Any) -> tuple[Any | None, str | None]: + """Build the first constructible summarization model candidate (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. + middleware wraps a nostream copy). The caller may use it as the parent's + token-counter/profile anchor or seed it as an independent generation model. A + per-name construction failure is swallowed and the next candidate tried; 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: @@ -851,9 +875,14 @@ def _build_summary_anchor(candidate_names: list[str | None], app_config: Any) -> continue tried.add(name) try: - model = create_chat_model(name=name, thinking_enabled=False, app_config=app_config, attach_tracing=False) + model = create_chat_model( + name=name, + thinking_enabled=_summary_model_thinking_enabled(name, app_config), + app_config=app_config, + attach_tracing=False, + ) except Exception: - logger.exception("Failed to build summary anchor model %r; trying the next candidate", name) + logger.exception("Failed to build summarization model %r; trying the next candidate", name) continue return model.with_config(tags=["middleware:summarize"]), name return None, None @@ -874,11 +903,11 @@ def _anchor_profile_max_input_tokens(model: Any) -> int | None: def _drop_unusable_fraction_clauses( - anchor_model: Any, + profile_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). + """Drop fraction clauses the run-profile model cannot resolve. LangChain's parent constructor raises ``ValueError`` for a fraction clause when ``profile["max_input_tokens"]`` is unavailable, which on a third-party @@ -898,14 +927,14 @@ def _drop_unusable_fraction_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: + if _anchor_profile_max_input_tokens(profile_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 summarization fraction trigger clause(s) %s: the run model exposes no context window to resolve them against. Declare `context_window` on the run model in config.yaml, or use absolute token/message thresholds.", dropped, ) new_keep = keep @@ -914,7 +943,7 @@ def _drop_unusable_fraction_clauses( # 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.", + "Summarization keep %s is unusable without the run model's context window; falling back to %s. Declare `context_window` on the run model in config.yaml to use fraction retention.", keep, new_keep, ) @@ -974,24 +1003,33 @@ def create_summarization_middleware( 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], + # The parent middleware uses one model for token counting and profile lookup. + # That owner must be the model receiving the next lead/subagent request, not an + # independently configured summary generator: a fraction of the latter's window + # can be dangerously late or unnecessarily early for the active run model. + profile_name = run_model_name or default_name + anchor_model, anchor_name = _build_summary_model( + [profile_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") + logger.warning("Summarization is enabled but no run-profile model could be constructed; compaction is unavailable for this build") return None + # A distinct explicit summary model owns generation only. Build it eagerly so + # invalid provider/configuration errors remain visible at agent construction as + # before, but seed the guarded cache rather than exposing its profile to LangChain. + # Cache ``None`` on failure so generation falls back to the run model for this + # middleware instance. This deliberately removes the legacy one-time lazy rebuild + # at first compaction, so a transient constructor failure during agent build does + # not recover until a new middleware instance is built. + prebuilt_models: dict[str | None, Any | None] = {} + if config.model_name is not None and config.model_name != anchor_name: + summary_model, _ = _build_summary_model([config.model_name], resolved_app_config) + prebuilt_models[config.model_name] = summary_model + # LangChain's SummarizationMiddleware raises ValueError at construction when a - # fraction clause is configured but the anchor exposes no usable profile + # fraction clause is configured but the active run model 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). @@ -1006,10 +1044,10 @@ def create_summarization_middleware( 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 " + "Every configured summarization trigger is fraction-based but run 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.", + "auto-compaction will not fire for this build. Declare `context_window` on the run model to enable " + "fraction triggers. Manual compaction (/compact) remains available.", anchor_name, ) kwargs: dict[str, Any] = { @@ -1037,5 +1075,6 @@ def create_summarization_middleware( configured_model_name=config.model_name, run_model_name=run_model_name, anchor_model_name=anchor_name, + prebuilt_models=prebuilt_models, extensions=extensions, ) diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py index cb75cd8007..5212dbafe8 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -18,7 +18,7 @@ from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummari from deerflow.agents.thread_state import ThreadState from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig -from deerflow.config.model_config import ModelConfig +from deerflow.config.model_config import ModelConfig, ReasoningCapabilities from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.summarization_config import ContextSize, SummarizationConfig @@ -1206,14 +1206,186 @@ def test_factory_null_case_anchor_is_run_model_not_models0(monkeypatch): assert result.summary_text == "from-run-model" +@pytest.mark.parametrize( + ("run_window", "summary_window", "current_tokens", "expected"), + [ + pytest.param(1_000, 100_000, 900, True, id="small-run-window-triggers"), + pytest.param(100_000, 1_000, 900, False, id="large-run-window-does-not-trigger"), + ], +) +def test_factory_fraction_trigger_uses_run_model_profile_with_explicit_summary( + monkeypatch, + run_window, + summary_window, + current_tokens, + expected, +): + """An explicit summary model owns generation, not context-budget policy. + + When the summary and active run models have different context windows, the + fraction trigger must follow the model that receives the next agent request. + Otherwise compaction can happen too late for a small run model or discard useful + history prematurely for a large one. + """ + run_model = MagicMock() + run_model.profile = {"max_input_tokens": run_window} + run_model.with_config.return_value = run_model + run_model.invoke.return_value = SimpleNamespace(text="from-run-model") + summary_model = MagicMock() + summary_model.profile = {"max_input_tokens": summary_window} + summary_model.with_config.return_value = summary_model + summary_model.invoke.return_value = SimpleNamespace(text="from-summary-model") + models = {"run-model": run_model, "summary-model": summary_model} + + monkeypatch.setattr( + "deerflow.agents.middlewares.summarization_middleware.create_chat_model", + lambda *, name=None, **_kwargs: models[name], + ) + cfg = _factory_app_config( + ("run-model", "summary-model"), + summary_model_name="summary-model", + summarization_kwargs={"trigger": ContextSize(type="fraction", value=0.8)}, + ) + + middleware = create_summarization_middleware( + app_config=cfg, + run_model_name="run-model", + keep=("messages", 2), + ) + + assert middleware is not None + assert middleware.model is run_model + assert middleware._get_profile_limits() == run_window + assert middleware._should_summarize([], current_tokens) is expected + + # Profile ownership must not change the generation model. + result = middleware.compact_state( + {"messages": _messages()}, + _runtime(), + force=True, + ) + assert result is not None + assert result.summary_text == "from-summary-model" + summary_model.invoke.assert_called_once() + run_model.invoke.assert_not_called() + + # Release identity records the independently configurable owners. + policy = middleware.release_policy_parameters() + assert policy["summary_model"] == "summary-model" + assert policy["profile_model"] == "run-model" + + +def test_factory_required_thinking_run_model_anchors_explicit_summary(monkeypatch: pytest.MonkeyPatch) -> None: + """A required-thinking run model remains the profile owner when a distinct + explicit model generates summaries. + + ``on_disable_request: reject`` makes ``thinking_enabled=False`` an invalid + constructor request, so the anchor must use the model's accepted mode rather + than falling through to the default model or disabling compaction. + """ + run_model = MagicMock() + run_model.profile = {"max_input_tokens": 4_096} + run_model.with_config.return_value = run_model + summary_model = MagicMock() + summary_model.profile = {"max_input_tokens": 65_536} + summary_model.with_config.return_value = summary_model + summary_model.invoke.return_value = SimpleNamespace(text="from-summary-model") + models = {"run-model": run_model, "summary-model": summary_model} + calls: list[tuple[str | None, bool]] = [] + + def _factory(*, name=None, thinking_enabled=False, **_kwargs): + calls.append((name, thinking_enabled)) + if name == "run-model" and not thinking_enabled: + raise ValueError("model requires thinking, but the request asked for it to be disabled") + return models[name] + + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _factory) + cfg = _factory_app_config( + ("run-model", "summary-model"), + summary_model_name="summary-model", + summarization_kwargs={"trigger": ContextSize(type="fraction", value=0.8)}, + ) + cfg.get_model_config("run-model").reasoning = ReasoningCapabilities( + thinking="required", + on_disable_request="reject", + ) + + middleware = create_summarization_middleware( + app_config=cfg, + run_model_name="run-model", + keep=("messages", 2), + ) + + assert middleware is not None + assert middleware.model is run_model + assert middleware._get_profile_limits() == 4_096 + assert calls == [("run-model", True), ("summary-model", False)] + + result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True) + + assert result is not None + assert result.summary_text == "from-summary-model" + summary_model.invoke.assert_called_once() + run_model.invoke.assert_not_called() + + +def test_direct_construction_without_app_config_keeps_run_model_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Direct middleware construction resolves the global config before building + the run-model fallback. + + This path has no injected ``app_config``. A failed explicit summary model must + still reach the configured run model, including when that model requires + thinking and rejects disable requests. + """ + cfg = _factory_app_config(("run-model",)) + cfg.get_model_config("run-model").reasoning = ReasoningCapabilities( + thinking="required", + on_disable_request="reject", + ) + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.get_app_config", lambda: cfg) + + run_model = MagicMock() + run_model.with_config.return_value = run_model + run_model.invoke.return_value = SimpleNamespace(text="from-run-model") + calls: list[tuple[str | None, bool, object]] = [] + + def _factory(*, name=None, thinking_enabled=False, app_config=None, **_kwargs): + calls.append((name, thinking_enabled, app_config)) + if not thinking_enabled: + raise ValueError("model requires thinking, but the request asked for it to be disabled") + return run_model + + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", _factory) + + explicit_summary_model = MagicMock() + explicit_summary_model.with_config.return_value = explicit_summary_model + explicit_summary_model.invoke.side_effect = RuntimeError("summary provider down") + middleware = DeerFlowSummarizationMiddleware( + model=explicit_summary_model, + trigger=("messages", 4), + keep=("messages", 2), + token_counter=len, + configured_model_name="summary-model", + run_model_name="run-model", + ) + + result = middleware.before_model({"messages": _messages()}, _runtime()) + + assert result is not None + assert result["summary_text"] == "from-run-model" + assert calls == [("run-model", True, None)] + + def test_factory_configured_constructor_failure_falls_back_to_run_model(monkeypatch): """A configured summary model whose *constructor* raises must not break middleware - creation or skip the healthy run model. The anchor falls through the broken - constructor to the run model, and generation summarizes with it (the reviewer's - ``configured='broken-summary'`` reproduction).""" + creation or skip the healthy run model. The run model remains the profile anchor, + the failed generation model is cached as unavailable, and generation falls back to + the run model (the reviewer's ``configured='broken-summary'`` reproduction).""" + attempted: list = [] built: list = [] def _factory(*, name=None, thinking_enabled=False, app_config=None, attach_tracing=True, **kwargs): + attempted.append(name) if name == "broken-summary": raise RuntimeError("cannot construct summary provider") model = MagicMock() @@ -1233,9 +1405,11 @@ def test_factory_configured_constructor_failure_falls_back_to_run_model(monkeypa assert middleware is not None # a broken configured constructor did not break creation assert "run-model" in built # the healthy run model was constructed as the anchor + assert attempted == ["run-model", "broken-summary"] result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True) assert result is not None assert result.summary_text == "from-run-model" + assert attempted == ["run-model", "broken-summary"] # failed config is not rebuilt def _profileless_anchor_stub() -> MagicMock: @@ -1248,6 +1422,52 @@ def _profileless_anchor_stub() -> MagicMock: return model +def test_factory_fraction_trigger_degrades_against_profileless_run_model_not_profiled_summary( + monkeypatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A summary model's profile must not make an unsafe run-model trigger valid.""" + run_model = _profileless_anchor_stub() + summary_model = _StaticChatModel( + text="from-summary-model", + profile={"max_input_tokens": 65_536}, + ) + models = {"run-model": run_model, "summary-model": summary_model} + monkeypatch.setattr( + "deerflow.agents.middlewares.summarization_middleware.create_chat_model", + lambda *, name=None, **_kwargs: models[name], + ) + cfg = _factory_app_config( + ("run-model", "summary-model"), + summary_model_name="summary-model", + summarization_kwargs={"trigger": ContextSize(type="fraction", value=0.8)}, + ) + + with caplog.at_level( + "WARNING", + logger="deerflow.agents.middlewares.summarization_middleware", + ): + middleware = create_summarization_middleware( + app_config=cfg, + run_model_name="run-model", + keep=("messages", 2), + ) + + assert middleware is not None + assert middleware.model is run_model + assert middleware.trigger is None + assert "run model" in caplog.text + + # Manual compaction remains available and still uses the explicit summary model. + result = middleware.compact_state( + {"messages": _messages()}, + _runtime(), + force=True, + ) + assert result is not None + assert result.summary_text == "from-summary-model" + + def test_factory_fraction_only_trigger_degrades_to_manual_compaction_only(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: """#3103 mechanism (b): a fraction trigger whose anchor exposes no usable profile must not raise out of ``create_summarization_middleware`` (which used to fail the