@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.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,14 +1,87 @@
1
1
  const http = require("http");
2
2
  const path = require("path");
3
3
  const createStorage = require("./lib/storage");
4
- const provider = require("./lib/provider-openai-compatible");
4
+ const openaiProvider = require("./lib/provider-openai-compatible");
5
+ const anthropicProvider = require("./lib/provider-anthropic");
6
+ function getProvider(ap) {
7
+ return (ap && ap.type === "anthropic") ? anthropicProvider : openaiProvider;
8
+ }
9
+
10
+ const DIRECT_COMPLETION_SCHEMA = {
11
+ type: "object",
12
+ properties: {
13
+ explanation: { type: "string" },
14
+ flow: { type: "array" },
15
+ changes: { type: "array" },
16
+ newNodes: { type: "array" },
17
+ newWires: { type: "array" },
18
+ removeNodes: { type: "array" },
19
+ question: { type: "string" },
20
+ mode: { type: "string" }
21
+ },
22
+ additionalProperties: true
23
+ };
24
+
25
+ const SAFE_NODE_TYPES = new Set([
26
+ "inject", "function", "change", "switch", "filter", "json", "xml", "csv",
27
+ "base64", "html", "split", "join", "sort", "batch", "debug", "status",
28
+ "comment", "link in", "link out", "link call", "junction"
29
+ ]);
30
+
31
+ const MODIFY_VERIFY_SKIP_PROPS = new Set([
32
+ "wires", "x", "y", "z", "g", "outputLabels", "inputLabels", "links"
33
+ ]);
34
+
35
+ function classifyFlowNodes(nodes) {
36
+ const classes = { safe: [], sideEffecting: [] };
37
+ if (!Array.isArray(nodes)) { return classes; }
38
+
39
+ nodes.forEach(function (node) {
40
+ if (!node || typeof node !== "object") { return; }
41
+ const summary = {
42
+ id: node.id,
43
+ type: node.type,
44
+ name: node.name || ""
45
+ };
46
+ if (SAFE_NODE_TYPES.has(node.type)) {
47
+ classes.safe.push(summary);
48
+ } else {
49
+ classes.sideEffecting.push(summary);
50
+ }
51
+ });
52
+ return classes;
53
+ }
54
+
55
+ function directCompletionResponseFormat(activeProvider, auditAction, useTools) {
56
+ if (useTools || (activeProvider && activeProvider.type === "anthropic") ||
57
+ (auditAction !== "generate" && auditAction !== "modify")) {
58
+ return null;
59
+ }
60
+ return {
61
+ type: "json_schema",
62
+ json_schema: {
63
+ name: "flowpilot_" + auditAction + "_response",
64
+ strict: false,
65
+ schema: DIRECT_COMPLETION_SCHEMA
66
+ }
67
+ };
68
+ }
5
69
  const generationSystemPrompt = require("./lib/generation-system-prompt");
6
70
  const documentSystemPrompt = require("./lib/document-system-prompt");
7
71
  const modifySystemPrompt = require("./lib/modify-system-prompt");
8
72
  const buildSystemPrompt = require("./lib/build-system-prompt");
9
73
  const personaPrompt = require("./lib/persona-prompt");
10
74
  const { buildCoreScript } = require("./lib/build-core-script");
75
+ const {
76
+ createChatDataStreamSplitter,
77
+ findChatDataMarker,
78
+ splitChatDataBlock
79
+ } = require("./lib/chat-data");
11
80
  const { extractJsonObject } = require("./lib/envelope");
81
+ const { repairEnvelope } = require("./lib/validator");
82
+ const { enforceAgentContract } = require("./lib/agent-contract");
83
+ const { isProviderShapedResponse } = require("./lib/provider-shape-check");
84
+ const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
12
85
 
