@manny-est/node-red-flowpilot 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowpilot.js CHANGED
@@ -1,7 +1,71 @@
1
1
  const http = require("http");
2
2
  const path = require("path");
3
3
  const createStorage = require("./lib/storage");
4
- const provider = require("./lib/provider-openai-compatible");
4
+ const openaiProvider = require("./lib/provider-openai-compatible");
5
+ const anthropicProvider = require("./lib/provider-anthropic");
6
+ function getProvider(ap) {
7
+ return (ap && ap.type === "anthropic") ? anthropicProvider : openaiProvider;
8
+ }
9
+
10
+ const DIRECT_COMPLETION_SCHEMA = {
11
+ type: "object",
12
+ properties: {
13
+ explanation: { type: "string" },
14
+ flow: { type: "array" },
15
+ changes: { type: "array" },
16
+ newNodes: { type: "array" },
17
+ newWires: { type: "array" },
18
+ removeNodes: { type: "array" },
19
+ question: { type: "string" },
20
+ mode: { type: "string" }
21
+ },
22
+ additionalProperties: true
23
+ };
24
+
25
+ const SAFE_NODE_TYPES = new Set([
26
+ "inject", "function", "change", "switch", "filter", "json", "xml", "csv",
27
+ "base64", "html", "split", "join", "sort", "batch", "debug", "status",
28
+ "comment", "link in", "link out", "link call", "junction"
29
+ ]);
30
+
31
+ const MODIFY_VERIFY_SKIP_PROPS = new Set([
32
+ "wires", "x", "y", "z", "g", "outputLabels", "inputLabels", "links"
33
+ ]);
34
+
35
+ function classifyFlowNodes(nodes) {
36
+ const classes = { safe: [], sideEffecting: [] };
37
+ if (!Array.isArray(nodes)) { return classes; }
38
+
39
+ nodes.forEach(function (node) {
40
+ if (!node || typeof node !== "object") { return; }
41
+ const summary = {
42
+ id: node.id,
43
+ type: node.type,
44
+ name: node.name || ""
45
+ };
46
+ if (SAFE_NODE_TYPES.has(node.type)) {
47
+ classes.safe.push(summary);
48
+ } else {
49
+ classes.sideEffecting.push(summary);
50
+ }
51
+ });
52
+ return classes;
53
+ }
54
+
55
+ function directCompletionResponseFormat(activeProvider, auditAction, useTools) {
56
+ if (useTools || (activeProvider && activeProvider.type === "anthropic") ||
57
+ (auditAction !== "generate" && auditAction !== "modify")) {
58
+ return null;
59
+ }
60
+ return {
61
+ type: "json_schema",
62
+ json_schema: {
63
+ name: "flowpilot_" + auditAction + "_response",
64
+ strict: false,
65
+ schema: DIRECT_COMPLETION_SCHEMA
66
+ }
67
+ };
68
+ }
5
69
  const generationSystemPrompt = require("./lib/generation-system-prompt");
6
70
  const documentSystemPrompt = require("./lib/document-system-prompt");
7
71
  const modifySystemPrompt = require("./lib/modify-system-prompt");
@@ -9,6 +73,7 @@ const buildSystemPrompt = require("./lib/build-system-prompt");
9
73
  const personaPrompt = require("./lib/persona-prompt");
10
74
  const { buildCoreScript } = require("./lib/build-core-script");
11
75
  const { extractJsonObject } = require("./lib/envelope");
76
+ const { repairEnvelope } = require("./lib/validator");
12
77
 
13
78
  module.exports = function flowPilotRuntime(RED) {
14
79
  const storage = createStorage(RED.settings.userDir);
@@ -404,10 +469,12 @@ module.exports = function flowPilotRuntime(RED) {
404
469
  // entry reports the same shape.
405
470
  // ---------------------------------------------------------------------
406
471
  function performanceAuditFields(messages, content, providerResult) {
472
+ const promptChars = (messages || []).reduce(function (sum, m) {
473
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
474
+ }, 0);
407
475
  const fields = {
408
- promptChars: (messages || []).reduce(function (sum, m) {
409
- return sum + (m && typeof m.content === "string" ? m.content.length : 0);
410
- }, 0),
476
+ promptChars: promptChars,
477
+ promptTokenEst: Math.round(promptChars / 4),
411
478
  completionChars: (content || "").length
412
479
  };
413
480
  if (providerResult && providerResult.timing) { fields.timing = providerResult.timing; }
@@ -415,6 +482,113 @@ module.exports = function flowPilotRuntime(RED) {
415
482
  return fields;
416
483
  }
417
484
 
485
+ // W0.2: server-side redaction-placeholder validator. If a model echoes a
486
+ // [redacted:...] sentinel as a proposed value in changes[].set, drop that
487
+ // field before it reaches the client. This replaces the client-side
488
+ // CRITICAL rule that previously tried to prompt the model out of this.
489
+ // Returns { cleanedChanges, skippedNote } — skippedNote is null when nothing
490
+ // was dropped.
491
+ function isRedactionSentinel(v) {
492
+ if (typeof v === "string") {
493
+ return v === "[unserializable]" || v === "[redacted]" || v.indexOf("[redacted:") === 0;
494
+ }
495
+ if (Array.isArray(v)) { return v.some(isRedactionSentinel); }
496
+ if (v !== null && typeof v === "object") {
497
+ return Object.keys(v).some(function (k) { return isRedactionSentinel(v[k]); });
498
+ }
499
+ return false;
500
+ }
501
+
502
+ function stripRedactionPlaceholders(changes) {
503
+ const dropped = [];
504
+ const cleanedChanges = (Array.isArray(changes) ? changes : []).map(function (entry) {
505
+ if (!entry || typeof entry !== "object" || !entry.set) { return entry; }
506
+ const cleanSet = {};
507
+ const droppedKeys = [];
508
+ Object.keys(entry.set).forEach(function (k) {
509
+ if (isRedactionSentinel(entry.set[k])) {
510
+ droppedKeys.push(k);
511
+ } else {
512
+ cleanSet[k] = entry.set[k];
513
+ }
514
+ });
515
+ if (droppedKeys.length) {
516
+ dropped.push({ id: entry.id, keys: droppedKeys });
517
+ }
518
+ return Object.assign({}, entry, { set: cleanSet });
519
+ });
520
+
521
+ let skippedNote = null;
522
+ if (dropped.length) {
523
+ const parts = dropped.map(function (d) {
524
+ return d.keys.join(", ") + (d.id ? " on " + d.id : "");
525
+ });
526
+ skippedNote = "Dropped redacted field(s) — these are credentials or secrets " +
527
+ "that FlowPilot cannot write. They are unchanged on the canvas. " +
528
+ "Update them directly in the Node-RED node editor if needed. " +
529
+ "(" + parts.join("; ") + ")";
530
+ }
531
+ return { cleanedChanges: cleanedChanges, skippedNote: skippedNote };
532
+ }
533
+
534
+ // B2: sanitized context can contain "[unserializable]" for opaque
535
+ // node-internal values. Models can echo those fields back as empty values,
536
+ // false, zero, objects, or other guessed defaults. No proposed value is
537
+ // safely diffable against an unknown original, so drop the field before
538
+ // reconstructing the full flow. Visible originals remain legitimate Modify
539
+ // targets because they do not carry the sentinel.
540
+ function stripUnserializableEchoes(set, originalNode) {
541
+ const clean = Object.assign({}, (set && typeof set === "object") ? set : {});
542
+ if (!originalNode) { return clean; }
543
+ Object.keys(clean).forEach(function (k) {
544
+ if (originalNode[k] === "[unserializable]") {
545
+ delete clean[k];
546
+ }
547
+ });
548
+ return clean;
549
+ }
550
+
551
+ // W0.4: when logAssembledPrompts is enabled, append one JSON-lines entry to
552
+ // assembled-prompts.log. Called after each provider round-trip with both
553
+ // the outgoing messages and the raw response content. Auth keys are never
554
+ // included — only baseUrl + model from the provider profile.
555
+ function maybeLogAssembledPrompt(mode, messages, responseContent, parseOutcome, activeProvider) {
556
+ const settings = storage.getSettings();
557
+ if (!settings.logAssembledPrompts) { return; }
558
+ const promptChars = (messages || []).reduce(function (sum, m) {
559
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
560
+ }, 0);
561
+ storage.appendAssembledPromptLog({
562
+ mode: mode,
563
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
564
+ model: activeProvider && activeProvider.model,
565
+ promptTokenEst: Math.round(promptChars / 4),
566
+ messageCount: (messages || []).length,
567
+ messages: messages,
568
+ responseChars: typeof responseContent === "string" ? responseContent.length : 0,
569
+ responseContent: responseContent,
570
+ parseOutcome: parseOutcome
571
+ });
572
+ }
573
+
574
+ // W0.1: warn when estimated prompt token count approaches the provider's
575
+ // configured context window (numCtx). Overflow is silent — instructions
576
+ // vanish with no error, which is exactly the "model ignores my rules"
577
+ // signature. 32k tokens is the practical floor for prompts this size.
578
+ // Called after buildMessages; numCtx=0 means unknown/unset, skip check.
579
+ function warnNumCtxOverflow(messages, activeProvider, mode) {
580
+ const numCtx = (activeProvider && activeProvider.numCtx) ? activeProvider.numCtx : 0;
581
+ const promptChars = (messages || []).reduce(function (sum, m) {
582
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
583
+ }, 0);
584
+ const promptTokenEst = Math.round(promptChars / 4);
585
+ if (numCtx > 0 && promptTokenEst > numCtx * 0.9) {
586
+ console.warn("[FlowPilot] num_ctx overflow risk: mode=%s estimated=%d tokens numCtx=%d (%.0f%% full)",
587
+ mode, promptTokenEst, numCtx, (promptTokenEst / numCtx) * 100);
588
+ }
589
+ return promptTokenEst;
590
+ }
591
+
418
592
  // ---------------------------------------------------------------------
419
593
  // Shared helper: format selected-node context (sanitized by the frontend)
420
594
  // into a system-message string for the model, plus counts for audit logs.
@@ -470,6 +644,16 @@ module.exports = function flowPilotRuntime(RED) {
470
644
  }
471
645
  }
472
646
 
647
+ const configNodes = (context && Array.isArray(context.configNodes)) ? context.configNodes : [];
648
+ if (configNodes.length > 0) {
649
+ content += (content ? "\n\n" : "") +
650
+ "Config nodes referenced by the selection (shared configuration " +
651
+ "objects not shown on the canvas; credentials are redacted). " +
652
+ "Use a config node's \"id\" to point an existing node at it via " +
653
+ "a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
654
+ JSON.stringify(configNodes) + "\n```";
655
+ }
656
+
473
657
  if (debugMessages.length > 0) {
474
658
  content += (content ? "\n\n" : "") +
475
659
  "The user attached recent Node-RED Debug sidebar output for " +
@@ -500,9 +684,10 @@ module.exports = function flowPilotRuntime(RED) {
500
684
  buildChatSystemPrompt(settings),
501
685
  history, historyTruncated, described, prompt
502
686
  );
687
+ warnNumCtxOverflow(messages, activeProvider, "chat");
503
688
 
504
689
  const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
505
- const result = await provider.chat(activeProvider, messages, chatOptions);
690
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
506
691
 
507
692
  if (result.toolCalls) {
508
693
  const perf = performanceAuditFields(messages, result.content, result);
@@ -537,6 +722,7 @@ module.exports = function flowPilotRuntime(RED) {
537
722
  buildChatSystemPrompt(settings),
538
723
  history, historyTruncated, described, prompt
539
724
  );
725
+ warnNumCtxOverflow(messages, activeProvider, "chat-stream");
540
726
 
541
727
  res.writeHead(200, {
542
728
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -556,7 +742,7 @@ module.exports = function flowPilotRuntime(RED) {
556
742
 
557
743
  let streamResult;
558
744
  try {
559
- streamResult = await provider.chatStream(activeProvider, messages,
745
+ streamResult = await getProvider(activeProvider).chatStream(activeProvider, messages,
560
746
  function (delta) {
561
747
  const visible = splitter.push(delta);
562
748
  if (visible) {
@@ -688,7 +874,7 @@ module.exports = function flowPilotRuntime(RED) {
688
874
  try {
689
875
  const settings = storage.getSettings();
690
876
  const activeProvider = storage.getActiveProvider(settings);
691
- const result = await provider.listModels(activeProvider);
877
+ const result = await getProvider(activeProvider).listModels(activeProvider);
692
878
  storage.appendAudit({
693
879
  action: "list_models",
694
880
  providerName: activeProvider.providerName,
@@ -796,7 +982,7 @@ module.exports = function flowPilotRuntime(RED) {
796
982
  try {
797
983
  const settings = storage.getSettings();
798
984
  const activeProvider = storage.getActiveProvider(settings);
799
- const result = await provider.chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
985
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
800
986
 
801
987
  storage.appendAudit(Object.assign({
802
988
  action: "agent_step",
@@ -941,8 +1127,8 @@ module.exports = function flowPilotRuntime(RED) {
941
1127
  // Capability probe — connectivity already succeeded above, so a
942
1128
  // probe failure here just means "no tool support", not a /test failure.
943
1129
  // Persist the result on the provider profile for the agentic tool-calling path.
944
- const probe = await provider.probeTools(activeProvider);
945
- const reasoning = provider.detectReasoning(result.raw);
1130
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1131
+ const reasoning = getProvider(activeProvider).detectReasoning(result.raw);
946
1132
  storage.appendAudit({
947
1133
  action: "capability_probe",
948
1134
  providerName: activeProvider.providerName,
@@ -998,12 +1184,12 @@ module.exports = function flowPilotRuntime(RED) {
998
1184
  const settings = storage.getSettings();
999
1185
  const activeProvider = storage.getActiveProvider(settings);
1000
1186
 
1001
- const probe = await provider.probeTools(activeProvider);
1002
- const chatResult = await provider.chat(activeProvider, [
1187
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1188
+ const chatResult = await getProvider(activeProvider).chat(activeProvider, [
1003
1189
  { role: "system", content: "You are a helpful assistant." },
1004
1190
  { role: "user", content: "Say hello." }
1005
1191
  ]);
1006
- const reasoning = provider.detectReasoning(chatResult.raw);
1192
+ const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
1007
1193
 
1008
1194
  const updatedProviders = (settings.providers || []).map(function (p) {
1009
1195
  return p.id === activeProvider.id
@@ -1050,7 +1236,7 @@ module.exports = function flowPilotRuntime(RED) {
1050
1236
  // out from runFlowGeneration so the streaming variant can build the
1051
1237
  // same request and swap provider.chat for provider.chatStream.
1052
1238
  // ---------------------------------------------------------------------
1053
- function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
1239
+ function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
1054
1240
  const settings = storage.getSettings();
1055
1241
  const activeProvider = storage.getActiveProvider(settings);
1056
1242
  const described = describeSelectionContext(context, settings.redactionEnabled);
@@ -1060,6 +1246,7 @@ module.exports = function flowPilotRuntime(RED) {
1060
1246
  // mode's own system prompt above already specifies.
1061
1247
  const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
1062
1248
  const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
1249
+ warnNumCtxOverflow(messages, activeProvider, auditAction);
1063
1250
  return { activeProvider, described, messages };
1064
1251
  }
1065
1252
 
@@ -1185,7 +1372,40 @@ module.exports = function flowPilotRuntime(RED) {
1185
1372
  // provider.chatStream). Throws an Error with .status and (when applicable)
1186
1373
  // .raw for the route to relay.
1187
1374
  // ---------------------------------------------------------------------
1188
- function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider) {
1375
+ function buildFpUidManifest(flow) {
1376
+ if (!Array.isArray(flow)) { return []; }
1377
+
1378
+ return flow
1379
+ .filter(function (node) {
1380
+ return node && node.type === "debug" &&
1381
+ typeof node.name === "string" && /^FP-UID\d+$/.test(node.name);
1382
+ })
1383
+ .sort(function (a, b) {
1384
+ const bySequence = Number(a.name.slice(6)) - Number(b.name.slice(6));
1385
+ if (bySequence !== 0) { return bySequence; }
1386
+ const byName = a.name.localeCompare(b.name);
1387
+ if (byName !== 0) { return byName; }
1388
+ return String(a.id || "").localeCompare(String(b.id || ""));
1389
+ })
1390
+ .map(function (tap) {
1391
+ const upstream = flow.find(function (node) {
1392
+ return node && Array.isArray(node.wires) && node.wires.some(function (port) {
1393
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
1394
+ });
1395
+ });
1396
+ const upstreamPort = upstream ? upstream.wires.findIndex(function (port) {
1397
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
1398
+ }) : -1;
1399
+ return {
1400
+ name: tap.name,
1401
+ id: tap.id,
1402
+ wiredFrom: upstream ? upstream.id : null,
1403
+ wiredFromPort: upstreamPort >= 0 ? upstreamPort : null
1404
+ };
1405
+ });
1406
+ }
1407
+
1408
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt) {
1189
1409
  const perf = performanceAuditFields(messages, content, providerResult);
1190
1410
 
1191
1411
  // Mode-mismatch redirect: the model may respond in plain prose —
@@ -1261,6 +1481,58 @@ module.exports = function flowPilotRuntime(RED) {
1261
1481
  }
1262
1482
  }
1263
1483
 
1484
+ // Constrained-decoding providers cannot emit the legacy plain-prose
1485
+ // redirect + hidden data block because response_format requires a JSON
1486
+ // object. Accept the equivalent top-level {"mode":"..."} envelope and
1487
+ // translate it back into the existing prose/suggestedAction result shape.
1488
+ // Reuse the original request as the chip prompt when the model omits one.
1489
+ if (typeof parsed.mode === "string" &&
1490
+ ["generate", "document", "modify", "chat"].indexOf(parsed.mode) !== -1 &&
1491
+ parsed.mode !== auditAction) {
1492
+ const redirectPrompt = (typeof parsed.prompt === "string" && parsed.prompt.trim())
1493
+ ? parsed.prompt.trim()
1494
+ : String(userPrompt || "").trim();
1495
+ const redirect = {
1496
+ mode: parsed.mode,
1497
+ prompt: redirectPrompt
1498
+ };
1499
+ if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
1500
+ redirect.selectionHint = parsed.selectionHint.trim();
1501
+ }
1502
+ const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
1503
+ ? parsed.explanation.trim()
1504
+ : "This request belongs in " + parsed.mode + " mode.";
1505
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
1506
+ return { prose: redirectProse, suggestedAction: redirect };
1507
+ }
1508
+
1509
+ // W2 — Class A repair pass. Run on every successfully-parsed envelope
1510
+ // before the mode-specific branches below. Repairs that don't apply
1511
+ // to a given mode's envelope shape are no-ops (e.g. repairFlowNodes
1512
+ // on an empty/absent flow array). Switch mismatches are surfaced as a
1513
+ // skippedNote on the modify result rather than a 422 — a targeted
1514
+ // message the model can act on, without discarding the rest of the
1515
+ // response.
1516
+ {
1517
+ const repaired = repairEnvelope(parsed);
1518
+ parsed = repaired.envelope;
1519
+ if (repaired.repairs.length) {
1520
+ console.info("[FlowPilot] W2 validator repaired %d field(s): %s",
1521
+ repaired.repairs.length,
1522
+ repaired.repairs.map(function (r) { return r.rule + ":" + r.detail; }).join("; "));
1523
+ }
1524
+ if (repaired.switchMismatches.length) {
1525
+ const detail = repaired.switchMismatches.map(function (m) {
1526
+ return "node " + m.id + " has " + m.rulesLen + " rule(s) but " + m.wiresLen + " wire port(s)";
1527
+ }).join("; ");
1528
+ // Surface as a validation warning on the parsed envelope — the
1529
+ // modify path will pick it up below and add it as a skippedNote.
1530
+ parsed._switchMismatchNote = "Switch rules/wires mismatch — " + detail +
1531
+ ". The number of wires[] entries must equal the number of rules[]. " +
1532
+ "Please resend with the corrected switch node.";
1533
+ }
1534
+ }
1535
+
1264
1536
  // Clarifying-question envelope. The model may ask ONE
1265
1537
  // follow-up question instead of producing a flow when the request is too
1266
1538
  // ambiguous to act on. The frontend renders the question as a normal
@@ -1306,12 +1578,18 @@ module.exports = function flowPilotRuntime(RED) {
1306
1578
  // since newNodes itself is copied through.
1307
1579
  const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
1308
1580
 
1581
+ // W0.2: strip any redaction-placeholder values from changes[].set before
1582
+ // they reach the client. Code owns the user notification now — the CRITICAL
1583
+ // block in the Modify prompt that tried to prevent this via instruction is
1584
+ // deleted. skippedNote is surfaced in modifyResult for the frontend to show.
1585
+ const { cleanedChanges, skippedNote: redactionSkippedNote } = stripRedactionPlaceholders(changes);
1586
+
1309
1587
  storage.appendAudit(Object.assign({
1310
1588
  action: auditAction,
1311
1589
  providerName: activeProvider.providerName,
1312
1590
  baseUrl: activeProvider.baseUrl,
1313
1591
  model: activeProvider.model,
1314
- changeCount: changes.length,
1592
+ changeCount: cleanedChanges.length,
1315
1593
  newNodeCount: newNodes.length,
1316
1594
  newWireCount: newWires.length,
1317
1595
  removeNodeCount: removeNodes.length,
@@ -1322,12 +1600,15 @@ module.exports = function flowPilotRuntime(RED) {
1322
1600
 
1323
1601
  const modifyResult = {
1324
1602
  explanation: parsed.explanation || "",
1325
- changes: changes,
1603
+ changes: cleanedChanges,
1326
1604
  newNodes: newNodes,
1327
1605
  newWires: newWires,
1328
1606
  removeNodes: removeNodes,
1329
1607
  newGroups: newGroups
1330
1608
  };
1609
+ // Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
1610
+ const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
1611
+ if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
1331
1612
  const modifyAction = extractSuggestedAction(parsed);
1332
1613
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
1333
1614
  return modifyResult;
@@ -1357,6 +1638,11 @@ module.exports = function flowPilotRuntime(RED) {
1357
1638
  newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
1358
1639
  newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
1359
1640
  };
1641
+ if (auditAction === "build") {
1642
+ const fpUidManifest = buildFpUidManifest(flow);
1643
+ if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
1644
+ if (flow.length) { flowResult.stepNodeClasses = classifyFlowNodes(flow); }
1645
+ }
1360
1646
  const flowAction = extractSuggestedAction(parsed);
1361
1647
  if (flowAction) { flowResult.suggestedAction = flowAction; }
1362
1648
  return flowResult;
@@ -1376,13 +1662,27 @@ module.exports = function flowPilotRuntime(RED) {
1376
1662
  // runChat's early return — so the route can hand it to the frontend
1377
1663
  // without running processGenerationContent yet.
1378
1664
  async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
1379
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1380
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
1381
- const result = await provider.chat(activeProvider, messages, chatOptions);
1665
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
1666
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, useTools);
1667
+ const chatOptions = useTools
1668
+ ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" }
1669
+ : (responseFormat ? { responseFormat: responseFormat } : undefined);
1670
+ const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
1382
1671
  if (result.toolCalls) {
1383
1672
  return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
1384
1673
  }
1385
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1674
+ const content = result.content || "";
1675
+ let parseOutcome = "unknown";
1676
+ try {
1677
+ const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
1678
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1679
+ return generated;
1680
+ } catch (err) {
1681
+ parseOutcome = "parse_error:" + (err.message || "");
1682
+ throw err;
1683
+ } finally {
1684
+ maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
1685
+ }
1386
1686
  }
1387
1687
 
1388
1688
  // ---------------------------------------------------------------------
@@ -1394,9 +1694,24 @@ module.exports = function flowPilotRuntime(RED) {
1394
1694
  // this resolves.
1395
1695
  // ---------------------------------------------------------------------
1396
1696
  async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
1397
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1398
- const result = await provider.chatStream(activeProvider, messages, onDelta);
1399
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1697
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
1698
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
1699
+ const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
1700
+ const result = await getProvider(activeProvider).chatStream(
1701
+ activeProvider, messages, onDelta, undefined, streamOptions
1702
+ );
1703
+ const content = result.content || "";
1704
+ let parseOutcome = "unknown";
1705
+ try {
1706
+ const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
1707
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1708
+ return generated;
1709
+ } catch (err) {
1710
+ parseOutcome = "parse_error:" + (err.message || "");
1711
+ throw err;
1712
+ } finally {
1713
+ maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
1714
+ }
1400
1715
  }
1401
1716
 
1402
1717
  // Relays a runFlowGeneration error to the client with the right status,
@@ -1534,10 +1849,17 @@ module.exports = function flowPilotRuntime(RED) {
1534
1849
  // Each patch's "set" is shallow-merged onto a copy of the original node.
1535
1850
  // "id", "x", "y", "z" can never move via a patch — strip them
1536
1851
  // defensively even though the prompt already forbids them.
1852
+ const originalById = {};
1853
+ originalNodes.forEach(function (n) {
1854
+ if (n && n.id !== undefined && n.id !== null) {
1855
+ originalById[String(n.id)] = n;
1856
+ }
1857
+ });
1858
+
1537
1859
  const patchById = {};
1538
1860
  validChanges.forEach(function (c) {
1539
1861
  const set = (c.set && typeof c.set === "object") ? c.set : {};
1540
- const clean = Object.assign({}, set);
1862
+ const clean = stripUnserializableEchoes(set, originalById[String(c.id)]);
1541
1863
  delete clean.id;
1542
1864
  delete clean.x;
1543
1865
  delete clean.y;
@@ -1666,6 +1988,35 @@ module.exports = function flowPilotRuntime(RED) {
1666
1988
 
1667
1989
  if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
1668
1990
 
1991
+ const verifySteps = [];
1992
+ validChanges.forEach(function (change) {
1993
+ const set = patchById[String(change.id)] || {};
1994
+ Object.keys(set).forEach(function (prop) {
1995
+ if (MODIFY_VERIFY_SKIP_PROPS.has(prop)) { return; }
1996
+ verifySteps.push({
1997
+ nodeId: change.id,
1998
+ check: "property",
1999
+ prop: prop,
2000
+ expected: set[prop]
2001
+ });
2002
+ });
2003
+ });
2004
+ newNodes.forEach(function (node) {
2005
+ if (!node || node.id === undefined || node.id === null) { return; }
2006
+ verifySteps.push({ nodeId: node.id, check: "exists" });
2007
+ });
2008
+ finalRemoveNodes.forEach(function (id) {
2009
+ verifySteps.push({ nodeId: id, check: "absent" });
2010
+ });
2011
+ newWires.forEach(function (wire) {
2012
+ verifySteps.push({
2013
+ fromId: wire.from,
2014
+ fromPort: wire.fromPort || 0,
2015
+ toId: wire.to,
2016
+ check: "wire"
2017
+ });
2018
+ });
2019
+
1669
2020
  const body = {
1670
2021
  explanation: result.explanation,
1671
2022
  flow: flow,
@@ -1674,6 +2025,7 @@ module.exports = function flowPilotRuntime(RED) {
1674
2025
  removeNodes: finalRemoveNodes,
1675
2026
  newGroups: newGroups
1676
2027
  };
2028
+ if (verifySteps.length) { body.verifySteps = verifySteps; }
1677
2029
  if (skippedDescriptions.length > 0) { body.skippedNote = skippedDescriptions.join(". ") + "."; }
1678
2030
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1679
2031
 
@@ -1861,10 +2213,13 @@ module.exports = function flowPilotRuntime(RED) {
1861
2213
  const historyTruncated = !!req.body.historyTruncated;
1862
2214
 
1863
2215
  const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
2216
+ const hasSwitch = Array.isArray(context && context.nodes) &&
2217
+ context.nodes.some(function (node) { return node && node.type === "switch"; });
2218
+ const modifyPrompt = modifySystemPrompt({ hasSwitch: hasSwitch });
1864
2219
 
1865
2220
  if (req.body.stream) {
1866
2221
  return runExecuteStream(
1867
- req, res, modifySystemPrompt, "modify", String(prompt).trim(), context,
2222
+ req, res, modifyPrompt, "modify", String(prompt).trim(), context,
1868
2223
  history, historyTruncated, finalize, req.body.conversationId
1869
2224
  );
1870
2225
  }
@@ -1872,7 +2227,7 @@ module.exports = function flowPilotRuntime(RED) {
1872
2227
  try {
1873
2228
  const useTools = !!req.body.tools;
1874
2229
  const result = await runFlowGeneration(
1875
- modifySystemPrompt, "modify", String(prompt).trim(), context,
2230
+ modifyPrompt, "modify", String(prompt).trim(), context,
1876
2231
  history, historyTruncated, useTools
1877
2232
  );
1878
2233
  if (result.toolCalls) {
@@ -5,8 +5,9 @@
5
5
  // is the framing: this is the FIRST step of a build -> deploy -> test -> fix
6
6
  // loop, not a one-shot generation, so the model should plan ahead briefly.
7
7
  const generationPrompt = require("./generation-system-prompt");
8
+ const { composePromptSections } = require("./prompt-fragments");
8
9
 
9
- module.exports = `You are FlowPilot, running in an agentic BUILD loop. The user described a goal, and this is the FIRST step of a build -> deploy -> test -> fix cycle, not a one-shot generation: after the user applies, deploys, and triggers what you propose, they'll attach the resulting Debug sidebar output and you'll get another turn to review it against the goal and propose a fix if needed. This can repeat a bounded number of times before stopping.
10
+ const buildFraming = `You are FlowPilot, running in an agentic BUILD loop. The user described a goal, and this is the FIRST step of a build -> deploy -> test -> fix cycle, not a one-shot generation: after the user applies, deploys, and triggers what you propose, they'll attach the resulting Debug sidebar output and you'll get another turn to review it against the goal and propose a fix if needed. This can repeat a bounded number of times before stopping.
10
11
 
11
12
  Because of that, "explanation" MUST start with a numbered "Plan:" block listing the steps you expect this to take to reach the goal — BEFORE any description of what this step builds. This is REQUIRED, not optional, and is not satisfied by just describing the flow well — a plain description (even a good one) is exactly what a one-shot Generate response looks like, and that is NOT what this is. Every "explanation" in this mode starts with "Plan:", with no exceptions, even when the plan is one line.
12
13
 
@@ -31,8 +32,29 @@ ALWAYS include at least one debug node in your proposed flow so the test-and-rev
31
32
  - Any other flow shape: add a debug node at the last meaningful output point.
32
33
  Never generate a build flow without a debug node. The loop cannot review what it cannot see.
33
34
 
34
- Everything below describes the envelope/rules for THIS step specifically they work exactly as written, including the parts that say "Generate mode": for the purposes of this prompt, treat that phrase as describing this build step, not a separate mode. The "explanation" field's content rules below still apply — your "Plan:" block comes first, then that content follows immediately after it in the same field.
35
+ FIRST-STEP ENVELOPE RULES (criticalread before writing your response):
35
36
 
36
- ---
37
+ 1. Your response in this first step must always use the "flow" array for new nodes. Never include "changes", "removeNodes", "newNodes", or "newWires" in a first-step response — those fields are only valid in fix iterations after the user has applied and tested. The "flow" key is the ONLY way to add nodes in this step.
37
38
 
38
- ` + generationPrompt;
39
+ 2. Context nodes (the user's existing selected nodes) are provided for reference only — to show what already exists so your new flow can complement it. They are NOT nodes you should remove. Never plan to delete context nodes in a first-step response. If you believe a context node is wrong or misplaced, note that in "explanation" and suggest the user fix it manually before applying your proposal.
40
+
41
+ 3. If the user's intent is ambiguous about whether to ADD new nodes alongside the existing canvas or to CHANGE/REPLACE existing nodes, ask a clarifying question (see below) rather than guessing. In particular: if the request could mean "add a test harness" OR "restructure the existing flow," always ask first.
42
+
43
+ Everything below describes the envelope/rules for THIS step specifically — they work exactly as written, including the parts that say "Generate mode": for the purposes of this prompt, treat that phrase as describing this build step, not a separate mode. The "explanation" field's content rules below still apply — your "Plan:" block comes first, then that content follows immediately after it in the same field.`;
44
+
45
+ const fpUidTapsFragment = `FP-UID checkpoint taps (temporary FlowPilot scaffolding):
46
+
47
+ For every external-call node in this build step — including http request, mqtt out, exec, file write, and any other node that reaches outside the flow — add a debug checkpoint tap that observes the message at that boundary.
48
+
49
+ - Name the taps exactly "FP-UID001", "FP-UID002", and so on, sequentially in flow order. Reset numbering to 001 for every new build response.
50
+ - Configure each tap for the complete message object using the Node-RED serialized form: "complete": "true". Also set "active": true, "tosidebar": true, and "wires": [].
51
+ - Wire each tap as a PARALLEL branch; never insert it inline or replace the main downstream connection. An external node with an output must wire to both its normal downstream target(s) and its FP-UID tap.
52
+ - If an external sink has no output port, branch the tap from the node feeding that sink so it observes the message being sent.
53
+ - These nodes are FlowPilot-owned scaffolding. Include them even when the user did not request debug nodes; FlowPilot will remove them when the task finishes.
54
+ - If this step has no external-call nodes, do not add any FP-UID tap.`;
55
+
56
+ module.exports = composePromptSections([
57
+ buildFraming,
58
+ generationPrompt,
59
+ fpUidTapsFragment
60
+ ]);