diff --git a/workflows/README.md b/workflows/README.md index 11d979f..575ac15 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -2,47 +2,62 @@ Exported as JSON and committed. The n8n instance is not the source of truth. -## Importing +## Deploying a change -n8n's REST API, with an owner session cookie. The activation endpoint is -separate from the update one and requires the current `versionId`: +**n8n does not re-register an active workflow when you PATCH it.** The running +webhook keeps executing the definition it was activated with, so an edit appears +to have no effect — and the failure it keeps producing is the *old* one, which +is a very good way to spend an afternoon debugging something already fixed. + +Deactivate, apply, reactivate, in that order: ``` -POST /rest/workflows # import -POST /rest/workflows//activate # {"versionId": ""} +POST /rest/workflows//deactivate {"versionId": ""} +PATCH /rest/workflows/ {"nodes":…, "connections":…, "versionId": ""} +POST /rest/workflows//activate {"versionId": ""} ``` -`PATCH /rest/workflows/` with `{"active": true}` returns 200 and leaves the -workflow inactive, silently. Use the activate endpoint. +`versionId` changes at every step, so re-read it each time. And note +`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently: +activation has its own endpoint. ## approvisionnement-check-availability -**Not working yet.** The `Exchange token` node fails, and what it fails with is -worth recording: +Working. `POST /webhook/check-availability` with the end user's token in +`X-Subject-Token`: ``` -Keycloak 500, an HTML login page as the body -NullPointerException: Cannot invoke UserSessionModel.getAuthenticatedClientSessionByClient - because "userSession" is null - at V1TokenExchangeProvider.exchangeClientToOIDCClient +"Peut-on promettre S10_1949 ?" + → {"answer":"Yes — 1952 Alpine Renault 1300 (S10_1949) can be promised: + 7305 in stock, above the reorder threshold of 50.","escalate":false} + +"Peut-on promettre la Vespa ?" + → {"answer":"No product reference in the question…","escalate":true} ``` -Two things stand out. Keycloak answers a malformed token exchange with a **500 -and a rendered login page**, which tells a client nothing. And it took the **V1** -exchange path, not the Standard one every other agent in this estate uses -successfully. +It exchanges the token 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`. No `notifications/initialized` is needed. -What has been eliminated, each by hand against the same running Keycloak: +MCP rather than the REST route, which would have been one 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. -- the client and its secret — Keycloak logs - `kc.clientId: classic-models-agent-approvisionnement`, so it authenticated, - which also proves `$env` reaches the node -- the subject token — the *same* token replayed with curl exchanges fine (200) -- omitting `subject_token_type` — still 200 -- omitting `openid` from the scope — still 200 -- using `audience=` instead of a scope — a clean 400, not this +The product code is read with a regex, not a model. This agent's critical path +is rules; a question naming no product escalates rather than being guessed at. +The regex is loose after the underscore on purpose — a reference that looks +valid but does not exist must reach the catalog and come back as "no such +product", because answering "you named no product" would be false. -So n8n is sending something the reproduction attempts are not. The next step is -to capture the request as the node actually emits it: point the node at an -in-cluster echo service and read the body. That was set up and the run did not -reach it, which is itself unexplained and where this stopped. +### Known gap + +A product code that does not exist returns an **empty body** rather than that +message. The run completes, every node executes including the one that composes +the answer, and the response is still empty — so the fault is between the +composing node and the webhook response, not in the decision logic. +`alwaysOutputData` on the read node did not change it. + +For an autonomous agent, silence is the worst possible answer. Not shipped as +acceptable — recorded as unfinished. diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index 7d9f6a6..a7da365 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -139,11 +139,12 @@ "position": [ 660, 0 - ] + ], + "alwaysOutputData": true }, { "parameters": { - "jsCode": "// Pull the product code out of the question.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_\\d+[A-Za-z]*\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n" + "jsCode": "// Pull the product code out of the question.\n//\n// Deliberately loose on the part after the underscore: a reference that\n// looks like one but does not exist must reach the catalog and come back\n// as \"no such product\". Rejecting it here would answer \"you named no\n// product\", which is false and sends the caller looking in the wrong place.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_[A-Za-z0-9]+\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n" }, "id": "decide-the-product", "name": "Decide the product", @@ -156,7 +157,7 @@ }, { "parameters": { - "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst raw = $input.first().json;\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nconst rpc = JSON.parse(line.slice(5).trim());\nif (rpc.error) {\n // A refusal is the caller's own permissions being applied. Reported, never\n // worked around.\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n" + "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $input.first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nconst rpc = JSON.parse(line.slice(5).trim());\nif (rpc.error) {\n // A refusal is the caller's own permissions being applied. Reported, never\n // worked around.\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}.`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised.`,\n escalate: false,\n}}];\n" }, "id": "answer", "name": "Answer",