fix(research): split compressor protected turns by role, not midpoint - #50411
fix(research): split compressor protected turns by role, not midpoint#50411Vesna-9 wants to merge 1 commit into
Conversation
The trajectory compressor decided where the compressible middle started
and ended by partitioning the protected turn indices around the trajectory
midpoint (n // 2): indices below it were treated as the protected head and
indices at or above it as the protected tail. That positional split breaks
whenever the first tool call lands in the second half of the trajectory:
the first-tool turn gets misclassified as a tail turn, so it becomes the
compressible region's end. The region then spans from just after the first
gpt turn up to the first tool turn, which means the compressor summarizes
the pre-tool conversation and leaves the entire tool-interaction middle
untouched.
Concretely, for a 24-turn trajectory whose first tool turn is at index 13
(protect_last_n_turns=4), the region was [3, 13): it compressed the early
human/gpt setup turns 3..12 and skipped the tool middle 14..19. That both
contradicts the documented strategy ("compress MIDDLE turns only, starting
from 2nd tool response") and frequently leaves the trajectory over the
token budget, since the heavy tool outputs in the middle are never touched.
The fix tracks the first-occurrence head turns in their own set and ends
the compressible region at the start of the protected last-N tail, instead
of splitting by n // 2. After the change the same trajectory yields the
region [14, 20): it compresses the tool middle and preserves the early
setup. The returned protected set is unchanged, so head/tail protection
and existing behavior for the common early-first-tool case are identical.
## What does this PR do?
Fixes a bug in `_find_protected_indices` in the trajectory compressor that
caused the wrong region of a trajectory to be summarized when the first
tool call occurred in the trajectory's second half. The compressible region
is now computed from the role-of-origin of each protected turn (first
system/human/gpt/tool turns form the head; the last N turns form the tail)
rather than from each turn's position relative to the midpoint. This keeps
a late first-tool turn in the protected head, so the region that actually
gets summarized is the tool-interaction middle, as documented, and the
compressor stops summarizing pre-tool setup turns it was meant to leave
alone.
## Related Issue
N/A
## Type of Change
- [x] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 🔒 Security fix
- [ ] 📝 Documentation update
- [ ] ✅ Tests (adding or improving test coverage)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 🎯 New skill (bundled or hub)
## Changes Made
- `trajectory_compressor.py`: in `TrajectoryCompressor._find_protected_indices`,
collect the first-occurrence head turns into a dedicated `head_protected`
set, derive `tail_start` from `protect_last_n_turns`, and set
`compressible_start = max(head_protected) + 1` and
`compressible_end = tail_start`. Removed the `n // 2` head/tail split.
The returned `protected` set is unchanged.
- `tests/test_trajectory_compressor.py`: added
`test_late_first_tool_compresses_tool_middle_not_pre_tool_chatter`
(24-turn trajectory with the first tool at index 13; asserts the region is
[14, 20)) and `test_compressible_region_excludes_all_protected_turns`
(invariant that no protected index falls inside the compressible region).
## How to Test
1. Reproduce the bug on the old code: for a 24-turn trajectory whose first
`tool` turn is at index 13 with `protect_last_n_turns=4`,
`_find_protected_indices` returned the region `[3, 13)` — compressing the
pre-tool turns and skipping the tool middle.
2. With this change the same input returns `[14, 20)`, so the tool-interaction
middle is compressed and the early setup is preserved.
3. Run `pytest tests/test_trajectory_compressor.py tests/test_trajectory_compressor_async.py -q`
— all tests pass, including the two new ones.
## Checklist
### Code
- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5.0)
### Documentation & Housekeeping
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
|
Duplicate of #7248 — both replace the same Related: #40495 / #40593 (clean tool_call/tool_response boundary snapping — a different facet of the same function). |
|
Thanks for the focused regression fix. Current main still uses the positional midpoint split in The existing tool-pair boundary handling added in Automated hermes-sweeper review. |
The trajectory compressor decided where the compressible middle started
and ended by partitioning the protected turn indices around the trajectory
midpoint (n // 2): indices below it were treated as the protected head and
indices at or above it as the protected tail. That positional split breaks
whenever the first tool call lands in the second half of the trajectory:
the first-tool turn gets misclassified as a tail turn, so it becomes the
compressible region's end. The region then spans from just after the first
gpt turn up to the first tool turn, which means the compressor summarizes
the pre-tool conversation and leaves the entire tool-interaction middle
untouched.
Concretely, for a 24-turn trajectory whose first tool turn is at index 13
(protect_last_n_turns=4), the region was [3, 13): it compressed the early
human/gpt setup turns 3..12 and skipped the tool middle 14..19. That both
contradicts the documented strategy ("compress MIDDLE turns only, starting
from 2nd tool response") and frequently leaves the trajectory over the
token budget, since the heavy tool outputs in the middle are never touched.
The fix tracks the first-occurrence head turns in their own set and ends
the compressible region at the start of the protected last-N tail, instead
of splitting by n // 2. After the change the same trajectory yields the
region [14, 20): it compresses the tool middle and preserves the early
setup. The returned protected set is unchanged, so head/tail protection
and existing behavior for the common early-first-tool case are identical.
What does this PR do?
Fixes a bug in
_find_protected_indicesin the trajectory compressor thatcaused the wrong region of a trajectory to be summarized when the first
tool call occurred in the trajectory's second half. The compressible region
is now computed from the role-of-origin of each protected turn (first
system/human/gpt/tool turns form the head; the last N turns form the tail)
rather than from each turn's position relative to the midpoint. This keeps
a late first-tool turn in the protected head, so the region that actually
gets summarized is the tool-interaction middle, as documented, and the
compressor stops summarizing pre-tool setup turns it was meant to leave
alone.
Related Issue
N/A
Type of Change
Changes Made
trajectory_compressor.py: inTrajectoryCompressor._find_protected_indices,collect the first-occurrence head turns into a dedicated
head_protectedset, derive
tail_startfromprotect_last_n_turns, and setcompressible_start = max(head_protected) + 1andcompressible_end = tail_start. Removed then // 2head/tail split.The returned
protectedset is unchanged.tests/test_trajectory_compressor.py: addedtest_late_first_tool_compresses_tool_middle_not_pre_tool_chatter(24-turn trajectory with the first tool at index 13; asserts the region is
[14, 20)) and
test_compressible_region_excludes_all_protected_turns(invariant that no protected index falls inside the compressible region).
How to Test
toolturn is at index 13 withprotect_last_n_turns=4,_find_protected_indicesreturned the region[3, 13)— compressing thepre-tool turns and skipping the tool middle.
[14, 20), so the tool-interactionmiddle is compressed and the early setup is preserved.
pytest tests/test_trajectory_compressor.py tests/test_trajectory_compressor_async.py -q— all tests pass, including the two new ones.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A