fix(context-compressor): clamp out-of-range tail-cut and summary-scan bounds (#75588) - #75635
Conversation
… bounds (NousResearch#75588) A short conversation whose protected head / tool-group alignment reached the end of the message list previously made `_find_tail_cut_by_tokens()` return `len(messages) + 1`. `compress()` then passed that value into `_find_latest_context_summary()` / `_find_context_summaries()`, which indexed `messages[idx]` without clamping and raised `IndexError`, escaping the compression path and failing the active gateway turn. Three defensive bounds: 1. `_find_tail_cut_by_tokens()` now early-returns `len(messages)` when `head_end >= len(messages)` so the caller's existing `compress_start >= compress_end` no-op path fires (issue body shape: 8-message transcript ending in tool-call/result group with protected head pushed to the end of the list). 2. `_find_context_summaries()` clamps `start`/`end` to `[0, len(messages)]` and tolerates non-dict rows so a stale `compress_end` from the tail-cut helper cannot index past the transcript. 3. `_find_latest_context_summary()` inherits the same defensive bounds via the helper above. Regression coverage in `tests/agent/test_context_compressor_short_transcript_tail_index_75588.py`: - `compress()` on the exact 8-message shape returns without raising and takes the no-compressible-window path. - `_find_tail_cut_by_tokens(messages, head_end=len(messages)) == len(messages)`. - `_find_context_summaries` / `_find_latest_context_summary` clamp `end > len(messages)`, `start > end`, `start < 0`, and skip non-dict rows instead of raising. No message is removed and no summary model is called when there is no compressible window. Closes NousResearch#75588
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused defensive fix. The helper-level premise remains valid on current main: agent/context_compressor.py:4940 can return len(messages) + 1, while agent/context_compressor.py:4172-4174 scans and indexes without bounding the range. The proposed guards address both.
Suggested changes
- Add a regression for the remaining current-main public caller:
ContextCompressor.has_content_to_compress()atagent/context_compressor.py:4953-4955, which the gateway checks atgateway/slash_commands.py:3975. For the reported fully protected shape it should returnFalse. The newcompress()assertions attests/agent/test_context_compressor_short_transcript_tail_index_75588.py:115already pass through the existing short-transcript return atagent/context_compressor.py:5028-5049, before the changed tail-cut path runs.
This is an automated hermes-sweeper review.
| # compress() is the public entry point. It must never raise for a | ||
| # well-formed message list, even when the alignment pushes the | ||
| # protected head to ``len(messages)``. | ||
| result = c.compress(messages) |
There was a problem hiding this comment.
On current main this call returns through compress()'s existing short-transcript guard at agent/context_compressor.py:5028-5049, before _find_tail_cut_by_tokens() executes. Please add a has_content_to_compress(messages) is False assertion for this shape, since the gateway uses that preflight at gateway/slash_commands.py:3975 and it exercises the changed helper path.
Summary
A short conversation whose protected head / tool-group alignment reached the
end of the message list previously made
_find_tail_cut_by_tokens()returnlen(messages) + 1.compress()then passed that value into_find_latest_context_summary()/_find_context_summaries(), which indexedmessages[idx]without clamping and raisedIndexError, escaping thecompression path and failing the active gateway turn.
Three defensive bounds:
_find_tail_cut_by_tokens()early-returnslen(messages)whenhead_end >= len(messages)so the caller's existingcompress_start >= compress_endno-op path fires._find_context_summaries()clampsstart/endto[0, len(messages)]and tolerates non-dict rows.
_find_latest_context_summary()inherits the same bounds via the helper.Changes
agent/context_compressor.py: three minimal bounds guards.tests/agent/test_context_compressor_short_transcript_tail_index_75588.py:11 new regression tests pinning the post-fix invariants.
How to Test
Checklist
— compressor is platform-neutral Python; no path/signal/process
primitives touched.
Risk & Impact
Low. Three bounds guards inside an already-defensive compression path; no
behaviour change for any transcript that previously succeeded. The
head_end >= nearly-return is strictly more conservative than the priormax(cut_idx, head_end + 1)floor — it returnsninstead ofn + 1,which is exactly the value the existing
compress_start >= compress_endguard expects.
Closes #75588