fix: re-anchor summary-input bound to current main + bound iterative path

Follow-ups on top of the cherry-picked #27748 mechanism:
- move the cap constant to module level with full rationale comment
  (class attribute aliases it so subclasses/tests can override)
- bound the iterative-update path too: the PREVIOUS SUMMARY block is
  passed through _bound_summary_input so a pathological rehydrated
  handoff cannot blow up the prompt (previous summary + new turns each
  capped)
- extra regression tests: byte-identical small-input passthrough
  (identity), direct bound+marker unit check, bound-after-per-message-
  truncation shape (hundreds of under-_CONTENT_MAX turns), iterative
  path bounded, marker vs classify_summary_content non-collision
- contributor email mapping for @robgfl45
This commit is contained in:
Teknium 2026-07-23 11:54:22 -07:00
parent 80ece3867b
commit b7a05b6b6f
3 changed files with 106 additions and 3 deletions

View File

@ -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}

View File

@ -0,0 +1 @@
robgfl45

View File

@ -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