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.

  4. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Nov 5, 2026
  5. Samecho commented on Nov 5, 2026

    @Samecho
    Contributor

    The installed-wheel end-to-end test for #7849 (6219432) passed with both the legacy and V2 executors on a2a-sdk 1.2.3. The peer receives the approval directly and completes. On the caller's next turn, it sends GetTask, retrieves the peer's result, and completes. The tool runs once.

    The test uses an ADK task-mode peer that completes with finish_task, with the in-process transport and scripted models from the repro. Passing end-to-end test.

  6. surajksharma07 commented on Nov 9, 2026

    @surajksharma07
    Collaborator

    Thanks @Samecho, good to see the installed-wheel run pass on both executors. Main and v2.14.0 still never call GetTask, so #7849 is still needed. One gap: your E2E peer is a task-mode agent that ends with finish_task, but the approver in the original repro is a plain chat LlmAgent. With no finish_task, _read_completed_peer_task returns None and the caller still sends to the completed task and gets the terminal-state error. That’s fine as a first step if the PR says so and points to #7251 for the rest.

    Could you also rebase? The branch is ~160 commits behind. It merges cleanly, but 1cb926d on main now reads finish_task args out of unary task history in _find_finish_task_args_from_history, so the history walk in your PR could probably reuse it. A peer that ends FAILED or CANCELED also still goes down the send path, so the coordinator gets the terminal-state error, not the peer’s own failure message.

    @msteiner-google are your real peers all ADK task-mode agents? That decides whether #7849 alone unblocks you. Keeping this open for the subscribe/push/polling part.

  7. Samecho commented on Nov 10, 2026

    @Samecho
    Contributor

    Thanks, I rebased #7849 onto main and updated the PR description. The next-turn GetTask check covers ADK task-mode peers that complete successfully with finish_task. Completion without that marker, including the plain chat approver, is tracked in #7251. FAILED and CANCELED still follow the existing send path.

    I kept the lookup scoped to the returned peer task so it matches the call ID and uses agent-authored history. The main helper can also search the caller's session. The A2A and agent registry suites passed with a2a-sdk 1.2.3 and 0.3.26. I also reran the installed-wheel test on both executors. The caller used GetTask on its next turn and recovered the result, and the tool ran once.

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.needs review[Status] The PR/issue is awaiting review from the maintainer

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions