Skip to content

RemoteA2aAgent never follows an interrupted peer task (A2A spec 7.6.2): a peer resumed out-of-band leaves the caller stuck #7811

Description

@msteiner-google

Is your feature request related to a problem?

A2A spec v1.0 section 7.6.1 says an agent in TASK_STATE_AUTH_REQUIRED MUST receive the credential out-of-band (unless an in-band mechanism was negotiated) and "MAY immediately continue Task processing after receiving the credential, without a requirement that clients send a follow-up message". Section 7.6.2 then covers the client's side:

Clients may not be aware of when the agent receives credentials out-of-band and subsequently continues Task processing. If a client does not have an active response stream open with the agent, the client risks missing Task updates. To avoid this, a client SHOULD perform one of the following: Subscribe to a stream of events for the Task [...], Register a webhook [...], Begin polling the Task [...]

RemoteA2aAgent does none of the three. When a peer's task is interrupted, the caller's invocation ends, and nothing ever looks at that peer task again. There is no subscribe, get_task or create_task_push_notification_config call anywhere under src/google/adk/a2a/agent/ at e3d0ec9, although the a2a-sdk Client it uses offers all three.

So if the peer is resumed out-of-band (a human's decision posted straight to the task that owns the gated tool, which is what 7.6.1 and 7.6.3 describe), the peer completes and the caller never finds out. The caller's own task stays interrupted indefinitely. In a chain (7.6.2's "chain of Tasks in TASK_STATE_AUTH_REQUIRED"), only the bottom task unwinds and every task above it is stranded.

Today the only way an ADK chain unwinds is in-band: the decision has to be sent to the top task and relayed down through every caller (which works from 2.11.0, after #6721). An approval system that delivers to the agent that owns the tool, as 7.6.3 recommends ("provided directly to the agent"), leaves every caller behind.

Describe the solution you'd like

When RemoteA2aAgent (mode="task") leaves a peer task interrupted (INPUT_REQUIRED or AUTH_REQUIRED), follow it, using one of the 7.6.2 mechanisms the peer's card supports (subscribe if capabilities.streaming, a push config if capabilities.push_notifications, else get_task polling). When the peer task reaches a terminal state without a message from this caller, resume the caller's delegation node with the peer's result, as if the reply had arrived on the original stream.

I realise this needs somewhere to re-enter the caller after its invocation has ended, so it is more than a client-side change. Two smaller steps would already help:

  1. On the caller's next turn, before re-dispatching into an open delegation, get_task the peer task. If it is already terminal, close the delegation from that task (its finish_task, or its final message/artifact).
  2. A documented hook (callback or interceptor) that receives the peer's task id and context id when the delegation is interrupted, so an application can follow the task itself and resume the caller.

Describe alternatives you've considered

  • Delivering the decision in-band to the top task. It works, but it routes the credential through every intermediate agent, which is the opposite of 7.6.3.
  • Cancelling the callers' tasks after the owner confirms the effect. That cleans up the task store, but the caller's ADK session still holds an open delegation pointing at a completed peer task.
  • A retention sweep. It removes rows; it does not unstick a conversation.

Steps to reproduce

  1. Install google-adk[a2a] and a2a-sdk[http-server]==1.2.3. Run here against main at e3d0ec9 (2.13.0).
  2. Save the script below as repro.py. It serves both agents with A2aAgentExecutor in process over httpx.ASGITransport and uses scripted models, so it needs no network or credentials. It records every JSON-RPC request the coordinator sends the approver.
  3. python repro.py

Observed behavior

1. coordinator task: TASK_STATE_INPUT_REQUIRED
   approver  task: TASK_STATE_INPUT_REQUIRED
2. approver  task: TASK_STATE_COMPLETED ["done: {'status': 'deleted', 'table': 't'}"]
3. coordinator task: TASK_STATE_INPUT_REQUIRED (after 2 s)
   coordinator -> approver before the decision: ['SendStreamingMessage']
   coordinator -> approver after it: []

The same task states and the same traffic with A2aAgentExecutor(runner=runner, force_new_version=True).

  1. Both tasks pause on the approver's adk_request_confirmation.
  2. The decision goes straight to the approver's task. The tool runs and the task completes.
  3. The coordinator's task is still interrupted. After its one SendStreamingMessage, the coordinator never contacted the approver again: no SubscribeToTask, no GetTask, no push config.

Expected behavior

At step 3 the coordinator has learned that the approver's task completed (by subscription, push or polling) and has completed its own task with the approver's result:

3. coordinator task: TASK_STATE_COMPLETED

Environment details

  • ADK Library Version: main at e3d0ec9 (2.13.0)
  • a2a-sdk: 1.2.3
  • Python: 3.14
  • OS: Linux
  • Model: none (scripted BaseLlm). The behavior does not depend on the model.

Related: #7810 (the same flow reports INPUT_REQUIRED rather than AUTH_REQUIRED), #6721.

Minimal reproduction code

"""A peer task resumed out-of-band never resumes the RemoteA2aAgent caller.

coordinator (chat LlmAgent, ADK executor)
  --A2A, RemoteA2aAgent(mode="task")--> approver (LlmAgent, ADK executor)

approver's tool needs a human's confirmation, so both tasks pause. The human's
decision is then delivered straight to approver's task (out-of-band from the
coordinator's point of view, A2A spec 7.6.1). approver resumes and completes.
The coordinator is never told: it does not subscribe to, poll, or register a
push config for the peer task (spec 7.6.2), so its own task stays interrupted.
Scripted models: no network, no credentials.
"""

import asyncio
import json
import logging
import uuid
import warnings

import httpx
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import (
    add_a2a_routes_to_fastapi,
    create_agent_card_routes,
    create_jsonrpc_routes,
)
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentInterface
from fastapi import FastAPI
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
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 Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools.function_tool import FunctionTool
from google.genai import types
from google.protobuf.json_format import MessageToDict

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

H = {"A2A-Version": "1.0"}
approver_methods: list[str] = []  # JSON-RPC methods the approver receives
approver_pause: dict = {}  # task id, context id, confirmation id


def delete_records(table: str) -> dict:
    """Deletes every record in a table. Destructive: needs a human's approval."""
    return {"status": "deleted", "table": table}


class CallOnce(BaseLlm):
    """Calls one tool; once it has the tool's response, answers with text."""

    tool: str
    args: dict = {}

    async def generate_content_async(self, llm_request, stream=False):
        fr = llm_request.contents[-1].parts[-1].function_response
        if fr:
            part = types.Part(text=f"done: {fr.response}")
        else:
            part = types.Part(
                function_call=types.FunctionCall(name=self.tool, args=self.args)
            )
        yield LlmResponse(content=types.Content(role="model", parts=[part]))


def card(name):
    return AgentCard(
        name=name,
        description=name,
        version="1",
        capabilities=AgentCapabilities(streaming=True),
        default_input_modes=["text/plain"],
        default_output_modes=["text/plain"],
        supported_interfaces=[
            AgentInterface(url=f"http://{name}.test/a2a", protocol_binding="JSONRPC")
        ],
    )


def record_pause(event):
    """Remember the approver's paused task and its confirmation call id."""
    status = getattr(event, "status", None)
    if status is None or not status.HasField("message"):
        return
    for p in status.message.parts:
        if p.HasField("data"):
            d = MessageToDict(p.data)
            if d.get("name") == "adk_request_confirmation" and "args" in d:
                approver_pause.update(
                    task_id=event.task_id,
                    context_id=event.context_id,
                    confirmation_id=d["id"],
                )


class Recording(httpx.AsyncBaseTransport):
    """Log each JSON-RPC method sent to the approver, then pass it through."""

    def __init__(self, inner):
        self.inner = inner

    async def handle_async_request(self, request):
        if request.method == "POST":
            approver_methods.append(json.loads(request.content).get("method"))
        return await self.inner.handle_async_request(request)


def serve(agent, on_event=None):
    """Serve an ADK agent with ADK's own A2aAgentExecutor, in process."""
    runner = Runner(
        app_name=agent.name,
        agent=agent,
        session_service=InMemorySessionService(),
        auto_create_session=True,
    )
    c = card(agent.name)
    handler = DefaultRequestHandler(
        A2aAgentExecutor(runner=runner), InMemoryTaskStore(), c
    )
    if on_event:
        orig = handler.agent_executor.execute

        async def execute(context, queue):
            orig_enqueue = queue.enqueue_event

            async def enqueue(e):
                on_event(e)
                await orig_enqueue(e)

            queue.enqueue_event = enqueue
            await orig(context, queue)

        handler.agent_executor.execute = execute
    app = FastAPI()
    add_a2a_routes_to_fastapi(
        app,
        agent_card_routes=create_agent_card_routes(c),
        jsonrpc_routes=create_jsonrpc_routes(handler, rpc_url="/a2a"),
    )
    return httpx.ASGITransport(app=app)


async def rpc(client, name, method, params):
    body = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    r = await client.post(f"http://{name}.test/a2a", json=body, headers=H)
    res = r.json()["result"]
    return res.get("task", res)


async def main():
    approver_asgi = serve(
            LlmAgent(
                name="approver",
                model=CallOnce(
                    model="fake", tool="delete_records", args={"table": "t"}
                ),
                tools=[FunctionTool(delete_records, require_confirmation=True)],
            ),
            record_pause,
        )
    approver_t = Recording(approver_asgi)  # only the coordinator's traffic
    coordinator_t = serve(
        LlmAgent(
            name="coordinator",
            model=CallOnce(model="fake", tool="approver"),
            sub_agents=[
                RemoteA2aAgent(
                    name="approver",
                    description="approver",
                    agent_card=card("approver"),
                    mode="task",
                    httpx_client=httpx.AsyncClient(transport=approver_t),
                )
            ],
        )
    )
    to_approver = httpx.AsyncClient(transport=approver_asgi)  # the human's side, unrecorded
    to_coordinator = httpx.AsyncClient(transport=coordinator_t)

    # 1. The user asks the coordinator; the approver pauses for a human.
    msg = {"messageId": uuid.uuid4().hex, "role": "ROLE_USER", "parts": [{"text": "go"}]}
    coord = await rpc(to_coordinator, "coordinator", "SendMessage", {"message": msg})
    print("1. coordinator task:", coord["status"]["state"])
    print("   approver  task:", (await rpc(to_approver, "approver", "GetTask",
                                         {"id": approver_pause["task_id"]}))["status"]["state"])
    before = list(approver_methods)

    # 2. The human's decision goes straight to the approver's task.
    decision = {
        "messageId": uuid.uuid4().hex,
        "role": "ROLE_USER",
        "taskId": approver_pause["task_id"],
        "contextId": approver_pause["context_id"],
        "parts": [{
            "data": {
                "id": approver_pause["confirmation_id"],
                "name": "adk_request_confirmation",
                "response": {"confirmed": True},
            },
            "metadata": {"adk_type": "function_response"},
        }],
    }
    done = await rpc(to_approver, "approver", "SendMessage", {"message": decision})
    texts = [p.get("text") for a in done.get("artifacts", []) for p in a["parts"]]
    print("2. approver  task:", done["status"]["state"], texts)

    # 3. Later: the coordinator has not noticed.
    await asyncio.sleep(2)
    coord = await rpc(to_coordinator, "coordinator", "GetTask", {"id": coord["id"]})
    print("3. coordinator task:", coord["status"]["state"], "(after 2 s)")
    print("   coordinator -> approver before the decision:", before)
    print("   coordinator -> approver after it:", approver_methods[len(before):])


asyncio.run(main())

Activity

  1. Samecho commented on Oct 29, 2026

    @Samecho
    Contributor

    I would like to pick this up. I would start with the next-turn check you suggested: fetch the existing peer task before sending another message, and complete the delegation from its result if it has already finished. Is anyone already working on this, and would that be a useful first step?

  2. added
    a2a[Component] This issue is related a2a support inside ADK.
    on Oct 30, 2026
  3. surajksharma07 commented on Nov 3, 2026

    @surajksharma07
    Collaborator

    @msteiner-google Reproduced this on 2.13.0 with a2a-sdk 1.2.3: the approver completes but the coordinator never calls GetTask so it stays INPUT_REQUIRED.

    As a workaround for now try having your app poll the peer task until it’s terminal and then resume the coordinator, with a small RemoteA2aAgent subclass that calls get_task on the paused task first and closes the delegation from its artifacts if it’s already COMPLETED. Locally that gave the coordinator the approver’s real result instead of the “terminal state” error.

    Are your real peers all ADK agents or are there non-ADK ones too? That tells us whether the finish_task-based approach in #7849 would be enough on its own.

    @Samecho could you also test #7849 end to end against this exact out-of-band flow and confirm it works before we take it to review? The full subscribe/push follow-up probably needs a design call from the A2A maintainers.

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

Metadata

Metadata

Labels

a2a[Component] This issue is related a2a support inside ADK.

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions