Skip to content

fix(runners): skip duplicate user event append on invocation retry - #6685

Open
rayneto06 wants to merge 2 commits into
google:mainfrom
rayneto06:fix/runner-duplicate-user-event-on-retry
Open

fix(runners): skip duplicate user event append on invocation retry#6685
rayneto06 wants to merge 2 commits into
google:mainfrom
rayneto06:fix/runner-duplicate-user-event-on-retry

Conversation

@rayneto06

Copy link
Copy Markdown

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 main
today. Two earlier pull requests addressed it and neither landed — neither
carries the merged label:

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_async API and the second call path below, which #4526's tests
could not reach.

Problem:

Runner._append_new_message_to_session appends the user event unconditionally
(src/google/adk/runners.py:1686 at main). Nothing checks whether the
invocation already recorded that message, so re-sending the same new_message
under the same invocation_id records the user turn twice.

Two call paths reach it, both through _handle_new_message:

  1. Resume_setup_context_for_resumed_invocation Step 3
    (runners.py:2143). This is the reproduction in runner: prevent duplicate user event append on invocation retry with same invocation_id #4506.
  2. Non-resumable app with a caller-supplied id
    _setup_context_for_new_invocation (runners.py:2084), which run_async
    calls at runners.py:1250, passing the caller's invocation_id through when
    the 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. The
duplicated 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 both
call paths converge — plus a predicate helper, _invocation_has_user_event.

The guard is placed there rather than in the resume path for two reasons:

  • A guard in the resume path alone would leave path 2 broken.
  • _handle_new_message runs run_on_user_message_callback before 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 retry
carrying 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 EventActions with an empty delta. The check is
scoped to the same invocation_id, so two genuinely distinct turns — which get
distinct 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.events list per user message,
immediately before a model call.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Four tests were added to tests/unittests/test_runners.py, all driving the
public Runner.run_async API rather than the private append helper:

Test Asserts
test_resumable_retry_with_same_message_appends_one_user_event Resumable app, resuming the same invocation_id with the same new_message1 user event
test_non_resumable_retry_with_same_message_appends_one_user_event Non-resumable app, run_async(invocation_id=<same>) twice with the same new_message1 user event
test_retry_with_a_different_message_still_appends Same invocation_id, different message → 2 user events
test_retry_with_a_different_state_delta_still_appends Same message, different state_delta2 user events, deltas preserved in order

The last two are non-regression tests: they pin that the guard is scoped to
identical content and identical state, not to the invocation id.

$ pytest ./tests/unittests/test_runners.py -q
82 passed, 17 warnings in 1.49s

That is the file's 78 pre-existing tests plus the 4 added here.

The two dedup tests fail without the runners.py change, which is what
makes them regression tests rather than tests of current behaviour:

$ git stash push -- src/google/adk/runners.py
$ pytest ./tests/unittests/test_runners.py -q -k "retry_with_same_message"
FAILED tests/unittests/test_runners.py::test_resumable_retry_with_same_message_appends_one_user_event
FAILED tests/unittests/test_runners.py::test_non_resumable_retry_with_same_message_appends_one_user_event
2 failed, 80 deselected, 5 warnings in 1.30s

$ git stash pop
$ pytest ./tests/unittests/test_runners.py -q -k "retry_with_same_message"
2 passed, 80 deselected, 5 warnings in 1.30s

Both failures are clean assert 2 == 1 assertion failures on the user-event
count.

