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:
@@ -0,0 +1,96 @@
|
||||
"""What the adapter must do regardless of any workflow.
|
||||
|
||||
The adapter owns the protocol and nothing else, so what is worth testing is the
|
||||
protocol edges: a token that is absent or oddly cased, a workflow that answers
|
||||
off-contract, and the escalation flag that must not be silently swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from a2a_n8n_adapter.config import Settings
|
||||
from a2a_n8n_adapter.server import _read, build_agent_card
|
||||
|
||||
|
||||
def settings(**over) -> Settings:
|
||||
base = dict(
|
||||
agent_name="approvisionnement",
|
||||
agent_description="Supply decisions.",
|
||||
skill_id="check_availability",
|
||||
skill_name="Check availability",
|
||||
skill_description="Can this be promised?",
|
||||
skill_examples="Can we promise S10_1949 by 15 March?\nIs S18_3782 running out?",
|
||||
webhook_url="http://n8n/webhook/x",
|
||||
)
|
||||
return Settings(**{**base, **over})
|
||||
|
||||
|
||||
def response(payload, status=200) -> httpx.Response:
|
||||
if isinstance(payload, str):
|
||||
return httpx.Response(status, text=payload)
|
||||
return httpx.Response(status, json=payload)
|
||||
|
||||
|
||||
class TestReadingTheWorkflow:
|
||||
def test_the_contracted_shape(self) -> None:
|
||||
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False)
|
||||
|
||||
def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
|
||||
assert _read(response([{"answer": "yes"}])) == ("yes", False)
|
||||
|
||||
def test_escalation_is_carried_not_swallowed(self) -> None:
|
||||
# An autonomous agent meeting a rule that needs approval must escalate.
|
||||
# Losing this flag would turn "a human must look" into "done".
|
||||
assert _read(response({"answer": "needs approval", "escalate": True})) == (
|
||||
"needs approval",
|
||||
True,
|
||||
)
|
||||
|
||||
def test_an_off_contract_reply_is_returned_raw(self) -> None:
|
||||
# Visible rather than reinterpreted: a workflow that has drifted should
|
||||
# look wrong, not be quietly guessed at.
|
||||
text, escalate = _read(response("plain text"))
|
||||
assert text == "plain text" and escalate is False
|
||||
|
||||
def test_an_empty_reply_says_so(self) -> None:
|
||||
assert _read(response("")) == ("(the workflow returned nothing)", False)
|
||||
|
||||
|
||||
class TestAgentCard:
|
||||
def test_it_advertises_exactly_one_skill(self) -> None:
|
||||
# One skill per adapter instance. A second one is a card change, which
|
||||
# is the moment to ask whether the agent has become a data proxy.
|
||||
card = build_agent_card(settings())
|
||||
assert len(card.skills) == 1
|
||||
assert card.skills[0].id == "check_availability"
|
||||
|
||||
def test_examples_reach_the_card(self) -> None:
|
||||
# A calling agent's model reads these to decide whether to ask at all.
|
||||
card = build_agent_card(settings())
|
||||
assert len(card.skills[0].examples) == 2
|
||||
|
||||
def test_the_card_advertises_the_public_url_not_the_bind_address(self) -> None:
|
||||
card = build_agent_card(settings(public_url="http://approvisionnement.agents:8080/"))
|
||||
assert card.supported_interfaces[0].url == "http://approvisionnement.agents:8080/"
|
||||
|
||||
|
||||
class TestSubjectToken:
|
||||
@pytest.mark.parametrize("raw", ["Bearer abc", "bearer abc", "BEARER abc", "abc"])
|
||||
def test_the_prefix_is_optional_and_case_insensitive(self, raw: str) -> None:
|
||||
from a2a_n8n_adapter.server import _subject_token
|
||||
|
||||
class Ctx:
|
||||
class call_context: # noqa: N801
|
||||
state = {"headers": {"Authorization": raw}}
|
||||
|
||||
assert _subject_token(Ctx()) == "abc"
|
||||
|
||||
def test_an_absent_token_is_none_rather_than_an_empty_string(self) -> None:
|
||||
from a2a_n8n_adapter.server import _subject_token
|
||||
|
||||
class Ctx:
|
||||
call_context = None
|
||||
|
||||
assert _subject_token(Ctx()) is None
|
||||
Reference in New Issue
Block a user