Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins/coreflux/scripts/mcp/coreflux-mqtt-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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"],
Expand Down
60 changes: 54 additions & 6 deletions plugins/coreflux/scripts/mcp/lib/lot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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()}.`);
}
Expand All @@ -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 <llmRoute> (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 <llmRoute> (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.");
}
}
Expand Down
133 changes: 132 additions & 1 deletion plugins/coreflux/test/lot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});

Expand Down Expand Up @@ -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)));
});
Loading