Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions tests/test_trajectory_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,59 @@ def test_disable_protect_first_system(self):
protected, _, _ = tc._find_protected_indices(trajectory)
assert 0 not in protected # system not protected

def test_late_first_tool_compresses_tool_middle_not_pre_tool_chatter(self):
"""The compressible region must follow role-of-origin, not n // 2.

When the first tool call lands in the second half of the trajectory,
the old positional split classified that head turn as a "tail" turn,
so the compressible region became [3, first_tool) β€” squeezing the
pre-tool conversation and leaving the tool-interaction middle intact,
and starting *before* the first tool response. The region must instead
begin right after the first tool turn and end at the protected tail.
"""
tc = _make_compressor() # protect_last_n_turns defaults to 4
# 24 turns; the first "tool" turn is at index 13 (second half).
trajectory = [
{"from": "system", "value": "sys"},
{"from": "human", "value": "q"},
]
for i in range(2, 13): # indices 2..12: no tool turns yet
trajectory.append({"from": "gpt" if i % 2 == 0 else "human", "value": "x"})
trajectory.append({"from": "tool", "value": "first tool result"}) # index 13
for i in range(14, 24): # indices 14..23: the tool-interaction middle + tail
trajectory.append({"from": "gpt" if i % 2 == 0 else "tool", "value": "y"})

protected, start, end = tc._find_protected_indices(trajectory)

first_tool = 13
tail_start = len(trajectory) - 4 # 20
# Region starts right after the first tool turn (not before it).
assert start == first_tool + 1 == 14
# Region ends where the protected last-4 turns begin.
assert end == tail_start == 20
# No protected turn is ever inside the compressible region.
assert not any(start <= idx < end for idx in protected)
# The first tool turn itself is protected as a head turn.
assert first_tool in protected

def test_compressible_region_excludes_all_protected_turns(self):
"""Invariant: protected turns are never inside the compressible region."""
tc = _make_compressor()
trajectory = [
{"from": "system", "value": "sys"},
{"from": "human", "value": "q"},
{"from": "gpt", "value": "a"},
{"from": "tool", "value": "r"},
{"from": "gpt", "value": "b"},
{"from": "tool", "value": "r2"},
{"from": "gpt", "value": "c"},
{"from": "tool", "value": "r3"},
{"from": "gpt", "value": "d"},
{"from": "human", "value": "thanks"},
]
protected, start, end = tc._find_protected_indices(trajectory)
assert not any(start <= idx < end for idx in protected)


# ---------------------------------------------------------------------------
# TrajectoryCompressor._extract_turn_content_for_summary
Expand Down
44 changes: 27 additions & 17 deletions trajectory_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,10 +488,14 @@ def _find_protected_indices(self, trajectory: List[Dict[str, str]]) -> Tuple[set
"""
n = len(trajectory)
protected = set()

# Head turns are the first occurrence of each role; the compressible
# region begins right after the last of them. Tracked separately from
# the tail so the boundary is decided by role-of-origin, not position.
head_protected = set()

# Track first occurrences
first_system = first_human = first_gpt = first_tool = None

for i, turn in enumerate(trajectory):
role = turn.get("from", "")
if role == "system" and first_system is None:
Expand All @@ -502,28 +506,34 @@ def _find_protected_indices(self, trajectory: List[Dict[str, str]]) -> Tuple[set
first_gpt = i
elif role == "tool" and first_tool is None:
first_tool = i

# Protect first turns
if self.config.protect_first_system and first_system is not None:
protected.add(first_system)
head_protected.add(first_system)
if self.config.protect_first_human and first_human is not None:
protected.add(first_human)
head_protected.add(first_human)
if self.config.protect_first_gpt and first_gpt is not None:
protected.add(first_gpt)
head_protected.add(first_gpt)
if self.config.protect_first_tool and first_tool is not None:
protected.add(first_tool)

# Protect last N turns
for i in range(max(0, n - self.config.protect_last_n_turns), n):
head_protected.add(first_tool)
protected.update(head_protected)

# Protect last N turns (the tail group)
tail_start = max(0, n - self.config.protect_last_n_turns) if self.config.protect_last_n_turns > 0 else n
for i in range(tail_start, n):
protected.add(i)

# Determine compressible region
# Start after the last protected head turn
head_protected = [i for i in protected if i < n // 2]
tail_protected = [i for i in protected if i >= n // 2]


# Determine compressible region: everything strictly between the last
# protected head turn and the start of the protected tail. We must NOT
# split protected indices by their position relative to the trajectory
# midpoint (n // 2): when the first tool call lands in the second half
# of the trajectory, a positional split misclassifies that head turn as
# a tail turn, which makes the compressor squeeze the pre-tool
# conversation and leave the tool-interaction middle untouched β€”
# contrary to the documented "compress MIDDLE turns only, starting from
# 2nd tool response" strategy.
compressible_start = max(head_protected) + 1 if head_protected else 0
compressible_end = min(tail_protected) if tail_protected else n
compressible_end = tail_start

return protected, compressible_start, compressible_end

Expand Down