@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/flowpilot.js CHANGED
@@ -1,5 +1,7 @@
1
- const http = require("http");
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
- // The node-level RED API passed to this module has no direct registry
598
- // lookup (no RED.nodes.getNodeList), so the node list is fetched via a
599
- // loopback call to Node-RED's own admin API (the same data the palette
600
- // sidebar uses) and cached briefly — the palette rarely changes, and
601
- // every chat/generate/document/modify request goes through
602
- // buildMessages, so this must stay cheap and synchronous.
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 installedNodesCache = null;
605
- let installedNodesCacheAt = 0;
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 (Date.now() - installedNodesCacheAt > INSTALLED_NODES_CACHE_TTL_MS) {
676
- refreshInstalledNodesCache();
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
- res.json(maskProviderSecrets(storage.getSettings()));
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
- // models) touches a provider's baseUrl until that exact URL has passed a
1312
- // real FlowPilot provider check — see isProviderShapedResponse
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 two routes allowed to
1315
- // contact an unconfirmed URL. confirmedBaseUrl/confirmedAt are written
1316
- // ONLY by those two routes on a passing check; lib/storage.js's
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, finalizeSimpleGeneration, req.body.conversationId
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 } = finalizeSimpleGeneration(generated);
3139
+ const { status, body } = finalize(generated);
2907
3140
  res.status(status).json(body);
2908
3141
  } catch (err) {
2909
3142
  sendGenerationError(res, "generate", err);
@@ -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). If a model still emits those fields on a turn that made no
8
- // tool calls, strip them before the response reaches the client and log
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) {