feat: an A2A facade for the n8n agents, and Approvisionnement's first workflow

The adapter is written once and parameterised, so both n8n agents get the same
A2A surface and differ only in the workflow behind the webhook. It owns the
protocol and nothing else: no Keycloak client, no credentials, and the end
user's token forwarded in a header so a workflow that logs its input does not
log the token with it. escalate: true fails the task rather than completing it,
because an autonomous agent meeting a rule that needs approval must hand it
back, not decide.

Serving this from the gateway was the repo's stated preference and does not
reach: A2A returns a Task with a lifecycle, and a proxy cannot synthesise that
from a webhook's single reply.

The check_availability workflow does the token exchange itself, using the
agent's client secret from the pod environment so the committed JSON holds no
secret, then talks MCP in two calls — initialize to obtain the session id from
a response header, then tools/call. It reads the product code with a regex
rather than a model: this agent's critical path is rules, and a question naming
no product escalates instead of being guessed at.

MCP rather than the REST route, which would have been one HTTP call instead of
two: the per-tool ACL by consumer group lives on the MCP route only. Going
through REST would give an autonomous agent broader access than a conversational
one, which is exactly backwards.

Status: the adapter is tested (13 tests) and the identity chain is verified by
hand, but the workflow fails at its token exchange and is not yet working —
see the note in workflows/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-29 13:07:02 +02:00
parent 411086b0b6
commit d9c376facf
13 changed files with 767 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
"""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,
)