Skip to content

AgentTool drops a HITL pause from the wrapped agent (incl. RemoteA2aAgent input_required): caller gets {"result": ""} and carries on #7245

Closed

Description

@msteiner-google

Describe the Bug

If the agent inside an AgentTool pauses for human input, the caller never hears about it. The pause can come from a FunctionTool(..., require_confirmation=True), which emits adk_request_confirmation, or from request_input, which emits adk_request_input. In both cases AgentTool.run_async returns "" and the caller's model gets {"result": ""} as the tool response. No event in the caller's run has long_running_tool_ids, so the caller's model keeps going. The pending confirmation or input is lost, because the nested run used its own throw-away InMemorySessionService.

This also happens with AgentTool(RemoteA2aAgent(...)) pointed at an ADK A2A server. Inside the nested run, the remote task reaches TASK_STATE_INPUT_REQUIRED and RemoteA2aAgent emits the adk_request_confirmation call with long_running_tool_ids. AgentTool throws that away.

For comparison, the same local agent attached as a mode='single_turn' sub-agent does pass the pause up: it runs as _SingleTurnAgentTool via ctx.run_node, and the caller's events carry long_running_tool_ids. That option doesn't exist for RemoteA2aAgent, because its mode only accepts "task" or None. mode="task" does pass the pause up, but it only returns an answer once the remote sends finish_task. Against a remote chat agent that just replies with text, the caller never got a function response. So for a remote chat agent, AgentTool is the only way to call it as a tool.

The 2.9.0 changelog lists "support multi-turn nested HITL pause and resumption across workflows and agent tools" (6d14518). That commit changed flows/llm_flows/_resume_utils.py and base_llm_flow.py, which is the run_node path. AgentTool.run_async was not changed, and the behavior below reproduces on 2.9.2.

Steps to Reproduce

  1. pip install google-adk==2.9.2 (a2a-sdk 1.1.5)
  2. Run the script below. It uses scripted models and an in-process A2A server over httpx.ASGITransport, so it needs no network or credentials.

Expected Behavior

The caller should pause the same way it does when the agent is a mode='single_turn' sub-agent. Its run should end with an event whose long_running_tool_ids names the pending adk_request_confirmation / adk_request_input call, and answering that call should resume the wrapped agent. At the very least, AgentTool should not report a paused run as a successful empty result.

Observed Behavior

AgentTool(local LlmAgent):
  caller got tool response: {'result': ''}
  caller said: carried on
AgentTool(RemoteA2aAgent -> ADK to_a2a server):
  caller got tool response: {'result': ''}
  caller said: carried on

publish never runs, and no event in the caller's run has long_running_tool_ids. The result is the same with ResumabilityConfig(is_resumable=True) on the caller's App, and with request_input in place of require_confirmation.

A plugin on the caller's App is inherited by AgentTool's nested Runner. In the remote case it shows the nested run ending on this event, which AgentTool then discards:

[worker] call adk_request_confirmation({...})  long_running_tool_ids=['adk-…']  a2a status: TASK_STATE_INPUT_REQUIRED

Environment

  • google-adk 2.9.2, a2a-sdk 1.1.5, google-genai 2.25.0
  • Python 3.13.12, Linux

Minimal Reproduction Code

import asyncio, httpx
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from google.adk.agents import LlmAgent
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools import FunctionTool
from google.adk.tools.agent_tool import AgentTool
from google.genai import types


class Scripted(BaseLlm):  # replies via a function of the request; no network
    fn: object

    async def generate_content_async(self, req, stream=False):
        yield self.fn(req.contents[-1].parts[-1])


def say(t):
    return LlmResponse(content=types.Content(role="model", parts=[types.Part(text=t)]))


def call(n, **a):
    return LlmResponse(content=types.Content(role="model", parts=[
        types.Part(function_call=types.FunctionCall(name=n, args=a))]))


def publish(value: str) -> dict:
    """Publishes a value."""
    return {"status": "published"}


def worker():
    return LlmAgent(
        name="worker", description="Publishes values.", instruction="x",
        tools=[FunctionTool(publish, require_confirmation=True)],
        model=Scripted(model="s", fn=lambda p: (
            say("done") if p.function_response else call("publish", value="42"))),
    )


async def run_caller(wrapped):
    caller = LlmAgent(
        name="caller", instruction="x", tools=[AgentTool(wrapped)],
        model=Scripted(model="s", fn=lambda p: (
            say("carried on") if p.function_response
            else call("worker", request="publish 42"))),
    )
    runner = InMemoryRunner(agent=caller)
    s = await runner.session_service.create_session(app_name=runner.app_name, user_id="u")
    async for e in runner.run_async(
        user_id="u", session_id=s.id,
        new_message=types.Content(role="user", parts=[types.Part(text="go")]),
    ):
        for fr in e.get_function_responses():
            print("  caller got tool response:", fr.response)
        if e.long_running_tool_ids:
            print("  caller paused:", e.long_running_tool_ids)
        if e.content and e.content.parts and e.content.parts[0].text:
            print("  caller said:", e.content.parts[0].text)


async def main():
    print("AgentTool(local LlmAgent):")
    await run_caller(worker())

    print("AgentTool(RemoteA2aAgent -> ADK to_a2a server):")
    app = to_a2a(worker(), host="localhost", port=8000)
    async with app.router.lifespan_context(app):
        remote = RemoteA2aAgent(
            name="worker", description="Publishes values.",
            agent_card="http://localhost:8000/.well-known/agent-card.json",
            httpx_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)),
        )
        await run_caller(remote)


asyncio.run(main())

Suggested fix

AgentTool.run_async (src/google/adk/tools/agent_tool.py:220-359 in 2.9.2) runs the wrapped agent in a new Runner with a fresh InMemorySessionService (lines 264-272). It then reduces the nested run to the text of the last event that has content (lines 317-345). It reads state_delta, error_message, content and grounding_metadata from nested events, but never long_running_tool_ids. When the nested run ends on a long-running call, that last event holds only function parts, so the result is "". And since the nested session is thrown away, there is nothing left to resume.

Options:

  • Run the wrapped agent through tool_context.run_node(...), the way _SingleTurnAgentTool already does (agent_tool.py:393-431). A child's interrupt then propagates via NodeInterruptedError (agents/context.py:609-615). In a local test on 2.9.2, _SingleTurnAgentTool(RemoteA2aAgent(...)) passed the pause up for both adk_request_confirmation and adk_request_input, and still returned a plain answer ({'result': '42'}) from a remote chat agent. I did not test resuming through it. A second option is to allow mode='single_turn' on RemoteA2aAgent, which currently only accepts Literal["task"] | None.
  • At minimum, check the nested events for long_running_tool_ids and return an explicit error, or raise, instead of "". That way the caller's model doesn't treat an unperformed action as done.

Related: #7246 ("AgentTool returns an empty string when the wrapped agent's final event has only function parts") — same extraction code, different symptom.

