Skip to content

A2aAgentExecutor reports a tool awaiting human confirmation as INPUT_REQUIRED, not AUTH_REQUIRED (A2A spec 7.6) #7810

Description

@msteiner-google

Is your feature request related to a problem?

A2A spec v1.0 section 7.6 (In-Task Authorization) names "an agent requiring human approval before a destructive action is taken" as one of its two examples. Section 7.6.1 says an agent requesting authorization from its client MUST move the task to TASK_STATE_AUTH_REQUIRED. Section 7.6.2 lets a client that is itself an agent pass the request up by moving its own task to TASK_STATE_AUTH_REQUIRED, "forming a chain of Tasks in TASK_STATE_AUTH_REQUIRED".

ADK's tool confirmation (FunctionTool(require_confirmation=True), tool_context.request_confirmation()) is how ADK asks a human to approve a tool call. But when the agent is served by A2aAgentExecutor, that pause is reported as TASK_STATE_INPUT_REQUIRED. TASK_STATE_AUTH_REQUIRED is reserved for the OAuth credential flow (adk_request_credential):

  • legacy executor: converters/event_converter.py, create_running_task_status_event (L568 at e3d0ec9): is_euc_call -> TS_AUTH_REQUIRED, any other long-running call -> TS_INPUT_REQUIRED
  • new executor: converters/long_running_functions.py L153: if data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME -> TS_AUTH_REQUIRED, else TS_INPUT_REQUIRED

So for an A2A client, an ADK agent waiting for a human to approve a destructive action looks exactly like one asking a clarifying question. A client (or a non-ADK coordinator) that routes approvals differently from questions cannot tell them apart. An ADK agent sitting between the human and the tool reports INPUT_REQUIRED too, so the 7.6.2 chain never forms.

Describe the solution you'd like

Report a pending adk_request_confirmation as TASK_STATE_AUTH_REQUIRED, in both executor paths. That means applying the existing adk_request_credential check to adk_request_confirmation as well. If changing the default is too disruptive, an opt-in on A2aAgentExecutorConfig would also work.

The resume side needs no change. RemoteA2aAgent and to_adk_event.py already treat TS_AUTH_REQUIRED and TS_INPUT_REQUIRED as the same interrupted, resumable state (_TASK_RESPONSE_BOUNDARY_STATES). The response to the confirmation keeps the same shape.

Per 7.6.4, the state would not authorise anything by itself. The tool still runs only when ToolConfirmation.confirmed is true.

Describe alternatives you've considered

We use an ExecuteInterceptor.after_agent that rewrites INPUT_REQUIRED to AUTH_REQUIRED when the status message carries an adk_request_confirmation call. It works, but every A2A deployment has to rediscover it, and it relies on ADK's internal call name.

Steps to reproduce

  1. Install google-adk[a2a] and a2a-sdk[http-server]==1.2.3. Run here against main at e3d0ec9. v2.13.0 has the same branches (event_converter.py L544, long_running_functions.py L153).
  2. Save the script below as repro.py. It serves each agent with A2aAgentExecutor in process over httpx.ASGITransport and uses scripted models, so it needs no network or credentials.
  3. python repro.py

Observed behavior

confirmation  TASK_STATE_INPUT_REQUIRED  ['adk_request_confirmation']
credential    TASK_STATE_AUTH_REQUIRED   ['adk_request_credential']
relayed       TASK_STATE_INPUT_REQUIRED  ['adk_request_confirmation']

The same output with A2aAgentExecutor(runner=runner, force_new_version=True).

  • confirmation: a tool awaiting human approval reports INPUT_REQUIRED.
  • credential: the OAuth flow reports AUTH_REQUIRED, which shows the executor does support the state.
  • relayed: a coordinator delegating to the confirmation agent with RemoteA2aAgent(mode="task") also reports INPUT_REQUIRED for its own task.

Expected behavior

confirmation  TASK_STATE_AUTH_REQUIRED   ['adk_request_confirmation']
credential    TASK_STATE_AUTH_REQUIRED   ['adk_request_credential']
relayed       TASK_STATE_AUTH_REQUIRED   ['adk_request_confirmation']

Environment details

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

Minimal reproduction code

"""A tool awaiting human confirmation is reported as INPUT_REQUIRED, not AUTH_REQUIRED.

Each case serves an ADK agent with ADK's own A2aAgentExecutor, in process, and
sends it one blocking A2A SendMessage. Scripted models: no network, no creds.

  confirmation  tool with require_confirmation=True    -> adk_request_confirmation
  credential    tool calling request_credential (OAuth) -> adk_request_credential
  relayed       a chat agent delegating (RemoteA2aAgent mode="task") to the
                confirmation agent: the state its *own* task reports
"""

import asyncio
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 fastapi.openapi.models import OAuth2, OAuthFlowAuthorizationCode, OAuthFlows
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.auth.auth_credential import AuthCredential, AuthCredentialTypes, OAuth2Auth
from google.adk.auth.auth_tool import AuthConfig
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.adk.tools.tool_context import ToolContext
from google.genai import types

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


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


def read_calendar(tool_context: ToolContext) -> dict:
    """Reads the user's calendar. Needs an OAuth token."""
    tool_context.request_credential(
        AuthConfig(
            auth_scheme=OAuth2(
                flows=OAuthFlows(
                    authorizationCode=OAuthFlowAuthorizationCode(
                        authorizationUrl="https://example.test/auth",
                        tokenUrl="https://example.test/token",
                        scopes={"calendar": "read"},
                    )
                )
            ),
            raw_auth_credential=AuthCredential(
                auth_type=AuthCredentialTypes.OAUTH2,
                oauth2=OAuth2Auth(client_id="id", client_secret="secret"),
            ),
        )
    )
    return {"status": "pending"}


class CallOnce(BaseLlm):
    """Calls one tool, then (if ever resumed) says done."""

    tool: str
    args: dict = {}

    async def generate_content_async(self, llm_request, stream=False):
        if llm_request.contents[-1].parts[-1].function_response:
            part = types.Part(text="done")
        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 serve(agent):
    """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
    )
    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.AsyncClient(transport=httpx.ASGITransport(app=app))


async def task_state(name, client):
    """Send one blocking SendMessage and return the task's state and call names."""
    body = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "SendMessage",
        "params": {
            "message": {
                "messageId": uuid.uuid4().hex,
                "role": "ROLE_USER",
                "parts": [{"text": "go"}],
            }
        },
    }
    r = await client.post(
        f"http://{name}.test/a2a", json=body, headers={"A2A-Version": "1.0"}
    )
    task = r.json()["result"]["task"]
    calls = [
        p["data"].get("name")
        for p in task["status"].get("message", {}).get("parts", [])
        if "data" in p
    ]
    return task["status"]["state"], calls


def approver():
    return LlmAgent(
        name="approver",
        model=CallOnce(model="fake", tool="delete_records", args={"table": "t"}),
        tools=[FunctionTool(delete_records, require_confirmation=True)],
    )


async def main():
    cases = {
        "confirmation": approver(),
        "credential": LlmAgent(
            name="calendar",
            model=CallOnce(model="fake", tool="read_calendar"),
            tools=[read_calendar],
        ),
        "relayed": LlmAgent(
            name="coordinator",
            model=CallOnce(model="fake", tool="approver"),
            sub_agents=[
                RemoteA2aAgent(
                    name="approver",
                    description="approver",
                    agent_card=card("approver"),
                    mode="task",
                    httpx_client=serve(approver()),
                )
            ],
        ),
    }
    for label, agent in cases.items():
        state, calls = await task_state(agent.name, serve(agent))
        print(f"{label:13} {state:26} {calls}")


asyncio.run(main())

Activity

  1. msteiner-google commented on Oct 29, 2026

    @msteiner-google
    Author

    Also, I will open a PR/CL if this is accepted.

  2. claxman commented on Oct 29, 2026

    @claxman
    Contributor

    Picking this up. Both executor paths treat only adk_request_credential as TASK_STATE_AUTH_REQUIRED, so adk_request_confirmation stays INPUT_REQUIRED.

    I will treat a confirmation call the same as the credential call in the legacy status update and in LongRunningFunctions. A unit test on each path fails while the state stays INPUT_REQUIRED.

  3. claxman commented on Oct 29, 2026

    @claxman
    Contributor

    Taking this.

    Both executors map only adk_request_credential to TASK_STATE_AUTH_REQUIRED, so adk_request_confirmation is reported as INPUT_REQUIRED. I am treating the confirmation call the same as the credential call in the legacy status event and in LongRunningFunctions. Other long-running calls stay input_required.

  4. added
    a2a[Component] This issue is related a2a support inside ADK.
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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