"""The A2A facade: an agent card, a task lifecycle, and a webhook behind it. The repo's own notes proposed serving this from the gateway instead — a static Agent Card and a route to the webhook, no new component. That does not reach. A2A is not request/response: a caller gets a `Task` with an id, a context, and a state that can be `input-required` or `failed`, and none of that can be synthesised from a webhook's single reply by a proxy. The gateway would have to template the whole lifecycle, at which point it is an adapter written in configuration. So: an adapter, written once and parameterised, which is the alternative those same notes anticipated. It is deliberately thin — it owns the protocol and nothing else. Every decision, every token exchange and every call to a domain happens in the workflow. The a2a-sdk facts below were established against the running library, not read: - `a2a.types` are protobuf messages, not pydantic models. - Use `DefaultRequestHandlerV2`; `DefaultRequestHandler` speaks 0.3 and rejects 1.0 traffic. `enable_v0_3_compat=True` keeps older callers working. - An executor must enqueue a `Task` before any status update when `context.current_task` is None, or the SDK answers "Agent should enqueue Task before TaskStatusUpdateEvent". """ from __future__ import annotations import contextlib import logging from collections.abc import AsyncIterator import httpx from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.request_handlers import DefaultRequestHandlerV2 from a2a.server.routes.agent_card_routes import create_agent_card_routes from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore, TaskUpdater from a2a.types import ( AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Part, Task, TaskState, TaskStatus, ) from starlette.applications import Starlette from .config import Settings logger = logging.getLogger(__name__) # The bearer the caller presents. It is the END USER's token, not the calling # agent's: the scoping model rests on `sub` staying the human throughout, while # `azp` names whichever agent is doing the asking. SUBJECT_TOKEN_HEADER = "authorization" # What the workflow reads it from. A header rather than the body, so a workflow # that logs its input does not log the token with it. WORKFLOW_TOKEN_HEADER = "x-subject-token" def build_agent_card(settings: Settings) -> AgentCard: return AgentCard( name=settings.agent_name, description=settings.agent_description, version=settings.agent_version, default_input_modes=["text/plain"], default_output_modes=["text/plain"], capabilities=AgentCapabilities(streaming=False), supported_interfaces=[ AgentInterface( url=settings.public_url, protocol_binding="JSONRPC", protocol_version="1.0", ) ], skills=[ AgentSkill( id=settings.skill_id, name=settings.skill_name, description=settings.skill_description, tags=[settings.agent_name], examples=settings.examples, ) ], ) class WorkflowExecutor(AgentExecutor): """Hands the question to the workflow and reports what it decided.""" def __init__(self, settings: Settings, http_client: httpx.AsyncClient) -> None: self._settings = settings self._http = http_client async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: updater = TaskUpdater(event_queue, context.task_id, context.context_id) if context.current_task is None: await event_queue.enqueue_event( Task( id=context.task_id, context_id=context.context_id, status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), ) ) await updater.start_work() subject_token = _subject_token(context) if subject_token is None: # Refused rather than answered. Without the end user's token the # workflow has no identity to act under, and an answer produced # anyway would be one nobody is accountable for. await updater.failed( message=updater.new_agent_message( parts=[Part(text="No bearer token on the request; the workflow cannot act.")] ) ) return try: response = await self._http.post( self._settings.webhook_url, headers={WORKFLOW_TOKEN_HEADER: subject_token}, json={ "question": context.get_user_input(), "context_id": context.context_id, "task_id": context.task_id, }, timeout=self._settings.webhook_timeout, ) except Exception as exc: # noqa: BLE001 — surfaced to the caller, logged here logger.exception("the workflow could not be reached") await updater.failed( message=updater.new_agent_message( parts=[Part(text=f"The workflow could not be reached: {exc}")] ) ) return if response.status_code >= 400: # The workflow's own body is the useful part — an n8n error carries # which node failed, which a status code does not. await updater.failed( message=updater.new_agent_message( parts=[Part(text=f"The workflow failed ({response.status_code}): " f"{response.text[:500]}")] ) ) return answer, escalate = _read(response) if escalate: # An autonomous agent that meets a rule needing approval must fail # cleanly and escalate, never decide. The caller sees a task that # did not complete, which is the honest shape for "a human has to # look at this". await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)])) return await updater.complete(message=updater.new_agent_message(parts=[Part(text=answer)])) async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: await TaskUpdater(event_queue, context.task_id, context.context_id).cancel() def _read(response: httpx.Response) -> tuple[str, bool]: """What the workflow said, and whether it is escalating. A workflow answers with `{"answer": "...", "escalate": false}`. Anything else is returned as raw text rather than guessed at — a workflow that has drifted from the contract should be visible, not silently reinterpreted. """ try: payload = response.json() except ValueError: return response.text.strip() or "(the workflow returned nothing)", False if isinstance(payload, list) and payload: # n8n returns an array when the workflow's last node emits items. payload = payload[0] if isinstance(payload, dict) and "answer" in payload: return str(payload["answer"]), bool(payload.get("escalate", False)) return response.text.strip() or "(the workflow returned nothing)", False def _subject_token(context: RequestContext) -> str | None: headers = {} call = context.call_context if call is not None: headers = {k.lower(): v for k, v in (call.state.get("headers") or {}).items()} raw = headers.get(SUBJECT_TOKEN_HEADER) if not raw: return None token = raw[7:] if raw[:7].lower() == "bearer " else raw return token.strip() or None def build_app(settings: Settings) -> Starlette: http_client = httpx.AsyncClient(verify=settings.verify) card = build_agent_card(settings) handler = DefaultRequestHandlerV2( agent_executor=WorkflowExecutor(settings, http_client), task_store=InMemoryTaskStore(), agent_card=card, ) @contextlib.asynccontextmanager async def lifespan(_app: Starlette) -> AsyncIterator[None]: yield await http_client.aclose() return Starlette( routes=create_agent_card_routes(card) + create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True), lifespan=lifespan, )