@manny-est/node-red-flowpilot 0.5.0 → 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 +94 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +22 -13
- package/flowpilot-core.css +187 -2
- package/flowpilot.js +462 -33
- package/lib/build-system-prompt.js +26 -4
- package/lib/core/apply-review.js +494 -64
- package/lib/core/init.js +67 -132
- package/lib/core/main.js +160 -7
- package/lib/core/modes.js +876 -79
- 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 +122 -16
- package/lib/storage.js +43 -2
- package/lib/validator.js +238 -0
- package/package.json +2 -2
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,13 +742,18 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
556
742
|
|
|
557
743
|
let streamResult;
|
|
558
744
|
try {
|
|
559
|
-
streamResult = await
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
745
|
+
streamResult = await getProvider(activeProvider).chatStream(activeProvider, messages,
|
|
746
|
+
function (delta) {
|
|
747
|
+
const visible = splitter.push(delta);
|
|
748
|
+
if (visible) {
|
|
749
|
+
visibleText += visible;
|
|
750
|
+
res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
function (reasoningDelta) {
|
|
754
|
+
res.write("data: " + JSON.stringify({ reasoningDelta: reasoningDelta }) + "\n\n");
|
|
564
755
|
}
|
|
565
|
-
|
|
756
|
+
);
|
|
566
757
|
} catch (err) {
|
|
567
758
|
res.write("data: " + JSON.stringify({ error: err.message }) + "\n\n");
|
|
568
759
|
res.end();
|
|
@@ -683,7 +874,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
683
874
|
try {
|
|
684
875
|
const settings = storage.getSettings();
|
|
685
876
|
const activeProvider = storage.getActiveProvider(settings);
|
|
686
|
-
const result = await
|
|
877
|
+
const result = await getProvider(activeProvider).listModels(activeProvider);
|
|
687
878
|
storage.appendAudit({
|
|
688
879
|
action: "list_models",
|
|
689
880
|
providerName: activeProvider.providerName,
|
|
@@ -751,6 +942,10 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
751
942
|
if (suggestedAction) { body.suggestedAction = suggestedAction; }
|
|
752
943
|
const questionOptions = extractQuestionOptions(chatData);
|
|
753
944
|
if (questionOptions) { body.questionOptions = questionOptions; }
|
|
945
|
+
// Pass reasoning_content through so the frontend can render a thinking
|
|
946
|
+
// block on the non-streaming (agent-loop) path too.
|
|
947
|
+
const rawMsg = result.raw && result.raw.choices && result.raw.choices[0] && result.raw.choices[0].message;
|
|
948
|
+
if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
|
|
754
949
|
res.json(body);
|
|
755
950
|
} catch (err) {
|
|
756
951
|
storage.appendAudit({ action: "chat_error", error: err.message });
|
|
@@ -787,7 +982,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
787
982
|
try {
|
|
788
983
|
const settings = storage.getSettings();
|
|
789
984
|
const activeProvider = storage.getActiveProvider(settings);
|
|
790
|
-
const result = await
|
|
985
|
+
const result = await getProvider(activeProvider).chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
|
|
791
986
|
|
|
792
987
|
storage.appendAudit(Object.assign({
|
|
793
988
|
action: "agent_step",
|
|
@@ -932,30 +1127,43 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
932
1127
|
// Capability probe — connectivity already succeeded above, so a
|
|
933
1128
|
// probe failure here just means "no tool support", not a /test failure.
|
|
934
1129
|
// Persist the result on the provider profile for the agentic tool-calling path.
|
|
935
|
-
const probe = await
|
|
1130
|
+
const probe = await getProvider(activeProvider).probeTools(activeProvider);
|
|
1131
|
+
const reasoning = getProvider(activeProvider).detectReasoning(result.raw);
|
|
936
1132
|
storage.appendAudit({
|
|
937
1133
|
action: "capability_probe",
|
|
938
1134
|
providerName: activeProvider.providerName,
|
|
939
1135
|
baseUrl: activeProvider.baseUrl,
|
|
940
1136
|
model: activeProvider.model,
|
|
941
|
-
supportsTools: probe.supportsTools
|
|
1137
|
+
supportsTools: probe.supportsTools,
|
|
1138
|
+
isReasoningModel: reasoning.isReasoningModel
|
|
942
1139
|
});
|
|
943
1140
|
|
|
944
1141
|
const updatedProviders = (settings.providers || []).map(function (p) {
|
|
945
1142
|
return p.id === activeProvider.id
|
|
946
|
-
? Object.assign({}, p, {
|
|
1143
|
+
? Object.assign({}, p, {
|
|
1144
|
+
supportsTools: probe.supportsTools,
|
|
1145
|
+
toolsProbedAt: new Date().toISOString(),
|
|
1146
|
+
isReasoningModel: reasoning.isReasoningModel,
|
|
1147
|
+
reasoningProbedAt: new Date().toISOString(),
|
|
1148
|
+
probedModel: activeProvider.model
|
|
1149
|
+
})
|
|
947
1150
|
: p;
|
|
948
1151
|
});
|
|
949
1152
|
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
|
|
950
1153
|
|
|
1154
|
+
const toolLabel = probe.supportsTools
|
|
1155
|
+
? "✓ Connected · ✓ Supports tools"
|
|
1156
|
+
: "✓ Connected · ⚠ No tool support — compatibility mode";
|
|
1157
|
+
const reasoningLabel = reasoning.isReasoningModel ? " · Reasoning model" : "";
|
|
1158
|
+
|
|
951
1159
|
res.json({
|
|
952
1160
|
message: chatMessage || "[No assistant message returned by provider]",
|
|
953
1161
|
raw: result.raw ? "[raw response captured]" : null,
|
|
954
1162
|
capability: {
|
|
955
1163
|
supportsTools: probe.supportsTools,
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1164
|
+
isReasoningModel: reasoning.isReasoningModel,
|
|
1165
|
+
probedModel: activeProvider.model,
|
|
1166
|
+
label: toolLabel + reasoningLabel
|
|
959
1167
|
}
|
|
960
1168
|
});
|
|
961
1169
|
} catch (err) {
|
|
@@ -964,6 +1172,58 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
964
1172
|
}
|
|
965
1173
|
});
|
|
966
1174
|
|
|
1175
|
+
// ---- Probe: silent capability re-check after model change -----------
|
|
1176
|
+
// Called by the frontend when it detects that the active provider's model
|
|
1177
|
+
// changed since the last full pre-flight — stale supportsTools silently
|
|
1178
|
+
// misroutes chat (agent-loop vs streaming/non-streaming). Runs probeTools
|
|
1179
|
+
// + a minimal chat for reasoning detection, saves all results including
|
|
1180
|
+
// probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
|
|
1181
|
+
|
|
1182
|
+
RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1183
|
+
try {
|
|
1184
|
+
const settings = storage.getSettings();
|
|
1185
|
+
const activeProvider = storage.getActiveProvider(settings);
|
|
1186
|
+
|
|
1187
|
+
const probe = await getProvider(activeProvider).probeTools(activeProvider);
|
|
1188
|
+
const chatResult = await getProvider(activeProvider).chat(activeProvider, [
|
|
1189
|
+
{ role: "system", content: "You are a helpful assistant." },
|
|
1190
|
+
{ role: "user", content: "Say hello." }
|
|
1191
|
+
]);
|
|
1192
|
+
const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
|
|
1193
|
+
|
|
1194
|
+
const updatedProviders = (settings.providers || []).map(function (p) {
|
|
1195
|
+
return p.id === activeProvider.id
|
|
1196
|
+
? Object.assign({}, p, {
|
|
1197
|
+
supportsTools: probe.supportsTools,
|
|
1198
|
+
toolsProbedAt: new Date().toISOString(),
|
|
1199
|
+
isReasoningModel: reasoning.isReasoningModel,
|
|
1200
|
+
reasoningProbedAt: new Date().toISOString(),
|
|
1201
|
+
probedModel: activeProvider.model
|
|
1202
|
+
})
|
|
1203
|
+
: p;
|
|
1204
|
+
});
|
|
1205
|
+
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
|
|
1206
|
+
|
|
1207
|
+
storage.appendAudit({
|
|
1208
|
+
action: "auto_probe",
|
|
1209
|
+
providerName: activeProvider.providerName,
|
|
1210
|
+
baseUrl: activeProvider.baseUrl,
|
|
1211
|
+
model: activeProvider.model,
|
|
1212
|
+
supportsTools: probe.supportsTools,
|
|
1213
|
+
isReasoningModel: reasoning.isReasoningModel
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
res.json({
|
|
1217
|
+
supportsTools: probe.supportsTools,
|
|
1218
|
+
isReasoningModel: reasoning.isReasoningModel,
|
|
1219
|
+
probedModel: activeProvider.model
|
|
1220
|
+
});
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
storage.appendAudit({ action: "auto_probe_error", error: err.message });
|
|
1223
|
+
res.status(500).json({ error: err.message });
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
|
|
967
1227
|
// ---- Generate: produce an importable flow fragment --------------------
|
|
968
1228
|
// Uses the generation system prompt and expects the model to return a single
|
|
969
1229
|
// JSON object { explanation, flow }. This first cut does NOT validate node
|
|
@@ -976,7 +1236,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
976
1236
|
// out from runFlowGeneration so the streaming variant can build the
|
|
977
1237
|
// same request and swap provider.chat for provider.chatStream.
|
|
978
1238
|
// ---------------------------------------------------------------------
|
|
979
|
-
function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
|
|
1239
|
+
function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
|
|
980
1240
|
const settings = storage.getSettings();
|
|
981
1241
|
const activeProvider = storage.getActiveProvider(settings);
|
|
982
1242
|
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
@@ -986,6 +1246,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
986
1246
|
// mode's own system prompt above already specifies.
|
|
987
1247
|
const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
|
|
988
1248
|
const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
|
|
1249
|
+
warnNumCtxOverflow(messages, activeProvider, auditAction);
|
|
989
1250
|
return { activeProvider, described, messages };
|
|
990
1251
|
}
|
|
991
1252
|
|
|
@@ -1111,7 +1372,40 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1111
1372
|
// provider.chatStream). Throws an Error with .status and (when applicable)
|
|
1112
1373
|
// .raw for the route to relay.
|
|
1113
1374
|
// ---------------------------------------------------------------------
|
|
1114
|
-
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) {
|
|
1115
1409
|
const perf = performanceAuditFields(messages, content, providerResult);
|
|
1116
1410
|
|
|
1117
1411
|
// Mode-mismatch redirect: the model may respond in plain prose —
|
|
@@ -1187,6 +1481,58 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1187
1481
|
}
|
|
1188
1482
|
}
|
|
1189
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
|
+
|
|
1190
1536
|
// Clarifying-question envelope. The model may ask ONE
|
|
1191
1537
|
// follow-up question instead of producing a flow when the request is too
|
|
1192
1538
|
// ambiguous to act on. The frontend renders the question as a normal
|
|
@@ -1232,12 +1578,18 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1232
1578
|
// since newNodes itself is copied through.
|
|
1233
1579
|
const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
|
|
1234
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
|
+
|
|
1235
1587
|
storage.appendAudit(Object.assign({
|
|
1236
1588
|
action: auditAction,
|
|
1237
1589
|
providerName: activeProvider.providerName,
|
|
1238
1590
|
baseUrl: activeProvider.baseUrl,
|
|
1239
1591
|
model: activeProvider.model,
|
|
1240
|
-
changeCount:
|
|
1592
|
+
changeCount: cleanedChanges.length,
|
|
1241
1593
|
newNodeCount: newNodes.length,
|
|
1242
1594
|
newWireCount: newWires.length,
|
|
1243
1595
|
removeNodeCount: removeNodes.length,
|
|
@@ -1248,12 +1600,15 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1248
1600
|
|
|
1249
1601
|
const modifyResult = {
|
|
1250
1602
|
explanation: parsed.explanation || "",
|
|
1251
|
-
changes:
|
|
1603
|
+
changes: cleanedChanges,
|
|
1252
1604
|
newNodes: newNodes,
|
|
1253
1605
|
newWires: newWires,
|
|
1254
1606
|
removeNodes: removeNodes,
|
|
1255
1607
|
newGroups: newGroups
|
|
1256
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(" "); }
|
|
1257
1612
|
const modifyAction = extractSuggestedAction(parsed);
|
|
1258
1613
|
if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
|
|
1259
1614
|
return modifyResult;
|
|
@@ -1283,6 +1638,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1283
1638
|
newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
|
|
1284
1639
|
newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
|
|
1285
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
|
+
}
|
|
1286
1646
|
const flowAction = extractSuggestedAction(parsed);
|
|
1287
1647
|
if (flowAction) { flowResult.suggestedAction = flowAction; }
|
|
1288
1648
|
return flowResult;
|
|
@@ -1302,13 +1662,27 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1302
1662
|
// runChat's early return — so the route can hand it to the frontend
|
|
1303
1663
|
// without running processGenerationContent yet.
|
|
1304
1664
|
async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
|
|
1305
|
-
const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
|
|
1306
|
-
const
|
|
1307
|
-
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);
|
|
1308
1671
|
if (result.toolCalls) {
|
|
1309
1672
|
return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
|
|
1310
1673
|
}
|
|
1311
|
-
|
|
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
|
+
}
|
|
1312
1686
|
}
|
|
1313
1687
|
|
|
1314
1688
|
// ---------------------------------------------------------------------
|
|
@@ -1320,9 +1694,24 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1320
1694
|
// this resolves.
|
|
1321
1695
|
// ---------------------------------------------------------------------
|
|
1322
1696
|
async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
|
|
1323
|
-
const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
|
|
1324
|
-
const
|
|
1325
|
-
|
|
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
|
+
}
|
|
1326
1715
|
}
|
|
1327
1716
|
|
|
1328
1717
|
// Relays a runFlowGeneration error to the client with the right status,
|
|
@@ -1460,10 +1849,17 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1460
1849
|
// Each patch's "set" is shallow-merged onto a copy of the original node.
|
|
1461
1850
|
// "id", "x", "y", "z" can never move via a patch — strip them
|
|
1462
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
|
+
|
|
1463
1859
|
const patchById = {};
|
|
1464
1860
|
validChanges.forEach(function (c) {
|
|
1465
1861
|
const set = (c.set && typeof c.set === "object") ? c.set : {};
|
|
1466
|
-
const clean =
|
|
1862
|
+
const clean = stripUnserializableEchoes(set, originalById[String(c.id)]);
|
|
1467
1863
|
delete clean.id;
|
|
1468
1864
|
delete clean.x;
|
|
1469
1865
|
delete clean.y;
|
|
@@ -1592,6 +1988,35 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1592
1988
|
|
|
1593
1989
|
if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
|
|
1594
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
|
+
|
|
1595
2020
|
const body = {
|
|
1596
2021
|
explanation: result.explanation,
|
|
1597
2022
|
flow: flow,
|
|
@@ -1600,6 +2025,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1600
2025
|
removeNodes: finalRemoveNodes,
|
|
1601
2026
|
newGroups: newGroups
|
|
1602
2027
|
};
|
|
2028
|
+
if (verifySteps.length) { body.verifySteps = verifySteps; }
|
|
1603
2029
|
if (skippedDescriptions.length > 0) { body.skippedNote = skippedDescriptions.join(". ") + "."; }
|
|
1604
2030
|
if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
|
|
1605
2031
|
|
|
@@ -1787,10 +2213,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1787
2213
|
const historyTruncated = !!req.body.historyTruncated;
|
|
1788
2214
|
|
|
1789
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 });
|
|
1790
2219
|
|
|
1791
2220
|
if (req.body.stream) {
|
|
1792
2221
|
return runExecuteStream(
|
|
1793
|
-
req, res,
|
|
2222
|
+
req, res, modifyPrompt, "modify", String(prompt).trim(), context,
|
|
1794
2223
|
history, historyTruncated, finalize, req.body.conversationId
|
|
1795
2224
|
);
|
|
1796
2225
|
}
|
|
@@ -1798,7 +2227,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1798
2227
|
try {
|
|
1799
2228
|
const useTools = !!req.body.tools;
|
|
1800
2229
|
const result = await runFlowGeneration(
|
|
1801
|
-
|
|
2230
|
+
modifyPrompt, "modify", String(prompt).trim(), context,
|
|
1802
2231
|
history, historyTruncated, useTools
|
|
1803
2232
|
);
|
|
1804
2233
|
if (result.toolCalls) {
|