diff --git a/workflows/README.md b/workflows/README.md index 9cb680d..4cd479d 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -88,3 +88,57 @@ Now: and through the A2A facade, `escalate: true` becomes a task in state **failed** rather than a completed one — the honest shape for "a human has to look at this". + +## approvisionnement-periodic-reorder + +The periodic half. A schedule trigger at 06:00, deliberately periodic rather +than a batch standing in for events: grouping references into a purchase +proposal follows a buying cycle, while *detecting* a coming stock-out is +reactive and waits for Kafka. + +Verified end to end on the live catalog: + +``` +scanned 110 of 110, complete +1 reference below the threshold of 50: + S24_2000 1960 BSA Gold Star DBD34 (Highway 66 Mini Classics) + on hand 15 → propose ordering 135 +escalate: true +``` + +### It stops without a mandate, and that is the point + +No user session exists at 06:00, so authority has to be pre-established. The +design calls for an `offline_access` mandate — the authorising user's refresh +token, stored encrypted, exchanged at trigger time, so `sub` stays the person +who mandated and `azp` says which agent acted. + +**That does not exist yet.** The first node looks for +`APPROVISIONNEMENT_MANDATE_TOKEN` and, finding none, halts before any exchange +and before reading anything. Verified: the run completes, `Exchange token` never +executes, nothing is read. An agent without an identity does nothing rather than +inventing an authority. + +### The proposal has nowhere to go + +It is computed and escalated, never written, because there is nothing to write +it to: the `classicmodels` schema has no purchase order and no supplier entity — +`productvendor` is a string on the product — and the catalog's only write tool +is `create-product`, which is the Catalogue agent's business. + +So `catalog:write` was **not** granted to this agent, contrary to the design's +scope list. A scope with no reachable target is an authorisation waiting for a +use, which is the wrong order. + +Closing this needs a `reorders` entity in the domain, or a channel to a human — +there is no mail server in the cluster either. Same shape as the design's own +note that a Livraison agent needs a `shipments` domain first. + +### It scans one page, on purpose + +200 covers the 110 references, and the node reports `complete: false` and +escalates if it ever does not. A proposal set computed from part of the catalog +looks exactly like one computed from all of it, so the difference has to be in +the output. A catalog that outgrows one page needs a paging loop **and** the +read-volume cap this repo already lists as mandatory for autonomous agents. + diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index 03d74f0..46b09d5 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -157,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 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\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\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\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = 'stock on hand; quantities committed on open orders are not deducted';\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 + `Basis: ${basis}.`\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 + `Basis: ${basis}.`,\n escalate: false,\n}}];\n" }, "id": "answer", "name": "Answer", diff --git a/workflows/approvisionnement-periodic-reorder.json b/workflows/approvisionnement-periodic-reorder.json new file mode 100644 index 0000000..b5c761d --- /dev/null +++ b/workflows/approvisionnement-periodic-reorder.json @@ -0,0 +1,293 @@ +{ + "name": "Approvisionnement \u2014 periodic reorder proposal", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "days", + "triggerAtHour": 6 + } + ] + } + }, + "id": "every-morning", + "name": "Every morning", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "jsCode": "// No user session exists at 6am, so the authority has to be pre-established.\n//\n// NOT IMPLEMENTED. The design calls for an offline_access mandate: the user who\n// authorises the agent obtains a refresh token, it is stored encrypted, and the\n// exchange happens at trigger time \u2014 so `sub` stays the person who mandated and\n// `azp` says which agent acted.\n//\n// Until that exists this run has no identity of its own. It reads\n// APPROVISIONNEMENT_MANDATE_TOKEN from the environment if one was placed there\n// by hand, and stops cleanly otherwise rather than inventing an authority.\nconst token = $env.APPROVISIONNEMENT_MANDATE_TOKEN;\nif (!token) {\n return [{ json: {\n halted: true,\n reason: 'No mandate. This run needs an offline_access token from the user who '\n + 'authorised the agent; none is configured, so it did nothing.',\n }}];\n}\nreturn [{ json: { halted: false, token } }];\n" + }, + "id": "mandate", + "name": "Mandate", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.halted }}", + "rightValue": false, + "operator": { + "type": "boolean", + "operation": "false" + } + } + ], + "combinator": "and" + } + }, + "id": "has-a-mandate?", + "name": "Has a mandate?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 440, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token", + "sendBody": true, + "contentType": "form-urlencoded", + "bodyParameters": { + "parameters": [ + { + "name": "grant_type", + "value": "urn:ietf:params:oauth:grant-type:token-exchange" + }, + { + "name": "client_id", + "value": "classic-models-agent-approvisionnement" + }, + { + "name": "client_secret", + "value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}" + }, + { + "name": "subject_token", + "value": "={{ $json.token }}" + }, + { + "name": "subject_token_type", + "value": "urn:ietf:params:oauth:token-type:access_token" + }, + { + "name": "scope", + "value": "openid mcp-catalog-audience token-exchange-broker-audience" + } + ] + }, + "options": {} + }, + "id": "exchange-token", + "name": "Exchange token", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 660, + -100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://catalog-dev.sttlab.local/catalog-mcp", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json, text/event-stream" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement-periodic\", \"version\": \"0.1.0\"}}}", + "options": { + "response": { + "response": { + "fullResponse": true + } + } + } + }, + "id": "mcp-initialize", + "name": "MCP initialize", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 880, + -100 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://catalog-dev.sttlab.local/catalog-mcp", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $('Exchange token').item.json.access_token }}" + }, + { + "name": "Accept", + "value": "application/json, text/event-stream" + }, + { + "name": "mcp-session-id", + "value": "={{ $json.headers['mcp-session-id'] }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\", \"params\": {\"name\": \"list-products\", \"arguments\": {\"query_page\": 1, \"query_page_size\": 200}}}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "id": "list-products", + "name": "List products", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1100, + -100 + ], + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "// Which references need a purchase order, and why.\n//\n// The proposal is computed and goes no further, because there is nowhere for it\n// to go: the classicmodels schema has no purchase order, no supplier entity \u2014\n// `productvendor` is a string on the product \u2014 and the catalog's only write\n// tool is create-product, which is the Catalogue agent's business, not this\n// one's. So this run escalates rather than writing, which is also what an\n// autonomous agent meeting a decision it cannot take is supposed to do.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\nreturn [{ json: {\n proposals,\n scanned,\n // Loud when the scan was partial: a proposal set computed from part of the\n // catalog looks exactly like one computed from all of it.\n complete: scanned >= total,\n escalate: proposals.length > 0 || scanned < total,\n reason: scanned < total\n ? `Scanned ${scanned} of ${total} references; the rest were not read.`\n : (proposals.length\n ? `${proposals.length} reference(s) below the threshold of ${THRESHOLD}. `\n + 'Nowhere to record a purchase order \u2014 the domain has no such concept \u2014 '\n + 'so this needs a human.'\n : 'Nothing below the threshold.'),\n}}];\n" + }, + "id": "propose-reorders", + "name": "Propose reorders", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1320, + -100 + ] + }, + { + "parameters": {}, + "id": "no-mandate", + "name": "No mandate", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 660, + 120 + ] + } + ], + "connections": { + "Every morning": { + "main": [ + [ + { + "node": "Mandate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mandate": { + "main": [ + [ + { + "node": "Has a mandate?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has a mandate?": { + "main": [ + [ + { + "node": "Exchange token", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No mandate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Exchange token": { + "main": [ + [ + { + "node": "MCP initialize", + "type": "main", + "index": 0 + } + ] + ] + }, + "MCP initialize": { + "main": [ + [ + { + "node": "List products", + "type": "main", + "index": 0 + } + ] + ] + }, + "List products": { + "main": [ + [ + { + "node": "Propose reorders", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +}