Repository navigation
A Workflow LlmAgent node with two pending tool confirmations does not keep waiting when only one is answered; the next node runs too early #7684
Description
🔴 Required Information
Describe the Bug:
In a Workflow, an LlmAgent node (agent) is followed by a FunctionNode (report). In one turn, the model calls a tool that has require_confirmation=True twice, so the run pauses with two adk_request_confirmation calls. The application confirms them one per message, for example as a user approves them one at a time. After the first confirmation, the node should keep waiting for the second. It does not:
- With a plain
Runner(node=workflow), the agent goes on to its model. The model gets the first tool's result and the placeholder{'error': 'This tool call requires confirmation, please approve or reject.'}for the second call.reportruns on that answer and the workflow finishes. The second confirmation then runs the tool and the model again, butreportdoes not run again, so the confirmed result never reaches the workflow. - With
ResumabilityConfig(is_resumable=True), the agent correctly waits. However,reportalready runs after the first confirmation, withNoneas its input, and runs a second time after the second confirmation.
Confirming both calls in one message works in both setups.
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, thenpython repro.py --resumable. Add--allto answer both confirmations in one message.
Expected Behavior:
Both setups print what --all prints: the model is consulted again only once both calls are answered, and report runs once, on that answer:
google-adk 2.12.0
model call 0 got: []
model call 1 got: [{'result': 'acted 1'}, {'result': 'acted 2'}]
report node ran with: ["done: [{'result': 'acted 1'}, {'result': 'acted 2'}]"]
Observed Behavior:
python repro.py:
google-adk 2.12.0
model call 0 got: []
model call 1 got: [{'result': 'acted 1'}, {'error': 'This tool call requires confirmation, please approve or reject.'}]
model call 2 got: [{'result': 'acted 1'}, {'result': 'acted 2'}]
report node ran with: ["done: [{'result': 'acted 1'}, {'error': 'This tool call requires confirmation, please approve or reject.'}]"]
python repro.py --resumable:
google-adk 2.12.0
model call 0 got: []
model call 1 got: [{'result': 'acted 1'}, {'result': 'acted 2'}]
report node ran with: [None, "done: [{'result': 'acted 1'}, {'result': 'acted 2'}]"]
Environment Details:
- ADK Library Version (pip show google-adk): 2.12.0; also 2.11.0 and
mainat 471341f - 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:
Not that I can tell: 2.11.0, 2.12.0 and main behave the same.
Logs:
See "Observed Behavior".
Screenshots / Video:
N/A
Additional Context:
The tool confirmation docs do not say that the confirmations of one model turn must be answered together. The code supports answering them one at a time in several places (main at 471341f):
_pause_left_calls_unansweredinflows/llm_flows/core/_resume.py:119-123says: "a partially answered pause keeps waiting".TestHITLConfirmationFlowWithParallelAgentAndResumableAppconfirms two pending calls in two messages.- The workflow reruns a node whose interrupts are only partly resolved (
workflow/utils/_replay_interceptor.py:113-121).
The problem appears to be in that rerun. It passes the node only the resolved answers and drops the unresolved interrupt ids (_replay_interceptor.py:117-119; only the else branch keeps interrupts = unresolved), so the node has to raise the open ones again itself. An LlmAgent node does not do that:
- Not resumable:
decide_resumechecks for a partially answered pause only wheninvocation_context.is_resumable(flows/llm_flows/core/_resume.py:343-349), so the flow calls the model with the placeholder still in place of the second result. - Resumable: the same check returns
PAUSE, but the step ends without any event. The node run has no output and no interrupt, so the workflow treats it as completed withNoneand startsreport.
In both setups, the second confirmation's request is still answered by _RequestConfirmationLlmRequestProcessor, which reruns the node after the workflow has moved on.
Minimal Reproduction Code:
"""An LlmAgent node of a Workflow calls a tool that requires confirmation twice in one model turn.
The application confirms the two pending `adk_request_confirmation` calls one per message."""
import asyncio
import sys
from importlib.metadata import version
from google.adk.agents import LlmAgent
from google.adk.apps.app import App, ResumabilityConfig
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
RESUMABLE = "--resumable" in sys.argv
ALL_AT_ONCE = "--all" in sys.argv
consultations, reports = [], []
class Model(BaseLlm):
model: str = "scripted"
async def generate_content_async(self, llm_request, stream=False):
results = [part.function_response.response for content in llm_request.contents
for part in content.parts or [] if part.function_response]
consultations.append(results)
if results:
parts = [types.Part(text=f"done: {results}")]
else:
parts = [types.Part(function_call=types.FunctionCall(id=f"call-{n}", name="act", args={"n": n}))
for n in (1, 2)]
yield LlmResponse(content=types.Content(role="model", parts=parts))
def act(n: int) -> str:
return f"acted {n}"
def report(node_input):
reports.append(node_input)
return node_input
agent = LlmAgent(name="agent", model=Model(), tools=[FunctionTool(act, require_confirmation=True)])
workflow = Workflow(name="root", edges=[(START, agent), (agent, FunctionNode(func=report, name="report"))])
async def main():
sessions = InMemorySessionService()
if RESUMABLE:
runner = Runner(app=App(name="app", root_agent=workflow,
resumability_config=ResumabilityConfig(is_resumable=True)), session_service=sessions)
else:
runner = Runner(app_name="app", node=workflow, session_service=sessions)
session = await sessions.create_session(app_name="app", user_id="u")
message, invocation = types.Content(role="user", parts=[types.Part(text="go")]), None
pending = []
for _ in range(4):
async for event in runner.run_async(user_id="u", session_id=session.id, new_message=message,
invocation_id=invocation if RESUMABLE else None):
invocation = event.invocation_id
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
answered = pending if ALL_AT_ONCE else pending[:1]
pending = pending[len(answered):]
message = types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
id=call.id, name=call.name, response={"confirmed": True})) for call in answered])
print("google-adk", version("google-adk"))
for index, results in enumerate(consultations):
print(f"model call {index} got: {results}")
print("report node ran with:", reports)
asyncio.run(main())How often has this issue occurred?:
- Always (100%)
Activity
widechaos commentedon Oct 22, 2026
I reproduced both paths on current main (
466648bb) with scripted models and network access blocked. In 16 cases covering confirmation order and approval/rejection, all eight one-at-a-time cases let the downstream node run early; all eight batch-confirmation controls passed. The local input-restoration candidate for #7650 leaves these results unchanged.May I work on a fix scoped to partial tool confirmations in a Workflow
LlmAgent? I would preserve the existing first-answer behavior for general rerun nodes and the non-resumable flow controls. I have not changed the shared resume logic; please let me know if someone is already working on this.llalitkumarrr commentedon Oct 27, 2026
Hello @yifanxiong272,
Could you please take a look at #7762 and share your feedback on whether this resolves your issue?
yifanxiong272 commentedon Oct 27, 2026
Thanks @llalitkumarrr, #7762 fixes it for me.
I ran the reproduction from the issue on #7762 (051348d) and on its base (2084efb, 2.13.0):