Repository navigation
A single_turn sub-agent paused on a tool confirmation takes over every later turn聽#7602
Description
馃敶 Required Information
Describe the Bug:
A mode='single_turn' sub-agent that pauses on a tool confirmation takes over the conversation. Every later user turn goes to that sub-agent instead of the agent that called it, and the caller never gets the sub-agent's result.
Setup: a chat root agent with one mode='single_turn' sub-agent. The sub-agent has a FunctionTool(..., require_confirmation=True). The root calls the sub-agent, the sub-agent calls the tool, and the turn pauses for confirmation.
- If the user types something else instead of answering, the sub-agent answers that message and every message after it. The root's model is never called again.
- If the user approves, the tool runs and the sub-agent replies straight to the user. Its result never comes back to the root as the response to the root's call, and the next turns still go to the sub-agent.
LlmAgent.mode documents single_turn as "Agents that complete a task without chatting with the user." Here it chats with the user indefinitely. The single-turn guide (docs/guides/agents/llm_agent/single_turn.md) also says a single_turn sub-agent "is not a transfer target. The parent agent cannot hand over control of the conversation to it."
Steps to Reproduce:
- Checkout of main
- Save the script below as
repro.py - Run
PYTHONPATH=src python repro.py. No network: the models are fakes that replay scripted replies.
Expected Behavior:
After the pause, a new user message goes to the root. An approval resumes the sub-agent, its result goes back to the root, and the root replies. Later turns go to the root.
Observed Behavior:
User types something else (resumable=True gives the same routing):
=== scenario=new_message resumable=False
--- turn 1: user -> 'pay 5 please'
root ['call helper']
helper ['call pay']
helper ['call adk_request_confirmation']
helper ["response pay {'error': 'This tool call requires confirmation, please approve or reject.'}"]
--- turn 2: user -> 'unrelated message 2'
helper ['HELPER: second reply']
--- turn 3: user -> 'unrelated message 3'
helper ['HELPER: third reply']
root LLM calls: 1 helper LLM calls: 3
User approves:
=== scenario=approve resumable=False
--- turn 1: user -> 'pay 5 please'
root ['call helper']
helper ['call pay']
helper ['call adk_request_confirmation']
helper ["response pay {'error': 'This tool call requires confirmation, please approve or reject.'}"]
--- turn 2: user -> 'approve confirmation'
helper ["response pay {'paid': 5}"]
helper ['HELPER: second reply']
--- turn 3: user -> 'unrelated message 3'
helper ['HELPER: third reply']
root LLM calls: 1 helper LLM calls: 3
Environment Details:
- ADK Library Version (pip show google-adk): 2.12.0, main at 4dbb55d
- Desktop OS: macOS 26.6.2
- Python Version (python -V): Python 3.12.13
Model Information:
- Are you using LiteLLM: No
- Which model is being used: a fake
BaseLlm, no real model call
馃煛 Optional Information
Root cause:
find_agent_to_run (src/google/adk/agents/_agent_router.py) passes the agent it picks through _resolve_task_delegator, which swaps an agent inside a delegation for the agent that delegated it. It only checks mode == "task":
if getattr(current, "mode", None) == "task":
delegator = current.parent_agentsingle_turn agents are delegated the same way (DELEGATED_TASK_MODES in src/google/adk/utils/_agent_mode.py holds both task and single_turn), but they are not mapped back. So the last event authored by the paused sub-agent makes it the agent to run, both on the event scan for a plain message and on the resume path for the confirmation answer.
c5ee35e ("keep sticky routing when a sub-agent runs in task mode") added this mapping for task only. The xfail test_single_turn_child_agent_interrupt_and_resume in tests/unittests/agents/test_llm_agent_interruptions.py ("Single-turn agent as subagent not supported yet") fails on main for this reason; it passes once single_turn is mapped too.
Minimal Reproduction Code:
"""Repro: a paused mode='single_turn' sub-agent takes over later turns.
root (chat) calls helper (mode='single_turn') as a tool. helper calls a tool
with require_confirmation=True, so the turn pauses. Scenario "new_message": the
user ignores the confirmation and types something else. Scenario "approve": the
user approves the confirmation. No real model: scripted BaseLlm.
"""
import asyncio
from typing import AsyncGenerator
import google.adk
from google.adk.agents import LlmAgent
from google.adk.apps import App, ResumabilityConfig
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools import FunctionTool
from google.genai import types
print('adk from', google.adk.__file__)
class Scripted(BaseLlm):
model: str = 'fake'
script: list = []
calls: int = 0
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
self.calls += 1
part = self.script.pop(0)
yield LlmResponse(content=types.Content(role='model', parts=[part]))
def fc(name, args):
return types.Part.from_function_call(name=name, args=args)
def pay(amount: int) -> dict:
"""Pays an amount."""
return {'paid': amount}
def show(e):
out = []
for p in (e.content.parts if e.content else []):
if p.text:
out.append(p.text)
elif p.function_call:
out.append(f'call {p.function_call.name}')
elif p.function_response:
out.append(f'response {p.function_response.name} {p.function_response.response}')
return out
async def scenario(name, resumable):
mr = Scripted(script=[fc('helper', {'request': 'pay 5'}),
types.Part(text='ROOT: second reply'),
types.Part(text='ROOT: third reply')])
mh = Scripted(script=[fc('pay', {'amount': 5}),
types.Part(text='HELPER: second reply'),
types.Part(text='HELPER: third reply')])
helper = LlmAgent(name='helper', model=mh, instruction='You pay things.',
mode='single_turn', description='Pays bills.',
tools=[FunctionTool(pay, require_confirmation=True)])
root = LlmAgent(name='root', model=mr, instruction='You chat.',
sub_agents=[helper])
app = App(name='app', root_agent=root,
resumability_config=ResumabilityConfig(is_resumable=resumable))
runner = InMemoryRunner(app=app)
s = await runner.session_service.create_session(app_name='app', user_id='u')
print(f'\n=== scenario={name} resumable={resumable}')
conf_id = None
for i in range(3):
if i == 0:
msg = types.UserContent('pay 5 please')
elif name == 'approve' and i == 1:
msg = types.Content(role='user', parts=[types.Part(function_response=types.FunctionResponse(
id=conf_id, name='adk_request_confirmation', response={'confirmed': True}))])
else:
msg = types.UserContent(f'unrelated message {i + 1}')
label = 'approve confirmation' if msg.parts[0].function_response else msg.parts[0].text
print(f'--- turn {i + 1}: user -> {label!r}')
async for e in runner.run_async(user_id='u', session_id=s.id, new_message=msg):
for fcall in e.get_function_calls():
if fcall.name == 'adk_request_confirmation':
conf_id = fcall.id
print(' ', e.author, show(e))
print('root LLM calls:', mr.calls, ' helper LLM calls:', mh.calls)
async def main():
for resumable in (False, True):
for name in ('new_message', 'approve'):
await scenario(name, resumable)
asyncio.run(main())How often has this issue occurred?:
- Always (100%)
Activity
widechaos commentedon Oct 18, 2026
May I take this issue? I reproduced all four Runner scenarios offline and have a small patch that uses the existing DELEGATED_TASK_MODES constant in _resolve_task_delegator.
With the patch, approval runs the tool once and returns the result to the caller with the original function-call ID; later messages go back to the caller. An unrelated message before approval does not run the tool. The four new regressions fail on unchanged main and pass with the patch, including event-stream invariant checks. The related suite has 197 passes and one existing task-mode xfail.
I can send the focused routing change and regressions if this is available for contribution.
Somay-kousis commentedon Oct 18, 2026
@widechaos thanks for reproducing it. I'd been working on this and opened #7605 a few minutes after your comment, with the same
DELEGATED_TASK_MODESchange. If your event-stream invariant checks cover something my tests don't, they'd be a good addition there.widechaos commentedon Oct 18, 2026
I compared #7605 with my local regression. Your test already covers rejection as well as approval and an unrelated message. The extra checks I had were on the caller's function-call/result boundary:
helperFunctionResponses from that resumed turn. Assert there is exactly one, its ID matches the original caller'shelperFunctionCall ID, and its payload contains the helper's result. Also check that the caller model's second request actually contains that result.helperFunctionResponse and no dangling FunctionCall in the caller model's next request. The tool must still have zero executions.I checked these locally for both resumability settings using the invariant-checking runner. I haven't run your PR locally. The ID and request-content assertions look like useful additions to your existing Runner test; there's no need for a second routing patch.
surajksharma07 commentedon Oct 19, 2026
Reproduced all four scenarios on main (2.12.0) @Somay-kousis. Checking mode in DELEGATED_TASK_MODES in _resolve_task_delegator looks like a good workaround: in my local run the root got the turn back and received the helper鈥檚 result with the original call ID for both resumability settings.
Could you confirm it holds up on your side too maybe with a real model and a nested chat delegator? Since test_single_turn_child_agent_interrupt_and_resume now passes it might be worth dropping its xfail marker in #7605 as well along with the call-ID checks @widechaos mentioned.
Once you鈥檝e tested it thoroughly and everything looks good we can move #7605 forward for review.
Somay-kousis commentedon Oct 19, 2026
@surajksharma07 thanks for checking. Done in 08010d2 on #7605:
test_run_async_paused_single_turn_agent_returns_turn_to_delegatornow also runs with a nested chat delegator (root transfers to acoordinator, which calls the single_turn helper), 12 cases in all. It adds the call-ID checks @widechaos described: after approve or reject, exactly onehelperresponse from the caller, with the original call ID and the helper's result, and that result is in the caller model's next request. After an unrelated message, nohelperresponse and no function call left in that request.test_single_turn_child_agent_interrupt_and_resume.On a real model: I got one clean run on Gemini (
gemini-flash-latest, which resolved togemini-3.8-flash), with #7605, the same nested setup and the user approving. The model picked the tools itself:The other real-model runs, including main for comparison, hit the free-tier quota (20 requests a day per model), so I don't have those to show. For the full matrix I ran the same agents through
LiteLlm's real OpenAI client against a local server that speaks the chat-completions format, as on #7560. All 3 answers and both resumability settings give the same routing; two of them below.With #7605:
On main:
tests/unittests/agents,test_runners.py,runners,flows,workflow: 3547 passed, 1 skipped, 5 xfailed. With main's_agent_router.pythe new tests fail.Somay-kousis commentedon Oct 19, 2026
Follow-up with the main comparison on a real model: Groq
openai/gpt-oss-120bthroughLiteLlm, same nested coordinator setup.With #7605:
On main:
surajksharma07 commentedon Oct 19, 2026
Pulled 08010d2 on top of current main (92bb67f) and it merges cleanly @Somay-kousis. test_agent_router, test_llm_agent_interruptions and the runner tests pass with the xfail gone. With main鈥檚 _agent_router.py 21 of the new cases fail. The wider agents/runners/flows/workflow suite gave 3556 passed. The only 2 failures were the ExecuteBashTool node tests which fail the same way on main here because I鈥檓 on Windows.
Reran the repro from the issue on the branch too. In all four scenarios the root gets turns 2 and 3 and after approval it gets the helper鈥檚 result. The same script on 2.12.0 still sends every turn to the helper (root model called once, helper three times) so this isn鈥檛 fixed in any release yet and #7605 is the fix.
Thanks @widechaos for the call-ID checks too. From my side #7605 looks ready for a maintainer review. It still needs the 2 pending workflows approved.