Skip to content

Keep a repeated user prompt when the turn is genuinely new (#781) - #855

Open
phanisaimunipalli wants to merge 1 commit into
mpfaffenberger:mainfrom
phanisaimunipalli:history-merge-repeated-prompt
Open

Keep a repeated user prompt when the turn is genuinely new (#781)#855
phanisaimunipalli wants to merge 1 commit into
mpfaffenberger:mainfrom
phanisaimunipalli:history-merge-repeated-prompt

Conversation

@phanisaimunipalli

Copy link
Copy Markdown

Fixes #781.

hash_message is deliberately timestamp-independent, so answering two different questions with yes produces the same hash both times:

hash('yes') turn 1: 6f2245e1533438c3
hash('yes') turn 2: 6f2245e1533438c3
identical: True

The merge loop skipped the second one as a duplicate. Because that left history ending on a ModelResponse, the trailing-response pop below it then removed the earlier assistant answer too. Running the real merge on a two-turn conversation:

history before : [USER 'yes'] [ASSIST 'Deleting the branch now.']
incoming       : [USER 'yes'] [ASSIST 'Deleting the branch now.'] [USER 'yes']
messages merged: 0
history after  : [USER 'yes']

user turns the model can see : 1   (expected 2)
prior assistant answer kept  : False (expected True)

Three messages in, one out, and it is written back to agent._message_history permanently. The model never sees the second question and loses its own previous answer.

Worth noting the comment already sitting above the collision guard:

# Always keep the newest message even on hash collision — short
# prompts like "yes"/"1" can collide and get silently dropped.

That is exactly right, but the unconditional continue one line earlier meant the guard was only ever reached for messages that were not already in existing_hashes — so it never protected the case it describes.

The fix

A hash collision is not the same thing as a duplicate. pydantic-ai passes the history it already knows about plus this turn's new prompt, so when it hands us more messages than we are holding, the last one is a genuinely new turn regardless of what its hash matches.

has_new_turn = len(messages) > len(history)
...
is_new_turn = i == last_idx and has_new_turn
if h in existing_hashes and not is_new_turn:
    continue

Why not the one-liner in the issue

The issue suggests exempting the last index unconditionally (if h in existing_hashes and i != last_idx). That breaks test_dedupes_by_hash, because an identical resend with no new turn also ends on a colliding last message, and history would grow every time. The message-count check separates the two cases.

I verified all three states rather than assuming:

variant result
current main new regression test fails (the bug)
unconditional last-index exemption test_dedupes_by_hash fails
this PR 27 passed

Testing

Two new tests in tests/agents/test_compaction.py: one for the repeated prompt, asserting both that the second turn survives and that the earlier assistant answer is not destroyed; and one guarding the opposite direction, that an identical resend with no new turn still dedupes.

tests/agents/ is 405 passed. ruff check and ruff format clean at 0.15.

…erger#781)

hash_message is timestamp-independent, so answering two different
questions with "yes" produces the same hash both times. The merge loop
skipped the second one as a duplicate, and because that left the history
ending on a ModelResponse, the trailing-response pop then removed the
earlier assistant answer as well. Both sides of the exchange disappeared,
and the loss was written back to agent._message_history permanently.

The comment above the collision guard already described the intended
behaviour; the unconditional `continue` one line earlier meant the guard
was only ever reached for messages that were not in existing_hashes.

Fixed by distinguishing a collision from a real duplicate using the
message count. pydantic-ai passes the history it knows about plus this
turn's new prompt, so being handed more messages than we hold means the
last one is a new turn even when its hash matches an earlier one.

The one-line fix suggested in the issue, exempting the last index
unconditionally, breaks test_dedupes_by_hash, because an identical
resend also ends on a colliding last message. Verified both directions:
reverting to the original code fails the new regression test, and the
unconditional variant fails test_dedupes_by_hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

History merge drops a repeated user prompt, and the trailing-response pop then deletes the prior assistant answer

1 participant