Skip to content

A single_turn sub-agent paused on a tool confirmation takes over every later turn聽#7602

Description

@Somay-kousis

馃敶 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:

  1. Checkout of main
  2. Save the script below as repro.py
  3. 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_agent

single_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

  1. widechaos commented on Oct 18, 2026

    @widechaos

    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.

  2. Somay-kousis commented on Oct 18, 2026

    @Somay-kousis
    ContributorAuthor

    @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_MODES change. If your event-stream invariant checks cover something my tests don't, they'd be a good addition there.

  3. widechaos commented on Oct 18, 2026

    @widechaos

    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:

    • After approval, collect the caller-authored helper FunctionResponses from that resumed turn. Assert there is exactly one, its ID matches the original caller's helper FunctionCall ID, and its payload contains the helper's result. Also check that the caller model's second request actually contains that result.
    • For an unrelated message before approval, assert there is no caller-authored helper FunctionResponse 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.

  4. surajksharma07 commented on Oct 19, 2026

    @surajksharma07
    Collaborator

    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.

  5. Somay-kousis commented on Oct 19, 2026

    @Somay-kousis
    ContributorAuthor

    @surajksharma07 thanks for checking. Done in 08010d2 on #7605:

    • test_run_async_paused_single_turn_agent_returns_turn_to_delegator now also runs with a nested chat delegator (root transfers to a coordinator, which calls the single_turn helper), 12 cases in all. It adds the call-ID checks @widechaos described: after approve or reject, exactly one helper response 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, no helper response and no function call left in that request.
    • Dropped the xfail on test_single_turn_child_agent_interrupt_and_resume.

    On a real model: I got one clean run on Gemini (gemini-flash-latest, which resolved to gemini-3.8-flash), with #7605, the same nested setup and the user approving. The model picked the tools itself:

      approve      resumable=False paid=[5] models=['gemini-3.8-flash']
        turn 2 text from: ['helper', 'coordinator']  | turn 3 text from: ['coordinator']
        coordinator got helper result in turn 2: [(True, {'result': 'The payment of 5 dollars is done.'})] (True = original call id)
        coordinator's next request carries it: True
    

    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:

    new_message  resumable=False paid=[]
        turn 2: [('coordinator', 'Caller reply')]
        turn 3: [('coordinator', 'Caller thanks')]
        coordinator's 2nd request, tool messages: []
    approve      resumable=False paid=[5]
        turn 2: [('helper', 'Helper done'), ('coordinator', 'Caller reply')]
        turn 3: [('coordinator', 'Caller thanks')]
        coordinator's 2nd request, tool messages: [('call_helper_1', '{"result": "Helper done"}')]
    

    On main:

    new_message  resumable=False paid=[]
        turn 2: [('helper', 'Helper done')]
        turn 3: [('helper', 'Helper chatting')]
        coordinator's 2nd request, tool messages: []
    approve      resumable=False paid=[5]
        turn 2: [('helper', 'Helper done')]
        turn 3: [('helper', 'Helper chatting')]
        coordinator's 2nd request, tool messages: []
    

    tests/unittests/agents, test_runners.py, runners, flows, workflow: 3547 passed, 1 skipped, 5 xfailed. With main's _agent_router.py the new tests fail.

  6. Somay-kousis commented on Oct 19, 2026

    @Somay-kousis
    ContributorAuthor

    Follow-up with the main comparison on a real model: Groq openai/gpt-oss-120b through LiteLlm, same nested coordinator setup.

    With #7605:

      new_message  resumable=False paid=[] models=['openai/gpt-oss-120b']
        turn 2 text from: ['coordinator']  | turn 3 text from: ['coordinator']
        coordinator got helper result in turn 2: [] (True = original call id)
        coordinator's next request carries it: False
      approve      resumable=False paid=[5] models=['openai/gpt-oss-120b']
        turn 2 text from: ['helper', 'coordinator']  | turn 3 text from: ['coordinator']
        coordinator got helper result in turn 2: [(True, {'result': 'Payment is done.'})] (True = original call id)
        coordinator's next request carries it: True
    

    On main:

      new_message  resumable=False paid=[] models=['openai/gpt-oss-120b']
        turn 2 text from: ['helper']  | turn 3 text from: []
        coordinator got helper result in turn 2: [] (True = original call id)
        coordinator's next request carries it: False
      approve      resumable=False paid=[5] models=['openai/gpt-oss-120b']
        turn 2 text from: ['helper']  | turn 3 text from: ['helper']
        coordinator got helper result in turn 2: [] (True = original call id)
        coordinator's next request carries it: False
    
  7. surajksharma07 commented on Oct 19, 2026

    @surajksharma07
    Collaborator

    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.

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

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions