From 6eb77d862f28646b9783b3ad351abf8a1afad46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Mon, 31 Aug 2026 14:39:03 +0200 Subject: [PATCH] Stop reporting a silent workflow as a completed task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n8n answers 200 with an empty body when a node throws before the Respond node runs — a refused token exchange is the common way in. The adapter read that as a completed task carrying "(the workflow returned nothing)", which tells the caller the task succeeded: the one thing certainly false. Off-contract replies get the same treatment. They are still returned raw so drift is visible, but a body that is not the contract cannot be read as agreement. Also removes a backlog section that described the events as unbuilt and said an event should carry sub and azp from the caller's token — the design the user overruled. Anyone reading it would have built the wrong consumer. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 67 ++++++++---------- .../__pycache__/server.cpython-312.pyc | Bin 10290 -> 10869 bytes adapter/src/a2a_n8n_adapter/server.py | 54 ++++++++++---- .../test_adapter.cpython-312-pytest-9.1.1.pyc | Bin 16216 -> 17212 bytes adapter/tests/test_adapter.py | 22 ++++-- 5 files changed, 85 insertions(+), 58 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a6d2108..e64395c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,56 +118,49 @@ Recouvrement's dunning is the candidate, and it is a human-validated step already. The `classic-models-mandate` client is left in the realm, unused, for that case. -## Backlog +## Events over Kafka — built, and one rule about them -**Events over Kafka, emitted by `classic-models-api`.** The target for the -reactive half. The estate emits no domain event today — the API has no signals -and no outbound webhook, and there is no bus in the cluster — which is what made -a nightly sweep look like the only option. +Built 2026-08-31. `classic-models-api` emits domain events through a +transactional outbox; a relay publishes them; `approvisionnement-stock-watch` +consumes `product.stock_changed` and is the reactive half this repo had been +waiting on. The contract is `classic-models-api/docs/DOMAIN_EVENTS.md` — read it +before writing a consumer. -The emission belongs **in the API**, not in a database CDC and not in a gateway -plugin. Only the API knows which change is a business event rather than a row -update, and only it holds the identity that made it — `sub` and `azp` from the -caller's token, which the event has to carry if a downstream agent is to act -under the same authority as the person who triggered it. +**An event carries no actor, and that is deliberate.** -Two facts about that codebase shape the work: +An earlier draft of this file said the opposite — that the event had to carry +`sub` and `azp` from the caller's token so a downstream agent could act under +the same authority. The user overruled it: -- Every write goes through one `BaseModelViewSet` in - `api/v1/classicmodels/views.py`. One hook there covers products, orders, - payments and the rest — Django signals scattered across models would be the - harder version of the same thing. -- **The models are `managed = False`.** The tables are the pre-existing - classicmodels schema and Django owns no migrations for them. A transactional - outbox means introducing the estate's first managed table beside unmanaged - ones — which works, and is worth deciding deliberately rather than - discovering. +> *"Nous gérons ici des événements, pas des 'commandes'. L'agent s'exécute avec +> sa propre identité machine, on se fout de savoir qui est à l'origine de +> l'émission de l'événement."* -An outbox is the part that matters: emitting from a viewset after `save()` drops -the event if the process dies between the two, and an agent that never hears -about a stock movement is worse than one that hears late. +The reasoning, kept because it generalises: an event is a **fact**, not a +command. A consumer acts under its own identity. An actor field would have had +no consumer and one obvious misuse — anyone able to write to the topic could name +any actor and have an agent act as them. A signed token is proof; a JSON field +is not. Causality travels as `traceparent`, in the system that exists for it. -Until this exists, detection stays unbuilt rather than being approximated by a -periodic scan: a batch that pretends to be reactive is worse than an absent -feature, because it looks finished. - -The periodic ordering run does not wait on any of it. +The emission still belongs **in the API** rather than in a database CDC or a +gateway plugin: only the API knows which change is a business event rather than +a row update. ## Two controls that become mandatory here -They were deferrable with a human in the loop. They are not, once an agent -loops on its own. +Both **built** 2026-08-31, before the first agent looped unattended. -- **Read volume caps per `(azp, sub)`**, enforced on Kong. An agent reads a - thousand records in seconds where a human clicks ten; a scope authorises the - read but says nothing about volume. +- **Read volume caps per `(azp, sub)`** — a scope authorises a read and says + nothing about volume; an agent reads a thousand records where a human clicks + ten. - **Authorisation on tool-call parameters**, not just the tool name. `write` authorises writing, not "this €40k transfer". -An autonomous agent hitting a rule that requires approval must **fail cleanly -and escalate**, never decide. And any approval screen must be rendered from the -structured tool-call parameters — never from a sentence the model produced, or a -prompt injection writes what the human validates. +The rules that stay true whatever is built next: an autonomous agent hitting a +rule that requires approval must **fail cleanly and escalate**, never decide. +And any approval screen must be rendered from the structured tool-call +parameters — never from a sentence the model produced, or a prompt injection +writes what the human validates. ## Before writing workflows diff --git a/adapter/src/a2a_n8n_adapter/__pycache__/server.cpython-312.pyc b/adapter/src/a2a_n8n_adapter/__pycache__/server.cpython-312.pyc index 44b643c5ae3b8da93e623fa4a619af1a05863fa8..3502eb4e673807ae6364d9551e8198f911f466bd 100644 GIT binary patch delta 3144 zcmai0Z)_CD72mnr`+w*2*>}G4AIuu#Kj#>`B|s>#(;8#LIgW9#fv|)W+`_U_#= zyJus^^h}E)P@n+~D2TSSs`{abkVdsqvYB!N=?n{-n-&(1rxKc~2s_*UL zfN79fcv?{U6Wc$=DTD4dWbb;(ww`w(F4dE=D!jE<1iZ?c; z?iR^&#Co|T(0b^Rtm}{66vPZ7% zVLvhg06BL$Go&Ws0~xB)BG&HSMrzne_nWqzAly6mFZWea{yge=Mhzo95LXjQ($H~{ zf*{Zx>|Jkt`5=t+LV#IrrL^iCf1zL{B%I?fy6GPI19GE-g>$VEUF3>aE z*zR@eNuMF?M&U{FA}b7NfrS^iOXR`_CQiJ-UHB*aZeT$G7J!-)RkZwUE0(5_9o)Lu~{G_ex02%ijyzklxg`D;D5BE+#QQ z|8bB; z?bi9`vNmVeHtWI`(p6($sJ0Ve49z#&Z{ zq!7{wHiXv!a;}cP_O|Y}eh624U#z>Mr$6UC*x5PIez2$iP+NOHJ&CGM_6V0}uJJYy zdL73No14`8z|Iv0-Zit2WIJq z0L2I#R#THgnx^DTT#+Gk1u3adDzr(68G;6B6#N)UQqUCO3lb>E3izeUq%fomrzj2- zfhuWrN|=NQAIcW*k!k^}0~ceMC|-N(ZGWN;AMZpVF1ripf z=^Q_llBaTxv^1rrB-xxs$FRgL$S0HIXoBz&{PkskS#H(Md4q4ao^M_DR4#ccKl2E) z`|f-Et32`bl0SNj-|jizv+Cdr-K!k$a<6)=wg%>_+>s3gFCD%1+ES?Yvq0TB`-(sG z)BQht`R2nhUQn-H6Ke*@|{Ji(*a_`_$?_hqo;_+VzF9&Lt0yT@C znnk|m?`r|hTk=d5Em82hnM?>AFvFKQqx&)pZ!TF4&udUc8K zC1B`yF2XF-yff7euQdSj8(JM8l#2kYs^9}Q8axM%cc|c01=?#$lf&FId#Vn>c zSi_TQV%7{y7?004Z z77bF1s=P$87-3P-ud^F<5$o@`a`ub5*P34;J(=1qu{>K&_m@$gj>E zCEI;0z;C~uotd56ot@=pU;f!C->)kwJOtXizxd|#Wb+kYgzmo5zHgZlnaXyxOe%xk zp}I7;Qj8KCQH1FM zjQKyC(sF~ABvPyFm)hij)GmkR$_s)Nw~;|2S6v`-P;uX|Z)+vwh+GY`yXB}Hg8nJF zR<415&%E81s9Rm6O^(&;&WPP9f&pvhx7-ud$2&Zo;R+P^0ZdP!IIdPGTb6=pF@2)&d5AkMKkb5i7 z)Epw0>B=uHyk_CVA+qup{%Pe}=U}t93TZV$kcXep;-#K>I6}jlV z?E1tT-1P85c#OWte;Yo({BX0LRHW#`O($`W(a-f&Fg%|;GV&CCzlRQW3-9-af#0mA zLx+W%p(KUD2Q_r4NBAJ@K;Dq-9;W2OARRg;d{`|Yk0k41=A(mjq*eH+D~W2ij?j^I z;nvYE;J5eFk%VyjfD`%PVf1*r$HHS2%y5cb1XvbiGtw2v@sp6_DFlLEG*I)!V(7FMyr9XfVMluz`!K6+9I7k z8r&H??q!E;+i|#KG+{dp5l;(`wFu$gh{pEMQQc;hCL5~kBF1*ygtQR0i~e7u<|mCZ zw&%!cGE3&{&k?wN&SEQU5Fco4tWk_9g^5{1%+!#~ooH$ujbRFjn@zZnP64T;rp1m-k=0SA|WK&=# zv+RVS$d=0qme*iL5i|Lm!O|H+)Ti=ARTh`W6>%o3XUA1#kC@AYJgY(-Q|BRsY|cH@xCKZ~uBr|9i!C?}>Hc#KQ-EQeN{;1Bz+#b1s^`0a~)q&^g=$TD^>+t2pC0pR=y4yut&rU7y}=V%N_by@W7c1-za+7jsn+A=Nu?%=aF>)+ib2sL>m3*9qI85 z!?M6G@&35jh@Z%?#F~}q&U6QaH>0o)Wd@&lR>oh7H&YkC7Ee$&|6RPT3%AV-JfUf% z^%r#JMK4(0OgGLM^94m`)}IXA2recXm(3cu0SsPJL+OAsh5sCBDIg)R8gnp F``^CUgnj@3 diff --git a/adapter/src/a2a_n8n_adapter/server.py b/adapter/src/a2a_n8n_adapter/server.py index ceb774c..c1c1418 100644 --- a/adapter/src/a2a_n8n_adapter/server.py +++ b/adapter/src/a2a_n8n_adapter/server.py @@ -27,6 +27,7 @@ from __future__ import annotations import contextlib import logging +from enum import Enum, auto from collections.abc import AsyncIterator import httpx @@ -152,12 +153,14 @@ class WorkflowExecutor(AgentExecutor): ) 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". + answer, outcome = _read(response) + if outcome is not Outcome.ANSWERED: + # Three different failures, one honest shape. An agent that met a + # rule needing approval must escalate rather than decide; a workflow + # that answered nothing did not answer; a workflow that answered off + # contract cannot be read as agreement. `completed` on any of them + # tells the caller the task succeeded, which is the one thing that is + # certainly false. await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)])) return @@ -167,23 +170,46 @@ class WorkflowExecutor(AgentExecutor): 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. +class Outcome(Enum): + """What came back, which is not the same question as whether HTTP worked.""" - 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. + #: The contract was honoured and the workflow is standing behind its answer. + ANSWERED = auto() + #: The workflow answered, and is handing the decision to a human. + ESCALATED = auto() + #: HTTP 200 with an empty body. n8n does that when a node throws before the + #: Respond node runs — a failed token exchange is the common way in. It is a + #: failure that looks exactly like a success from the status code alone. + SILENT = auto() + #: A body that is not the contract. Returned raw rather than guessed at: a + #: workflow that has drifted should be visible, not reinterpreted. + OFF_CONTRACT = auto() + + +def _read(response: httpx.Response) -> tuple[str, Outcome]: + """What the workflow said, and what kind of thing it said. + + A workflow answers with `{"answer": "...", "escalate": false}`. """ + body = response.text.strip() + if not body: + return ( + "The workflow produced no answer. It most often means a node threw " + "before the reply was built — a refused token exchange, usually.", + Outcome.SILENT, + ) + try: payload = response.json() except ValueError: - return response.text.strip() or "(the workflow returned nothing)", False + return body, Outcome.OFF_CONTRACT 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 + answer = str(payload["answer"]) + return answer, Outcome.ESCALATED if payload.get("escalate", False) else Outcome.ANSWERED + return body, Outcome.OFF_CONTRACT def _subject_token(context: RequestContext) -> str | None: diff --git a/adapter/tests/__pycache__/test_adapter.cpython-312-pytest-9.1.1.pyc b/adapter/tests/__pycache__/test_adapter.cpython-312-pytest-9.1.1.pyc index a9816cb30f70ad471695d2571d847337d32685b5..9319c007a06adef5fa77d0d4e6e6ecd58f4f1b40 100644 GIT binary patch delta 4452 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 diff --git a/adapter/tests/test_adapter.py b/adapter/tests/test_adapter.py index 5e88d6a..1708893 100644 --- a/adapter/tests/test_adapter.py +++ b/adapter/tests/test_adapter.py @@ -11,7 +11,7 @@ import httpx import pytest from a2a_n8n_adapter.config import Settings -from a2a_n8n_adapter.server import _read, build_agent_card +from a2a_n8n_adapter.server import Outcome, _read, build_agent_card def settings(**over) -> Settings: @@ -35,27 +35,35 @@ def response(payload, status=200) -> httpx.Response: class TestReadingTheWorkflow: def test_the_contracted_shape(self) -> None: - assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False) + assert _read(response({"answer": "yes", "escalate": False})) == ("yes", Outcome.ANSWERED) def test_n8n_wraps_its_last_node_in_an_array(self) -> None: - assert _read(response([{"answer": "yes"}])) == ("yes", False) + assert _read(response([{"answer": "yes"}])) == ("yes", Outcome.ANSWERED) 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, + Outcome.ESCALATED, ) 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 + # Off contract is not agreement. Returned raw so the drift is visible, + # but never as a completed answer. + text, outcome = _read(response("plain text")) + assert text == "plain text" and outcome is Outcome.OFF_CONTRACT def test_an_empty_reply_says_so(self) -> None: - assert _read(response("")) == ("(the workflow returned nothing)", False) + # HTTP 200 with an empty body is what n8n returns when a node throws + # before the reply is built — a refused token exchange, usually. Reading + # it as a completed answer told the caller the task succeeded, which was + # the one thing certainly false. + text, outcome = _read(response("")) + assert outcome is Outcome.SILENT + assert "no answer" in text class TestAgentCard: