Repository navigation
AgentTool drops a HITL pause from the wrapped agent (incl. RemoteA2aAgent input_required): caller gets {"result": ""} and carries on #7245
Description
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
pip install google-adk==2.9.2(a2a-sdk 1.1.5)- 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_SingleTurnAgentToolalready does (agent_tool.py:393-431). A child's interrupt then propagates viaNodeInterruptedError(agents/context.py:609-615). In a local test on 2.9.2,_SingleTurnAgentTool(RemoteA2aAgent(...))passed the pause up for bothadk_request_confirmationandadk_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 allowmode='single_turn'onRemoteA2aAgent, which currently only acceptsLiteral["task"] | None. - At minimum, check the nested events for
long_running_tool_idsand 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
chelsealong commentedon Sep 23, 2026
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.
fix(tools): don't flag a long-running call resolved in-turn as paused
sanketpatil06 commentedon Sep 24, 2026
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?
msteiner-google commentedon Sep 28, 2026
I tested #7250 at b7034e5 with the repro from the description, extended with
request_input, a resumable callerApp, and an ordinaryLongRunningFunctionToolas a control (Python 3.13, a2a-sdk 1.1.5, google-genai 2.25.0; script below).eb1a002)require_confirmation{'result': ''}request_input{'result': ''}require_confirmation, callerResumabilityConfig(is_resumable=True){'result': ''}RemoteA2aAgent,require_confirmation{'result': ''}RemoteA2aAgent,request_input{'result': ''}LongRunningFunctionToolanswered 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:
AgentTooldrops a HITL pause from the wrapped agent (incl.RemoteA2aAgentinput_required): caller gets{"result": ""}and carries on #7245" in the PR description to "Part ofAgentTooldrops a HITL pause from the wrapped agent (incl.RemoteA2aAgentinput_required): caller gets{"result": ""}and carries on #7245", so merging does not close this issue. The propagation part (running the wrapped agent throughtool_context.run_node, or allowingmode='single_turn'onRemoteA2aAgent) is still open.RemoteA2aAgent:RemoteA2aAgent(..., mode='single_turn')raises aValidationError, becausemodeisLiteral["task"] | None. The only sub-agent option there ismode="task", which only returns once the remote sendsfinish_task(RemoteA2aAgent(mode="task"): delegation to a non-ADK A2A agent never closes when the task ends inTASK_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
fix: surface HITL pauses in AgentTool instead of returning ""