@manny-est/node-red-flowpilot 0.6.0-beta.1 → 0.6.1
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 +119 -13
- package/README.md +10 -1
- package/flowpilot-core.css +15 -0
- package/flowpilot-node-entry.js +15 -0
- package/flowpilot.js +296 -63
- package/lib/agent-contract.js +8 -3
- package/lib/core/history.js +151 -9
- package/lib/core/init.js +90 -21
- package/lib/core/main.js +120 -26
- package/lib/core/modes.js +237 -21
- package/lib/core/selection-context.js +17 -0
- package/lib/default-system-prompt.js +2 -2
- package/lib/document-system-prompt.js +4 -2
- package/lib/generation-system-prompt.js +2 -2
- package/lib/modify-system-prompt.js +1 -1
- package/lib/prompt-fragments.js +10 -6
- package/lib/provider-anthropic.js +12 -2
- package/lib/provider-openai-compatible.js +14 -2
- package/lib/provider-shape-check.js +1 -1
- package/lib/storage.js +6 -0
- package/package.json +3 -2
package/flowpilot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
const
|
|
1
|
+
const https = require("https");
|
|
2
2
|
const path = require("path");
|
|
3
|
+
const nodeRedRegistry = require("@node-red/registry");
|
|
4
|
+
const PACKAGE_VERSION = require("./package.json").version;
|
|
3
5
|
const createStorage = require("./lib/storage");
|
|
4
6
|
const openaiProvider = require("./lib/provider-openai-compatible");
|
|
5
7
|
const anthropicProvider = require("./lib/provider-anthropic");
|
|
@@ -82,8 +84,152 @@ const { repairEnvelope } = require("./lib/validator");
|
|
|
82
84
|
const { enforceAgentContract } = require("./lib/agent-contract");
|
|
83
85
|
const { isProviderShapedResponse } = require("./lib/provider-shape-check");
|
|
84
86
|
const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
|
|
87
|
+
const UPDATE_CHECK_URL = "https://registry.npmjs.org/-/package/@manny-est/node-red-flowpilot/dist-tags";
|
|
88
|
+
const UPDATE_CHECK_SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
|
|
89
|
+
const UPDATE_CHECK_FAILURE_TTL_MS = 15 * 60 * 1000;
|
|
90
|
+
const RUN_EVENTS_MAX_STORED = 50;
|
|
91
|
+
const runEventsStore = new Map();
|
|
92
|
+
let updateCheckCache = null;
|
|
93
|
+
|
|
94
|
+
function parseVersion(v) {
|
|
95
|
+
var parts = String(v).split("-");
|
|
96
|
+
var core = parts[0].split(".").map(Number);
|
|
97
|
+
var pre = parts.length > 1 ? parts.slice(1).join("-").split(".") : null;
|
|
98
|
+
return { core: core, pre: pre };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isNewer(remoteVersion, localVersion) {
|
|
102
|
+
var r = parseVersion(remoteVersion), l = parseVersion(localVersion);
|
|
103
|
+
for (var i = 0; i < 3; i++) {
|
|
104
|
+
var rv = r.core[i] || 0, lv = l.core[i] || 0;
|
|
105
|
+
if (rv !== lv) { return rv > lv; }
|
|
106
|
+
}
|
|
107
|
+
// core version numbers are equal
|
|
108
|
+
if (!r.pre && l.pre) { return true; } // remote is a full release, local is a prerelease of the same core
|
|
109
|
+
if (r.pre && !l.pre) { return false; } // remote is a prerelease, local is already past it
|
|
110
|
+
if (!r.pre && !l.pre) { return false; } // identical plain releases
|
|
111
|
+
// both are prereleases of the same core version — compare the last dot segment numerically if possible
|
|
112
|
+
var rn = Number(r.pre[r.pre.length - 1]);
|
|
113
|
+
var ln = Number(l.pre[l.pre.length - 1]);
|
|
114
|
+
if (!isNaN(rn) && !isNaN(ln)) { return rn > ln; }
|
|
115
|
+
return r.pre.join(".") !== l.pre.join(".") && r.pre.join(".") > l.pre.join(".");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function updateCheckFallbackResponse() {
|
|
119
|
+
return { enabled: true, updateAvailable: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fetchUpdateDistTags() {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
let url;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL(UPDATE_CHECK_URL);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
reject(err);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const req = https.request({
|
|
133
|
+
method: "GET",
|
|
134
|
+
hostname: url.hostname,
|
|
135
|
+
port: url.port || 443,
|
|
136
|
+
path: url.pathname + url.search,
|
|
137
|
+
headers: { Accept: "application/json" }
|
|
138
|
+
}, (res) => {
|
|
139
|
+
let data = "";
|
|
140
|
+
res.setEncoding("utf8");
|
|
141
|
+
res.on("data", chunk => { data += chunk; });
|
|
142
|
+
res.on("end", () => {
|
|
143
|
+
let parsed = null;
|
|
144
|
+
try {
|
|
145
|
+
parsed = data ? JSON.parse(data) : null;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
reject(err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (res.statusCode !== 200 || !parsed || typeof parsed !== "object") {
|
|
151
|
+
reject(new Error("Update check failed."));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
resolve(parsed);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
req.on("error", reject);
|
|
159
|
+
req.setTimeout(5000, () => {
|
|
160
|
+
req.destroy(new Error("Timeout"));
|
|
161
|
+
});
|
|
162
|
+
req.end();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
85
165
|
|
|
166
|
+
async function performUpdateCheck() {
|
|
167
|
+
try {
|
|
168
|
+
const distTags = await fetchUpdateDistTags();
|
|
169
|
+
const tag = PACKAGE_VERSION.indexOf("-") !== -1 ? "beta" : "latest";
|
|
170
|
+
const tagVersion = distTags && distTags[tag];
|
|
171
|
+
if (typeof tagVersion !== "string" || !tagVersion) {
|
|
172
|
+
throw new Error("Missing dist-tag.");
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
succeeded: true,
|
|
176
|
+
result: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
updateAvailable: isNewer(tagVersion, PACKAGE_VERSION),
|
|
179
|
+
latestVersion: tagVersion,
|
|
180
|
+
currentVersion: PACKAGE_VERSION,
|
|
181
|
+
tag: tag
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return {
|
|
186
|
+
succeeded: false,
|
|
187
|
+
result: updateCheckFallbackResponse()
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Found live (0.6.1), while verifying the palette-cache fix below: Node-RED
|
|
193
|
+
// loads flowpilot.js TWICE per process start, and always has — package.json
|
|
194
|
+
// declares BOTH "nodes": {"flowpilot": "flowpilot-node-entry.js"} (itself a
|
|
195
|
+
// shim added earlier this same release for a related but distinct bug — see
|
|
196
|
+
// that shim's own comment) AND "plugins": {"flowpilot": "flowpilot.html"}.
|
|
197
|
+
// @node-red/registry/lib/loader.js's loadPluginConfig derives a companion
|
|
198
|
+
// runtime file for every "plugins" entry via the SAME basename-replace
|
|
199
|
+
// pattern (file.replace(/\.[^.]+$/,".js")) that caused the earlier
|
|
200
|
+
// flowpilot.html double-load — "flowpilot.html" -> "flowpilot.js", which
|
|
201
|
+
// genuinely exists, so Node-RED loads and calls it a second time completely
|
|
202
|
+
// independently of the "nodes" entry's shim. Confirmed live: a temporary
|
|
203
|
+
// diagnostic showed flowPilotRuntime executing twice per restart, and a
|
|
204
|
+
// registered RED.events.on("nodes-started", ...) listener firing twice.
|
|
205
|
+
// Unlike the client-side html/script duplication, this one can't be closed
|
|
206
|
+
// by renaming what "plugins" points to without duplicating flowpilot.html's
|
|
207
|
+
// content under a second filename — not worth the maintenance burden for
|
|
208
|
+
// what's fundamentally the same fix in spirit. Guard here instead.
|
|
209
|
+
//
|
|
210
|
+
// A first attempt at this guard used a plain module-level `let` flag,
|
|
211
|
+
// reasoning that Node's require() cache makes a module's own top-level code
|
|
212
|
+
// run exactly once. That reasoning doesn't hold here: confirmed by reading
|
|
213
|
+
// @node-red/registry/lib/loader.js directly, the "nodes" entry is loaded via
|
|
214
|
+
// loadNodeSet, which uses a dynamic import() of a file:// URL (see its
|
|
215
|
+
// `pathToFileURL(node.file)` call), while the "plugins" entry's companion
|
|
216
|
+
// file is loaded via loadPlugin, which uses a plain require(). Node's CJS
|
|
217
|
+
// require() cache and its ESM import() module cache are not the same
|
|
218
|
+
// cache — a module reached through both paths can genuinely execute its
|
|
219
|
+
// top-level code twice, each with its OWN independent closure (own
|
|
220
|
+
// `let flowPilotRuntimeInitialized`, own everything). Confirmed live: a
|
|
221
|
+
// module-level flag did NOT stop the second invocation's independent
|
|
222
|
+
// httpAdmin route registrations from being the ones actually serving
|
|
223
|
+
// requests, while the diagnostic-carrying instance sat silent.
|
|
224
|
+
//
|
|
225
|
+
// Fix: mark completion on `global` instead. Node's global object is a
|
|
226
|
+
// true process-wide singleton — unaffected by which loader/module-cache
|
|
227
|
+
// reached this file — so it's a reliable gate regardless of how many
|
|
228
|
+
// separate module realms this file's code ends up evaluated in.
|
|
86
229
|
module.exports = function flowPilotRuntime(RED) {
|
|
230
|
+
if (global.__flowPilotRuntimeInitialized) { return; }
|
|
231
|
+
global.__flowPilotRuntimeInitialized = true;
|
|
232
|
+
|
|
87
233
|
const storage = createStorage(RED.settings.userDir);
|
|
88
234
|
|
|
89
235
|
// ---------------------------------------------------------------------
|
|
@@ -385,14 +531,17 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
385
531
|
},
|
|
386
532
|
targetNodeIds: {
|
|
387
533
|
oneOf: [
|
|
388
|
-
{ type: "string", enum: ["all"] },
|
|
534
|
+
{ type: "string", enum: ["all", "instance"] },
|
|
389
535
|
{
|
|
390
536
|
type: "array",
|
|
391
537
|
items: { type: "string" },
|
|
392
538
|
minItems: 1
|
|
393
539
|
}
|
|
394
540
|
],
|
|
395
|
-
description: "Optional resolved node target for Document redirects."
|
|
541
|
+
description: "Optional resolved node target for Document redirects. " +
|
|
542
|
+
"\"all\" is the entire active flow/tab; \"instance\" is every flow " +
|
|
543
|
+
"tab in the whole Node-RED instance. Omit when the scope genuinely " +
|
|
544
|
+
"can't be resolved from context."
|
|
396
545
|
}
|
|
397
546
|
},
|
|
398
547
|
required: ["mode", "prompt", "explanation"],
|
|
@@ -432,7 +581,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
432
581
|
|
|
433
582
|
function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
|
|
434
583
|
const writesEnabled = settings.enableAgentWrite === true &&
|
|
435
|
-
mode === "modify" && writesAllowed !== false &&
|
|
584
|
+
(mode === "modify" || mode === "generate") && writesAllowed !== false &&
|
|
436
585
|
activeProvider && activeProvider.supportsTools === true;
|
|
437
586
|
return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
|
|
438
587
|
}
|
|
@@ -594,17 +743,42 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
594
743
|
// their node types when relevant and otherwise stick to core nodes
|
|
595
744
|
// rather than proposing types that aren't installed.
|
|
596
745
|
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
746
|
+
// Node-RED's admin GET /nodes route ultimately reads the runtime registry's
|
|
747
|
+
// getNodeList(). A previous fix tried to reach that through RED.nodes, but
|
|
748
|
+
// Node-RED 5's @node-red/registry/lib/util.js createNodeApi only copies a
|
|
749
|
+
// small allow-list onto the node-level RED API (createNode/getNode/
|
|
750
|
+
// eachNode/addCredentials/getCredentials/deleteCredentials) —
|
|
751
|
+
// getNodeList is NOT one of them. Confirmed live: RED.nodes.getNodeList is
|
|
752
|
+
// undefined in this runtime, so describeInstalledNodes() quietly returned
|
|
753
|
+
// null from its catch on every request even after flows:started had fired.
|
|
754
|
+
//
|
|
755
|
+
// Use the registry module directly instead. FlowPilot already runs inside
|
|
756
|
+
// the same Node-RED process, and @node-red/registry is the exact source
|
|
757
|
+
// runtime.nodes.getNodeList() delegates to.
|
|
758
|
+
//
|
|
759
|
+
// Found live (0.6.1): this WAS a cached snapshot, through three
|
|
760
|
+
// successive designs (a naive restart-seeded TTL timer; a
|
|
761
|
+
// "nodes-started"-gated refresh; that plus a bounded settle re-check on
|
|
762
|
+
// top), because the original data source was an HTTP loopback to this
|
|
763
|
+
// same instance's own /nodes admin route — expensive enough to be worth
|
|
764
|
+
// caching, and, on any adminAuth-enabled instance, unable to
|
|
765
|
+
// authenticate itself at all (a real bug, fixed by switching to the
|
|
766
|
+
// in-process RED.nodes.getNodeList() call below). Once the data source
|
|
767
|
+
// is a synchronous, already-in-memory function call, caching it stops
|
|
768
|
+
// paying for itself — it only adds staleness/timing bugs, three of
|
|
769
|
+
// which got live-found in a row this same afternoon (the gate never
|
|
770
|
+
// set; set but the fetch 401ing; set and fetched but still an early
|
|
771
|
+
// snapshot for a slow-registering package like uibuilder). The one
|
|
772
|
+
// thing still worth gating is genuinely knowing nothing yet: before
|
|
773
|
+
// Node-RED's own "flows:started" event (the non-deprecated alias of
|
|
774
|
+
// "nodes-started", confirmed emitted from
|
|
775
|
+
// @node-red/runtime/lib/flows/index.js right after every active flow's
|
|
776
|
+
// own start() resolves) has fired at least once,
|
|
777
|
+
// describeInstalledNodes() returns null — "don't know yet" instead of a
|
|
778
|
+
// false "confirmed absent."
|
|
603
779
|
// ---------------------------------------------------------------------
|
|
604
|
-
let
|
|
605
|
-
|
|
606
|
-
let installedNodesRefreshInFlight = false;
|
|
607
|
-
const INSTALLED_NODES_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
780
|
+
let nodesRegistryReady = false;
|
|
781
|
+
RED.events.on("flows:started", function () { nodesRegistryReady = true; });
|
|
608
782
|
|
|
609
783
|
function buildInstalledNodesContent(list) {
|
|
610
784
|
if (!Array.isArray(list)) { return null; }
|
|
@@ -643,39 +817,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
643
817
|
return content;
|
|
644
818
|
}
|
|
645
819
|
|
|
646
|
-
function refreshInstalledNodesCache() {
|
|
647
|
-
if (installedNodesRefreshInFlight) { return; }
|
|
648
|
-
installedNodesRefreshInFlight = true;
|
|
649
|
-
|
|
650
|
-
const root = String(RED.settings.httpAdminRoot || "/").replace(/\/+$/, "");
|
|
651
|
-
const req = http.get({
|
|
652
|
-
host: "127.0.0.1",
|
|
653
|
-
port: RED.settings.uiPort,
|
|
654
|
-
path: root + "/nodes",
|
|
655
|
-
headers: { Accept: "application/json" },
|
|
656
|
-
timeout: 5000
|
|
657
|
-
}, function (res) {
|
|
658
|
-
const chunks = [];
|
|
659
|
-
res.on("data", function (chunk) { chunks.push(chunk); });
|
|
660
|
-
res.on("end", function () {
|
|
661
|
-
installedNodesRefreshInFlight = false;
|
|
662
|
-
if (res.statusCode !== 200) { return; }
|
|
663
|
-
try {
|
|
664
|
-
const list = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
665
|
-
installedNodesCache = buildInstalledNodesContent(list);
|
|
666
|
-
installedNodesCacheAt = Date.now();
|
|
667
|
-
} catch (err) { /* leave previous cache value in place */ }
|
|
668
|
-
});
|
|
669
|
-
});
|
|
670
|
-
req.on("error", function () { installedNodesRefreshInFlight = false; });
|
|
671
|
-
req.on("timeout", function () { req.destroy(); installedNodesRefreshInFlight = false; });
|
|
672
|
-
}
|
|
673
|
-
|
|
674
820
|
function describeInstalledNodes() {
|
|
675
|
-
if (
|
|
676
|
-
|
|
821
|
+
if (!nodesRegistryReady) { return null; }
|
|
822
|
+
try {
|
|
823
|
+
return buildInstalledNodesContent(nodeRedRegistry.getNodeList());
|
|
824
|
+
} catch (err) {
|
|
825
|
+
return null;
|
|
677
826
|
}
|
|
678
|
-
return installedNodesCache;
|
|
679
827
|
}
|
|
680
828
|
|
|
681
829
|
// Chat-only: the user's base system prompt plus a freshly-generated
|
|
@@ -1233,12 +1381,43 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1233
1381
|
|
|
1234
1382
|
RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
1235
1383
|
try {
|
|
1236
|
-
|
|
1384
|
+
const responseBody = maskProviderSecrets(storage.getSettings());
|
|
1385
|
+
responseBody.flowpilotVersion = PACKAGE_VERSION;
|
|
1386
|
+
res.json(responseBody);
|
|
1237
1387
|
} catch (err) {
|
|
1238
1388
|
res.status(500).json({ error: err.message });
|
|
1239
1389
|
}
|
|
1240
1390
|
});
|
|
1241
1391
|
|
|
1392
|
+
RED.httpAdmin.get("/flowpilot/update-check", RED.auth.needsPermission("settings.read"), async function (req, res) {
|
|
1393
|
+
const settings = storage.getSettings();
|
|
1394
|
+
if (settings.checkForUpdates === false) {
|
|
1395
|
+
res.json({ enabled: false });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const now = Date.now();
|
|
1400
|
+
if (updateCheckCache) {
|
|
1401
|
+
const ageMs = now - updateCheckCache.checkedAt;
|
|
1402
|
+
if (updateCheckCache.succeeded && ageMs < UPDATE_CHECK_SUCCESS_TTL_MS) {
|
|
1403
|
+
res.json(updateCheckCache.result);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (!updateCheckCache.succeeded && ageMs < UPDATE_CHECK_FAILURE_TTL_MS) {
|
|
1407
|
+
res.json(updateCheckFallbackResponse());
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const check = await performUpdateCheck();
|
|
1413
|
+
updateCheckCache = {
|
|
1414
|
+
checkedAt: now,
|
|
1415
|
+
succeeded: check.succeeded,
|
|
1416
|
+
result: check.result
|
|
1417
|
+
};
|
|
1418
|
+
res.json(check.result);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1242
1421
|
// ---- Settings: default system prompt (for "Reset to default") -------
|
|
1243
1422
|
|
|
1244
1423
|
RED.httpAdmin.get("/flowpilot/default-system-prompt", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
@@ -1249,6 +1428,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1249
1428
|
}
|
|
1250
1429
|
});
|
|
1251
1430
|
|
|
1431
|
+
RED.httpAdmin.get("/flowpilot/run-events/:runId", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
1432
|
+
const runId = req.params.runId;
|
|
1433
|
+
res.json({ runId: runId, events: runEventsStore.get(runId) || [] });
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1252
1436
|
// ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
|
|
1253
1437
|
// Serves the shared renderer (flowpilot-core.js, the same script
|
|
1254
1438
|
// flowpilot.html loads for the sidebar) plus its stylesheet and the
|
|
@@ -1307,13 +1491,21 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1307
1491
|
// a known limitation, not fixed by this ticket.
|
|
1308
1492
|
|
|
1309
1493
|
// ---- Provider confirmation gate (ADR-007, SSRF mitigation) -----------
|
|
1310
|
-
// No operational request (chat/generate/modify/document/build/agent-step
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1494
|
+
// No operational request (chat/generate/modify/document/build/agent-step)
|
|
1495
|
+
// touches a provider's baseUrl until that exact URL has passed a real
|
|
1496
|
+
// FlowPilot provider check — see isProviderShapedResponse
|
|
1313
1497
|
// (lib/provider-shape-check.js) below
|
|
1314
|
-
// and /flowpilot/test, /flowpilot/probe, the only
|
|
1315
|
-
// contact an unconfirmed URL.
|
|
1316
|
-
//
|
|
1498
|
+
// and /flowpilot/test, /flowpilot/probe, /flowpilot/models — the only
|
|
1499
|
+
// three routes allowed to contact an unconfirmed URL. The first two
|
|
1500
|
+
// WRITE confirmedBaseUrl/confirmedAt on a passing check (the deliberate
|
|
1501
|
+
// confirming action); /flowpilot/models never does — it's read-only
|
|
1502
|
+
// reconnaissance (listing available models), safe to allow pre-confirmation
|
|
1503
|
+
// the same way /probe is, but not itself a confirmation. All three stay
|
|
1504
|
+
// safe against an unconfirmed/malicious target the same way: a strict
|
|
1505
|
+
// shape check on any "success" response (isProviderShapedResponse for
|
|
1506
|
+
// chat-shaped, isModelsListShaped for a models list) and a generic,
|
|
1507
|
+
// never-reflects-the-body error on failure (enforced at the shared HTTP
|
|
1508
|
+
// client layer in lib/provider-*.js). lib/storage.js's
|
|
1317
1509
|
// reconcileProviderSecrets is the other half — it strips any
|
|
1318
1510
|
// client-supplied confirmedBaseUrl/confirmedAt on save and clears
|
|
1319
1511
|
// confirmation whenever baseUrl or apiKey actually changes, so
|
|
@@ -1399,16 +1591,22 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1399
1591
|
// ---- Models: list models via the active provider's /v1/models -------
|
|
1400
1592
|
// Always acts on the SAVED active provider (the frontend saves the form
|
|
1401
1593
|
// first, mirroring Pre-flight check), and never errors out for a provider
|
|
1402
|
-
// that doesn't support /v1/models — see listModels().
|
|
1594
|
+
// that doesn't support /v1/models — see listModels(). No request body is
|
|
1595
|
+
// read here, so no body-presence check applies (unlike /flowpilot/settings,
|
|
1596
|
+
// which does act on its body).
|
|
1597
|
+
//
|
|
1598
|
+
// This is the THIRD route allowed to touch an unconfirmed baseUrl (ADR-007)
|
|
1599
|
+
// — users need to see a model list before ever running Pre-flight check,
|
|
1600
|
+
// and the confirmation gate would otherwise block that. Kept safe the same
|
|
1601
|
+
// way /flowpilot/test and /flowpilot/probe are: listModels() only trusts a
|
|
1602
|
+
// genuinely provider-shaped response (isModelsListShaped) and never
|
|
1603
|
+
// reflects a raw failure body. Does not itself write confirmedBaseUrl/
|
|
1604
|
+
// confirmedAt — only the deliberate /flowpilot/test action confirms.
|
|
1403
1605
|
|
|
1404
1606
|
RED.httpAdmin.post("/flowpilot/models", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1405
|
-
if (!hasRequestBody(req.body)) {
|
|
1406
|
-
return res.status(400).json({ error: "Request body is required." });
|
|
1407
|
-
}
|
|
1408
1607
|
try {
|
|
1409
1608
|
const settings = storage.getSettings();
|
|
1410
1609
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1411
|
-
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
1412
1610
|
const result = await getProvider(activeProvider).listModels(activeProvider);
|
|
1413
1611
|
storage.appendAudit({
|
|
1414
1612
|
action: "list_models",
|
|
@@ -1562,6 +1760,17 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1562
1760
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1563
1761
|
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
1564
1762
|
if (!execution) { return; }
|
|
1763
|
+
if (Array.isArray(req.body.events) && typeof execution.runId === "string" && execution.runId) {
|
|
1764
|
+
runEventsStore.set(execution.runId, req.body.events);
|
|
1765
|
+
if (runEventsStore.size > RUN_EVENTS_MAX_STORED) {
|
|
1766
|
+
runEventsStore.delete(runEventsStore.keys().next().value);
|
|
1767
|
+
}
|
|
1768
|
+
maybeLogDebugEvent("run_events", {
|
|
1769
|
+
mode: req.body.mode || "chat",
|
|
1770
|
+
runId: execution.runId,
|
|
1771
|
+
events: req.body.events
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1565
1774
|
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
1566
1775
|
|
|
1567
1776
|
const messages = req.body && req.body.messages;
|
|
@@ -1650,7 +1859,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1650
1859
|
r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
|
|
1651
1860
|
);
|
|
1652
1861
|
}
|
|
1653
|
-
: finalizeSimpleGeneration;
|
|
1862
|
+
: function (r) { return finalizeSimpleGeneration(r, execution); };
|
|
1654
1863
|
const { status, body } = finalize(generated);
|
|
1655
1864
|
return res.status(status).json(body);
|
|
1656
1865
|
}
|
|
@@ -2295,6 +2504,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2295
2504
|
return modifyResult;
|
|
2296
2505
|
}
|
|
2297
2506
|
|
|
2507
|
+
enforceAgentContract(parsed, auditContext, false);
|
|
2508
|
+
|
|
2298
2509
|
const flow = Array.isArray(parsed.flow) ? parsed.flow : null;
|
|
2299
2510
|
if (!flow) {
|
|
2300
2511
|
const err = new Error("The response did not contain a 'flow' array.");
|
|
@@ -2319,6 +2530,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2319
2530
|
newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
|
|
2320
2531
|
newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
|
|
2321
2532
|
};
|
|
2533
|
+
if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
|
|
2534
|
+
flowResult.strippedFields = parsed.strippedFields.slice();
|
|
2535
|
+
}
|
|
2322
2536
|
if (auditAction === "build") {
|
|
2323
2537
|
const fpUidManifest = buildFpUidManifest(flow);
|
|
2324
2538
|
if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
|
|
@@ -2353,7 +2567,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2353
2567
|
? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
|
|
2354
2568
|
: [];
|
|
2355
2569
|
const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
|
|
2356
|
-
const toolChoice = auditAction === "modify" && execution &&
|
|
2570
|
+
const toolChoice = (auditAction === "modify" || auditAction === "generate") && execution &&
|
|
2357
2571
|
execution.strategy === "agent" ? "required" : "auto";
|
|
2358
2572
|
let chatOptions = toolsEnabled
|
|
2359
2573
|
? { tools: offeredTools, toolChoice: toolChoice }
|
|
@@ -2498,7 +2712,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2498
2712
|
// Used by both the non-streaming route (res.status(status).json(body)) and
|
|
2499
2713
|
// the streaming route (relayed as the final SSE event).
|
|
2500
2714
|
// ---------------------------------------------------------------------
|
|
2501
|
-
function finalizeSimpleGeneration(result) {
|
|
2715
|
+
function finalizeSimpleGeneration(result, execution) {
|
|
2502
2716
|
if (result.question) {
|
|
2503
2717
|
const body = { explanation: result.explanation, question: result.question, flow: null };
|
|
2504
2718
|
if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
|
|
@@ -2511,6 +2725,19 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2511
2725
|
if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
|
|
2512
2726
|
return { status: 200, body: proseBody };
|
|
2513
2727
|
}
|
|
2728
|
+
if (execution && execution.strategy === "agent") {
|
|
2729
|
+
const agentBody = {
|
|
2730
|
+
explanation: result.explanation || "",
|
|
2731
|
+
prose: true,
|
|
2732
|
+
flow: null
|
|
2733
|
+
};
|
|
2734
|
+
if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
|
|
2735
|
+
agentBody.strippedFields = result.strippedFields.slice();
|
|
2736
|
+
}
|
|
2737
|
+
if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
|
|
2738
|
+
if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
|
|
2739
|
+
return { status: 200, body: agentBody };
|
|
2740
|
+
}
|
|
2514
2741
|
return { status: 200, body: result };
|
|
2515
2742
|
}
|
|
2516
2743
|
|
|
@@ -2871,6 +3098,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2871
3098
|
}
|
|
2872
3099
|
|
|
2873
3100
|
RED.httpAdmin.post("/flowpilot/generate", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
3101
|
+
const settings = storage.getSettings();
|
|
3102
|
+
const activeProvider = storage.getActiveProvider(settings);
|
|
3103
|
+
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
3104
|
+
if (!execution) { return; }
|
|
3105
|
+
|
|
2874
3106
|
const prompt = req.body && req.body.prompt;
|
|
2875
3107
|
|
|
2876
3108
|
if (!prompt || !String(prompt).trim()) {
|
|
@@ -2879,11 +3111,12 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2879
3111
|
|
|
2880
3112
|
const history = sanitizeHistory(req.body.history);
|
|
2881
3113
|
const historyTruncated = !!req.body.historyTruncated;
|
|
3114
|
+
const finalize = function (result) { return finalizeSimpleGeneration(result, execution); };
|
|
2882
3115
|
|
|
2883
3116
|
if (req.body.stream) {
|
|
2884
3117
|
return runExecuteStream(
|
|
2885
3118
|
req, res, generationSystemPrompt, "generate", prompt, req.body && req.body.context,
|
|
2886
|
-
history, historyTruncated,
|
|
3119
|
+
history, historyTruncated, finalize, req.body.conversationId, execution
|
|
2887
3120
|
);
|
|
2888
3121
|
}
|
|
2889
3122
|
|
|
@@ -2891,7 +3124,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2891
3124
|
const useTools = !!req.body.tools;
|
|
2892
3125
|
const generated = await runFlowGeneration(
|
|
2893
3126
|
generationSystemPrompt, "generate", prompt, req.body && req.body.context,
|
|
2894
|
-
history, historyTruncated, useTools
|
|
3127
|
+
history, historyTruncated, useTools, execution
|
|
2895
3128
|
);
|
|
2896
3129
|
if (generated.toolCalls) {
|
|
2897
3130
|
return res.json({
|
|
@@ -2903,7 +3136,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2903
3136
|
});
|
|
2904
3137
|
}
|
|
2905
3138
|
recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
|
|
2906
|
-
const { status, body } =
|
|
3139
|
+
const { status, body } = finalize(generated);
|
|
2907
3140
|
res.status(status).json(body);
|
|
2908
3141
|
} catch (err) {
|
|
2909
3142
|
sendGenerationError(res, "generate", err);
|
package/lib/agent-contract.js
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// The agent-strategy contract: a "strategy":"agent" turn is only ever
|
|
5
5
|
// allowed to mutate the flow via a WRITE tool call, never via the classic
|
|
6
6
|
// JSON-envelope mutation fields (changes/newNodes/newWires/removeNodes/
|
|
7
|
-
// newGroups
|
|
8
|
-
//
|
|
7
|
+
// newGroups for Modify; flow for Generate/Document/Build — Generate's own
|
|
8
|
+
// envelope shape uses "flow", not "newNodes"/"newWires", found live during
|
|
9
|
+
// the 0.6.0 FINISH-list pass: the field was missing from this list entirely,
|
|
10
|
+
// so an agent-strategy Generate final turn with no tool calls could emit a
|
|
11
|
+
// full flow array completely unprotected). If a model still emits those
|
|
12
|
+
// fields on a turn that made no tool calls, strip them before the response
|
|
13
|
+
// reaches the client and log
|
|
9
14
|
// what was stripped — the two mutation code paths (classic envelope vs.
|
|
10
15
|
// agentic WRITE tools) must stay mutually exclusive per agent turn.
|
|
11
16
|
// Classic-strategy turns and turns that DID make tool calls are untouched
|
|
@@ -13,7 +18,7 @@
|
|
|
13
18
|
// violating shape it exists to catch.
|
|
14
19
|
// ---------------------------------------------------------------------
|
|
15
20
|
|
|
16
|
-
const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
|
|
21
|
+
const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "flow"];
|
|
17
22
|
|
|
18
23
|
function enforceAgentContract(result, execution, hasToolCalls) {
|
|
19
24
|
if (!result || !execution || execution.strategy !== "agent" || hasToolCalls) {
|