diff --git a/backend/docs/summarization.md b/backend/docs/summarization.md index e478f07a9..ccd8c0779 100644 --- a/backend/docs/summarization.md +++ b/backend/docs/summarization.md @@ -95,6 +95,18 @@ 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 + 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. + **Multiple Triggers:** ```yaml trigger: diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index c57be988b..7d4067f4e 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -4,6 +4,7 @@ from __future__ import annotations import html import logging +from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Protocol, override, runtime_checkable @@ -18,6 +19,7 @@ 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.config.summarization_config import DEFAULT_KEEP from deerflow.extensions.notify import notify_context_compacted from deerflow.models import create_chat_model from deerflow.utils.messages import is_real_user_message @@ -799,6 +801,73 @@ def _build_summary_anchor(candidate_names: list[str | None], app_config: Any) -> 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, @@ -858,10 +927,32 @@ def create_summarization_middleware( 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 or config.keep.to_tuple(), + "keep": keep_tuple, } if config.trim_tokens_to_summarize is not None: kwargs["trim_tokens_to_summarize"] = config.trim_tokens_to_summarize diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index 0d212bdd1..36ba5ed90 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -37,9 +37,11 @@ class ModelConfig(BaseModel): gt=0, description=( "Positive total context window size in tokens (prompt + completion). Used to compute the real-time " - "context usage percentage displayed in the chat UI. Distinct from `max_tokens`, which is the " - "per-call output cap passed to the provider. Leave unset if unknown; the UI will hide the " - "percentage." + "context usage percentage displayed in the chat UI, and attached to the model's langchain profile " + "(`max_input_tokens`) so fraction-based summarization triggers can resolve their thresholds for " + "third-party OpenAI-compatible models that carry no built-in profile. Distinct from `max_tokens`, " + "which is the per-call output cap passed to the provider. Leave unset if unknown; the UI will hide " + "the percentage and fraction summarization clauses will degrade with a warning." ), ) stream_chunk_timeout: float | None = Field( diff --git a/backend/packages/harness/deerflow/config/summarization_config.py b/backend/packages/harness/deerflow/config/summarization_config.py index ac130ee29..965cd2ac1 100644 --- a/backend/packages/harness/deerflow/config/summarization_config.py +++ b/backend/packages/harness/deerflow/config/summarization_config.py @@ -1,11 +1,16 @@ """Configuration for conversation summarization.""" +import math from typing import Literal -from pydantic import BaseModel, Field +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): @@ -14,6 +19,34 @@ class ContextSize(BaseModel): 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) @@ -41,7 +74,7 @@ class SummarizationConfig(BaseModel): "{'type': 'fraction', 'value': 0.8} triggers at 80% of model's max input tokens", ) keep: ContextSize = Field( - default_factory=lambda: ContextSize(type="messages", value=20), + 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, " diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index 21f541a3d..8ebadd3ad 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -308,10 +308,29 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * if "stream_usage" in getattr(model_class, "model_fields", {}): model_settings_from_config["stream_usage"] = True + # Translate the declared context window into the langchain profile so + # profile-dependent features (e.g. SummarizationMiddleware fraction triggers, + # which resolve thresholds from profile["max_input_tokens"]) work for + # third-party OpenAI-compatible models whose SDK ships no profile of its + # own (#3103). ``profile`` is a metadata-only BaseChatModel field + # (exclude=True) and never reaches the provider request payload. An + # explicit profile from a caller or model_overrides is never clobbered. + translate_context_window = bool(model_config.context_window) and "profile" not in kwargs and "profile" not in model_settings_from_config + _warn_unknown_model_settings(model_class, name, model_settings_from_config) model_instance = model_class(**kwargs, **model_settings_from_config) + if translate_context_window: + # Applied *after* construction and merged into the provider's inferred + # profile: passing ``profile`` to the constructor would REPLACE the whole + # inferred metadata (tool_calling, structured_output, io capabilities, + # output limits) with just this one key. The declared window wins on + # max_input_tokens itself — the operator set it precisely because the + # inferred (or absent) value doesn't match their gateway. + inferred_profile = getattr(model_instance, "profile", None) + model_instance.profile = {**(inferred_profile or {}), "max_input_tokens": model_config.context_window} + if attach_tracing: callbacks = build_tracing_callbacks() if callbacks: diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 20675464d..35952f6f7 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -155,6 +155,85 @@ def test_context_window_never_reaches_the_provider_client(monkeypatch): assert "context_window" not in FakeChatModel.captured_kwargs +def test_context_window_attaches_langchain_profile(monkeypatch): + """The declared context window is translated into the langchain ``profile`` + so profile-dependent features (e.g. SummarizationMiddleware fraction triggers, + which resolve thresholds from ``profile["max_input_tokens"]``) work for + third-party OpenAI-compatible models whose SDK ships no profile of its own + (#3103: `trigger: fraction` used to crash the whole agent build).""" + model = _make_model("windowed") + model.context_window = 200_000 + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + created = factory_module.create_chat_model(name="windowed") + + assert "profile" not in FakeChatModel.captured_kwargs + assert created.profile == {"max_input_tokens": 200_000} + + +def test_context_window_merges_into_inferred_profile(monkeypatch): + """A provider-inferred profile must survive the context_window translation: + passing ``profile`` to the constructor would REPLACE the whole inferred + metadata (tool_calling, structured_output, output limits) with the single + key, changing LangChain feature selection. The declared window wins on + ``max_input_tokens`` itself — the operator declared it because the inferred + value doesn't match their gateway.""" + inferred = { + "tool_calling": True, + "structured_output": True, + "max_output_tokens": 16_384, + "max_input_tokens": 999_999, + } + + class _InferredProfileChatModel(FakeChatModel): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.profile = dict(inferred) + + model = _make_model("windowed") + model.context_window = 200_000 + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg, model_class=_InferredProfileChatModel) + + created = factory_module.create_chat_model(name="windowed") + + assert created.profile == { + "tool_calling": True, + "structured_output": True, + "max_output_tokens": 16_384, + "max_input_tokens": 200_000, + } + + +def test_unset_context_window_leaves_profile_unset(monkeypatch): + """No declared window -> no invented profile; fraction triggers degrade + with a warning instead of silently assuming a capacity.""" + cfg = _make_app_config([_make_model("opaque")]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + created = factory_module.create_chat_model(name="opaque") + + assert "profile" not in FakeChatModel.captured_kwargs + assert created.profile is None + + +def test_context_window_does_not_clobber_explicit_profile(monkeypatch): + """A caller-supplied profile wins over the context_window translation.""" + model = _make_model("windowed") + model.context_window = 200_000 + cfg = _make_app_config([model]) + _patch_factory(monkeypatch, cfg) + + FakeChatModel.captured_kwargs = {} + created = factory_module.create_chat_model(name="windowed", profile={"max_input_tokens": 100}) + + assert FakeChatModel.captured_kwargs.get("profile") == {"max_input_tokens": 100} + assert created.profile == {"max_input_tokens": 100} + + def test_appends_all_tracing_callbacks(monkeypatch): cfg = _make_app_config([_make_model("alpha")]) _patch_factory(monkeypatch, cfg) diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py index eb0306da4..856f39634 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -10,13 +10,17 @@ from langchain_core.language_models import BaseChatModel from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult from langgraph.constants import TAG_NOSTREAM +from pydantic import ValidationError from deerflow.agents.memory.summarization_hook import memory_flush_hook from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY, DynamicContextMiddleware, is_dynamic_context_reminder from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, SummarizationEvent, SummaryGenerationError, create_summarization_middleware from deerflow.agents.thread_state import ThreadState +from deerflow.config.app_config import AppConfig from deerflow.config.memory_config import MemoryConfig -from deerflow.config.summarization_config import SummarizationConfig +from deerflow.config.model_config import ModelConfig +from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.summarization_config import ContextSize, SummarizationConfig def _messages() -> list: @@ -1077,11 +1081,11 @@ def test_before_summarization_hook_not_fired_when_summary_fails(monkeypatch: pyt assert captured == [] -def _factory_app_config(model_names, *, summary_model_name=None): +def _factory_app_config(model_names, *, summary_model_name=None, summarization_kwargs=None): """AppConfig-shaped stub for the factory: summarization enabled + ordered models.""" models = [SimpleNamespace(name=name) for name in model_names] return SimpleNamespace( - summarization=SummarizationConfig(enabled=True, model_name=summary_model_name), + summarization=SummarizationConfig(enabled=True, model_name=summary_model_name, **(summarization_kwargs or {})), memory=MemoryConfig(enabled=False), models=models, get_model_config=lambda name: next((model for model in models if model.name == name), None), @@ -1141,6 +1145,184 @@ def test_factory_configured_constructor_failure_falls_back_to_run_model(monkeypa assert result.summary_text == "from-run-model" +def _profileless_anchor_stub() -> MagicMock: + """Anchor stub whose ``.profile`` is unusable (not a Mapping), like any + third-party OpenAI-compatible client constructed without a profile.""" + model = MagicMock() + model.with_config.return_value = model + model.invoke.return_value = SimpleNamespace(text="summary") + model.ainvoke = AsyncMock(return_value=SimpleNamespace(text="summary")) + return 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 + whole agent build). With no absolute clause to keep, the middleware still + constructs as never-firing so manual compaction (/compact, force=True, never + consults trigger clauses) keeps working; the warning names the config fix.""" + fake_model = _profileless_anchor_stub() + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kw: fake_model) + cfg = _factory_app_config(("models0",), 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="models0", keep=("messages", 2)) + + assert middleware is not None # degraded, not raised — and not disabled either + assert "context_window" in caplog.text # the warning names the fix + assert "Manual compaction" in caplog.text # and says manual /compact still works + result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True) + assert result is not None # forced compaction never consults trigger clauses + assert result.summary_text == "summary" + + +def test_factory_drops_only_fraction_clauses_and_keeps_absolute_ones(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: + """Mixed [fraction, messages] triggers degrade to the messages clause alone: + construction succeeds and message-count compaction still fires.""" + fake_model = _profileless_anchor_stub() + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kw: fake_model) + cfg = _factory_app_config( + ("models0",), + summarization_kwargs={"trigger": [ContextSize(type="fraction", value=0.8), ContextSize(type="messages", value=3)]}, + ) + + with caplog.at_level("WARNING", logger="deerflow.agents.middlewares.summarization_middleware"): + middleware = create_summarization_middleware(app_config=cfg, run_model_name="models0", keep=("messages", 2)) + + assert middleware is not None # the absolute clause kept the middleware alive + assert "context_window" in caplog.text + result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True) + assert result is not None + assert result.summary_text == "summary" + + +def test_factory_keep_fraction_falls_back_to_messages_default(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: + """A fraction ``keep`` against a profile-less anchor falls back to the + messages default instead of failing construction.""" + fake_model = _profileless_anchor_stub() + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kw: fake_model) + cfg = _factory_app_config( + ("models0",), + summarization_kwargs={"trigger": ContextSize(type="messages", value=3), "keep": ContextSize(type="fraction", value=0.3)}, + ) + + with caplog.at_level("WARNING", logger="deerflow.agents.middlewares.summarization_middleware"): + middleware = create_summarization_middleware(app_config=cfg, run_model_name="models0") + + assert middleware is not None + assert middleware.keep == ("messages", 20) # SummarizationConfig's documented default + + +def test_factory_null_trigger_with_fraction_keep_still_constructs(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: + """``trigger: null`` + fraction ``keep``: the long-standing "enabled but never + auto-triggers" setup must keep constructing — with the keep degraded to the + messages default — rather than disabling compaction. On main this exact config + crashes the agent build (fraction keep needs a profile).""" + fake_model = _profileless_anchor_stub() + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kw: fake_model) + cfg = _factory_app_config(("models0",), summarization_kwargs={"keep": ContextSize(type="fraction", value=0.3)}) + + with caplog.at_level("WARNING", logger="deerflow.agents.middlewares.summarization_middleware"): + middleware = create_summarization_middleware(app_config=cfg, run_model_name="models0") + + assert middleware is not None # never-firing but constructed, same as any trigger: null setup + assert middleware.keep == ("messages", 20) + assert "context_window" in caplog.text + + +def test_factory_fraction_trigger_survives_when_anchor_has_profile(monkeypatch) -> None: + """With a usable profile on the anchor (the factory attaches one from + ``context_window``), the fraction clause is kept as configured — construction + succeeding is itself the regression pin (#3103: it used to raise).""" + model = _StaticChatModel(profile={"max_input_tokens": 65536}) + assert model.profile == {"max_input_tokens": 65536} + monkeypatch.setattr("deerflow.agents.middlewares.summarization_middleware.create_chat_model", lambda **kw: model) + cfg = _factory_app_config(("models0",), summarization_kwargs={"trigger": ContextSize(type="fraction", value=0.8)}) + + middleware = create_summarization_middleware(app_config=cfg, run_model_name="models0", keep=("messages", 2)) + + assert middleware is not None + result = middleware.compact_state({"messages": _messages()}, _runtime(), force=True) + assert result is not None + assert result.summary_text == "ok" + + +def test_context_size_rejects_percent_style_fraction_value() -> None: + """A fraction written percent-style (80 instead of 0.8) would resolve to + int(capacity * 80) — a threshold the context can never reach, so the trigger + silently never fires. Config load is the failure point, not a dead trigger.""" + with pytest.raises(ValidationError, match="fraction ContextSize value must be in"): + ContextSize(type="fraction", value=80) + + +def test_context_size_rejects_non_positive_absolute_values() -> None: + with pytest.raises(ValidationError, match="tokens ContextSize value must be positive"): + ContextSize(type="tokens", value=0) + with pytest.raises(ValidationError, match="messages ContextSize value must be positive"): + ContextSize(type="messages", value=-5) + + +def test_context_size_rejects_non_finite_values() -> None: + """YAML .nan / .inf pass pydantic's float parsing but never describe a usable + threshold (``count >= nan`` is always False, and ``nan <= 0`` is False so the + positivity check alone would not catch them) — they must fail at config load.""" + with pytest.raises(ValidationError, match="ContextSize value must be finite"): + ContextSize(type="tokens", value=float("nan")) + with pytest.raises(ValidationError, match="ContextSize value must be finite"): + ContextSize(type="fraction", value=float("inf")) + + +def test_context_size_rejects_fractional_message_counts() -> None: + """``messages`` values slice the message list at compaction time + (``messages[-keep:]``) — a float raises ``TypeError: list indices must be + integers or slices, not float`` mid-compaction, so config load must reject + it first. Even an integral float (``20.0``) is a float index to a slice.""" + with pytest.raises(ValidationError, match="messages ContextSize value must be a whole number"): + ContextSize(type="messages", value=1.5) + with pytest.raises(ValidationError, match="messages ContextSize value must be a whole number"): + ContextSize(type="messages", value=20.0) + + +def test_context_size_accepts_boundary_values() -> None: + assert ContextSize(type="fraction", value=1).to_tuple() == ("fraction", 1) + assert ContextSize(type="fraction", value=0.8).to_tuple() == ("fraction", 0.8) + assert ContextSize(type="messages", value=20).to_tuple() == ("messages", 20) + + +def test_factory_wiring_context_window_to_fraction_trigger_end_to_end() -> None: + """Pins the two halves of the fix together without monkeypatching + ``create_chat_model``: a ``context_window``-declared model gets a profile from + the real model factory, the fraction clause survives + ``_drop_unusable_fraction_clauses``, and the middleware constructs with the + trigger intact. The middleware-side tests stub the factory and the + factory-side tests stop at captured kwargs — this is the automated pin of the + shipped contract (the manual E2E in the PR body was the only wiring proof).""" + model = ModelConfig( + name="gw-64k", + display_name="gw-64k", + description=None, + use="langchain_openai:ChatOpenAI", + model="some-64k-model", + base_url="https://third-party-gateway.example.com/v1", + api_key="sk-test", + supports_thinking=False, + supports_reasoning_effort=False, + supports_vision=False, + context_window=65_536, + ) + cfg = AppConfig( + models=[model], + sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"), + summarization=SummarizationConfig(enabled=True, trigger=ContextSize(type="fraction", value=0.8)), + memory=MemoryConfig(enabled=False), + ) + + middleware = create_summarization_middleware(app_config=cfg, run_model_name="gw-64k") + + assert middleware is not None # on main this raises: no profile without the factory translation + assert middleware.model.profile == {"max_input_tokens": 65_536} + + class _RaisingTextResponse: """A provider response whose ``.text`` accessor fails — a realistic malformed result.""" diff --git a/config.example.yaml b/config.example.yaml index e2c098750..168d06c05 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -102,8 +102,12 @@ max_recursion_limit: 1000 # Two token fields look similar but mean different things: # - `max_tokens` is the per-call OUTPUT cap passed to the provider. # - `context_window` is a positive integer for the total context capacity -# (prompt + completion) and drives the real-time "% context used" indicator -# in the chat UI. +# (prompt + completion). It drives the real-time "% context used" indicator +# in the chat UI and feeds the model's langchain profile, which fraction-based +# summarization triggers (`summarization.trigger: [{type: fraction, ...}]`) +# resolve their thresholds from. Third-party OpenAI-compatible models carry no +# built-in profile, so without `context_window` a fraction trigger degrades +# (dropped with a warning) instead of crashing the agent build. # Leave `context_window` unset if the provider limit is unknown; the percentage # will not render. Verify configured values against the provider's model docs. # @@ -1760,7 +1764,14 @@ summarization: # Uncomment to also trigger when message count reaches 50 # - type: messages # value: 50 - # Uncomment to trigger when 80% of model's max input tokens is reached + # Uncomment to trigger when 80% of model's max input tokens is reached. + # The percentage resolves from the SUMMARY model's declared `context_window` + # (summarization.model_name when set, else the run's own model): declare it + # on that models entry — third-party OpenAI-compatible models carry no + # built-in profile. Without it the fraction clause is dropped with a warning + # and any remaining absolute clauses keep working. If a separate summary + # model with a larger window is configured, prefer absolute `tokens` + # thresholds sized for the run model instead. # - type: fraction # value: 0.8