diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 00ad0a40318ec..2baa2731c627c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -303,6 +303,20 @@ _SUMMARY_RATIO = 0.20 # itself a context-pressure source and slows every compaction. _SUMMARY_TOKENS_CEILING = 10_000 +# Aggregate cap on the serialized turn block fed to the summarizer prompt +# (chars). Per-message truncation (_CONTENT_MAX / _TOOL_ARGS_MAX) alone is +# not enough: a compression window with hundreds of already-truncated turns +# can still produce a multi-hundred-KB prompt that blows past slow auxiliary +# backends' context limits or timeouts (Codex Responses fallback paths +# especially). 160K chars ≈ 40K tokens — comfortably inside every supported +# aux model's window while leaving room for the template + previous summary. +# Applied AFTER per-message truncation, with head+tail retention and an +# explicit omitted-middle marker (see _bound_summary_input). This is a +# prompt-side bound only — NEVER add a max_tokens wire cap on the summary +# call (see the no-wire-cap contract test in +# test_compression_small_ctx_threshold_floor.py). +_SUMMARY_INPUT_MAX_CHARS = 160_000 + # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -2440,7 +2454,10 @@ class ContextCompressor(ContextEngine): _CONTENT_TAIL = 1500 # chars kept from the end _TOOL_ARGS_MAX = 1500 # tool call argument chars _TOOL_ARGS_HEAD = 1200 # kept from the start of tool args - _SUMMARY_INPUT_MAX_CHARS = 160_000 # total serialized turns sent to aux summarizer + # Aggregate cap over the whole serialized block, applied AFTER the + # per-message limits above. Alias of the module-level constant (which + # carries the full rationale) so subclasses/tests can override per-class. + _SUMMARY_INPUT_MAX_CHARS = _SUMMARY_INPUT_MAX_CHARS def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str: """Serialize conversation turns into labeled text for the summarizer. @@ -3040,13 +3057,22 @@ Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command out Write only the summary body. Do not include any preamble or prefix.""" if self._previous_summary: - # Iterative update: preserve existing info, add new progress + # Iterative update: preserve existing info, add new progress. + # Bound the previous-summary block with the same aggregate cap as + # the serialized new turns: a normal summary is far below the cap + # (the output side is held to a ~10K-token ceiling), but a + # pathological handoff rehydrated from a persisted session can be + # arbitrarily large — the iterative prompt (previous summary + + # new turns) must stay bounded too. + _bounded_previous_summary = self._bound_summary_input( + self._previous_summary + ) prompt = f"""{_summarizer_preamble} You are updating a context compaction summary. A previous compaction produced the summary below. New conversation turns have occurred since then and need to be incorporated. PREVIOUS SUMMARY: -{self._previous_summary} +{_bounded_previous_summary} NEW TURNS TO INCORPORATE: {content_to_summarize}{_memory_section} diff --git a/contributors/emails/cluster2@Cluster2s-Mac-Studio.local b/contributors/emails/cluster2@Cluster2s-Mac-Studio.local new file mode 100644 index 0000000000000..0a829ee7efaef --- /dev/null +++ b/contributors/emails/cluster2@Cluster2s-Mac-Studio.local @@ -0,0 +1 @@ +robgfl45 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 5f8b21f805150..926678108fefd 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -4000,3 +4000,79 @@ class TestSummaryPromptBounding: assert "summary input truncated" in prompt assert "FIRST_SENTINEL" in prompt assert "LAST_SENTINEL" in prompt + + def test_small_input_returned_byte_identical(self): + """Inputs at or under the cap must pass through completely untouched.""" + small = "hello world\n\n[USER]: do the thing" + assert ContextCompressor._bound_summary_input(small) is small + exactly_at_cap = "a" * ContextCompressor._SUMMARY_INPUT_MAX_CHARS + assert ContextCompressor._bound_summary_input(exactly_at_cap) is exactly_at_cap + + def test_bound_respected_on_oversized_input_with_marker(self): + """Direct unit check: output length ≤ cap, marker present, edges kept.""" + cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS + content = "HEAD_EDGE " + ("m" * (cap * 3)) + " TAIL_EDGE" + bounded = ContextCompressor._bound_summary_input(content) + assert len(bounded) <= cap + assert "summary input truncated" in bounded + assert bounded.startswith("HEAD_EDGE") + assert bounded.endswith("TAIL_EDGE") + + def test_bound_applies_after_per_message_truncation(self): + """The aggregate cap catches what per-message truncation alone misses: + hundreds of turns, each individually under _CONTENT_MAX, still sum to + an unbounded serialized block without _bound_summary_input.""" + with patch("agent.context_compressor.get_model_context_length", return_value=272000): + c = ContextCompressor(model="test", quiet_mode=True) + # Each message body is < _CONTENT_MAX so per-message truncation is a + # no-op — only the aggregate bound can cap the total. + messages = [ + {"role": "user", "content": "y" * (c._CONTENT_MAX - 100)} + for _ in range(60) + ] + serialized = c._serialize_for_summary(messages) + assert len(serialized) > c._SUMMARY_INPUT_MAX_CHARS # unbounded without the cap + bounded = c._bound_summary_input(serialized) + assert len(bounded) <= c._SUMMARY_INPUT_MAX_CHARS + assert "summary input truncated" in bounded + + def test_iterative_update_path_is_bounded(self): + """The iterative prompt (previous summary + new turns) must be bounded + too — a pathological rehydrated handoff must not blow up the prompt.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "updated summary" + + with patch("agent.context_compressor.get_model_context_length", return_value=272000): + c = ContextCompressor(model="test", quiet_mode=True) + cap = c._SUMMARY_INPUT_MAX_CHARS + c._previous_summary = "PREV_HEAD " + ("p" * (cap * 2)) + " PREV_TAIL" + + messages = [ + {"role": "user", "content": f"turn-{i}-" + ("x" * 6000)} + for i in range(80) + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + summary = c._generate_summary(messages) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert summary.startswith(SUMMARY_PREFIX) + # previous summary block + new-turns block each capped, plus the + # fixed template: well under 3x the cap (unbounded would be ~800K). + assert len(prompt) < 2 * cap + 30_000 + assert "PREV_HEAD" in prompt + assert "PREV_TAIL" in prompt + assert "summary input truncated" in prompt + + def test_marker_does_not_collide_with_summary_classifier(self): + """The omitted-middle marker must never make bounded content classify + as a compaction handoff (SUMMARY_PREFIX / merged-handoff patterns).""" + cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS + bounded = ContextCompressor._bound_summary_input("z" * (cap * 2)) + assert "summary input truncated" in bounded + assert ContextCompressor.classify_summary_content(bounded) is None + # Marker alone (worst case: lands at the start of a message) is not a + # handoff prefix either. + marker_only = bounded[bounded.index("\n\n...[summary input truncated"):] + assert ContextCompressor.classify_summary_content(marker_only.lstrip()) is None