From 954c8de1422a70b069f0f00cd4682ff89ff58eba Mon Sep 17 00:00:00 2001 From: Vitor Babo Date: Fri, 16 Oct 2026 00:02:39 +0100 Subject: [PATCH 1/2] feat(lot_lint): recognise DEFINE AGENT and skip FROM-trigger false warning lot_lint (Cursor plugin MCP) treated DEFINE AGENT as unknown. Mirror the broker parser: USES LLM ROUTE|CONNECTOR first, required DEFAULT_USER, ADD TOOL "name" with TOPICS|TARGET|LEVEL, deploy as -addAgent after routes. Also skip the missing-AS-TRIGGER warning when a model inherits via FROM. --- .../scripts/mcp/coreflux-mqtt-mcp.mjs | 6 +- plugins/coreflux/scripts/mcp/lib/lot.mjs | 60 +++++++- plugins/coreflux/test/lot.test.mjs | 133 +++++++++++++++++- 3 files changed, 189 insertions(+), 10 deletions(-) diff --git a/plugins/coreflux/scripts/mcp/coreflux-mqtt-mcp.mjs b/plugins/coreflux/scripts/mcp/coreflux-mqtt-mcp.mjs index d29ad50..96ad6ef 100644 --- a/plugins/coreflux/scripts/mcp/coreflux-mqtt-mcp.mjs +++ b/plugins/coreflux/scripts/mcp/coreflux-mqtt-mcp.mjs @@ -590,7 +590,7 @@ const tools = [ { name: "lot_lint", description: - "Statically check LoT source for the mistakes that break the broker parser (indentation, indented triggers, missing DO/WITH/THEN, MESSAGE keyword, 0-based TOPIC POSITION, quoted action/model/route/rule names, ADD METADATA, hardcoded passwords, PAYLOAD on timers, models without AS TRIGGER…). Pass either code or a path to a .lot/.lotnb file. Run before deploying.", + "Statically check LoT source for the mistakes that break the broker parser (indentation, indented triggers, missing DO/WITH/THEN, MESSAGE keyword, 0-based TOPIC POSITION, quoted action/model/route/agent/rule names, ADD METADATA, hardcoded passwords, PAYLOAD on timers, models without AS TRIGGER (skipped when the model inherits via FROM)…). Pass either code or a path to a .lot/.lotnb file. Run before deploying.", inputSchema: { type: "object", properties: { @@ -615,7 +615,7 @@ const tools = [ { name: "lot_deploy", description: - "Deploy LoT source (one or more DEFINE blocks, or a Python script with a # Script Name header) to the broker. Splits entities, lints them, deploys in broker order (models → actions → routes → rules → python → themes → panels) and returns each envelope. Use dryRun=true to see the plan first.", + "Deploy LoT source (one or more DEFINE blocks, or a Python script with a # Script Name header) to the broker. Splits entities, lints them, deploys in broker order (models → actions → routes → agents → rules → python → themes → panels) and returns each envelope. Use dryRun=true to see the plan first.", inputSchema: { type: "object", properties: { @@ -672,7 +672,7 @@ const tools = [ inputSchema: { type: "object", properties: { - kind: { type: "string", enum: ["ACTION", "MODEL", "ROUTE", "RULE", "PYTHON", "THEME", "PANEL"] }, + kind: { type: "string", enum: ["ACTION", "MODEL", "ROUTE", "AGENT", "RULE", "PYTHON", "THEME", "PANEL"] }, name: { type: "string" }, }, required: ["kind", "name"], diff --git a/plugins/coreflux/scripts/mcp/lib/lot.mjs b/plugins/coreflux/scripts/mcp/lib/lot.mjs index c7049ca..6fd14d8 100644 --- a/plugins/coreflux/scripts/mcp/lib/lot.mjs +++ b/plugins/coreflux/scripts/mcp/lib/lot.mjs @@ -5,19 +5,20 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; /** Deploy verb per entity kind, in the order the broker loads projects. */ -export const ENTITY_ORDER = ["MODEL", "ACTION", "ROUTE", "RULE", "PYTHON", "THEME", "PANEL"]; +export const ENTITY_ORDER = ["MODEL", "ACTION", "ROUTE", "AGENT", "RULE", "PYTHON", "THEME", "PANEL"]; export const ENTITY_COMMANDS = { MODEL: { add: "-addModel", remove: "-removeModel" }, ACTION: { add: "-addAction", remove: "-removeAction" }, ROUTE: { add: "-addRoute", remove: "-removeRoute" }, + AGENT: { add: "-addAgent", remove: "-removeAgent" }, RULE: { add: "-addRule", remove: "-removeRule" }, PYTHON: { add: "-addPython", remove: "-removePython" }, THEME: { add: "-addTheme", remove: "-removeTheme" }, PANEL: { add: "-addPanel", remove: "-removePanel" }, }; -const DEFINE_RE = /^DEFINE\s+(ACTION|MODEL|ROUTE|RULE|THEME|PANEL|VISU|THING)\b\s*("([^"]+)"|([A-Za-z0-9_\-.]+))?/i; +const DEFINE_RE = /^DEFINE\s+(ACTION|MODEL|ROUTE|AGENT|RULE|THEME|PANEL|VISU|THING)\b\s*("([^"]+)"|([A-Za-z0-9_\-.]+))?/i; const SCRIPT_NAME_RE = /^\s*#\s*Script Name:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/im; function stripComments(line) { @@ -167,7 +168,7 @@ export function lintLot(source) { if (define) { if (indent.length > 0) add("error", lineNo, "DEFINE must start at column 0.", "Remove the leading indentation."); currentKind = define[1].toUpperCase(); - if (["ACTION", "MODEL", "ROUTE", "RULE"].includes(currentKind) && define[3]) { + if (["ACTION", "MODEL", "ROUTE", "AGENT", "RULE"].includes(currentKind) && define[3]) { add("error", lineNo, `${currentKind.charAt(0)}${currentKind.slice(1).toLowerCase()} name "${define[3]}" is quoted; the parser expects a bare identifier.`, `Write DEFINE ${currentKind} ${define[3].replace(/[^A-Za-z0-9_]/g, "_")}.`); } if (currentKind === "MODEL" && /\bFROM\s+"/i.test(trimmed)) { @@ -253,7 +254,7 @@ export function lintLot(source) { // Uppercase keyword check on the first token of a statement line. const firstToken = trimmed.split(/\s+/)[0]; - const knownKeywords = ["DEFINE", "ON", "DO", "SET", "PUBLISH", "KEEP", "IF", "ELSE", "SWITCH", "CASE", "DEFAULT", "LOOP", "UNTIL", "CALL", "TRIGGER", "STORE", "ADD", "WITH", "INPUT", "RETURN", "OUTPUT", "GET", "DELETE", "ALLOW", "DENY", "BIND", "AT", "INVOKE", "FETCH", "NAVIGATE"]; + const knownKeywords = ["DEFINE", "ON", "DO", "SET", "PUBLISH", "KEEP", "IF", "ELSE", "SWITCH", "CASE", "DEFAULT", "LOOP", "UNTIL", "CALL", "TRIGGER", "STORE", "ADD", "WITH", "INPUT", "RETURN", "OUTPUT", "GET", "DELETE", "ALLOW", "DENY", "BIND", "AT", "INVOKE", "FETCH", "NAVIGATE", "USES"]; if (knownKeywords.includes(firstToken.toUpperCase()) && firstToken !== firstToken.toUpperCase()) { add("error", lineNo, `Keyword "${firstToken}" must be uppercase.`, `Write ${firstToken.toUpperCase()}.`); } @@ -276,11 +277,58 @@ export function lintLot(source) { } } - // Model trigger sanity. + // DEFINE AGENT structural checks (mirrors broker ParseAgentDefinition + AgentDefinition.FromRoute). + for (const entity of splitLotEntities(source)) { + if (entity.kind !== "AGENT") continue; + const bodyLines = entity.code + .split("\n") + .slice(1) + .map((line) => stripComments(line).trim()) + .filter((line) => line.length > 0); + if (bodyLines.length === 0) { + add( + "error", + entity.line, + `Agent "${entity.name}" has an empty body.`, + "Start with USES LLM ROUTE (or CONNECTOR), then WITH DEFAULT_USER \"…\".", + ); + } else if (!/^USES\s+LLM\s+(ROUTE|CONNECTOR)\s+[A-Za-z_][A-Za-z0-9_\-.]*\s*$/i.test(bodyLines[0])) { + add( + "error", + entity.line, + `Agent "${entity.name}" must start with USES LLM ROUTE (or CONNECTOR).`, + 'First body line: USES LLM ROUTE GPT55', + ); + } + if (!/\bWITH\s+DEFAULT_USER\b/i.test(entity.code)) { + add( + "error", + entity.line, + `Agent "${entity.name}" is missing required WITH DEFAULT_USER.`, + 'Add WITH DEFAULT_USER "agent_svc".', + ); + } + for (const line of bodyLines) { + if (!/^ADD\s+TOOL\b/i.test(line)) continue; + if (!/^ADD\s+TOOL\s+"[^"]+"\s*$/i.test(line)) { + add( + "error", + entity.line, + `Agent "${entity.name}": ADD TOOL requires a quoted tool name.`, + 'ADD TOOL "read_mqtt"', + ); + } + } + } + + // Model trigger sanity. Child models that inherit via FROM Parent get the parent's + // AS TRIGGER; only warn when this model both binds topics and declares neither. for (const entity of splitLotEntities(source)) { if (entity.kind !== "MODEL") continue; + const header = entity.code.split("\n")[0] ?? ""; + const inheritsTrigger = /\bFROM\s+[A-Za-z_][A-Za-z0-9_\-.]*/i.test(header); const hasTopicBinding = /\bWITH\s+TOPIC\s+"/i.test(entity.code.split("\n").slice(1).join("\n")); - if (hasTopicBinding && !/\bAS\s+TRIGGER\b/i.test(entity.code)) { + if (hasTopicBinding && !/\bAS\s+TRIGGER\b/i.test(entity.code) && !inheritsTrigger) { add("warning", entity.line, `Model "${entity.name}" binds topics but marks none AS TRIGGER, so it will never auto-publish.`, "Add AS TRIGGER to the field whose update should publish the model."); } } diff --git a/plugins/coreflux/test/lot.test.mjs b/plugins/coreflux/test/lot.test.mjs index 890c7f7..1c3fea3 100644 --- a/plugins/coreflux/test/lot.test.mjs +++ b/plugins/coreflux/test/lot.test.mjs @@ -51,17 +51,25 @@ test("sortForDeploy follows broker load order", () => { { kind: "MODEL", name: "m" }, { kind: "PYTHON", name: "py" }, { kind: "ROUTE", name: "rt" }, + { kind: "AGENT", name: "ag" }, { kind: "THEME", name: "t" }, ]); assert.deepEqual( sorted.map((entity) => entity.kind), - ["MODEL", "ACTION", "ROUTE", "RULE", "PYTHON", "THEME", "PANEL"], + ["MODEL", "ACTION", "ROUTE", "AGENT", "RULE", "PYTHON", "THEME", "PANEL"], ); }); test("deployCommand prefixes the right verb without wrapping quotes", () => { assert.equal(deployCommand({ kind: "ACTION", code: VALID_ACTION }), `-addAction ${VALID_ACTION}`); assert.equal(deployCommand({ kind: "PYTHON", code: "# Script Name: X\nprint(1)" }), "-addPython # Script Name: X\nprint(1)"); + assert.equal( + deployCommand({ + kind: "AGENT", + code: 'DEFINE AGENT Planner\n USES LLM ROUTE GPT55\n WITH DEFAULT_USER "u"', + }), + '-addAgent DEFINE AGENT Planner\n USES LLM ROUTE GPT55\n WITH DEFAULT_USER "u"', + ); assert.throws(() => deployCommand({ kind: "THING", code: "" }), /cannot be deployed/); }); @@ -156,3 +164,126 @@ test("loadLotFile parses .lotnb cells, adds Script Name headers and sorts for de rmSync(dir, { recursive: true, force: true }); } }); + +const SHOWCASE_DEMO_FLIGHTS = `DEFINE AGENT DemoFlightsAgent + USES LLM ROUTE LocalOllama + WITH DESCRIPTION "Answers questions about departures and gates" + WITH DEFAULT_USER "flights-viewer" + WITH INTERACTION_TOPIC "flights/demo/assistant" + ADD TOOL "read_mqtt" + WITH TOPICS "flights/demo/#"`; + +const SHOWCASE_MAINTENANCE = `DEFINE AGENT MaintenancePlanner + USES LLM ROUTE FactoryOllama + WITH DESCRIPTION "Watches line 1 temperatures and OEE and writes maintenance notes" + WITH DEFAULT_USER "maintenance-bot" + WITH INTERACTION_TOPIC "bottling/line1/planner" + ADD TOOL "read_mqtt" + WITH TOPICS "bottling/#" + ADD TOOL "write_mqtt" + WITH TOPICS "bottling/line1/reports/#" + WITH LEVEL "write"`; + +const SHOWCASE_ENERGY = `DEFINE AGENT EnergyAgent + USES LLM ROUTE BuildingOllama + WITH DESCRIPTION "Explains building power use and peak alarms" + WITH DEFAULT_USER "energy-bot" + WITH INTERACTION_TOPIC "building/hq/agents/energy" + ADD TOOL "read_mqtt" + WITH TOPICS "building/hq/energy/#, building/hq/alarms/#"`; + +const SHOWCASE_COMFORT = `DEFINE AGENT ComfortAgent + USES LLM ROUTE BuildingOllama + WITH DESCRIPTION "Answers comfort questions about each room" + WITH DEFAULT_USER "comfort-bot" + WITH INTERACTION_TOPIC "building/hq/agents/comfort" + WITH AGENTS "EnergyAgent" + ADD TOOL "read_mqtt" + WITH TOPICS "building/hq/#"`; + +const SHOWCASE_AGENT_WITH_CONNECTOR = `DEFINE AGENT Planner + USES LLM CONNECTOR GPT55 + WITH DEFAULT_USER "agent_svc" + ADD TOOL "execute_actions" + WITH TARGET "RestartLineX" + WITH LEVEL "write"`; + +test("splitLotEntities recognises DEFINE AGENT", () => { + const source = `${SHOWCASE_DEMO_FLIGHTS}\n\n${VALID_ROUTE}`; + const entities = splitLotEntities(source); + assert.deepEqual( + entities.map((entity) => [entity.kind, entity.name]), + [ + ["AGENT", "DemoFlightsAgent"], + ["ROUTE", "PlantDb"], + ], + ); +}); + +test("lintLot accepts showcase DEFINE AGENT cells", () => { + for (const source of [ + SHOWCASE_DEMO_FLIGHTS, + SHOWCASE_MAINTENANCE, + SHOWCASE_ENERGY, + SHOWCASE_COMFORT, + SHOWCASE_AGENT_WITH_CONNECTOR, + ]) { + const findings = lintLot(source); + assert.deepEqual(findings, [], `unexpected findings for:\n${source}\n${JSON.stringify(findings, null, 2)}`); + } +}); + +test("lintLot rejects malformed DEFINE AGENT blocks", () => { + const cases = [ + [ + "missing USES", + `DEFINE AGENT A\n WITH DEFAULT_USER "u"`, + /must start with USES LLM ROUTE/, + ], + [ + "USES not first", + `DEFINE AGENT A\n WITH DEFAULT_USER "u"\n USES LLM ROUTE R`, + /must start with USES LLM ROUTE/, + ], + [ + "missing DEFAULT_USER", + `DEFINE AGENT A\n USES LLM ROUTE R`, + /DEFAULT_USER/, + ], + [ + "ADD TOOL without quoted name", + `DEFINE AGENT A\n USES LLM ROUTE R\n WITH DEFAULT_USER "u"\n ADD TOOL read_mqtt`, + /ADD TOOL requires a quoted tool name/, + ], + [ + "quoted agent name", + `DEFINE AGENT "Bad"\n USES LLM ROUTE R\n WITH DEFAULT_USER "u"`, + /quoted/, + ], + [ + "empty body", + `DEFINE AGENT Empty`, + /empty body/, + ], + ]; + for (const [label, source, pattern] of cases) { + const errors = lintLot(source).filter((finding) => finding.severity === "error"); + assert.ok( + errors.some((finding) => pattern.test(finding.message)), + `${label}: expected ${pattern} in ${JSON.stringify(errors)}`, + ); + } +}); + +test("lintLot skips AS TRIGGER warning when model inherits via FROM", () => { + const child = `DEFINE MODEL AdvancedSensor FROM SensorReading WITH TOPIC "sensors/advanced" + ADD STRING "firmware" WITH "v2.0" + ADD DOUBLE "batteryLevel" WITH TOPIC "sensors/battery"`; + const warnings = lintLot(child).filter((finding) => finding.severity === "warning"); + assert.ok(!warnings.some((finding) => /AS TRIGGER/.test(finding.message)), JSON.stringify(warnings)); + + const orphan = `DEFINE MODEL Lonely WITH TOPIC "sensors/lonely" + ADD DOUBLE "batteryLevel" WITH TOPIC "sensors/battery"`; + const orphanWarnings = lintLot(orphan).filter((finding) => finding.severity === "warning"); + assert.ok(orphanWarnings.some((finding) => /AS TRIGGER/.test(finding.message))); +}); From 301bcfb774b24c663a939b1b4d9148ea25506f2d Mon Sep 17 00:00:00 2001 From: Vitor Babo Date: Fri, 16 Oct 2026 00:05:12 +0100 Subject: [PATCH 2/2] chore: re-trigger CI after e2e Version-topic flake