Skip to content

After a tool confirmation resumes a Workflow, an LlmAgent node's model receives the workflow's user message instead of the node's input #7650

Description

@yifanxiong272

🔴 Required Information

Describe the Bug:
A Workflow runs a FunctionNode (prepare) and then an LlmAgent node whose tool has require_confirmation=True. The agent's first model call starts with its node input, the output of prepare. The model calls the tool, the run pauses for the confirmation, and the application confirms it with a FunctionResponse ({"confirmed": True}). After the run resumes, the agent's next model call no longer contains its node input: the conversation starts with the workflow's original user message instead. The model thus loses the input it was asked to work on, and receives the workflow's message, which was not meant for this agent.

Steps to Reproduce:

  1. pip install google-adk==2.12.0
  2. Save the script under "Minimal Reproduction Code" as repro.py (no model or API key is needed; the model is a scripted BaseLlm).
  3. Run python repro.py.

Expected Behavior:
Both model calls of the agent start with its node input:

google-adk 2.12.0
model call 0: conversation starts with ['node input: add 2 and 3']
model call 1: conversation starts with ['node input: add 2 and 3']

Observed Behavior:
The model call after the confirmation starts with the workflow's user message:

google-adk 2.12.0
model call 0: conversation starts with ['node input: add 2 and 3']
model call 1: conversation starts with ['workflow message']

Environment Details:

  • ADK Library Version (pip show google-adk): 2.12.0; also 2.11.0 and main at 1daa0ec
  • Desktop OS: macOS 15.7.3
  • Python Version (python -V): Python 3.13.9

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (the reproduction uses a scripted BaseLlm)

🟡 Optional Information

Regression:
No; 2.11.0, 2.12.0 and main behave the same.

Logs:
See "Observed Behavior".

Screenshots / Video:
N/A

Additional Context:
This appears to come from how the node input reaches a single_turn agent (google/adk/workflow/_llm_agent_wrapper.py, prepare_llm_agent_input, main at 1daa0ec):

  1. The node input is delivered as a user event appended to the in-memory ctx.session.events (ctx.session.events.append(user_event)), not through the session service, so it is not persisted.
  2. When the node is re-run to resume (ctx.resume_inputs is set), the input is deliberately not appended again, so that it does not shadow the user's FunctionResponse.
  3. The resumed invocation reloads the session from the session service, where the node input never was, so the agent's conversation falls back to the workflow's user message.

Minimal Reproduction Code:

"""An LlmAgent node of a Workflow whose tool requires confirmation: after the confirmation resumes
the run, the model's conversation starts with the workflow's user message instead of the node input."""
import asyncio
from importlib.metadata import version

from google.adk.agents import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools import FunctionTool
from google.adk.workflow import START, FunctionNode, Workflow
from google.genai import types

seen = []


class Model(BaseLlm):
    model: str = "scripted"

    async def generate_content_async(self, llm_request, stream=False):
        first = [part.text for part in llm_request.contents[0].parts if part.text]
        seen.append(first)
        answered = any(part.function_response for content in llm_request.contents for part in content.parts or [])
        part = (types.Part(text="done") if answered else
                types.Part(function_call=types.FunctionCall(id="call-1", name="add", args={"a": 2, "b": 3})))
        yield LlmResponse(content=types.Content(role="model", parts=[part]))


def add(a: int, b: int) -> int:
    return a + b


def prepare(node_input):
    return "node input: add 2 and 3"


agent = LlmAgent(name="agent", model=Model(), tools=[FunctionTool(add, require_confirmation=True)])
prepared = FunctionNode(func=prepare, name="prepare")
workflow = Workflow(name="root", edges=[(START, prepared), (prepared, agent)])


async def main():
    sessions = InMemorySessionService()
    runner = Runner(app_name="app", node=workflow, session_service=sessions)
    session = await sessions.create_session(app_name="app", user_id="u")
    message = types.Content(role="user", parts=[types.Part(text="workflow message")])
    while True:
        pending = []
        async for event in runner.run_async(user_id="u", session_id=session.id, new_message=message):
            for part in event.content.parts or [] if event.content else []:
                if part.function_call and part.function_call.id in (event.long_running_tool_ids or ()):
                    pending.append(part.function_call)
        if not pending:
            break
        message = types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
            id=call.id, name=call.name, response={"confirmed": True})) for call in pending])
    print("google-adk", version("google-adk"))
    for index, first in enumerate(seen):
        print(f"model call {index}: conversation starts with {first}")


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Activity

  1. widechaos commented on Oct 21, 2026

    @widechaos

    I reproduced this on current main (a221ccd8) with the public offline Runner script. A local patch restores the transient node input before that node run's history, leaving the confirmation response at the end. Eight Runner cases pass, covering approval, rejection, repeated confirmations and parallel nodes without persisting or mixing their inputs. The Workflow suite also passes.

    May I submit a focused fix for this issue?

  2. sanketpatil06 commented on Oct 26, 2026

    @sanketpatil06

    Thanks for the detailed report and repro, @yifanxiong272.

    We reproduced this on google-adk 2.13.0. After the confirmation resumes the run, the agent's model call starts with 'workflow message' instead of the node input. Your analysis is correct. On resume, prepare_llm_agent_input deliberately skips re-adding the node input. That input was only ever held in memory and never saved to the session, so it's gone when the resumed run reloads the session.

    Workaround: until a fix lands, have the upstream node write its output to state and reference it in the agent's instruction. The instruction is rebuilt from saved state on every model call, so the input survives the resume:

    from google.adk.agents.context import Context
    
    def prepare(ctx: Context, node_input):
        value = "node input: add 2 and 3"
        ctx.state["prepared_input"] = value
        return value
    
    agent = LlmAgent(
        name="agent",
        model=...,
        instruction="Work on: {prepared_input}",
        tools=[FunctionTool(add, require_confirmation=True)],
    )

    After the resume, the conversation history still starts with the workflow message. Treat the instruction as the source of truth for the node input.

    @widechaos, your approach looks right: put the node input back before the node's earlier history, and keep the FunctionResponse last. Please go ahead and open the PR with the Runner cases you mentioned (approval, rejection, repeated confirmations, parallel nodes). Our team will review it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

tools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions