fix(runners): skip duplicate user event append on invocation retry - #6685
Open
rayneto06 wants to merge 2 commits into
Open
fix(runners): skip duplicate user event append on invocation retry#6685rayneto06 wants to merge 2 commits into
rayneto06 wants to merge 2 commits into
Conversation
Four tests driving the public run_async API: retrying an invocation with the same invocation_id and the same new_message must leave exactly one user event in the session, on both the resumable and the non-resumable path. Two non-regression tests pin that a different message or a different state_delta still appends. Both dedup tests fail on main today, on the bug reported in google#4506.
_append_new_message_to_session appended the user event unconditionally, so an invocation that re-sends the same new_message recorded the user turn twice. Both call paths reach it: resuming a resumable invocation, and a non-resumable app given an explicit invocation_id by a caller such as an at-least-once task queue. The guard sits at that convergence point, after the plugin callback has run, and matches on both content and state_delta so a retry carrying a different delta still persists. Related: google#4506
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
rayneto06
force-pushed
the
fix/runner-duplicate-user-event-on-retry
branch
from
August 11, 2026 21:56
e67ae33 to
82356c2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Prior art — please read before triaging this as a duplicate.
Issue #4506 was closed as completed, but the bug is still present in
maintoday. Two earlier pull requests addressed it and neither landed — neither
carries the
mergedlabel:formatting errors and update the branch to
main. The request wentunanswered and the PR was closed ten days later. Most of its 20 files never
touch this bug.
of runner: avoid duplicate user event append on invocation retry #4507 in the same triage pass — reasonable queue hygiene at the time, but
it means the small change was never reviewed on its own merits.
The guard placement in this PR follows the proposal in #4526 by @davidahmann,
which was never merged. What this PR adds on top of it is coverage through the
public
run_asyncAPI and the second call path below, which #4526's testscould not reach.
Problem:
Runner._append_new_message_to_sessionappends the user event unconditionally(
src/google/adk/runners.py:1686atmain). Nothing checks whether theinvocation already recorded that message, so re-sending the same
new_messageunder the same
invocation_idrecords the user turn twice.Two call paths reach it, both through
_handle_new_message:_setup_context_for_resumed_invocationStep 3(
runners.py:2143). This is the reproduction in runner: prevent duplicate user event append on invocation retry with same invocation_id #4506._setup_context_for_new_invocation(runners.py:2084), whichrun_asynccalls at
runners.py:1250, passing the caller'sinvocation_idthrough whenthe app is not resumable.
Path 2 is worth calling out because it needs no resumability configuration at
all. Any caller that retries
run_async(invocation_id=<same>, new_message=<same>)duplicates the user turn on a plain, non-resumable app. Theduplicated turn is then replayed to the model as conversation history on every
subsequent invocation in that session.
I hit this in a production service that reprocesses invocations through a task
queue with automatic retry, passing the same
invocation_id.Solution:
A single guard inside
_append_new_message_to_session— the point where bothcall paths converge — plus a predicate helper,
_invocation_has_user_event.The guard is placed there rather than in the resume path for two reasons:
_handle_new_messagerunsrun_on_user_message_callbackbefore the append,and a plugin may rewrite the message. Comparing post-callback content compares
what actually gets persisted rather than what the caller passed in.
A deliberate consequence of that placement: the plugin callback still runs on
the retry. This PR is about the duplicated event in the session timeline;
making plugin side effects idempotent is the plugin's own concern and is out of
scope here.
The predicate matches on the content and the
state_delta, so a retrycarrying a different state delta is still appended — it has an effect left to
persist.
state_delta or {}mirrors how the event is built a few lines below,where a falsy delta produces an
EventActionswith an empty delta. The check isscoped to the same
invocation_id, so two genuinely distinct turns — which getdistinct invocation ids — are untouched, including a user who re-sends identical
text in a new turn.
Cost is one pass over the in-memory
session.eventslist per user message,immediately before a model call.
Testing Plan
Unit Tests:
Four tests were added to
tests/unittests/test_runners.py, all driving thepublic
Runner.run_asyncAPI rather than the private append helper:test_resumable_retry_with_same_message_appends_one_user_eventinvocation_idwith the samenew_message→ 1 user eventtest_non_resumable_retry_with_same_message_appends_one_user_eventrun_async(invocation_id=<same>)twice with the samenew_message→ 1 user eventtest_retry_with_a_different_message_still_appendsinvocation_id, different message → 2 user eventstest_retry_with_a_different_state_delta_still_appendsstate_delta→ 2 user events, deltas preserved in orderThe last two are non-regression tests: they pin that the guard is scoped to
identical content and identical state, not to the invocation id.
That is the file's 78 pre-existing tests plus the 4 added here.
The two dedup tests fail without the
runners.pychange, which is whatmakes them regression tests rather than tests of current behaviour:
Both failures are clean
assert 2 == 1assertion failures on the user-eventcount.
I also ran the full
tests/unittestssuite. It has 24 failures on my Windowsmachine, and the failure set is identical with and without this change — I
verified that by re-running exactly those node ids against
main'srunners.py. None of them are intest_runners.py, and they lookWindows-specific rather than related to this change (for example
UnicodeEncodeError: 'charmap' codec can't encode character '✅'incli/conformance/_generate_markdown_utils.py, writing non-ASCII to a fileopened without
encoding='utf-8').Manual End-to-End (E2E) Tests:
Runner setup — a standalone script using a minimal
BaseAgent(no LLM call),InMemorySessionService, and only the publicrun_asyncAPI. It runs bothcall paths and prints the resulting session timeline:
Before the fix — the user turn is recorded twice on both paths:
After the fix — the user turn is recorded once on both paths:
The agent still re-runs on the retry, which is the existing resume behaviour and
is unchanged by this PR — only the duplicated user event is gone.
Formatting and hooks:
pre-commiton the two changed files:ruff,isort,pyink,addlicense,codespell,end-of-file-fixerandtrailing-whitespaceall pass, and no hookrewrote either file.
Three hooks could not launch on this Windows machine —
check-new-py-prefixandupdate-constraintsneed/bin/bash, andcompliance-checksneeds apythonon
PATH. I ranscripts/check_new_py_files.shandscripts/compliance_checks.pydirectly against the shell and interpreter available here; both exit 0.
One note in case it is useful: on a clean checkout of
main,pre-commit run --all-filesalso hasmdformatrewrite roughly 160 markdownfiles under
contributing/that are unrelated to any change. I reverted thoseso this PR stays at two files.
tox— not completed locally, so I am not claiming it passed. I time-boxedit to 10 minutes on this machine. The
py311environment got about two thirdsof the way through
tests/unittestsbefore the box expired, andpy312wasstill resolving its interpreter when I stopped it. The failures visible in that
partial run are the same Windows-specific ones described above. I am relying on
CI here for the version matrix.
Checklist
Not applicable — this change has no dependencies on other modules.
Additional context
The diff is exactly two files,
src/google/adk/runners.pyandtests/unittests/test_runners.py(+219/−0), and the branch is cut from currentupstream/main.Happy to adjust the approach if you would prefer the guard scoped to the resume
path only — the trade-off is that it would leave path 2 above unfixed.