Repository navigation
Description
🔴 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:
pip install google-adk==2.12.0- Save the script under "Minimal Reproduction Code" as
repro.py(no model or API key is needed; the model is a scriptedBaseLlm). - 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
mainat 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):
- 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. - When the node is re-run to resume (
ctx.resume_inputsis set), the input is deliberately not appended again, so that it does not shadow the user'sFunctionResponse. - 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
widechaos commentedon Oct 21, 2026
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?
sanketpatil06 commentedon Oct 26, 2026
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_inputdeliberately 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:@widechaos, your approach looks right: put the node input back before the node's earlier history, and keep the
FunctionResponselast. Please go ahead and open the PR with the Runner cases you mentioned (approval, rejection, repeated confirmations, parallel nodes). Our team will review it.