fix: prevent duplicate synthetic user event on single-turn agent resumption
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run

When resuming a single-turn agent node from an HITL tool confirmation pause,
prepare_llm_agent_input was unconditionally appending a synthetic user event,
shadowing the user's FunctionResponse and causing an infinite confirmation loop.
This change skips synthetic user input injection when resume_inputs are present.

Co-authored-by: Shangjie Chen <deanchen@google.com>
PiperOrigin-RevId: 969178203
This commit is contained in:
Shangjie Chen
2026-08-22 17:43:49 -07:00
committed by Copybara-Service
parent d9f4d3d288
commit e753651b7d
2 changed files with 47 additions and 2 deletions
+14 -2
View File
@@ -291,7 +291,9 @@ def prepare_llm_agent_input(
"""Prepares the input for running LlmAgent as a node.
For ``single_turn`` mode, append a user-role event with the input
directly to session.events (legacy behavior).
directly to session.events (legacy behavior). When resuming with
``resume_inputs``, skip appending to avoid injecting duplicate synthetic
user events that shadow user function responses.
For ``task`` mode, the input is the parent's task-delegation FC
args. Those are NOT appended here — the content-builder
@@ -305,7 +307,17 @@ def prepare_llm_agent_input(
For workflow nodes running in a sub-branch, stamp the input event with that
branch. A private node input should not look like the shared root user turn.
"""
if node_input is None or agent.mode != 'single_turn':
# Skip injection if:
# 1. No input was provided.
# 2. Agent is not single_turn (task mode handles its own leading turn).
# 3. Resuming from pause/interrupt (e.g. HITL confirmation): node is re-run
# with resume_inputs; injecting synthetic user input would shadow the
# user's FunctionResponse on the branch tail and cause infinite loops.
if (
node_input is None
or agent.mode != 'single_turn'
or bool(ctx.resume_inputs)
):
return
agent_input = to_user_content(node_input)
user_event = Event(author='user', message=agent_input)
@@ -239,6 +239,39 @@ async def test_single_turn_input_event_inherits_branch_and_scope(
assert event.isolation_scope == 'scope-1'
@pytest.mark.asyncio
async def test_single_turn_input_skipped_when_resuming(
request: pytest.FixtureRequest,
):
"""Resuming a single-turn agent node skips duplicate user input and retains initial input."""
from google.adk.workflow._llm_agent_wrapper import prepare_llm_agent_input
agent = _make_agent(mode='single_turn')
ic = await create_parent_invocation_context(request.function.__name__, agent)
ic.branch = 'parent.worker@1'
# Pre-populate session with turn 1's initial user input
original_event = Event(
author='user',
branch='parent.worker@1',
content=types.Content(
role='user', parts=[types.Part(text='turn 1 initial input')]
),
)
ic.session.events.append(original_event)
ctx = Context(
invocation_context=ic, resume_inputs={'worker@1': {'confirmed': True}}
)
initial_len = len(ic.session.events)
prepare_llm_agent_input(agent, ctx, 'hello')
# Verify no duplicate user input was appended on resume
assert len(ic.session.events) == initial_len
# Verify the resumed node still sees the initial user input from turn 1
assert ic.session.events[-1].content.parts[0].text == 'turn 1 initial input'
# --- build_node auto-wrapping ---