Activity

  1. chelsealong commented on Sep 23, 2026

    @chelsealong
    Contributor

    Picking this one up now — opening a PR shortly. Flagging it here so nobody duplicates the work; if someone is already on it, say so and I will drop mine.

  2. sanketpatil06 commented on Sep 24, 2026

    @sanketpatil06

    Hi @msteiner-google, thanks for reporting this.
    We investigated the issue and were able to confirm and reproduce the behavior on our end.
    We see that PR #7250 is already open to address this. Could you please test it out and confirm whether it resolves the issue for you before we move forward with reviewing and merging it?

  3. added
    request clarification[Status] The maintainer need clarification or more information from the author
    on Sep 24, 2026
  4. msteiner-google commented on Sep 28, 2026

    @msteiner-google
    Author

    I tested #7250 at b7034e5 with the repro from the description, extended with request_input, a resumable caller App, and an ordinary LongRunningFunctionTool as a control (Python 3.13, a2a-sdk 1.1.5, google-genai 2.25.0; script below).

    Case PR base (eb1a002) #7250
    local agent, require_confirmation {'result': ''} explicit "paused" error
    local agent, request_input {'result': ''} explicit "paused" error
    local agent, require_confirmation, caller ResumabilityConfig(is_resumable=True) {'result': ''} explicit "paused" error
    RemoteA2aAgent, require_confirmation {'result': ''} explicit "paused" error
    RemoteA2aAgent, request_input {'result': ''} explicit "paused" error
    LongRunningFunctionTool answered in the same turn (control) {'result': 'done 42'} {'result': 'done 42'}

    In every pausing case, the caller's run still has no event with long_running_tool_ids, and the caller's model carries on.

    #7250 delivers the fallback from the description ("at the very least, AgentTool should not report a paused run as a successful empty result"): the caller's model is now told the action did not happen, and the in-turn long-running case is unaffected. Happy for it to go in as a stopgap.

    It does not fix the issue itself. The caller still does not pause, the pending confirmation or input is still discarded with the nested session, and there is nothing to resume. A tool that needs approval can still never run behind an AgentTool.

    Two requests:

    1. Change "Fixes AgentTool drops a HITL pause from the wrapped agent (incl. RemoteA2aAgent input_required): caller gets {"result": ""} and carries on #7245" in the PR description to "Part of AgentTool drops a HITL pause from the wrapped agent (incl. RemoteA2aAgent input_required): caller gets {"result": ""} and carries on #7245", so merging does not close this issue. The propagation part (running the wrapped agent through tool_context.run_node, or allowing mode='single_turn' on RemoteA2aAgent) is still open.
    2. The error text says "Wrap it as a single_turn sub-agent instead". That does not work for a RemoteA2aAgent: RemoteA2aAgent(..., mode='single_turn') raises a ValidationError, because mode is Literal["task"] | None. The only sub-agent option there is mode="task", which only returns once the remote sends finish_task (RemoteA2aAgent(mode="task"): delegation to a non-ADK A2A agent never closes when the task ends in TASK_STATE_COMPLETED; the coordinator never gets its turn back #7248). Could the hint depend on the agent type, or be left out for remote agents?
    Test script
    """Repro for google/adk-python#7245 against PR #7250.
    
    Scripted models + in-process A2A server over httpx.ASGITransport: no network.
    """
    
    import asyncio
    import warnings
    
    import httpx
    from google.adk.a2a.utils.agent_to_a2a import to_a2a
    from google.adk.agents import LlmAgent
    from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
    from google.adk.apps import App, ResumabilityConfig
    from google.adk.models.base_llm import BaseLlm
    from google.adk.models.llm_response import LlmResponse
    from google.adk.runners import InMemoryRunner
    from google.adk.tools import FunctionTool, LongRunningFunctionTool, request_input
    from google.adk.tools.agent_tool import AgentTool
    from google.genai import types
    
    warnings.filterwarnings("ignore")
    
    
    class Scripted(BaseLlm):
        fn: object
    
        async def generate_content_async(self, req, stream=False):
            yield self.fn(req.contents[-1].parts[-1])
    
    
    def say(t):
        return LlmResponse(content=types.Content(role="model", parts=[types.Part(text=t)]))
    
    
    def call(n, **a):
        return LlmResponse(
            content=types.Content(
                role="model",
                parts=[types.Part(function_call=types.FunctionCall(name=n, args=a))],
            )
        )
    
    
    def publish(value: str) -> dict:
        """Publishes a value."""
        return {"status": "published"}
    
    
    def slow(value: str) -> dict:
        """Starts a slow job."""
        return {"status": "pending"}
    
    
    def worker(kind="confirm"):
        if kind == "confirm":
            tools = [FunctionTool(publish, require_confirmation=True)]
            first = lambda: call("publish", value="42")
        elif kind == "input":
            tools = [request_input]
            first = lambda: call("adk_request_input", message="which value?")
        else:  # ordinary LongRunningFunctionTool that resolves in-turn
            tools = [LongRunningFunctionTool(slow)]
            first = lambda: call("slow", value="42")
        return LlmAgent(
            name="worker",
            description="Publishes values.",
            instruction="x",
            tools=tools,
            model=Scripted(
                model="s", fn=lambda p: say("done 42") if p.function_response else first()
            ),
        )
    
    
    async def run_caller(wrapped, resumable=False):
        caller = LlmAgent(
            name="caller",
            instruction="x",
            tools=[AgentTool(wrapped)],
            model=Scripted(
                model="s",
                fn=lambda p: say("carried on")
                if p.function_response
                else call("worker", request="publish 42"),
            ),
        )
        if resumable:
            runner = InMemoryRunner(
                app=App(
                    name="caller_app",
                    root_agent=caller,
                    resumability_config=ResumabilityConfig(is_resumable=True),
                )
            )
        else:
            runner = InMemoryRunner(agent=caller)
        s = await runner.session_service.create_session(
            app_name=runner.app_name, user_id="u"
        )
        paused = False
        async for e in runner.run_async(
            user_id="u",
            session_id=s.id,
            new_message=types.Content(role="user", parts=[types.Part(text="go")]),
        ):
            for fr in e.get_function_responses():
                print("  caller got tool response:", fr.response)
            if e.long_running_tool_ids:
                paused = True
                print("  caller paused:", e.long_running_tool_ids)
            if e.content and e.content.parts and e.content.parts[0].text:
                print("  caller said:", e.content.parts[0].text)
        print("  caller run has long_running_tool_ids:", paused)
    
    
    async def main():
        for kind in ("confirm", "input", "longrunning"):
            print(f"AgentTool(local LlmAgent, {kind}):")
            await run_caller(worker(kind))
        print("AgentTool(local LlmAgent, confirm), caller App resumable:")
        await run_caller(worker("confirm"), resumable=True)
    
        for kind in ("confirm", "input"):
            print(f"AgentTool(RemoteA2aAgent -> ADK to_a2a server, {kind}):")
            app = to_a2a(worker(kind), host="localhost", port=8000)
            async with app.router.lifespan_context(app):
                remote = RemoteA2aAgent(
                    name="worker",
                    description="Publishes values.",
                    agent_card="http://localhost:8000/.well-known/agent-card.json",
                    httpx_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)),
                )
                await run_caller(remote)
    
        print("Error-message hint: RemoteA2aAgent(mode='single_turn'):")
        try:
            RemoteA2aAgent(
                name="worker",
                agent_card="http://localhost:8000/.well-known/agent-card.json",
                mode="single_turn",
            )
            print("  accepted")
        except Exception as exc:
            print(f"  rejected: {type(exc).__name__}: {str(exc).splitlines()[0]}")
    
    
    asyncio.run(main())
  5. removed
    request clarification[Status] The maintainer need clarification or more information from the author
    on Oct 6, 2026
  6. added a commit that references this issue on Oct 15, 2026
    9a5510e
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