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
+57
View File
@@ -0,0 +1,57 @@
"""One adapter, configured per agent.
Both n8n agents get the same image and differ only by environment. That is the
point: the A2A surface is identical for every agent in the estate, and what
distinguishes Approvisionnement from Recouvrement is the workflow behind the
webhook, not the protocol in front of it.
Nothing here is a secret. The adapter forwards the end user's token and holds
none of its own — the Keycloak client secret belongs to the workflow, stored as
an n8n credential, so the component that can mint tokens is the one that uses
them.
"""
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="ADAPTER_", env_file=".env")
# --- who this instance is ------------------------------------------------
agent_name: str = Field(..., description="e.g. approvisionnement")
agent_description: str = Field(...)
agent_version: str = "0.1.0"
# --- the one skill it exposes -------------------------------------------
# One skill per adapter instance, deliberately. An agent that grows a second
# skill is an agent whose card should say so explicitly, and reviewing that
# is the moment to ask whether it has quietly become a data proxy.
skill_id: str = Field(..., description="e.g. check_availability")
skill_name: str = Field(...)
skill_description: str = Field(...)
skill_examples: str = ""
"""Newline-separated. They go in the card, which is what a calling agent's
model reads to decide whether this is the specialist to ask."""
# --- the workflow behind it ---------------------------------------------
webhook_url: str = Field(..., description="the n8n Webhook node's production URL")
# A workflow is not a chat: it runs rules, calls APIs, and may wait on a
# slow backend. The default here is generous for that, not for a model.
webhook_timeout: float = 120.0
# --- transport -----------------------------------------------------------
ca_bundle: str | None = None
host: str = "0.0.0.0"
port: int = 8080
public_url: str = "http://localhost:8080/"
@property
def verify(self) -> str | bool:
return self.ca_bundle if self.ca_bundle else True
@property
def examples(self) -> list[str]:
return [line.strip() for line in self.skill_examples.splitlines() if line.strip()]