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
13 changes: 11 additions & 2 deletions code_puppy/agents/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,13 +331,22 @@ async def history_processor(
existing_hashes = {hash_message(m) for m in history}
messages_added = 0
last_idx = len(messages) - 1
# pydantic-ai hands us the history it knows about plus this turn's new
# prompt, so being handed more messages than we hold means the last one
# is genuinely new. Its hash can still collide: hashes are
# timestamp-independent, so a repeated short prompt like "yes" or "1"
# hashes identically to the earlier one. Treating that as a duplicate
# drops the turn, and the trailing-ModelResponse pop below then removes
# the preceding assistant answer too.
has_new_turn = len(messages) > len(history)
for i, msg in enumerate(messages):
h = hash_message(msg)
if h in existing_hashes:
is_new_turn = i == last_idx and has_new_turn
if h in existing_hashes and not is_new_turn:
continue
# Always keep the newest message even on hash collision — short
# prompts like "yes"/"1" can collide and get silently dropped.
if i == last_idx or h not in compacted_hashes:
if is_new_turn or h not in compacted_hashes:
history.append(msg)
messages_added += 1

Expand Down
34 changes: 34 additions & 0 deletions tests/agents/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,40 @@ async def test_last_message_preserved_even_on_compacted_hash_collision(self):
await make_history_processor(agent)(_ctx(), [_user_msg("yes")])
assert len(agent._message_history) == 1

async def test_repeated_user_prompt_is_not_dropped_as_duplicate(self):
"""A second "yes" answering a different question must survive.

Hashes are timestamp-independent, so a repeated short prompt hashes
identically to the earlier one. Treating that as a duplicate dropped
the turn, and the trailing-ModelResponse pop then removed the previous
assistant answer as well, so the model lost both sides of the exchange.
"""
agent = _FakeAgent(model_max=1_000_000)
agent._message_history = [_user_msg("yes"), _assistant_text("Deleting it now.")]
incoming = [
_user_msg("yes"),
_assistant_text("Deleting it now."),
_user_msg("yes"),
]
with patch.object(_compaction, "get_compaction_threshold", return_value=0.95):
result = await make_history_processor(agent)(_ctx(), incoming)

user_turns = [m for m in result if isinstance(m, ModelRequest)]
assert len(user_turns) == 2, "the second 'yes' was dropped"
assert any(isinstance(m, ModelResponse) for m in result), (
"the earlier assistant answer was destroyed by the trailing pop"
)

async def test_resent_history_with_no_new_turn_still_dedupes(self):
"""Guard the other direction: an identical resend must not grow history."""
agent = _FakeAgent(model_max=1_000_000)
agent._message_history = [_user_msg("yes"), _assistant_text("done")]
with patch.object(_compaction, "get_compaction_threshold", return_value=0.95):
await make_history_processor(agent)(
_ctx(), [_user_msg("yes"), _assistant_text("done")]
)
assert len(agent._message_history) == 1 # trailing response popped

async def test_strips_trailing_model_responses(self):
agent = _FakeAgent(model_max=1_000_000)
msgs = [_user_msg("q"), _assistant_text("a"), _assistant_text("trailing")]
Expand Down