diff --git a/src/google/adk/workflow/_llm_agent_wrapper.py b/src/google/adk/workflow/_llm_agent_wrapper.py index 4ef60d34..bb82db0f 100644 --- a/src/google/adk/workflow/_llm_agent_wrapper.py +++ b/src/google/adk/workflow/_llm_agent_wrapper.py @@ -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) diff --git a/tests/unittests/workflow/test_llm_agent_as_node.py b/tests/unittests/workflow/test_llm_agent_as_node.py index f0f9ad6b..aa12a65f 100644 --- a/tests/unittests/workflow/test_llm_agent_as_node.py +++ b/tests/unittests/workflow/test_llm_agent_as_node.py @@ -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 ---