@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/CHANGELOG.md +79 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +22 -13
- package/flowpilot-core.css +128 -2
- package/flowpilot.js +382 -27
- package/lib/build-system-prompt.js +26 -4
- package/lib/core/apply-review.js +234 -53
- package/lib/core/init.js +10 -0
- package/lib/core/main.js +92 -6
- package/lib/core/modes.js +698 -24
- package/lib/core/selection-context.js +28 -1
- package/lib/default-system-prompt.js +13 -9
- package/lib/document-system-prompt.js +19 -30
- package/lib/generation-system-prompt.js +24 -40
- package/lib/modify-system-prompt.js +98 -70
- package/lib/prompt-fragments.js +45 -0
- package/lib/provider-anthropic.js +385 -0
- package/lib/provider-openai-compatible.js +12 -3
- package/lib/storage.js +43 -2
- package/lib/validator.js +238 -0
- package/package.json +1 -1
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
|
|
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:
|
|
409
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
945
|
-
const reasoning =
|
|
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
|
|
1002
|
-
const chatResult = await
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
1381
|
-
const
|
|
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
|
-
|
|
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
|
|
1399
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
FIRST-STEP ENVELOPE RULES (critical — read 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
|
-
|
|
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
|
+
]);
|