13
86
  module.exports = function flowPilotRuntime(RED) {
14
87
  const storage = createStorage(RED.settings.userDir);
@@ -26,12 +99,11 @@ module.exports = function flowPilotRuntime(RED) {
26
99
  "that may have been said earlier, ask the user.";
27
100
 
28
101
  // ---------------------------------------------------------------------
29
- // Tier-1 READ tools the model may call autonomously during
30
- // a chat turn. Their data (RED.nodes, live selection, debug buffer) lives
31
- // only in the editor, so each call is executed CLIENT-SIDE and its result
32
- // passed back through the same sanitizer as selection context — a tool
33
- // result can never carry a raw secret. WRITE actions are never exposed as
34
- // tools; they stay on the existing diff/review/apply envelope.
102
+ // Tier-1 READ tools the model may call autonomously. Their data
103
+ // (RED.nodes, live selection, debug buffer) lives only in the editor, so
104
+ // each call is executed CLIENT-SIDE and its result passed back through the
105
+ // same sanitizer as selection context — a tool result can never carry a
106
+ // raw secret.
35
107
  // ---------------------------------------------------------------------
36
108
  const AGENT_READ_TOOLS = [
37
109
  {
@@ -140,6 +212,247 @@ module.exports = function flowPilotRuntime(RED) {
140
212
  }
141
213
  ];
142
214
 
215
+ // W7 Round 1 WRITE tools. One call represents one plan/todo item: a small
216
+ // coherent bundle, never one call per field. `tier` is FlowPilot metadata,
217
+ // not part of either provider's tool schema; providerToolDefinitions()
218
+ // strips it before the request leaves the server. The client combines this
219
+ // registry tier with classifyFlowNodes-equivalent per-call node safety so a
220
+ // write-gated call only prompts when it actually touches an unsafe type.
221
+ const WRITE_TOOLS = [
222
+ {
223
+ tier: "write-gated",
224
+ type: "function",
225
+ function: {
226
+ name: "apply_step",
227
+ description: "Apply ONE plan item as a small bundle of sparse " +
228
+ "property patches, at most one new node, and its immediate wires. " +
229
+ "Use one call for the whole item, not one call per field. Existing " +
230
+ "wire removal/replacement is expressed as a sparse " +
231
+ "changes[].set.wires final value.",
232
+ parameters: {
233
+ type: "object",
234
+ properties: {
235
+ summary: {
236
+ type: "string",
237
+ description: "Short todo-item description of this step."
238
+ },
239
+ changes: {
240
+ type: "array",
241
+ description: "Sparse patches for existing nodes.",
242
+ items: {
243
+ type: "object",
244
+ properties: {
245
+ id: { type: "string" },
246
+ set: { type: "object", additionalProperties: true }
247
+ },
248
+ required: ["id", "set"],
249
+ additionalProperties: false
250
+ }
251
+ },
252
+ newNodes: {
253
+ type: "array",
254
+ description: "At most one new node for this step, using a temporary id.",
255
+ items: {
256
+ type: "object",
257
+ properties: {
258
+ id: { type: "string" },
259
+ type: { type: "string" }
260
+ },
261
+ required: ["id", "type"],
262
+ additionalProperties: true
263
+ }
264
+ },
265
+ newWires: {
266
+ type: "array",
267
+ description: "Immediate wires for this step; ids may refer to " +
268
+ "existing nodes or newNodes temporary ids.",
269
+ items: {
270
+ type: "object",
271
+ properties: {
272
+ from: { type: "string" },
273
+ fromPort: { type: "integer", minimum: 0 },
274
+ to: { type: "string" }
275
+ },
276
+ required: ["from", "fromPort", "to"],
277
+ additionalProperties: false
278
+ }
279
+ }
280
+ },
281
+ required: ["summary"],
282
+ additionalProperties: false
283
+ }
284
+ }
285
+ },
286
+ {
287
+ tier: "write-gated",
288
+ type: "function",
289
+ function: {
290
+ name: "remove_step",
291
+ description: "Remove one node as ONE plan item. Its connected wires " +
292
+ "are removed with it by the existing editor apply path.",
293
+ parameters: {
294
+ type: "object",
295
+ properties: {
296
+ summary: {
297
+ type: "string",
298
+ description: "Short todo-item description of this removal."
299
+ },
300
+ nodeId: { type: "string", description: "Existing node id to remove." }
301
+ },
302
+ required: ["summary", "nodeId"],
303
+ additionalProperties: false
304
+ }
305
+ }
306
+ },
307
+ {
308
+ tier: "write-gated",
309
+ type: "function",
310
+ function: {
311
+ name: "rename_node",
312
+ description: "Rename one existing node as ONE plan item.",
313
+ parameters: {
314
+ type: "object",
315
+ properties: {
316
+ summary: {
317
+ type: "string",
318
+ description: "Short todo-item description of this rename."
319
+ },
320
+ nodeId: { type: "string", description: "Existing node id to rename." },
321
+ name: { type: "string", description: "Exact final node name." }
322
+ },
323
+ required: ["summary", "nodeId", "name"],
324
+ additionalProperties: false
325
+ }
326
+ }
327
+ },
328
+ {
329
+ // ADR-003 R3a: grouping is visual-only, classified safe (auto-apply)
330
+ // regardless of which node types are grouped — "write-safe" here means
331
+ // "never eligible for consent-gating" (the mechanism
332
+ // writeToolCallNeedsConsent actually keys on), not "non-mutating" —
333
+ // this call does mutate (creates a group, pushes history), unlike
334
+ // ask_user, the tier's other current member.
335
+ tier: "write-safe",
336
+ type: "function",
337
+ function: {
338
+ name: "group_nodes",
339
+ description: "Create a new named group from existing, already-verified " +
340
+ "node ids. No nested groups (a group id among nodeIds) and no " +
341
+ "editing an existing group's membership — both are reported as an " +
342
+ "unsupported_operation instead of attempted.",
343
+ parameters: {
344
+ type: "object",
345
+ properties: {
346
+ name: { type: "string", description: "Name for the new group." },
347
+ nodeIds: {
348
+ type: "array",
349
+ description: "Existing node ids to include in the new group.",
350
+ items: { type: "string" },
351
+ minItems: 1
352
+ }
353
+ },
354
+ required: ["name", "nodeIds"],
355
+ additionalProperties: false
356
+ }
357
+ }
358
+ },
359
+ {
360
+ tier: "write-safe",
361
+ type: "function",
362
+ function: {
363
+ name: "redirect_mode",
364
+ description: "End this Modify agent turn without mutating anything " +
365
+ "and redirect the user to Generate, Document, or Chat instead.",
366
+ parameters: {
367
+ type: "object",
368
+ properties: {
369
+ mode: {
370
+ type: "string",
371
+ enum: ["generate", "document", "chat"],
372
+ description: "The mode this request actually belongs in."
373
+ },
374
+ prompt: {
375
+ type: "string",
376
+ description: "Ready-to-send prompt to prefill after switching modes."
377
+ },
378
+ explanation: {
379
+ type: "string",
380
+ description: "Visible reply shown to the user before the redirect chip."
381
+ },
382
+ selectionHint: {
383
+ type: "string",
384
+ description: "Optional selection guidance for Document redirects."
385
+ },
386
+ targetNodeIds: {
387
+ oneOf: [
388
+ { type: "string", enum: ["all"] },
389
+ {
390
+ type: "array",
391
+ items: { type: "string" },
392
+ minItems: 1
393
+ }
394
+ ],
395
+ description: "Optional resolved node target for Document redirects."
396
+ }
397
+ },
398
+ required: ["mode", "prompt", "explanation"],
399
+ additionalProperties: false
400
+ }
401
+ }
402
+ },
403
+ {
404
+ tier: "write-safe",
405
+ type: "function",
406
+ function: {
407
+ name: "ask_user",
408
+ description: "Ask the user a clarifying question, pause this loop, " +
409
+ "and resume with their answer. This does not mutate the flow.",
410
+ parameters: {
411
+ type: "object",
412
+ properties: {
413
+ question: { type: "string" },
414
+ options: {
415
+ type: "array",
416
+ items: { type: "string" },
417
+ description: "Optional short answer choices."
418
+ }
419
+ },
420
+ required: ["question"],
421
+ additionalProperties: false
422
+ }
423
+ }
424
+ }
425
+ ];
426
+
427
+ function providerToolDefinitions(tools) {
428
+ return tools.map(function (tool) {
429
+ return { type: tool.type, function: tool.function };
430
+ });
431
+ }
432
+
433
+ function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
434
+ const writesEnabled = settings.enableAgentWrite === true &&
435
+ mode === "modify" && writesAllowed !== false &&
436
+ activeProvider && activeProvider.supportsTools === true;
437
+ return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
438
+ }
439
+
440
+ function toolTierMap(toolCalls) {
441
+ if (!Array.isArray(toolCalls) || !toolCalls.length) { return null; }
442
+ const tiersByName = {};
443
+ WRITE_TOOLS.forEach(function (tool) {
444
+ tiersByName[tool.function.name] = tool.tier;
445
+ });
446
+ const byCallId = {};
447
+ toolCalls.forEach(function (call) {
448
+ const name = call && call.function && call.function.name;
449
+ if (call && call.id && tiersByName[name]) {
450
+ byCallId[call.id] = tiersByName[name];
451
+ }
452
+ });
453
+ return Object.keys(byCallId).length ? byCallId : null;
454
+ }
455
+
143
456
  // Keep only well-formed { role: "user"|"assistant", content: <string> }
144
457
  // entries. Anything else (bad shapes, empty content, other roles) is
145
458
  // dropped rather than rejected outright — the history is advisory context,
@@ -404,10 +717,12 @@ module.exports = function flowPilotRuntime(RED) {
404
717
  // entry reports the same shape.
405
718
  // ---------------------------------------------------------------------
406
719
  function performanceAuditFields(messages, content, providerResult) {
720
+ const promptChars = (messages || []).reduce(function (sum, m) {
721
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
722
+ }, 0);
407
723
  const fields = {
408
- promptChars: (messages || []).reduce(function (sum, m) {
409
- return sum + (m && typeof m.content === "string" ? m.content.length : 0);
410
- }, 0),
724
+ promptChars: promptChars,
725
+ promptTokenEst: Math.round(promptChars / 4),
411
726
  completionChars: (content || "").length
412
727
  };
413
728
  if (providerResult && providerResult.timing) { fields.timing = providerResult.timing; }
@@ -415,16 +730,186 @@ module.exports = function flowPilotRuntime(RED) {
415
730
  return fields;
416
731
  }
417
732
 
733
+ // W0.2: server-side redaction-placeholder validator. If a model echoes a
734
+ // [redacted:...] sentinel as a proposed value in changes[].set, drop that
735
+ // field before it reaches the client. This replaces the client-side
736
+ // CRITICAL rule that previously tried to prompt the model out of this.
737
+ // Returns { cleanedChanges, skippedNote } — skippedNote is null when nothing
738
+ // was dropped.
739
+ function isRedactionSentinel(v) {
740
+ if (typeof v === "string") {
741
+ return v === "[unserializable]" || v === "[redacted]" || v.indexOf("[redacted:") === 0;
742
+ }
743
+ if (Array.isArray(v)) { return v.some(isRedactionSentinel); }
744
+ if (v !== null && typeof v === "object") {
745
+ return Object.keys(v).some(function (k) { return isRedactionSentinel(v[k]); });
746
+ }
747
+ return false;
748
+ }
749
+
750
+ function stripRedactionPlaceholders(changes) {
751
+ const dropped = [];
752
+ const cleanedChanges = (Array.isArray(changes) ? changes : []).map(function (entry) {
753
+ if (!entry || typeof entry !== "object" || !entry.set) { return entry; }
754
+ const cleanSet = {};
755
+ const droppedKeys = [];
756
+ Object.keys(entry.set).forEach(function (k) {
757
+ if (isRedactionSentinel(entry.set[k])) {
758
+ droppedKeys.push(k);
759
+ } else {
760
+ cleanSet[k] = entry.set[k];
761
+ }
762
+ });
763
+ if (droppedKeys.length) {
764
+ dropped.push({ id: entry.id, keys: droppedKeys });
765
+ }
766
+ return Object.assign({}, entry, { set: cleanSet });
767
+ });
768
+
769
+ let skippedNote = null;
770
+ if (dropped.length) {
771
+ const parts = dropped.map(function (d) {
772
+ return d.keys.join(", ") + (d.id ? " on " + d.id : "");
773
+ });
774
+ skippedNote = "Dropped redacted field(s) — these are credentials or secrets " +
775
+ "that FlowPilot cannot write. They are unchanged on the canvas. " +
776
+ "Update them directly in the Node-RED node editor if needed. " +
777
+ "(" + parts.join("; ") + ")";
778
+ }
779
+ return { cleanedChanges: cleanedChanges, skippedNote: skippedNote };
780
+ }
781
+
782
+ // B2: sanitized context can contain "[unserializable]" for opaque
783
+ // node-internal values. Models can echo those fields back as empty values,
784
+ // false, zero, objects, or other guessed defaults. No proposed value is
785
+ // safely diffable against an unknown original, so drop the field before
786
+ // reconstructing the full flow. Visible originals remain legitimate Modify
787
+ // targets because they do not carry the sentinel.
788
+ function stripUnserializableEchoes(set, originalNode) {
789
+ const clean = Object.assign({}, (set && typeof set === "object") ? set : {});
790
+ if (!originalNode) { return clean; }
791
+ Object.keys(clean).forEach(function (k) {
792
+ if (originalNode[k] === "[unserializable]") {
793
+ delete clean[k];
794
+ }
795
+ });
796
+ return clean;
797
+ }
798
+
799
+ // Append a provider-turn event to debug.log when debugLogging is enabled.
800
+ // Callers pass only post-redaction messages/content and non-secret provider
801
+ // metadata; auth keys and headers must never be included.
802
+ function maybeLogDebugEvent(type, fields) {
803
+ const settings = storage.getSettings();
804
+ if (!settings.debugLogging) { return; }
805
+ fields = fields || {};
806
+ const messages = fields.messages || [];
807
+ const promptChars = (messages || []).reduce(function (sum, m) {
808
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
809
+ }, 0);
810
+ storage.appendDebugLog(Object.assign({
811
+ type: type,
812
+ promptTokenEst: Math.round(promptChars / 4),
813
+ messageCount: messages.length
814
+ }, fields));
815
+ }
816
+
817
+ // W0.1: warn when estimated prompt token count approaches the provider's
818
+ // configured context window (numCtx). Overflow is silent — instructions
819
+ // vanish with no error, which is exactly the "model ignores my rules"
820
+ // signature. 32k tokens is the practical floor for prompts this size.
821
+ // Called after buildMessages; numCtx=0 means unknown/unset, skip check.
822
+ function warnNumCtxOverflow(messages, activeProvider, mode) {
823
+ const numCtx = (activeProvider && activeProvider.numCtx) ? activeProvider.numCtx : 0;
824
+ const promptChars = (messages || []).reduce(function (sum, m) {
825
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
826
+ }, 0);
827
+ const promptTokenEst = Math.round(promptChars / 4);
828
+ if (numCtx > 0 && promptTokenEst > numCtx * 0.9) {
829
+ console.warn("[FlowPilot] num_ctx overflow risk: mode=%s estimated=%d tokens numCtx=%d (%.0f%% full)",
830
+ mode, promptTokenEst, numCtx, (promptTokenEst / numCtx) * 100);
831
+ }
832
+ return promptTokenEst;
833
+ }
834
+
418
835
  // ---------------------------------------------------------------------
419
836
  // Shared helper: format selected-node context (sanitized by the frontend)
420
837
  // into a system-message string for the model, plus counts for audit logs.
421
838
  // Returns null when there's no selection — used by both /chat and
422
839
  // /generate so the two describe context identically and never drift.
423
840
  // ---------------------------------------------------------------------
424
- function describeSelectionContext(context, redactionEnabled) {
841
+ function maxSelectionContextChars(settings) {
842
+ const configured = Number(settings && settings.maxContextChars);
843
+ return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 12000;
844
+ }
845
+
846
+ function joinSelectionContextSections(sections) {
847
+ return sections
848
+ .filter(function (section) { return !!section && typeof section.content === "string" && section.content.length > 0; })
849
+ .map(function (section) { return section.content; })
850
+ .join("\n\n");
851
+ }
852
+
853
+ function selectionContextTruncationNote(maxChars, omittedSections, hardCutoff) {
854
+ let note = "\n\n[FlowPilot truncated selection context to fit maxContextChars=" + maxChars + ".";
855
+ if (omittedSections.length) {
856
+ note += " Omitted sections: " + omittedSections.join(", ") + ".";
857
+ }
858
+ if (hardCutoff) {
859
+ note += " Remaining content was hard-cut and may end mid-JSON.";
860
+ }
861
+ return note + "]";
862
+ }
863
+
864
+ function truncateSelectionContext(sections, maxChars) {
865
+ let activeSections = (sections || []).filter(function (section) { return !!section; });
866
+ let content = joinSelectionContextSections(activeSections);
867
+ if (!maxChars || maxChars <= 0 || content.length <= maxChars) {
868
+ return { content: content, truncated: false };
869
+ }
870
+
871
+ const omittedSections = [];
872
+ ["debug", "config", "perNode", "edges", "subflows"].forEach(function (key) {
873
+ if (content.length <= maxChars) { return; }
874
+ const nextSections = [];
875
+ let removed = false;
876
+ activeSections.forEach(function (section) {
877
+ if (!removed && section.key === key && section.required !== true) {
878
+ removed = true;
879
+ omittedSections.push(section.label);
880
+ return;
881
+ }
882
+ nextSections.push(section);
883
+ });
884
+ if (removed) {
885
+ activeSections = nextSections;
886
+ content = joinSelectionContextSections(activeSections);
887
+ }
888
+ });
889
+
890
+ if (content.length <= maxChars) {
891
+ const note = selectionContextTruncationNote(maxChars, omittedSections, false);
892
+ if (content.length + note.length <= maxChars) {
893
+ content += note;
894
+ }
895
+ return { content: content, truncated: true };
896
+ }
897
+
898
+ const hardCutNote = selectionContextTruncationNote(maxChars, omittedSections, true);
899
+ if (hardCutNote.length >= maxChars) {
900
+ return { content: content.slice(0, maxChars), truncated: true };
901
+ }
902
+ return {
903
+ content: content.slice(0, maxChars - hardCutNote.length) + hardCutNote,
904
+ truncated: true
905
+ };
906
+ }
907
+
908
+ function describeSelectionContext(context, settings) {
425
909
  const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
426
910
  const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
427
911
  if (nodes.length === 0 && debugMessages.length === 0) { return null; }
912
+ settings = settings || {};
428
913
 
429
914
  const connections = (context && context.connections) ? context.connections : {};
430
915
  const edges = Array.isArray(connections.edges) ? connections.edges : [];
@@ -436,7 +921,7 @@ module.exports = function flowPilotRuntime(RED) {
436
921
  // changes. redactionEnabled only controls the SEPARATE secret-shaped-value
437
922
  // scrubbing (password/token/apiKey-looking fields elsewhere in a node's
438
923
  // config) — tell the model the truth about which protection is active.
439
- const credentialNote = redactionEnabled === false
924
+ const credentialNote = settings.redactionEnabled === false
440
925
  ? "Redaction is OFF for this session — context may contain sensitive " +
441
926
  "values the user chose to share (e.g. embedded API keys or tokens); " +
442
927
  "handle carefully and never volunteer them. Node-RED's separate " +
@@ -447,37 +932,104 @@ module.exports = function flowPilotRuntime(RED) {
447
932
  "(it requires re-confirming a type-to-confirm phrase, by design)."
448
933
  : "This is sanitized configuration; credentials are redacted.";
449
934
 
450
- let content = "";
935
+ const sections = [];
451
936
  if (nodes.length > 0) {
452
- content += "The user has selected the following Node-RED nodes as context. " +
453
- credentialNote + "\n\n" +
454
- "Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```";
937
+ sections.push({
938
+ key: "nodes",
939
+ label: "selected nodes",
940
+ required: true,
941
+ content: "The user has selected the following Node-RED nodes as context. " +
942
+ credentialNote + "\n\n" +
943
+ "Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```"
944
+ });
455
945
  if (edges.length > 0) {
456
- content += "\n\nConnections — directed edges by node id (a node's wires " +
457
- "describe its OUTPUTS; one edge per output port; fromId/toId refer " +
458
- "to the \"id\" fields in Nodes above):\n```json\n" +
459
- JSON.stringify(edges) + "\n```";
460
- content += "\n\nPer-node wiring summary, with readable \"Name [type]\" " +
461
- "labels (inputs are reconstructed, since Node-RED nodes do not " +
462
- "store their own inputs; subFlow groups nodes into connected " +
463
- "sub-flows):\n```json\n" +
464
- JSON.stringify(perNode) + "\n```";
946
+ sections.push({
947
+ key: "edges",
948
+ label: "connection edges",
949
+ content: "Connections — directed edges by node id (a node's wires " +
950
+ "describe its OUTPUTS; one edge per output port; fromId/toId refer " +
951
+ "to the \"id\" fields in Nodes above):\n```json\n" +
952
+ JSON.stringify(edges) + "\n```"
953
+ });
954
+ sections.push({
955
+ key: "perNode",
956
+ label: "per-node wiring summary",
957
+ content: "Per-node wiring summary, with readable \"Name [type]\" " +
958
+ "labels (inputs are reconstructed, since Node-RED nodes do not " +
959
+ "store their own inputs; subFlow groups nodes into connected " +
960
+ "sub-flows):\n```json\n" +
961
+ JSON.stringify(perNode) + "\n```"
962
+ });
465
963
  }
466
964
  if (subFlowCount > 1) {
467
- content += "\n\nNote: the selection contains " + subFlowCount + " separate, " +
468
- "unconnected sub-flows (see each node's subFlow number). Treat " +
469
- "them as distinct unless the user says otherwise.";
965
+ sections.push({
966
+ key: "subflows",
967
+ label: "sub-flow note",
968
+ content: "Note: the selection contains " + subFlowCount + " separate, " +
969
+ "unconnected sub-flows (see each node's subFlow number). Treat " +
970
+ "them as distinct unless the user says otherwise."
971
+ });
470
972
  }
471
973
  }
472
974
 
975
+ const configNodes = settings.allowConfigContext === true &&
976
+ context && Array.isArray(context.configNodes) ? context.configNodes : [];
977
+ if (configNodes.length > 0) {
978
+ sections.push({
979
+ key: "config",
980
+ label: "config nodes",
981
+ required: sections.length === 0,
982
+ content: "Config nodes referenced by the selection (shared configuration " +
983
+ "objects not shown on the canvas; credentials are redacted). " +
984
+ "Use a config node's \"id\" to point an existing node at it via " +
985
+ "a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
986
+ JSON.stringify(configNodes) + "\n```"
987
+ });
988
+ }
989
+
473
990
  if (debugMessages.length > 0) {
474
- content += (content ? "\n\n" : "") +
475
- "The user attached recent Node-RED Debug sidebar output for " +
476
- "troubleshooting (runtime data, may be truncated):\n```json\n" +
477
- JSON.stringify(debugMessages) + "\n```";
991
+ sections.push({
992
+ key: "debug",
993
+ label: "debug messages",
994
+ required: sections.length === 0,
995
+ content: "The user attached recent Node-RED Debug sidebar output for " +
996
+ "troubleshooting (runtime data, may be truncated):\n```json\n" +
997
+ JSON.stringify(debugMessages) + "\n```"
998
+ });
478
999
  }
479
1000
 
480
- return { content: content, nodeCount: nodes.length, connectionCount: edges.length, debugMessageCount: debugMessages.length };
1001
+ const limited = truncateSelectionContext(sections, maxSelectionContextChars(settings));
1002
+ return {
1003
+ content: limited.content,
1004
+ nodeCount: nodes.length,
1005
+ connectionCount: edges.length,
1006
+ debugMessageCount: debugMessages.length,
1007
+ truncated: limited.truncated
1008
+ };
1009
+ }
1010
+
1011
+ const AGENT_TRUNCATION_NUDGE =
1012
+ "your last reply was cut off — emit ONLY the tool call";
1013
+
1014
+ function agentTurnOptions(settings, options) {
1015
+ const configured = Number(settings.agentTurnMaxTokens);
1016
+ return Object.assign({}, options || {}, {
1017
+ maxTokens: Number.isInteger(configured) && configured > 0 ? configured : 4096
1018
+ });
1019
+ }
1020
+
1021
+ async function chatWithAgentCap(provider, activeProvider, messages, options) {
1022
+ const first = await provider.chat(activeProvider, messages, options);
1023
+ if (first.finishReason !== "length") { return first; }
1024
+
1025
+ const retryMessages = messages.concat([
1026
+ { role: "system", content: AGENT_TRUNCATION_NUDGE }
1027
+ ]);
1028
+ const retry = await provider.chat(activeProvider, retryMessages, options);
1029
+ if (retry.finishReason === "length") {
1030
+ retry.fallbackToClassic = true;
1031
+ }
1032
+ return retry;
481
1033
  }
482
1034
 
483
1035
  // ---------------------------------------------------------------------
@@ -485,27 +1037,56 @@ module.exports = function flowPilotRuntime(RED) {
485
1037
  // log it, and return the result. Used by both /chat and /test so the two
486
1038
  // never drift apart. contextMode is recorded for the audit trail.
487
1039
  // ---------------------------------------------------------------------
488
- // useTools: when true, the request offers AGENT_READ_TOOLS
1040
+ // useTools: when true and the active provider supports native tools, the
1041
+ // request offers the mode-appropriate agent tools
489
1042
  // with tool_choice "auto". If the provider responds with tool_calls instead
490
1043
  // of a final message, we return early with `toolCalls` + the `messages`
491
1044
  // array built so far (so the caller/frontend can append the tool results
492
1045
  // and continue via /flowpilot/agent-step) — nothing is recorded to the
493
1046
  // transcript yet, since this isn't the final answer for the turn.
494
- async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools) {
1047
+ async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools, strategy) {
495
1048
  const settings = storage.getSettings();
496
1049
  const activeProvider = storage.getActiveProvider(settings);
497
1050
 
498
- const described = describeSelectionContext(context, settings.redactionEnabled);
1051
+ // Shared with /flowpilot/test (contextMode === "connectivity-test"),
1052
+ // which IS the confirming check itself and is exempt — every other
1053
+ // caller (/flowpilot/chat) is a real operational request and gated.
1054
+ if (contextMode !== "connectivity-test" && !isProviderConfirmed(activeProvider)) {
1055
+ throw providerUnconfirmedError();
1056
+ }
1057
+
1058
+ const described = describeSelectionContext(context, settings);
499
1059
  const messages = buildMessages(
500
1060
  buildChatSystemPrompt(settings),
501
1061
  history, historyTruncated, described, prompt
502
1062
  );
1063
+ warnNumCtxOverflow(messages, activeProvider, "chat");
503
1064
 
504
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
505
- const result = await provider.chat(activeProvider, messages, chatOptions);
1065
+ const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
1066
+ let chatOptions = toolsEnabled
1067
+ ? { tools: agentToolsFor(settings, activeProvider, "chat"), toolChoice: "auto" }
1068
+ : undefined;
1069
+ const provider = getProvider(activeProvider);
1070
+ let result;
1071
+ if (strategy === "agent") {
1072
+ chatOptions = agentTurnOptions(settings, chatOptions);
1073
+ result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
1074
+ } else {
1075
+ result = await provider.chat(activeProvider, messages, chatOptions);
1076
+ }
506
1077
 
507
- if (result.toolCalls) {
1078
+ if (result.toolCalls || result.fallbackToClassic) {
508
1079
  const perf = performanceAuditFields(messages, result.content, result);
1080
+ if (result.toolCalls) {
1081
+ maybeLogDebugEvent("tool_call", {
1082
+ mode: "chat",
1083
+ providerBaseUrl: activeProvider.baseUrl,
1084
+ model: activeProvider.model,
1085
+ messages: messages,
1086
+ toolCalls: result.toolCalls,
1087
+ responseContent: result.content || null
1088
+ });
1089
+ }
509
1090
  return { settings, activeProvider, result, perf, messages, toolCalls: result.toolCalls };
510
1091
  }
511
1092
 
@@ -515,6 +1096,15 @@ module.exports = function flowPilotRuntime(RED) {
515
1096
  const split = splitChatDataBlock(result.content || "");
516
1097
 
517
1098
  recordTranscriptTurn(conversationId, "chat", prompt, split.message);
1099
+ maybeLogDebugEvent("assistant_reply", {
1100
+ mode: "chat",
1101
+ providerBaseUrl: activeProvider.baseUrl,
1102
+ model: activeProvider.model,
1103
+ messages: messages,
1104
+ responseChars: typeof result.content === "string" ? result.content.length : 0,
1105
+ responseContent: result.content || "",
1106
+ parseOutcome: "received"
1107
+ });
518
1108
 
519
1109
  const perf = performanceAuditFields(messages, result.content, result);
520
1110
 
@@ -532,11 +1122,18 @@ module.exports = function flowPilotRuntime(RED) {
532
1122
  const settings = storage.getSettings();
533
1123
  const activeProvider = storage.getActiveProvider(settings);
534
1124
 
535
- const described = describeSelectionContext(context, settings.redactionEnabled);
1125
+ // Checked before any SSE headers are written, so the caller's catch
1126
+ // block can still send a normal JSON 409 (res.headersSent is false).
1127
+ if (!isProviderConfirmed(activeProvider)) {
1128
+ throw providerUnconfirmedError();
1129
+ }
1130
+
1131
+ const described = describeSelectionContext(context, settings);
536
1132
  const messages = buildMessages(
537
1133
  buildChatSystemPrompt(settings),
538
1134
  history, historyTruncated, described, prompt
539
1135
  );
1136
+ warnNumCtxOverflow(messages, activeProvider, "chat-stream");
540
1137
 
541
1138
  res.writeHead(200, {
542
1139
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -556,7 +1153,7 @@ module.exports = function flowPilotRuntime(RED) {
556
1153
 
557
1154
  let streamResult;
558
1155
  try {
559
- streamResult = await provider.chatStream(activeProvider, messages,
1156
+ streamResult = await getProvider(activeProvider).chatStream(activeProvider, messages,
560
1157
  function (delta) {
561
1158
  const visible = splitter.push(delta);
562
1159
  if (visible) {
@@ -602,13 +1199,41 @@ module.exports = function flowPilotRuntime(RED) {
602
1199
  }, performanceAuditFields(messages, full, streamResult)));
603
1200
 
604
1201
  recordTranscriptTurn(conversationId, "chat", prompt, visibleText);
1202
+ maybeLogDebugEvent("assistant_reply", {
1203
+ mode: "chat-stream",
1204
+ providerBaseUrl: activeProvider.baseUrl,
1205
+ model: activeProvider.model,
1206
+ messages: messages,
1207
+ responseChars: typeof full === "string" ? full.length : 0,
1208
+ responseContent: full || "",
1209
+ parseOutcome: "received"
1210
+ });
605
1211
  }
606
1212
 
607
1213
  // ---- Settings: read --------------------------------------------------
608
1214
 
1215
+ // Never let a real provider apiKey reach an HTTP response — storage's own
1216
+ // getSettings()/saveSettings() still return the real key internally
1217
+ // (getActiveProvider -> provider.chat depends on it), this masks ONLY the
1218
+ // two client-facing routes below. hasApiKey lets the UI show "a key is
1219
+ // saved" without ever receiving it; API_KEY_UNCHANGED is what the client
1220
+ // echoes back on save to mean "leave it alone" (see
1221
+ // reconcileProviderSecrets in lib/storage.js, the other half of this).
1222
+ function maskProviderSecrets(settings) {
1223
+ const masked = Object.assign({}, settings);
1224
+ masked.providers = (Array.isArray(settings.providers) ? settings.providers : []).map(function (p) {
1225
+ const hasApiKey = !!(p && p.apiKey && String(p.apiKey).trim());
1226
+ const next = Object.assign({}, p);
1227
+ next.apiKey = hasApiKey ? API_KEY_UNCHANGED : "";
1228
+ next.hasApiKey = hasApiKey;
1229
+ return next;
1230
+ });
1231
+ return masked;
1232
+ }
1233
+
609
1234
  RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
610
1235
  try {
611
- res.json(storage.getSettings());
1236
+ res.json(maskProviderSecrets(storage.getSettings()));
612
1237
  } catch (err) {
613
1238
  res.status(500).json({ error: err.message });
614
1239
  }
@@ -662,18 +1287,110 @@ module.exports = function flowPilotRuntime(RED) {
662
1287
  res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
663
1288
  });
664
1289
 
1290
+ // Scoped to /flowpilot/* specifically — RED.httpAdmin is Node-RED's own
1291
+ // shared admin app, so an unscoped .use() here would also intercept
1292
+ // malformed-JSON errors on core Node-RED admin routes (flow deploy, node
1293
+ // install, etc.), well beyond this ticket's intent to harden FlowPilot's
1294
+ // own endpoints.
1295
+ // CODEX-027 follow-up: a scoped RED.httpAdmin.use(errorHandler) here
1296
+ // (tried both with and without a "/flowpilot" path prefix) never actually
1297
+ // intercepted a malformed-JSON body-parse error live — Express's own
1298
+ // default HTML error page still won, for every /flowpilot/* route tested,
1299
+ // meaning Node-RED's core httpAdmin setup already fully resolves that
1300
+ // error (parser -> its own handler -> response sent) before a route
1301
+ // registered by a loaded plugin ever gets a chance to react, regardless
1302
+ // of where among the plugin's own routes it's positioned. Reverted rather
1303
+ // than ship a handler that silently never fires. The two higher-value
1304
+ // parts of this ticket (empty-body validation, 404s for missing
1305
+ // conversations) are real and verified working; malformed-JSON responses
1306
+ // still return Express's default HTML page, not clean JSON — flagged as
1307
+ // a known limitation, not fixed by this ticket.
1308
+
1309
+ // ---- 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
1313
+ // (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
1317
+ // reconcileProviderSecrets is the other half — it strips any
1318
+ // client-supplied confirmedBaseUrl/confirmedAt on save and clears
1319
+ // confirmation whenever baseUrl or apiKey actually changes, so
1320
+ // confirmation can never be forged or silently carried over to a
1321
+ // different URL.
1322
+
1323
+ function isProviderConfirmed(provider) {
1324
+ // typeof check, not truthiness: baseUrl "" is a real, documented,
1325
+ // supported value (Anthropic's "leave blank for api.anthropic.com"
1326
+ // convention) — a provider CAN be legitimately confirmed with an empty
1327
+ // confirmedBaseUrl, and `"" && ...` would silently evaluate false,
1328
+ // locking that configuration out of confirmation forever. Only an
1329
+ // actually-absent (never confirmed) field should fail this check.
1330
+ return !!provider && typeof provider.confirmedBaseUrl === "string" &&
1331
+ provider.confirmedBaseUrl === provider.baseUrl;
1332
+ }
1333
+
1334
+ const PROVIDER_UNCONFIRMED_MESSAGE = "This provider hasn't passed a connection test yet — run Test Provider first.";
1335
+
1336
+ // For routes that resolve activeProvider themselves and can respond
1337
+ // directly (models, agent-step, chat, chat-stream) — matches the
1338
+ // {error:"code", message:"text"} shape requireExecutionContract already
1339
+ // uses elsewhere in this file.
1340
+ function requireConfirmedProvider(res, activeProvider) {
1341
+ if (isProviderConfirmed(activeProvider)) { return true; }
1342
+ res.status(409).json({ error: "provider_unconfirmed", message: PROVIDER_UNCONFIRMED_MESSAGE });
1343
+ return false;
1344
+ }
1345
+
1346
+ // For the generation-family helpers (runFlowGeneration/
1347
+ // runFlowGenerationStream), which don't have direct access to `res` — they
1348
+ // throw, and the route's existing sendGenerationError/stream-error path
1349
+ // turns .status/.code into the actual response.
1350
+ function providerUnconfirmedError() {
1351
+ const err = new Error(PROVIDER_UNCONFIRMED_MESSAGE);
1352
+ err.status = 409;
1353
+ err.code = "provider_unconfirmed";
1354
+ return err;
1355
+ }
1356
+
1357
+ // The confirming check's own pass/fail criterion (B3) — extracted to its
1358
+ // own module (lib/provider-shape-check.js) so it has a real, re-executable
1359
+ // unit test rather than only code-review-level evidence.
1360
+
1361
+ function hasRequestBody(body) {
1362
+ return !!body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length > 0;
1363
+ }
1364
+
1365
+ function validateSettingsSaveBody(body) {
1366
+ if (!hasRequestBody(body)) {
1367
+ return "Settings payload is required.";
1368
+ }
1369
+ if (!Array.isArray(body.providers) || body.providers.length === 0) {
1370
+ return "Settings payload must include at least one provider.";
1371
+ }
1372
+ if (!body.activeProviderId || !String(body.activeProviderId).trim()) {
1373
+ return "Settings payload must include an activeProviderId.";
1374
+ }
1375
+ return null;
1376
+ }
1377
+
665
1378
  // ---- Settings: write -------------------------------------------------
666
1379
 
667
1380
  RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
1381
+ const validationError = validateSettingsSaveBody(req.body);
1382
+ if (validationError) {
1383
+ return res.status(400).json({ error: validationError });
1384
+ }
668
1385
  try {
669
- const saved = storage.saveSettings(req.body || {});
1386
+ const saved = storage.saveSettings(req.body);
670
1387
  storage.appendAudit({
671
1388
  action: "settings_saved",
672
1389
  providerName: saved.providerName,
673
1390
  baseUrl: saved.baseUrl,
674
1391
  model: saved.model
675
1392
  });
676
- res.json(saved);
1393
+ res.json(maskProviderSecrets(saved));
677
1394
  } catch (err) {
678
1395
  res.status(500).json({ error: err.message });
679
1396
  }
@@ -685,10 +1402,14 @@ module.exports = function flowPilotRuntime(RED) {
685
1402
  // that doesn't support /v1/models — see listModels().
686
1403
 
687
1404
  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
+ }
688
1408
  try {
689
1409
  const settings = storage.getSettings();
690
1410
  const activeProvider = storage.getActiveProvider(settings);
691
- const result = await provider.listModels(activeProvider);
1411
+ if (!requireConfirmedProvider(res, activeProvider)) { return; }
1412
+ const result = await getProvider(activeProvider).listModels(activeProvider);
692
1413
  storage.appendAudit({
693
1414
  action: "list_models",
694
1415
  providerName: activeProvider.providerName,
@@ -720,6 +1441,11 @@ module.exports = function flowPilotRuntime(RED) {
720
1441
  try {
721
1442
  await runChatStream(req, res, prompt, req.body.context, history, historyTruncated, req.body.conversationId);
722
1443
  } catch (err) {
1444
+ if (!res.headersSent && err && err.status) {
1445
+ const errBody = { error: err.code || err.message };
1446
+ if (err.code) { errBody.message = err.message; }
1447
+ return res.status(err.status).json(errBody);
1448
+ }
723
1449
  storage.appendAudit({ action: "chat_stream_error", error: err.message });
724
1450
  if (!res.headersSent) {
725
1451
  res.status(500).json({ error: err.message });
@@ -733,7 +1459,8 @@ module.exports = function flowPilotRuntime(RED) {
733
1459
  try {
734
1460
  const useTools = !!req.body.tools;
735
1461
  const { activeProvider, result, perf, chatMessage, chatData, messages, toolCalls } =
736
- await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated, req.body.conversationId, useTools);
1462
+ await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated,
1463
+ req.body.conversationId, useTools, req.body.strategy);
737
1464
 
738
1465
  storage.appendAudit(Object.assign({
739
1466
  action: "chat",
@@ -743,6 +1470,9 @@ module.exports = function flowPilotRuntime(RED) {
743
1470
  toolCallCount: toolCalls ? toolCalls.length : 0
744
1471
  }, perf));
745
1472
 
1473
+ if (result.fallbackToClassic) {
1474
+ return res.json({ fallbackToClassic: true, usage: result.usage || null });
1475
+ }
746
1476
  if (toolCalls) {
747
1477
  return res.json({ toolCalls: toolCalls, messages: messages, content: result.content || null, usage: result.usage || null });
748
1478
  }
@@ -762,6 +1492,11 @@ module.exports = function flowPilotRuntime(RED) {
762
1492
  if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
763
1493
  res.json(body);
764
1494
  } catch (err) {
1495
+ if (err && err.status) {
1496
+ const errBody = { error: err.code || err.message };
1497
+ if (err.code) { errBody.message = err.message; }
1498
+ return res.status(err.status).json(errBody);
1499
+ }
765
1500
  storage.appendAudit({ action: "chat_error", error: err.message });
766
1501
  res.status(500).json({ error: err.message });
767
1502
  }
@@ -786,7 +1521,49 @@ module.exports = function flowPilotRuntime(RED) {
786
1521
  // `context` (for describeSelectionContext / modify's originalNodes) and
787
1522
  // `prompt` (for transcript recording) are passed through from the
788
1523
  // initial request.
1524
+ const EXECUTION_STRATEGIES = new Set(["agent", "classic"]);
1525
+ const EXECUTION_ENTRIES = new Set([
1526
+ "chat", "document", "generate", "build", "modify", "build-review", "build-existing"
1527
+ ]);
1528
+
1529
+ function requireExecutionContract(req, res, settings, activeProvider) {
1530
+ const body = (req && req.body) || {};
1531
+ if (!EXECUTION_STRATEGIES.has(body.strategy) || !EXECUTION_ENTRIES.has(body.entry)) {
1532
+ res.status(400).json({
1533
+ error: "strategy_required",
1534
+ message: "strategy and entry are required and must be recognized values."
1535
+ });
1536
+ return null;
1537
+ }
1538
+ if (body.strategy === "agent" &&
1539
+ !(settings.enableAgentWrite === true && activeProvider && activeProvider.supportsTools === true)) {
1540
+ res.status(409).json({
1541
+ error: "agent_strategy_unavailable",
1542
+ message: "The agent strategy requires enableAgentWrite and a tool-capable provider."
1543
+ });
1544
+ return null;
1545
+ }
1546
+ return {
1547
+ strategy: body.strategy,
1548
+ entry: body.entry,
1549
+ conversationId: body.conversationId || null,
1550
+ runId: body.runId || null,
1551
+ // CLAUDE-014: plain-language note for the client-side decision (consent
1552
+ // gate / ask_user / loop-checkpoint) that triggered this request, only
1553
+ // sent when settings.debugLogging is on — threaded into
1554
+ // maybeLogDebugEvent calls below so debug.log shows what the user
1555
+ // actually decided instead of only raw tool-result JSON.
1556
+ debugNote: body.debugNote || null
1557
+ };
1558
+ }
1559
+
789
1560
  RED.httpAdmin.post("/flowpilot/agent-step", RED.auth.needsPermission("settings.write"), async function (req, res) {
1561
+ const settings = storage.getSettings();
1562
+ const activeProvider = storage.getActiveProvider(settings);
1563
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
1564
+ if (!execution) { return; }
1565
+ if (!requireConfirmedProvider(res, activeProvider)) { return; }
1566
+
790
1567
  const messages = req.body && req.body.messages;
791
1568
  if (!Array.isArray(messages) || messages.length === 0) {
792
1569
  return res.status(400).json({ error: "messages array is required." });
@@ -794,13 +1571,36 @@ module.exports = function flowPilotRuntime(RED) {
794
1571
  const mode = req.body.mode || "chat";
795
1572
 
796
1573
  try {
797
- const settings = storage.getSettings();
798
- const activeProvider = storage.getActiveProvider(settings);
799
- const result = await provider.chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
1574
+ const toolsEnabled = activeProvider.supportsTools === true;
1575
+ const offeredTools = toolsEnabled
1576
+ ? agentToolsFor(settings, activeProvider, mode, execution.strategy === "agent")
1577
+ : [];
1578
+ let chatOptions = toolsEnabled
1579
+ ? { tools: offeredTools, toolChoice: "auto" }
1580
+ : undefined;
1581
+ if (execution.strategy === "agent") {
1582
+ chatOptions = agentTurnOptions(settings, chatOptions);
1583
+ }
1584
+ // Messages include client-produced role:"tool" results. Pass them
1585
+ // straight to the adapter; OpenAI-compatible providers receive them
1586
+ // unchanged and Anthropic converts only the outer message envelope to
1587
+ // native tool_result blocks.
1588
+ const provider = getProvider(activeProvider);
1589
+ const result = execution.strategy === "agent"
1590
+ ? await chatWithAgentCap(provider, activeProvider, messages, chatOptions)
1591
+ : await provider.chat(activeProvider, messages, chatOptions);
1592
+
1593
+ if (result.fallbackToClassic) {
1594
+ return res.json({ fallbackToClassic: true, usage: result.usage || null });
1595
+ }
800
1596
 
801
1597
  storage.appendAudit(Object.assign({
802
1598
  action: "agent_step",
803
1599
  mode: mode,
1600
+ strategy: execution.strategy,
1601
+ entry: execution.entry,
1602
+ conversationId: execution.conversationId,
1603
+ runId: execution.runId,
804
1604
  providerName: activeProvider.providerName,
805
1605
  baseUrl: activeProvider.baseUrl,
806
1606
  model: activeProvider.model,
@@ -808,16 +1608,48 @@ module.exports = function flowPilotRuntime(RED) {
808
1608
  }, performanceAuditFields(messages, result.content, result)));
809
1609
 
810
1610
  if (result.toolCalls) {
811
- return res.json({ toolCalls: result.toolCalls, content: result.content || null, usage: result.usage || null });
1611
+ maybeLogDebugEvent("tool_call", {
1612
+ mode: mode,
1613
+ providerBaseUrl: activeProvider.baseUrl,
1614
+ model: activeProvider.model,
1615
+ messages: messages,
1616
+ toolCalls: result.toolCalls,
1617
+ responseContent: result.content || null,
1618
+ debugNote: execution.debugNote || undefined
1619
+ });
1620
+ return res.json({
1621
+ toolCalls: result.toolCalls,
1622
+ toolTiers: toolTierMap(result.toolCalls),
1623
+ content: result.content || null,
1624
+ usage: result.usage || null
1625
+ });
812
1626
  }
813
1627
 
1628
+ maybeLogDebugEvent("assistant_reply", {
1629
+ mode: mode,
1630
+ providerBaseUrl: activeProvider.baseUrl,
1631
+ model: activeProvider.model,
1632
+ messages: messages,
1633
+ responseChars: typeof result.content === "string" ? result.content.length : 0,
1634
+ responseContent: result.content || "",
1635
+ parseOutcome: "received",
1636
+ debugNote: execution.debugNote || undefined
1637
+ });
1638
+
814
1639
  if (mode !== "chat") {
815
1640
  const context = req.body.context;
816
- const described = describeSelectionContext(context, settings.redactionEnabled);
817
- const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
1641
+ const described = describeSelectionContext(context, settings);
1642
+ const generated = processGenerationContent(
1643
+ result.content || "", result, messages, mode, described, activeProvider,
1644
+ req.body.prompt, execution
1645
+ );
818
1646
  recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
819
1647
  const finalize = (mode === "modify")
820
- ? function (r) { return finalizeModifyResult(r, (context && Array.isArray(context.nodes)) ? context.nodes : []); }
1648
+ ? function (r) {
1649
+ return finalizeModifyResult(
1650
+ r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
1651
+ );
1652
+ }
821
1653
  : finalizeSimpleGeneration;
822
1654
  const { status, body } = finalize(generated);
823
1655
  return res.status(status).json(body);
@@ -833,7 +1665,7 @@ module.exports = function flowPilotRuntime(RED) {
833
1665
  if (questionOptions) { body.questionOptions = questionOptions; }
834
1666
  res.json(body);
835
1667
  } catch (err) {
836
- sendGenerationError(res, mode + "_agent_step", err);
1668
+ sendGenerationError(res, mode + "_agent_step", err, execution);
837
1669
  }
838
1670
  });
839
1671
 
@@ -891,6 +1723,9 @@ module.exports = function flowPilotRuntime(RED) {
891
1723
  RED.httpAdmin.get("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.read"), function (req, res) {
892
1724
  const id = sanitizeConversationId(req.params.id);
893
1725
  if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
1726
+ if (storage.listConversationIds().indexOf(id) === -1) {
1727
+ return res.status(404).json({ error: "Conversation not found." });
1728
+ }
894
1729
  try {
895
1730
  res.json({ id: id, messages: storage.readTranscript(id) });
896
1731
  } catch (err) {
@@ -901,6 +1736,9 @@ module.exports = function flowPilotRuntime(RED) {
901
1736
  RED.httpAdmin.delete("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.write"), function (req, res) {
902
1737
  const id = sanitizeConversationId(req.params.id);
903
1738
  if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
1739
+ if (storage.listConversationIds().indexOf(id) === -1) {
1740
+ return res.status(404).json({ error: "Conversation not found." });
1741
+ }
904
1742
  try {
905
1743
  storage.deleteTranscript(id);
906
1744
  storage.appendAudit({ action: "conversation_delete", conversationId: id });
@@ -926,11 +1764,37 @@ module.exports = function flowPilotRuntime(RED) {
926
1764
  // reply at all." Never depends on chat history or flow context.
927
1765
 
928
1766
  RED.httpAdmin.post("/flowpilot/test", RED.auth.needsPermission("settings.write"), async function (req, res) {
1767
+ if (!hasRequestBody(req.body)) {
1768
+ return res.status(400).json({ error: "Request body is required." });
1769
+ }
929
1770
  const prompt = (req.body && req.body.prompt) || "Say hello from FlowPilot.";
930
1771
 
931
1772
  try {
1773
+ // This IS the provider-confirmation check (ADR-007) — the one request
1774
+ // allowed to touch a not-yet-confirmed baseUrl. runChat("connectivity-
1775
+ // test") skips the confirmation gate for exactly this call.
932
1776
  const { settings, activeProvider, result, perf, chatMessage } = await runChat(prompt, "connectivity-test");
933
1777
 
1778
+ // Strict pass criterion (B3): a 200 with SOME JSON body is not enough
1779
+ // — an internal service or a cloud metadata endpoint can return that
1780
+ // trivially. Require an actually provider-shaped chat-completion
1781
+ // response, or the check fails and the provider stays unconfirmed.
1782
+ // The error returned to the client is deliberately generic — never
1783
+ // the upstream body — so a probe against a non-provider target
1784
+ // yields nothing readable (the actual SSRF seal).
1785
+ if (!isProviderShapedResponse(activeProvider.type, result.raw)) {
1786
+ storage.appendAudit({
1787
+ action: "chat_test_error",
1788
+ providerName: activeProvider.providerName,
1789
+ baseUrl: activeProvider.baseUrl,
1790
+ error: "not_provider_shaped"
1791
+ });
1792
+ return res.status(422).json({
1793
+ error: "provider_check_failed",
1794
+ message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
1795
+ });
1796
+ }
1797
+
934
1798
  storage.appendAudit(Object.assign({
935
1799
  action: "chat_test",
936
1800
  providerName: activeProvider.providerName,
@@ -941,8 +1805,8 @@ module.exports = function flowPilotRuntime(RED) {
941
1805
  // Capability probe — connectivity already succeeded above, so a
942
1806
  // probe failure here just means "no tool support", not a /test failure.
943
1807
  // Persist the result on the provider profile for the agentic tool-calling path.
944
- const probe = await provider.probeTools(activeProvider);
945
- const reasoning = provider.detectReasoning(result.raw);
1808
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1809
+ const reasoning = getProvider(activeProvider).detectReasoning(result.raw);
946
1810
  storage.appendAudit({
947
1811
  action: "capability_probe",
948
1812
  providerName: activeProvider.providerName,
@@ -959,11 +1823,16 @@ module.exports = function flowPilotRuntime(RED) {
959
1823
  toolsProbedAt: new Date().toISOString(),
960
1824
  isReasoningModel: reasoning.isReasoningModel,
961
1825
  reasoningProbedAt: new Date().toISOString(),
962
- probedModel: activeProvider.model
1826
+ probedModel: activeProvider.model,
1827
+ // The check above passed — this exact baseUrl is now
1828
+ // confirmed. Cleared automatically (reconcileProviderSecrets,
1829
+ // lib/storage.js) the moment baseUrl or apiKey changes.
1830
+ confirmedBaseUrl: activeProvider.baseUrl,
1831
+ confirmedAt: new Date().toISOString()
963
1832
  })
964
1833
  : p;
965
1834
  });
966
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1835
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
967
1836
 
968
1837
  const toolLabel = probe.supportsTools
969
1838
  ? "✓ Connected · ✓ Supports tools"
@@ -994,16 +1863,35 @@ module.exports = function flowPilotRuntime(RED) {
994
1863
  // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
995
1864
 
996
1865
  RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
1866
+ if (!hasRequestBody(req.body)) {
1867
+ return res.status(400).json({ error: "Request body is required." });
1868
+ }
997
1869
  try {
998
1870
  const settings = storage.getSettings();
999
1871
  const activeProvider = storage.getActiveProvider(settings);
1000
1872
 
1001
- const probe = await provider.probeTools(activeProvider);
1002
- const chatResult = await provider.chat(activeProvider, [
1873
+ // This is the OTHER route allowed to touch an unconfirmed baseUrl
1874
+ // (ADR-007) same confirming-check treatment as /flowpilot/test.
1875
+ const probe = await getProvider(activeProvider).probeTools(activeProvider);
1876
+ const chatResult = await getProvider(activeProvider).chat(activeProvider, [
1003
1877
  { role: "system", content: "You are a helpful assistant." },
1004
1878
  { role: "user", content: "Say hello." }
1005
1879
  ]);
1006
- const reasoning = provider.detectReasoning(chatResult.raw);
1880
+
1881
+ if (!isProviderShapedResponse(activeProvider.type, chatResult.raw)) {
1882
+ storage.appendAudit({
1883
+ action: "auto_probe_error",
1884
+ providerName: activeProvider.providerName,
1885
+ baseUrl: activeProvider.baseUrl,
1886
+ error: "not_provider_shaped"
1887
+ });
1888
+ return res.status(422).json({
1889
+ error: "provider_check_failed",
1890
+ message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
1891
+ });
1892
+ }
1893
+
1894
+ const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
1007
1895
 
1008
1896
  const updatedProviders = (settings.providers || []).map(function (p) {
1009
1897
  return p.id === activeProvider.id
@@ -1012,11 +1900,13 @@ module.exports = function flowPilotRuntime(RED) {
1012
1900
  toolsProbedAt: new Date().toISOString(),
1013
1901
  isReasoningModel: reasoning.isReasoningModel,
1014
1902
  reasoningProbedAt: new Date().toISOString(),
1015
- probedModel: activeProvider.model
1903
+ probedModel: activeProvider.model,
1904
+ confirmedBaseUrl: activeProvider.baseUrl,
1905
+ confirmedAt: new Date().toISOString()
1016
1906
  })
1017
1907
  : p;
1018
1908
  });
1019
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1909
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
1020
1910
 
1021
1911
  storage.appendAudit({
1022
1912
  action: "auto_probe",
@@ -1050,16 +1940,17 @@ module.exports = function flowPilotRuntime(RED) {
1050
1940
  // out from runFlowGeneration so the streaming variant can build the
1051
1941
  // same request and swap provider.chat for provider.chatStream.
1052
1942
  // ---------------------------------------------------------------------
1053
- function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
1943
+ function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
1054
1944
  const settings = storage.getSettings();
1055
1945
  const activeProvider = storage.getActiveProvider(settings);
1056
- const described = describeSelectionContext(context, settings.redactionEnabled);
1946
+ const described = describeSelectionContext(context, settings);
1057
1947
  // Persona applies to the "explanation" field only (a real hand-off/
1058
1948
  // transition moment — "here's the flow I built for you") — never to
1059
1949
  // node names, ids, or any structural JSON, which stays exactly as each
1060
1950
  // mode's own system prompt above already specifies.
1061
1951
  const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
1062
1952
  const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
1953
+ warnNumCtxOverflow(messages, activeProvider, auditAction);
1063
1954
  return { activeProvider, described, messages };
1064
1955
  }
1065
1956
 
@@ -1068,7 +1959,8 @@ module.exports = function flowPilotRuntime(RED) {
1068
1959
  // parsed envelope. Validated but non-critical — a malformed or missing
1069
1960
  // suggestion is just dropped (returns null), never an error, since chips
1070
1961
  // are an additive hint on top of the real response.
1071
- // { mode: "generate"|"document"|"modify"|"chat", prompt: "...", selectionHint?: "..." }
1962
+ // { mode: "generate"|"document"|"modify"|"chat", prompt: "...",
1963
+ // selectionHint?: "...", targetNodeIds?: "all"|string[] }
1072
1964
  // ---------------------------------------------------------------------
1073
1965
  function extractSuggestedAction(parsed) {
1074
1966
  const sa = parsed && parsed.suggestedAction;
@@ -1080,6 +1972,14 @@ module.exports = function flowPilotRuntime(RED) {
1080
1972
  if (typeof sa.selectionHint === "string" && sa.selectionHint.trim()) {
1081
1973
  result.selectionHint = sa.selectionHint.trim();
1082
1974
  }
1975
+ if (sa.targetNodeIds === "all") {
1976
+ result.targetNodeIds = "all";
1977
+ } else if (Array.isArray(sa.targetNodeIds)) {
1978
+ const targetNodeIds = sa.targetNodeIds
1979
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
1980
+ .map(function (id) { return id.trim(); });
1981
+ if (targetNodeIds.length) { result.targetNodeIds = targetNodeIds; }
1982
+ }
1083
1983
  return result;
1084
1984
  }
1085
1985
 
@@ -1110,71 +2010,6 @@ module.exports = function flowPilotRuntime(RED) {
1110
2010
  // createChatDataStreamSplitter below so the marker/JSON are never flashed
1111
2011
  // to the user mid-stream.
1112
2012
  // ---------------------------------------------------------------------
1113
- const CHAT_DATA_MARKER = "<<<FLOWPILOT_DATA>>>";
1114
-
1115
- function splitChatDataBlock(content) {
1116
- const text = String(content || "");
1117
- const idx = text.indexOf(CHAT_DATA_MARKER);
1118
- if (idx === -1) { return { message: text, data: null }; }
1119
-
1120
- const message = text.slice(0, idx).replace(/\s+$/, "");
1121
- const jsonStr = text.slice(idx + CHAT_DATA_MARKER.length).trim();
1122
- let data = null;
1123
- try { data = JSON.parse(jsonStr); } catch (e) { data = null; }
1124
- return { message: message, data: data };
1125
- }
1126
-
1127
- // Streaming counterpart of splitChatDataBlock: buffers just enough of the
1128
- // tail to detect CHAT_DATA_MARKER even if it's split across provider
1129
- // chunks, without delaying normal text. push(delta) returns the portion of
1130
- // `delta` (plus any previously-held tail) that's safe to display now —
1131
- // possibly "". Once the marker is seen, all further input is buffered as
1132
- // the JSON data block instead of being displayed. finish() returns any
1133
- // held-back text that turned out NOT to be part of the marker (a false
1134
- // positive at end of stream) plus the parsed data block, if any.
1135
- // ---------------------------------------------------------------------
1136
- function createChatDataStreamSplitter() {
1137
- let held = "";
1138
- let inData = false;
1139
- let dataBuf = "";
1140
-
1141
- function push(delta) {
1142
- if (inData) { dataBuf += delta; return ""; }
1143
-
1144
- const combined = held + delta;
1145
- const idx = combined.indexOf(CHAT_DATA_MARKER);
1146
- if (idx !== -1) {
1147
- inData = true;
1148
- dataBuf = combined.slice(idx + CHAT_DATA_MARKER.length);
1149
- held = "";
1150
- return combined.slice(0, idx);
1151
- }
1152
-
1153
- // No full marker yet — check whether the tail of `combined` is a
1154
- // prefix of the marker (i.e. the marker may be split across chunks)
1155
- // and hold that part back.
1156
- const maxOverlap = Math.min(combined.length, CHAT_DATA_MARKER.length - 1);
1157
- let overlap = 0;
1158
- for (let len = maxOverlap; len >= 1; len--) {
1159
- if (combined.slice(-len) === CHAT_DATA_MARKER.slice(0, len)) { overlap = len; break; }
1160
- }
1161
- held = overlap ? combined.slice(-overlap) : "";
1162
- return overlap ? combined.slice(0, -overlap) : combined;
1163
- }
1164
-
1165
- function finish() {
1166
- const tail = held;
1167
- held = "";
1168
- let data = null;
1169
- if (inData) {
1170
- try { data = JSON.parse(dataBuf.trim()); } catch (e) { data = null; }
1171
- }
1172
- return { tail: tail, data: data };
1173
- }
1174
-
1175
- return { push: push, finish: finish };
1176
- }
1177
-
1178
2013
  // ---------------------------------------------------------------------
1179
2014
  // Shared helper: parse, validate and audit a completed provider response
1180
2015
  // for a generation-style request, returning { question } / { prose } /
@@ -1185,8 +2020,42 @@ module.exports = function flowPilotRuntime(RED) {
1185
2020
  // provider.chatStream). Throws an Error with .status and (when applicable)
1186
2021
  // .raw for the route to relay.
1187
2022
  // ---------------------------------------------------------------------
1188
- function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider) {
2023
+ function buildFpUidManifest(flow) {
2024
+ if (!Array.isArray(flow)) { return []; }
2025
+
2026
+ return flow
2027
+ .filter(function (node) {
2028
+ return node && node.type === "debug" &&
2029
+ typeof node.name === "string" && /^FP-UID\d+$/.test(node.name);
2030
+ })
2031
+ .sort(function (a, b) {
2032
+ const bySequence = Number(a.name.slice(6)) - Number(b.name.slice(6));
2033
+ if (bySequence !== 0) { return bySequence; }
2034
+ const byName = a.name.localeCompare(b.name);
2035
+ if (byName !== 0) { return byName; }
2036
+ return String(a.id || "").localeCompare(String(b.id || ""));
2037
+ })
2038
+ .map(function (tap) {
2039
+ const upstream = flow.find(function (node) {
2040
+ return node && Array.isArray(node.wires) && node.wires.some(function (port) {
2041
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
2042
+ });
2043
+ });
2044
+ const upstreamPort = upstream ? upstream.wires.findIndex(function (port) {
2045
+ return Array.isArray(port) && port.indexOf(tap.id) !== -1;
2046
+ }) : -1;
2047
+ return {
2048
+ name: tap.name,
2049
+ id: tap.id,
2050
+ wiredFrom: upstream ? upstream.id : null,
2051
+ wiredFromPort: upstreamPort >= 0 ? upstreamPort : null
2052
+ };
2053
+ });
2054
+ }
2055
+
2056
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt, auditContext) {
1189
2057
  const perf = performanceAuditFields(messages, content, providerResult);
2058
+ const auditFields = auditContext || {};
1190
2059
 
1191
2060
  // Mode-mismatch redirect: the model may respond in plain prose —
1192
2061
  // addressing a request that doesn't belong in generate/document/modify —
@@ -1195,11 +2064,11 @@ module.exports = function flowPilotRuntime(RED) {
1195
2064
  // it would otherwise grab the "{" inside the data block and treat it as
1196
2065
  // a broken envelope.
1197
2066
  let envelopeParsed;
1198
- if (content.indexOf(CHAT_DATA_MARKER) !== -1) {
2067
+ if (findChatDataMarker(content)) {
1199
2068
  const preSplit = splitChatDataBlock(content);
1200
2069
  const proseMessage = preSplit.message.trim();
1201
2070
  if (proseMessage && proseMessage[0] !== "{") {
1202
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2071
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1203
2072
  const proseResult = { prose: proseMessage };
1204
2073
  if (preSplit.data) {
1205
2074
  const proseAction = extractSuggestedAction(preSplit.data);
@@ -1238,7 +2107,7 @@ module.exports = function flowPilotRuntime(RED) {
1238
2107
  // render it as a normal assistant message and keep the action armed.
1239
2108
  // Errors stay reserved for empty responses or a found-but-broken {...}.
1240
2109
  if (parseErr.noJsonFound && content.trim()) {
1241
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2110
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1242
2111
  // Mode-mismatch redirect: a prose reply may carry the same hidden
1243
2112
  // <<<FLOWPILOT_DATA>>> block as Chat, suggesting a mode switch (e.g.
1244
2113
  // "chat" when the request was actually a question, not a
@@ -1253,7 +2122,7 @@ module.exports = function flowPilotRuntime(RED) {
1253
2122
  }
1254
2123
  return proseResult;
1255
2124
  }
1256
- storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, perf));
2125
+ storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, auditFields, perf));
1257
2126
  const err = new Error("Could not parse a flow from the response: " + parseErr.message);
1258
2127
  err.status = 422;
1259
2128
  err.raw = content;
@@ -1261,13 +2130,73 @@ module.exports = function flowPilotRuntime(RED) {
1261
2130
  }
1262
2131
  }
1263
2132
 
2133
+ // Constrained-decoding providers cannot emit the legacy plain-prose
2134
+ // redirect + hidden data block because response_format requires a JSON
2135
+ // object. Accept the equivalent top-level {"mode":"..."} envelope and
2136
+ // translate it back into the existing prose/suggestedAction result shape.
2137
+ // Reuse the original request as the chip prompt when the model omits one.
2138
+ if (typeof parsed.mode === "string" &&
2139
+ ["generate", "document", "modify", "chat"].indexOf(parsed.mode) !== -1 &&
2140
+ parsed.mode !== auditAction) {
2141
+ const redirectPrompt = (typeof parsed.prompt === "string" && parsed.prompt.trim())
2142
+ ? parsed.prompt.trim()
2143
+ : String(userPrompt || "").trim();
2144
+ const redirect = {
2145
+ mode: parsed.mode,
2146
+ prompt: redirectPrompt
2147
+ };
2148
+ if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
2149
+ redirect.selectionHint = parsed.selectionHint.trim();
2150
+ }
2151
+ if (parsed.targetNodeIds === "all") {
2152
+ redirect.targetNodeIds = "all";
2153
+ } else if (Array.isArray(parsed.targetNodeIds)) {
2154
+ const targetNodeIds = parsed.targetNodeIds
2155
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
2156
+ .map(function (id) { return id.trim(); });
2157
+ if (targetNodeIds.length) { redirect.targetNodeIds = targetNodeIds; }
2158
+ }
2159
+ const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
2160
+ ? parsed.explanation.trim()
2161
+ : "This request belongs in " + parsed.mode + " mode.";
2162
+ storage.appendAudit(Object.assign({ action: auditAction + "_mode_redirect" }, auditFields, perf));
2163
+ return { prose: redirectProse, suggestedAction: redirect };
2164
+ }
2165
+
2166
+ // W2 — Class A repair pass. Run on every successfully-parsed envelope
2167
+ // before the mode-specific branches below. Repairs that don't apply
2168
+ // to a given mode's envelope shape are no-ops (e.g. repairFlowNodes
2169
+ // on an empty/absent flow array). Switch mismatches are surfaced as a
2170
+ // skippedNote on the modify result rather than a 422 — a targeted
2171
+ // message the model can act on, without discarding the rest of the
2172
+ // response.
2173
+ {
2174
+ const repaired = repairEnvelope(parsed);
2175
+ parsed = repaired.envelope;
2176
+ if (repaired.repairs.length) {
2177
+ console.info("[FlowPilot] W2 validator repaired %d field(s): %s",
2178
+ repaired.repairs.length,
2179
+ repaired.repairs.map(function (r) { return r.rule + ":" + r.detail; }).join("; "));
2180
+ }
2181
+ if (repaired.switchMismatches.length) {
2182
+ const detail = repaired.switchMismatches.map(function (m) {
2183
+ return "node " + m.id + " has " + m.rulesLen + " rule(s) but " + m.wiresLen + " wire port(s)";
2184
+ }).join("; ");
2185
+ // Surface as a validation warning on the parsed envelope — the
2186
+ // modify path will pick it up below and add it as a skippedNote.
2187
+ parsed._switchMismatchNote = "Switch rules/wires mismatch — " + detail +
2188
+ ". The number of wires[] entries must equal the number of rules[]. " +
2189
+ "Please resend with the corrected switch node.";
2190
+ }
2191
+ }
2192
+
1264
2193
  // Clarifying-question envelope. The model may ask ONE
1265
2194
  // follow-up question instead of producing a flow when the request is too
1266
2195
  // ambiguous to act on. The frontend renders the question as a normal
1267
2196
  // assistant message and keeps the Execute action armed for the answer.
1268
2197
  if (typeof parsed.question === "string" && parsed.question.trim() &&
1269
2198
  (!Array.isArray(parsed.flow) || parsed.flow.length === 0)) {
1270
- storage.appendAudit(Object.assign({ action: auditAction + "_question" }, perf));
2199
+ storage.appendAudit(Object.assign({ action: auditAction + "_question" }, auditFields, perf));
1271
2200
  const questionResult = { question: parsed.question, explanation: parsed.explanation || "" };
1272
2201
  const questionAction = extractSuggestedAction(parsed);
1273
2202
  if (questionAction) { questionResult.suggestedAction = questionAction; }
@@ -1293,6 +2222,24 @@ module.exports = function flowPilotRuntime(RED) {
1293
2222
  throw err;
1294
2223
  }
1295
2224
 
2225
+ // Do not silently turn malformed structured fields into empty arrays.
2226
+ // A model reply such as {"changes": {...}} is plainly an attempted
2227
+ // Modify envelope, not prose or a legitimate no-op. Treat wrong field
2228
+ // types like other envelope parse failures so the client uses its safe
2229
+ // 422 handling and never presents raw JSON as a trusted assistant reply.
2230
+ const modifyArrayFields = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
2231
+ const invalidArrayFields = modifyArrayFields.filter(function (field) {
2232
+ return field in parsed && !Array.isArray(parsed[field]);
2233
+ });
2234
+ if (invalidArrayFields.length) {
2235
+ const err = new Error("The response contained non-array modify field(s): " + invalidArrayFields.join(", ") + ".");
2236
+ err.status = 422;
2237
+ err.raw = content;
2238
+ throw err;
2239
+ }
2240
+
2241
+ enforceAgentContract(parsed, auditContext, false);
2242
+
1296
2243
  const changes = Array.isArray(parsed.changes) ? parsed.changes : [];
1297
2244
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1298
2245
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
@@ -1306,28 +2253,43 @@ module.exports = function flowPilotRuntime(RED) {
1306
2253
  // since newNodes itself is copied through.
1307
2254
  const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
1308
2255
 
2256
+ // W0.2: strip any redaction-placeholder values from changes[].set before
2257
+ // they reach the client. Code owns the user notification now — the CRITICAL
2258
+ // block in the Modify prompt that tried to prevent this via instruction is
2259
+ // deleted. skippedNote is surfaced in modifyResult for the frontend to show.
2260
+ const { cleanedChanges, skippedNote: redactionSkippedNote } = stripRedactionPlaceholders(changes);
2261
+
1309
2262
  storage.appendAudit(Object.assign({
1310
2263
  action: auditAction,
1311
2264
  providerName: activeProvider.providerName,
1312
2265
  baseUrl: activeProvider.baseUrl,
1313
2266
  model: activeProvider.model,
1314
- changeCount: changes.length,
2267
+ changeCount: cleanedChanges.length,
1315
2268
  newNodeCount: newNodes.length,
1316
2269
  newWireCount: newWires.length,
1317
2270
  removeNodeCount: removeNodes.length,
1318
2271
  newGroupCount: newGroups.length,
1319
2272
  contextNodeCount: described ? described.nodeCount : 0,
1320
2273
  contextConnectionCount: described ? described.connectionCount : 0
1321
- }, perf));
2274
+ }, auditFields, perf));
1322
2275
 
1323
2276
  const modifyResult = {
1324
2277
  explanation: parsed.explanation || "",
1325
- changes: changes,
2278
+ changes: cleanedChanges,
1326
2279
  newNodes: newNodes,
1327
2280
  newWires: newWires,
1328
2281
  removeNodes: removeNodes,
1329
2282
  newGroups: newGroups
1330
2283
  };
2284
+ if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
2285
+ modifyResult.strippedFields = parsed.strippedFields.slice();
2286
+ }
2287
+ if (Array.isArray(parsed.verifySteps) && parsed.verifySteps.length) {
2288
+ modifyResult.verifySteps = parsed.verifySteps.slice();
2289
+ }
2290
+ // Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
2291
+ const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
2292
+ if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
1331
2293
  const modifyAction = extractSuggestedAction(parsed);
1332
2294
  if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
1333
2295
  return modifyResult;
@@ -1349,7 +2311,7 @@ module.exports = function flowPilotRuntime(RED) {
1349
2311
  nodeCount: flow.length,
1350
2312
  contextNodeCount: described ? described.nodeCount : 0,
1351
2313
  contextConnectionCount: described ? described.connectionCount : 0
1352
- }, perf));
2314
+ }, auditFields, perf));
1353
2315
 
1354
2316
  const flowResult = {
1355
2317
  explanation: parsed.explanation || "",
@@ -1357,6 +2319,11 @@ module.exports = function flowPilotRuntime(RED) {
1357
2319
  newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
1358
2320
  newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
1359
2321
  };
2322
+ if (auditAction === "build") {
2323
+ const fpUidManifest = buildFpUidManifest(flow);
2324
+ if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
2325
+ if (flow.length) { flowResult.stepNodeClasses = classifyFlowNodes(flow); }
2326
+ }
1360
2327
  const flowAction = extractSuggestedAction(parsed);
1361
2328
  if (flowAction) { flowResult.suggestedAction = flowAction; }
1362
2329
  return flowResult;
@@ -1370,19 +2337,101 @@ module.exports = function flowPilotRuntime(RED) {
1370
2337
  // action name, and how the route validates its inputs beforehand. Throws
1371
2338
  // an Error with .status and (when applicable) .raw for the route to relay.
1372
2339
  // ---------------------------------------------------------------------
1373
- // Step 4: useTools offers AGENT_READ_TOOLS (explore-then-propose). If the
2340
+ // Step 4: useTools offers the mode-appropriate agent tools. WRITE tools
2341
+ // are Modify-only and require enableAgentWrite plus a tool-capable
2342
+ // provider. If the
1374
2343
  // provider responds with tool_calls instead of a final envelope, returns
1375
2344
  // early with { toolCalls, messages, content, usage } — same shape as
1376
2345
  // runChat's early return — so the route can hand it to the frontend
1377
2346
  // without running processGenerationContent yet.
1378
- async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
1379
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1380
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
1381
- const result = await provider.chat(activeProvider, messages, chatOptions);
2347
+ async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools, execution) {
2348
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
2349
+ if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
2350
+ const settings = storage.getSettings();
2351
+ const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
2352
+ const offeredTools = toolsEnabled
2353
+ ? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
2354
+ : [];
2355
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
2356
+ const toolChoice = auditAction === "modify" && execution &&
2357
+ execution.strategy === "agent" ? "required" : "auto";
2358
+ let chatOptions = toolsEnabled
2359
+ ? { tools: offeredTools, toolChoice: toolChoice }
2360
+ : (responseFormat ? { responseFormat: responseFormat } : undefined);
2361
+ const provider = getProvider(activeProvider);
2362
+ let result;
2363
+ if (execution && execution.strategy === "agent") {
2364
+ chatOptions = agentTurnOptions(settings, chatOptions);
2365
+ result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
2366
+ } else {
2367
+ result = await provider.chat(activeProvider, messages, chatOptions);
2368
+ }
2369
+ if (result.fallbackToClassic) {
2370
+ return { fallbackToClassic: true, usage: result.usage || null };
2371
+ }
1382
2372
  if (result.toolCalls) {
1383
- return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
2373
+ // F-5: this is the FIRST turn of an agent-strategy request (routed
2374
+ // through /flowpilot/{generate,build,document,modify}, not the
2375
+ // /flowpilot/agent-step continuation endpoint below, which already
2376
+ // audits unconditionally) — it can carry a real WRITE tool call, so
2377
+ // it must not be the one turn that leaves zero record. Previously
2378
+ // this path returned before any storage.appendAudit call; a request
2379
+ // whose very first turn was a tool call (e.g. an immediate ask_user,
2380
+ // or — as here — the opening WRITE call of a multi-item Modify)
2381
+ // vanished from audit.log entirely. maybeLogDebugEvent below is
2382
+ // additive (only fires when settings.debugLogging is on); this
2383
+ // appendAudit call is unconditional, matching /flowpilot/agent-step.
2384
+ storage.appendAudit({
2385
+ action: "first_turn_tool_call",
2386
+ mode: auditAction,
2387
+ strategy: (execution && execution.strategy) || null,
2388
+ entry: (execution && execution.entry) || null,
2389
+ conversationId: (execution && execution.conversationId) || null,
2390
+ runId: (execution && execution.runId) || null,
2391
+ providerName: activeProvider.providerName,
2392
+ baseUrl: activeProvider.baseUrl,
2393
+ model: activeProvider.model,
2394
+ toolCallCount: result.toolCalls.length
2395
+ });
2396
+ maybeLogDebugEvent("tool_call", {
2397
+ mode: auditAction,
2398
+ providerBaseUrl: activeProvider.baseUrl,
2399
+ model: activeProvider.model,
2400
+ messages: messages,
2401
+ toolCalls: result.toolCalls,
2402
+ responseContent: result.content || null
2403
+ });
2404
+ return {
2405
+ toolCalls: result.toolCalls,
2406
+ toolTiers: toolTierMap(result.toolCalls),
2407
+ messages: messages,
2408
+ content: result.content || null,
2409
+ usage: result.usage || null
2410
+ };
2411
+ }
2412
+ const content = result.content || "";
2413
+ let parseOutcome = "unknown";
2414
+ try {
2415
+ const generated = processGenerationContent(
2416
+ content, result, messages, auditAction, described, activeProvider, userPrompt, execution
2417
+ );
2418
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
2419
+ return generated;
2420
+ } catch (err) {
2421
+ parseOutcome = "parse_error:" + (err.message || "");
2422
+ throw err;
2423
+ } finally {
2424
+ maybeLogDebugEvent("assistant_reply", {
2425
+ mode: auditAction,
2426
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
2427
+ model: activeProvider && activeProvider.model,
2428
+ messages: messages,
2429
+ responseChars: typeof content === "string" ? content.length : 0,
2430
+ responseContent: content,
2431
+ parseOutcome: parseOutcome,
2432
+ debugNote: (execution && execution.debugNote) || undefined
2433
+ });
1384
2434
  }
1385
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1386
2435
  }
1387
2436
 
1388
2437
  // ---------------------------------------------------------------------
@@ -1393,21 +2442,52 @@ module.exports = function flowPilotRuntime(RED) {
1393
2442
  // while the rest of the JSON (the "flow" array etc.) is buffered until
1394
2443
  // this resolves.
1395
2444
  // ---------------------------------------------------------------------
1396
- async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
1397
- const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1398
- const result = await provider.chatStream(activeProvider, messages, onDelta);
1399
- return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
2445
+ async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta, auditContext) {
2446
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
2447
+ if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
2448
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
2449
+ const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
2450
+ const result = await getProvider(activeProvider).chatStream(
2451
+ activeProvider, messages, onDelta, undefined, streamOptions
2452
+ );
2453
+ const content = result.content || "";
2454
+ let parseOutcome = "unknown";
2455
+ try {
2456
+ const generated = processGenerationContent(
2457
+ content, result, messages, auditAction, described, activeProvider, userPrompt, auditContext
2458
+ );
2459
+ parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
2460
+ return generated;
2461
+ } catch (err) {
2462
+ parseOutcome = "parse_error:" + (err.message || "");
2463
+ throw err;
2464
+ } finally {
2465
+ maybeLogDebugEvent("assistant_reply", {
2466
+ mode: auditAction,
2467
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
2468
+ model: activeProvider && activeProvider.model,
2469
+ messages: messages,
2470
+ responseChars: typeof content === "string" ? content.length : 0,
2471
+ responseContent: content,
2472
+ parseOutcome: parseOutcome,
2473
+ debugNote: (auditContext && auditContext.debugNote) || undefined
2474
+ });
2475
+ }
1400
2476
  }
1401
2477
 
1402
2478
  // Relays a runFlowGeneration error to the client with the right status,
1403
2479
  // falling back to 500 for anything that didn't set .status itself.
1404
- function sendGenerationError(res, auditAction, err) {
2480
+ function sendGenerationError(res, auditAction, err, auditContext) {
1405
2481
  if (err && err.status) {
1406
2482
  const body = { error: err.message };
2483
+ if (err.code) { body.code = err.code; }
1407
2484
  if (err.raw) { body.raw = err.raw; }
1408
2485
  return res.status(err.status).json(body);
1409
2486
  }
1410
- storage.appendAudit({ action: auditAction + "_error", error: err.message });
2487
+ storage.appendAudit(Object.assign(
2488
+ { action: auditAction + "_error", error: err.message },
2489
+ auditContext || {}
2490
+ ));
1411
2491
  res.status(500).json({ error: err.message });
1412
2492
  }
1413
2493
 
@@ -1445,7 +2525,7 @@ module.exports = function flowPilotRuntime(RED) {
1445
2525
  // the /flowpilot/modify route handler. Used by both the non-streaming and
1446
2526
  // streaming routes.
1447
2527
  // ---------------------------------------------------------------------
1448
- function finalizeModifyResult(result, originalNodes) {
2528
+ function finalizeModifyResult(result, originalNodes, auditContext) {
1449
2529
  if (result.question) {
1450
2530
  const questionBody = { explanation: result.explanation, question: result.question, flow: null };
1451
2531
  if (result.suggestedAction) { questionBody.suggestedAction = result.suggestedAction; }
@@ -1458,6 +2538,22 @@ module.exports = function flowPilotRuntime(RED) {
1458
2538
  if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1459
2539
  return { status: 200, body: proseBody };
1460
2540
  }
2541
+ if (auditContext && auditContext.strategy === "agent") {
2542
+ const agentBody = {
2543
+ explanation: result.explanation || "",
2544
+ prose: true,
2545
+ flow: null
2546
+ };
2547
+ if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
2548
+ agentBody.strippedFields = result.strippedFields.slice();
2549
+ }
2550
+ if (Array.isArray(result.verifySteps) && result.verifySteps.length) {
2551
+ agentBody.verifySteps = result.verifySteps.slice();
2552
+ }
2553
+ if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
2554
+ if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
2555
+ return { status: 200, body: agentBody };
2556
+ }
1461
2557
 
1462
2558
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1463
2559
 
@@ -1534,10 +2630,17 @@ module.exports = function flowPilotRuntime(RED) {
1534
2630
  // Each patch's "set" is shallow-merged onto a copy of the original node.
1535
2631
  // "id", "x", "y", "z" can never move via a patch — strip them
1536
2632
  // defensively even though the prompt already forbids them.
2633
+ const originalById = {};
2634
+ originalNodes.forEach(function (n) {
2635
+ if (n && n.id !== undefined && n.id !== null) {
2636
+ originalById[String(n.id)] = n;
2637
+ }
2638
+ });
2639
+
1537
2640
  const patchById = {};
1538
2641
  validChanges.forEach(function (c) {
1539
2642
  const set = (c.set && typeof c.set === "object") ? c.set : {};
1540
- const clean = Object.assign({}, set);
2643
+ const clean = stripUnserializableEchoes(set, originalById[String(c.id)]);
1541
2644
  delete clean.id;
1542
2645
  delete clean.x;
1543
2646
  delete clean.y;
@@ -1664,7 +2767,41 @@ module.exports = function flowPilotRuntime(RED) {
1664
2767
  }
1665
2768
  }
1666
2769
 
1667
- if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
2770
+ if (newGroups.length > 0) {
2771
+ storage.appendAudit(Object.assign(
2772
+ { action: "modify_groups", count: newGroups.length },
2773
+ auditContext || {}
2774
+ ));
2775
+ }
2776
+
2777
+ const verifySteps = [];
2778
+ validChanges.forEach(function (change) {
2779
+ const set = patchById[String(change.id)] || {};
2780
+ Object.keys(set).forEach(function (prop) {
2781
+ if (MODIFY_VERIFY_SKIP_PROPS.has(prop)) { return; }
2782
+ verifySteps.push({
2783
+ nodeId: change.id,
2784
+ check: "property",
2785
+ prop: prop,
2786
+ expected: set[prop]
2787
+ });
2788
+ });
2789
+ });
2790
+ newNodes.forEach(function (node) {
2791
+ if (!node || node.id === undefined || node.id === null) { return; }
2792
+ verifySteps.push({ nodeId: node.id, check: "exists" });
2793
+ });
2794
+ finalRemoveNodes.forEach(function (id) {
2795
+ verifySteps.push({ nodeId: id, check: "absent" });
2796
+ });
2797
+ newWires.forEach(function (wire) {
2798
+ verifySteps.push({
2799
+ fromId: wire.from,
2800
+ fromPort: wire.fromPort || 0,
2801
+ toId: wire.to,
2802
+ check: "wire"
2803
+ });
2804
+ });
1668
2805
 
1669
2806
  const body = {
1670
2807
  explanation: result.explanation,
@@ -1674,6 +2811,7 @@ module.exports = function flowPilotRuntime(RED) {
1674
2811
  removeNodes: finalRemoveNodes,
1675
2812
  newGroups: newGroups
1676
2813
  };
2814
+ if (verifySteps.length) { body.verifySteps = verifySteps; }
1677
2815
  if (skippedDescriptions.length > 0) { body.skippedNote = skippedDescriptions.join(". ") + "."; }
1678
2816
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1679
2817
 
@@ -1693,7 +2831,7 @@ module.exports = function flowPilotRuntime(RED) {
1693
2831
  // `data: {"error": <body>, "status": <status>}`, since SSE responses can't
1694
2832
  // change their HTTP status after headers are sent.
1695
2833
  // ---------------------------------------------------------------------
1696
- async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId) {
2834
+ async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId, auditContext) {
1697
2835
  res.writeHead(200, {
1698
2836
  "Content-Type": "text/event-stream; charset=utf-8",
1699
2837
  "Cache-Control": "no-cache, no-transform",
@@ -1706,12 +2844,18 @@ module.exports = function flowPilotRuntime(RED) {
1706
2844
  try {
1707
2845
  result = await runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, function (delta) {
1708
2846
  res.write("data: " + JSON.stringify({ delta: delta }) + "\n\n");
1709
- });
2847
+ }, auditContext);
1710
2848
  } catch (err) {
1711
2849
  const status = err && err.status ? err.status : 500;
1712
2850
  const body = { error: err.message };
2851
+ if (err && err.code) { body.code = err.code; }
1713
2852
  if (err && err.raw) { body.raw = err.raw; }
1714
- if (!err || !err.status) { storage.appendAudit({ action: auditAction + "_error", error: err.message }); }
2853
+ if (!err || !err.status) {
2854
+ storage.appendAudit(Object.assign(
2855
+ { action: auditAction + "_error", error: err.message },
2856
+ auditContext || {}
2857
+ ));
2858
+ }
1715
2859
  res.write("data: " + JSON.stringify({ error: body, status: status }) + "\n\n");
1716
2860
  res.write("data: [DONE]\n\n");
1717
2861
  res.end();
@@ -1750,7 +2894,13 @@ module.exports = function flowPilotRuntime(RED) {
1750
2894
  history, historyTruncated, useTools
1751
2895
  );
1752
2896
  if (generated.toolCalls) {
1753
- return res.json({ toolCalls: generated.toolCalls, messages: generated.messages, content: generated.content, usage: generated.usage });
2897
+ return res.json({
2898
+ toolCalls: generated.toolCalls,
2899
+ toolTiers: generated.toolTiers,
2900
+ messages: generated.messages,
2901
+ content: generated.content,
2902
+ usage: generated.usage
2903
+ });
1754
2904
  }
1755
2905
  recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
1756
2906
  const { status, body } = finalizeSimpleGeneration(generated);
@@ -1790,7 +2940,13 @@ module.exports = function flowPilotRuntime(RED) {
1790
2940
  history, historyTruncated, useTools
1791
2941
  );
1792
2942
  if (built.toolCalls) {
1793
- return res.json({ toolCalls: built.toolCalls, messages: built.messages, content: built.content, usage: built.usage });
2943
+ return res.json({
2944
+ toolCalls: built.toolCalls,
2945
+ toolTiers: built.toolTiers,
2946
+ messages: built.messages,
2947
+ content: built.content,
2948
+ usage: built.usage
2949
+ });
1794
2950
  }
1795
2951
  recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
1796
2952
  const { status, body } = finalizeSimpleGeneration(built);
@@ -1802,7 +2958,7 @@ module.exports = function flowPilotRuntime(RED) {
1802
2958
 
1803
2959
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
1804
2960
  const context = req.body && req.body.context;
1805
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
2961
+ const described = describeSelectionContext(context, storage.getSettings());
1806
2962
 
1807
2963
  if (!described) {
1808
2964
  return res.status(400).json({ error: "Select the node(s) you want documented first." });
@@ -1830,7 +2986,13 @@ module.exports = function flowPilotRuntime(RED) {
1830
2986
  history, historyTruncated, useTools
1831
2987
  );
1832
2988
  if (documented.toolCalls) {
1833
- return res.json({ toolCalls: documented.toolCalls, messages: documented.messages, content: documented.content, usage: documented.usage });
2989
+ return res.json({
2990
+ toolCalls: documented.toolCalls,
2991
+ toolTiers: documented.toolTiers,
2992
+ messages: documented.messages,
2993
+ content: documented.content,
2994
+ usage: documented.usage
2995
+ });
1834
2996
  }
1835
2997
  recordTranscriptTurn(req.body.conversationId, "document", userPrompt, transcriptTextFromGenerationResult(documented));
1836
2998
  const { status, body } = finalizeSimpleGeneration(documented);
@@ -1841,8 +3003,13 @@ module.exports = function flowPilotRuntime(RED) {
1841
3003
  });
1842
3004
 
1843
3005
  RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
3006
+ const settings = storage.getSettings();
3007
+ const activeProvider = storage.getActiveProvider(settings);
3008
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
3009
+ if (!execution) { return; }
3010
+
1844
3011
  const context = req.body && req.body.context;
1845
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
3012
+ const described = describeSelectionContext(context, settings);
1846
3013
 
1847
3014
  if (!described) {
1848
3015
  return res.status(400).json({ error: "Select the node(s) you want to modify first." });
@@ -1860,29 +3027,43 @@ module.exports = function flowPilotRuntime(RED) {
1860
3027
  const history = sanitizeHistory(req.body.history);
1861
3028
  const historyTruncated = !!req.body.historyTruncated;
1862
3029
 
1863
- const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
3030
+ const finalize = function (result) { return finalizeModifyResult(result, originalNodes, execution); };
3031
+ const hasSwitch = Array.isArray(context && context.nodes) &&
3032
+ context.nodes.some(function (node) { return node && node.type === "switch"; });
3033
+ // Keep the legacy prompt byte-identical for the explicit classic
3034
+ // strategy. Agent prompt behavior keys only off the propagated strategy.
3035
+ const modifyPrompt = modifySystemPrompt({
3036
+ hasSwitch: hasSwitch,
3037
+ agentWriteEnabled: execution.strategy === "agent"
3038
+ });
1864
3039
 
1865
3040
  if (req.body.stream) {
1866
3041
  return runExecuteStream(
1867
- req, res, modifySystemPrompt, "modify", String(prompt).trim(), context,
1868
- history, historyTruncated, finalize, req.body.conversationId
3042
+ req, res, modifyPrompt, "modify", String(prompt).trim(), context,
3043
+ history, historyTruncated, finalize, req.body.conversationId, execution
1869
3044
  );
1870
3045
  }
1871
3046
 
1872
3047
  try {
1873
3048
  const useTools = !!req.body.tools;
1874
3049
  const result = await runFlowGeneration(
1875
- modifySystemPrompt, "modify", String(prompt).trim(), context,
1876
- history, historyTruncated, useTools
3050
+ modifyPrompt, "modify", String(prompt).trim(), context,
3051
+ history, historyTruncated, useTools, execution
1877
3052
  );
1878
3053
  if (result.toolCalls) {
1879
- return res.json({ toolCalls: result.toolCalls, messages: result.messages, content: result.content, usage: result.usage });
3054
+ return res.json({
3055
+ toolCalls: result.toolCalls,
3056
+ toolTiers: result.toolTiers,
3057
+ messages: result.messages,
3058
+ content: result.content,
3059
+ usage: result.usage
3060
+ });
1880
3061
  }
1881
3062
  recordTranscriptTurn(req.body.conversationId, "modify", String(prompt).trim(), transcriptTextFromGenerationResult(result));
1882
3063
  const { status, body } = finalize(result);
1883
3064
  res.status(status).json(body);
1884
3065
  } catch (err) {
1885
- sendGenerationError(res, "modify", err);
3066
+ sendGenerationError(res, "modify", err, execution);
1886
3067
  }
1887
3068
  });
1888
3069
  };