I also ran the full tests/unittests suite. It has 24 failures on my Windows
machine, and the failure set is identical with and without this change — I
verified that by re-running exactly those node ids against main's
runners.py. None of them are in test_runners.py, and they look
Windows-specific rather than related to this change (for example
UnicodeEncodeError: 'charmap' codec can't encode character '✅' in
cli/conformance/_generate_markdown_utils.py, writing non-ASCII to a file
opened 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 public run_async API. It runs both
call paths and prints the resulting session timeline:

class EchoAgent(BaseAgent):
  """Minimal agent: one model event per invocation, no LLM call."""

  async def _run_async_impl(self, invocation_context):
    yield Event(
        invocation_id=invocation_context.invocation_id,
        author=self.name,
        content=types.Content(role="model", parts=[types.Part(text="ack")]),
    )


# 1. Resumable app: run once, then resume that invocation_id with the same text.
runner = Runner(
    app=App(
        name=APP_NAME,
        root_agent=EchoAgent(name="root_agent"),
        resumability_config=ResumabilityConfig(is_resumable=True),
    ),
    session_service=session_service,
)

# 2. Non-resumable app: deliver the same request twice with the same id.
runner = Runner(
    app_name=APP_NAME,
    agent=EchoAgent(name="root_agent"),
    session_service=session_service,
)

Before the fix — the user turn is recorded twice on both paths:

$ git stash push -- src/google/adk/runners.py   # revert just the guard
$ python repro_4506.py

=== 1. Resumable app: resume the same invocation_id ===
  first run created invocation_id=e-77040f85-d916-46a1-ae20-7cfc6b73416e
  session timeline after resuming with the same new_message:
    [e-77040f85-d916-46a1-ae20-7cfc6b73416e] user       'hello'
    [e-77040f85-d916-46a1-ae20-7cfc6b73416e] root_agent 'ack'
    [e-77040f85-d916-46a1-ae20-7cfc6b73416e] user       'hello'      <-- duplicate
    [e-77040f85-d916-46a1-ae20-7cfc6b73416e] root_agent 'ack'
  -> user events: 2

=== 2. Non-resumable app: task queue retries the same request ===
  delivery attempt 1 with invocation_id='inv-task-retry'
  delivery attempt 2 with invocation_id='inv-task-retry'
  session timeline after two at-least-once deliveries:
    [inv-task-retry] user       'hello'
    [inv-task-retry] root_agent 'ack'
    [inv-task-retry] user       'hello'                              <-- duplicate
    [inv-task-retry] root_agent 'ack'
  -> user events: 2

=== summary ===
  resumable resume     -> 2 user event(s), expected 1
  non-resumable retry  -> 2 user event(s), expected 1
  RESULT: FAIL (user turn duplicated)

After the fix — the user turn is recorded once on both paths:

$ git stash pop
$ python repro_4506.py

=== 1. Resumable app: resume the same invocation_id ===
  first run created invocation_id=e-245271b8-cf65-4104-9fb1-887474e928a7
  session timeline after resuming with the same new_message:
    [e-245271b8-cf65-4104-9fb1-887474e928a7] user       'hello'
    [e-245271b8-cf65-4104-9fb1-887474e928a7] root_agent 'ack'
    [e-245271b8-cf65-4104-9fb1-887474e928a7] root_agent 'ack'
  -> user events: 1

=== 2. Non-resumable app: task queue retries the same request ===
  delivery attempt 1 with invocation_id='inv-task-retry'
  delivery attempt 2 with invocation_id='inv-task-retry'
  session timeline after two at-least-once deliveries:
    [inv-task-retry] user       'hello'
    [inv-task-retry] root_agent 'ack'
    [inv-task-retry] root_agent 'ack'
  -> user events: 1

=== summary ===
  resumable resume     -> 1 user event(s), expected 1
  non-resumable retry  -> 1 user event(s), expected 1
  RESULT: PASS

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-commit on the two changed files: ruff, isort, pyink, addlicense,
codespell, end-of-file-fixer and trailing-whitespace all pass, and no hook
rewrote either file.

Three hooks could not launch on this Windows machine — check-new-py-prefix and
update-constraints need /bin/bash, and compliance-checks needs a python
on PATH. I ran scripts/check_new_py_files.sh and scripts/compliance_checks.py
directly 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-files also has mdformat rewrite roughly 160 markdown
files under contributing/ that are unrelated to any change. I reverted those
so this PR stays at two files.

toxnot completed locally, so I am not claiming it passed. I time-boxed
it to 10 minutes on this machine. The py311 environment got about two thirds
of the way through tests/unittests before the box expired, and py312 was
still 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

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.
    Not applicable — this change has no dependencies on other modules.

Additional context

The diff is exactly two files, src/google/adk/runners.py and
tests/unittests/test_runners.py (+219/−0), and the branch is cut from current
upstream/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.

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
@google-cla

google-cla Bot commented Aug 11, 2026

Copy link
Copy Markdown

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.

@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Aug 11, 2026
@rayneto06
rayneto06 force-pushed the fix/runner-duplicate-user-event-on-retry branch from e67ae33 to 82356c2 Compare August 11, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core [Component] This issue is related to the core interface and implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants