Repository navigation
Description
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.pyL153:if data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME->TS_AUTH_REQUIRED, elseTS_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
- Install
google-adk[a2a]anda2a-sdk[http-server]==1.2.3. Run here againstmainat e3d0ec9. v2.13.0 has the same branches (event_converter.pyL544,long_running_functions.pyL153). - Save the script below as
repro.py. It serves each agent withA2aAgentExecutorin process overhttpx.ASGITransportand uses scripted models, so it needs no network or credentials. 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 reportsINPUT_REQUIRED.credential: the OAuth flow reportsAUTH_REQUIRED, which shows the executor does support the state.relayed: a coordinator delegating to theconfirmationagent withRemoteA2aAgent(mode="task")also reportsINPUT_REQUIREDfor 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:
mainat 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
msteiner-google commentedon Oct 29, 2026
Also, I will open a PR/CL if this is accepted.
claxman commentedon Oct 29, 2026
Picking this up. Both executor paths treat only
adk_request_credentialasTASK_STATE_AUTH_REQUIRED, soadk_request_confirmationstaysINPUT_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 staysINPUT_REQUIRED.claxman commentedon Oct 29, 2026
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.