@manny-est/node-red-flowpilot 0.5.2 → 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
@@ -72,8 +72,16 @@ const modifySystemPrompt = require("./lib/modify-system-prompt");
72
72
  const buildSystemPrompt = require("./lib/build-system-prompt");
73
73
  const personaPrompt = require("./lib/persona-prompt");
74
74
  const { buildCoreScript } = require("./lib/build-core-script");
75
+ const {
76
+ createChatDataStreamSplitter,
77
+ findChatDataMarker,
78
+ splitChatDataBlock
79
+ } = require("./lib/chat-data");
75
80
  const { extractJsonObject } = require("./lib/envelope");
76
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;
77
85
 
78
86
  module.exports = function flowPilotRuntime(RED) {
79
87
  const storage = createStorage(RED.settings.userDir);
@@ -91,12 +99,11 @@ module.exports = function flowPilotRuntime(RED) {
91
99
  "that may have been said earlier, ask the user.";
92
100
 
93
101
  // ---------------------------------------------------------------------
94
- // Tier-1 READ tools the model may call autonomously during
95
- // a chat turn. Their data (RED.nodes, live selection, debug buffer) lives
96
- // only in the editor, so each call is executed CLIENT-SIDE and its result
97
- // passed back through the same sanitizer as selection context — a tool
98
- // result can never carry a raw secret. WRITE actions are never exposed as
99
- // 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.
100
107
  // ---------------------------------------------------------------------
101
108
  const AGENT_READ_TOOLS = [
102
109
  {
@@ -205,6 +212,247 @@ module.exports = function flowPilotRuntime(RED) {
205
212
  }
206
213
  ];
207
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
+
208
456
  // Keep only well-formed { role: "user"|"assistant", content: <string> }
209
457
  // entries. Anything else (bad shapes, empty content, other roles) is
210
458
  // dropped rather than rejected outright — the history is advisory context,
@@ -548,27 +796,22 @@ module.exports = function flowPilotRuntime(RED) {
548
796
  return clean;
549
797
  }
550
798
 
551
- // W0.4: when logAssembledPrompts is enabled, append one JSON-lines entry to
552
- // assembled-prompts.log. Called after each provider round-trip with both
553
- // the outgoing messages and the raw response content. Auth keys are never
554
- // included only baseUrl + model from the provider profile.
555
- function maybeLogAssembledPrompt(mode, messages, responseContent, parseOutcome, activeProvider) {
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) {
556
803
  const settings = storage.getSettings();
557
- if (!settings.logAssembledPrompts) { return; }
804
+ if (!settings.debugLogging) { return; }
805
+ fields = fields || {};
806
+ const messages = fields.messages || [];
558
807
  const promptChars = (messages || []).reduce(function (sum, m) {
559
808
  return sum + (m && typeof m.content === "string" ? m.content.length : 0);
560
809
  }, 0);
561
- storage.appendAssembledPromptLog({
562
- mode: mode,
563
- providerBaseUrl: activeProvider && activeProvider.baseUrl,
564
- model: activeProvider && activeProvider.model,
810
+ storage.appendDebugLog(Object.assign({
811
+ type: type,
565
812
  promptTokenEst: Math.round(promptChars / 4),
566
- messageCount: (messages || []).length,
567
- messages: messages,
568
- responseChars: typeof responseContent === "string" ? responseContent.length : 0,
569
- responseContent: responseContent,
570
- parseOutcome: parseOutcome
571
- });
813
+ messageCount: messages.length
814
+ }, fields));
572
815
  }
573
816
 
574
817
  // W0.1: warn when estimated prompt token count approaches the provider's
@@ -595,10 +838,78 @@ module.exports = function flowPilotRuntime(RED) {
595
838
  // Returns null when there's no selection — used by both /chat and
596
839
  // /generate so the two describe context identically and never drift.
597
840
  // ---------------------------------------------------------------------
598
- 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) {
599
909
  const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
600
910
  const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
601
911
  if (nodes.length === 0 && debugMessages.length === 0) { return null; }
912
+ settings = settings || {};
602
913
 
603
914
  const connections = (context && context.connections) ? context.connections : {};
604
915
  const edges = Array.isArray(connections.edges) ? connections.edges : [];
@@ -610,7 +921,7 @@ module.exports = function flowPilotRuntime(RED) {
610
921
  // changes. redactionEnabled only controls the SEPARATE secret-shaped-value
611
922
  // scrubbing (password/token/apiKey-looking fields elsewhere in a node's
612
923
  // config) — tell the model the truth about which protection is active.
613
- const credentialNote = redactionEnabled === false
924
+ const credentialNote = settings.redactionEnabled === false
614
925
  ? "Redaction is OFF for this session — context may contain sensitive " +
615
926
  "values the user chose to share (e.g. embedded API keys or tokens); " +
616
927
  "handle carefully and never volunteer them. Node-RED's separate " +
@@ -621,47 +932,104 @@ module.exports = function flowPilotRuntime(RED) {
621
932
  "(it requires re-confirming a type-to-confirm phrase, by design)."
622
933
  : "This is sanitized configuration; credentials are redacted.";
623
934
 
624
- let content = "";
935
+ const sections = [];
625
936
  if (nodes.length > 0) {
626
- content += "The user has selected the following Node-RED nodes as context. " +
627
- credentialNote + "\n\n" +
628
- "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
+ });
629
945
  if (edges.length > 0) {
630
- content += "\n\nConnections — directed edges by node id (a node's wires " +
631
- "describe its OUTPUTS; one edge per output port; fromId/toId refer " +
632
- "to the \"id\" fields in Nodes above):\n```json\n" +
633
- JSON.stringify(edges) + "\n```";
634
- content += "\n\nPer-node wiring summary, with readable \"Name [type]\" " +
635
- "labels (inputs are reconstructed, since Node-RED nodes do not " +
636
- "store their own inputs; subFlow groups nodes into connected " +
637
- "sub-flows):\n```json\n" +
638
- 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
+ });
639
963
  }
640
964
  if (subFlowCount > 1) {
641
- content += "\n\nNote: the selection contains " + subFlowCount + " separate, " +
642
- "unconnected sub-flows (see each node's subFlow number). Treat " +
643
- "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
+ });
644
972
  }
645
973
  }
646
974
 
647
- const configNodes = (context && Array.isArray(context.configNodes)) ? context.configNodes : [];
975
+ const configNodes = settings.allowConfigContext === true &&
976
+ context && Array.isArray(context.configNodes) ? context.configNodes : [];
648
977
  if (configNodes.length > 0) {
649
- content += (content ? "\n\n" : "") +
650
- "Config nodes referenced by the selection (shared configuration " +
651
- "objects not shown on the canvas; credentials are redacted). " +
652
- "Use a config node's \"id\" to point an existing node at it via " +
653
- "a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
654
- JSON.stringify(configNodes) + "\n```";
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
+ });
655
988
  }
656
989
 
657
990
  if (debugMessages.length > 0) {
658
- content += (content ? "\n\n" : "") +
659
- "The user attached recent Node-RED Debug sidebar output for " +
660
- "troubleshooting (runtime data, may be truncated):\n```json\n" +
661
- 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
+ });
662
999
  }
663
1000
 
664
- 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;
665
1033
  }
666
1034
 
667
1035
  // ---------------------------------------------------------------------
@@ -669,28 +1037,56 @@ module.exports = function flowPilotRuntime(RED) {
669
1037
  // log it, and return the result. Used by both /chat and /test so the two
670
1038
  // never drift apart. contextMode is recorded for the audit trail.
671
1039
  // ---------------------------------------------------------------------
672
- // 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
673
1042
  // with tool_choice "auto". If the provider responds with tool_calls instead
674
1043
  // of a final message, we return early with `toolCalls` + the `messages`
675
1044
  // array built so far (so the caller/frontend can append the tool results
676
1045
  // and continue via /flowpilot/agent-step) — nothing is recorded to the
677
1046
  // transcript yet, since this isn't the final answer for the turn.
678
- async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools) {
1047
+ async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools, strategy) {
679
1048
  const settings = storage.getSettings();
680
1049
  const activeProvider = storage.getActiveProvider(settings);
681
1050
 
682
- 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);
683
1059
  const messages = buildMessages(
684
1060
  buildChatSystemPrompt(settings),
685
1061
  history, historyTruncated, described, prompt
686
1062
  );
687
1063
  warnNumCtxOverflow(messages, activeProvider, "chat");
688
1064
 
689
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
690
- const result = await getProvider(activeProvider).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
+ }
691
1077
 
692
- if (result.toolCalls) {
1078
+ if (result.toolCalls || result.fallbackToClassic) {
693
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
+ }
694
1090
  return { settings, activeProvider, result, perf, messages, toolCalls: result.toolCalls };
695
1091
  }
696
1092
 
@@ -700,6 +1096,15 @@ module.exports = function flowPilotRuntime(RED) {
700
1096
  const split = splitChatDataBlock(result.content || "");
701
1097
 
702
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
+ });
703
1108
 
704
1109
  const perf = performanceAuditFields(messages, result.content, result);
705
1110
 
@@ -717,7 +1122,13 @@ module.exports = function flowPilotRuntime(RED) {
717
1122
  const settings = storage.getSettings();
718
1123
  const activeProvider = storage.getActiveProvider(settings);
719
1124
 
720
- 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);
721
1132
  const messages = buildMessages(
722
1133
  buildChatSystemPrompt(settings),
723
1134
  history, historyTruncated, described, prompt
@@ -788,13 +1199,41 @@ module.exports = function flowPilotRuntime(RED) {
788
1199
  }, performanceAuditFields(messages, full, streamResult)));
789
1200
 
790
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
+ });
791
1211
  }
792
1212
 
793
1213
  // ---- Settings: read --------------------------------------------------
794
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
+
795
1234
  RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
796
1235
  try {
797
- res.json(storage.getSettings());
1236
+ res.json(maskProviderSecrets(storage.getSettings()));
798
1237
  } catch (err) {
799
1238
  res.status(500).json({ error: err.message });
800
1239
  }
@@ -848,18 +1287,110 @@ module.exports = function flowPilotRuntime(RED) {
848
1287
  res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
849
1288
  });
850
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
+
851
1378
  // ---- Settings: write -------------------------------------------------
852
1379
 
853
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
+ }
854
1385
  try {
855
- const saved = storage.saveSettings(req.body || {});
1386
+ const saved = storage.saveSettings(req.body);
856
1387
  storage.appendAudit({
857
1388
  action: "settings_saved",
858
1389
  providerName: saved.providerName,
859
1390
  baseUrl: saved.baseUrl,
860
1391
  model: saved.model
861
1392
  });
862
- res.json(saved);
1393
+ res.json(maskProviderSecrets(saved));
863
1394
  } catch (err) {
864
1395
  res.status(500).json({ error: err.message });
865
1396
  }
@@ -871,9 +1402,13 @@ module.exports = function flowPilotRuntime(RED) {
871
1402
  // that doesn't support /v1/models — see listModels().
872
1403
 
873
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
+ }
874
1408
  try {
875
1409
  const settings = storage.getSettings();
876
1410
  const activeProvider = storage.getActiveProvider(settings);
1411
+ if (!requireConfirmedProvider(res, activeProvider)) { return; }
877
1412
  const result = await getProvider(activeProvider).listModels(activeProvider);
878
1413
  storage.appendAudit({
879
1414
  action: "list_models",
@@ -906,6 +1441,11 @@ module.exports = function flowPilotRuntime(RED) {
906
1441
  try {
907
1442
  await runChatStream(req, res, prompt, req.body.context, history, historyTruncated, req.body.conversationId);
908
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
+ }
909
1449
  storage.appendAudit({ action: "chat_stream_error", error: err.message });
910
1450
  if (!res.headersSent) {
911
1451
  res.status(500).json({ error: err.message });
@@ -919,7 +1459,8 @@ module.exports = function flowPilotRuntime(RED) {
919
1459
  try {
920
1460
  const useTools = !!req.body.tools;
921
1461
  const { activeProvider, result, perf, chatMessage, chatData, messages, toolCalls } =
922
- 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);
923
1464
 
924
1465
  storage.appendAudit(Object.assign({
925
1466
  action: "chat",
@@ -929,6 +1470,9 @@ module.exports = function flowPilotRuntime(RED) {
929
1470
  toolCallCount: toolCalls ? toolCalls.length : 0
930
1471
  }, perf));
931
1472
 
1473
+ if (result.fallbackToClassic) {
1474
+ return res.json({ fallbackToClassic: true, usage: result.usage || null });
1475
+ }
932
1476
  if (toolCalls) {
933
1477
  return res.json({ toolCalls: toolCalls, messages: messages, content: result.content || null, usage: result.usage || null });
934
1478
  }
@@ -948,6 +1492,11 @@ module.exports = function flowPilotRuntime(RED) {
948
1492
  if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
949
1493
  res.json(body);
950
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
+ }
951
1500
  storage.appendAudit({ action: "chat_error", error: err.message });
952
1501
  res.status(500).json({ error: err.message });
953
1502
  }
@@ -972,7 +1521,49 @@ module.exports = function flowPilotRuntime(RED) {
972
1521
  // `context` (for describeSelectionContext / modify's originalNodes) and
973
1522
  // `prompt` (for transcript recording) are passed through from the
974
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
+
975
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
+
976
1567
  const messages = req.body && req.body.messages;
977
1568
  if (!Array.isArray(messages) || messages.length === 0) {
978
1569
  return res.status(400).json({ error: "messages array is required." });
@@ -980,13 +1571,36 @@ module.exports = function flowPilotRuntime(RED) {
980
1571
  const mode = req.body.mode || "chat";
981
1572
 
982
1573
  try {
983
- const settings = storage.getSettings();
984
- const activeProvider = storage.getActiveProvider(settings);
985
- const result = await getProvider(activeProvider).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
+ }
986
1596
 
987
1597
  storage.appendAudit(Object.assign({
988
1598
  action: "agent_step",
989
1599
  mode: mode,
1600
+ strategy: execution.strategy,
1601
+ entry: execution.entry,
1602
+ conversationId: execution.conversationId,
1603
+ runId: execution.runId,
990
1604
  providerName: activeProvider.providerName,
991
1605
  baseUrl: activeProvider.baseUrl,
992
1606
  model: activeProvider.model,
@@ -994,16 +1608,48 @@ module.exports = function flowPilotRuntime(RED) {
994
1608
  }, performanceAuditFields(messages, result.content, result)));
995
1609
 
996
1610
  if (result.toolCalls) {
997
- 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
+ });
998
1626
  }
999
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
+
1000
1639
  if (mode !== "chat") {
1001
1640
  const context = req.body.context;
1002
- const described = describeSelectionContext(context, settings.redactionEnabled);
1003
- 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
+ );
1004
1646
  recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
1005
1647
  const finalize = (mode === "modify")
1006
- ? 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
+ }
1007
1653
  : finalizeSimpleGeneration;
1008
1654
  const { status, body } = finalize(generated);
1009
1655
  return res.status(status).json(body);
@@ -1019,7 +1665,7 @@ module.exports = function flowPilotRuntime(RED) {
1019
1665
  if (questionOptions) { body.questionOptions = questionOptions; }
1020
1666
  res.json(body);
1021
1667
  } catch (err) {
1022
- sendGenerationError(res, mode + "_agent_step", err);
1668
+ sendGenerationError(res, mode + "_agent_step", err, execution);
1023
1669
  }
1024
1670
  });
1025
1671
 
@@ -1077,6 +1723,9 @@ module.exports = function flowPilotRuntime(RED) {
1077
1723
  RED.httpAdmin.get("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.read"), function (req, res) {
1078
1724
  const id = sanitizeConversationId(req.params.id);
1079
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
+ }
1080
1729
  try {
1081
1730
  res.json({ id: id, messages: storage.readTranscript(id) });
1082
1731
  } catch (err) {
@@ -1087,6 +1736,9 @@ module.exports = function flowPilotRuntime(RED) {
1087
1736
  RED.httpAdmin.delete("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.write"), function (req, res) {
1088
1737
  const id = sanitizeConversationId(req.params.id);
1089
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
+ }
1090
1742
  try {
1091
1743
  storage.deleteTranscript(id);
1092
1744
  storage.appendAudit({ action: "conversation_delete", conversationId: id });
@@ -1112,11 +1764,37 @@ module.exports = function flowPilotRuntime(RED) {
1112
1764
  // reply at all." Never depends on chat history or flow context.
1113
1765
 
1114
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
+ }
1115
1770
  const prompt = (req.body && req.body.prompt) || "Say hello from FlowPilot.";
1116
1771
 
1117
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.
1118
1776
  const { settings, activeProvider, result, perf, chatMessage } = await runChat(prompt, "connectivity-test");
1119
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
+
1120
1798
  storage.appendAudit(Object.assign({
1121
1799
  action: "chat_test",
1122
1800
  providerName: activeProvider.providerName,
@@ -1145,11 +1823,16 @@ module.exports = function flowPilotRuntime(RED) {
1145
1823
  toolsProbedAt: new Date().toISOString(),
1146
1824
  isReasoningModel: reasoning.isReasoningModel,
1147
1825
  reasoningProbedAt: new Date().toISOString(),
1148
- 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()
1149
1832
  })
1150
1833
  : p;
1151
1834
  });
1152
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1835
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
1153
1836
 
1154
1837
  const toolLabel = probe.supportsTools
1155
1838
  ? "✓ Connected · ✓ Supports tools"
@@ -1180,15 +1863,34 @@ module.exports = function flowPilotRuntime(RED) {
1180
1863
  // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
1181
1864
 
1182
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
+ }
1183
1869
  try {
1184
1870
  const settings = storage.getSettings();
1185
1871
  const activeProvider = storage.getActiveProvider(settings);
1186
1872
 
1873
+ // This is the OTHER route allowed to touch an unconfirmed baseUrl
1874
+ // (ADR-007) — same confirming-check treatment as /flowpilot/test.
1187
1875
  const probe = await getProvider(activeProvider).probeTools(activeProvider);
1188
1876
  const chatResult = await getProvider(activeProvider).chat(activeProvider, [
1189
1877
  { role: "system", content: "You are a helpful assistant." },
1190
1878
  { role: "user", content: "Say hello." }
1191
1879
  ]);
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
+
1192
1894
  const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
1193
1895
 
1194
1896
  const updatedProviders = (settings.providers || []).map(function (p) {
@@ -1198,11 +1900,13 @@ module.exports = function flowPilotRuntime(RED) {
1198
1900
  toolsProbedAt: new Date().toISOString(),
1199
1901
  isReasoningModel: reasoning.isReasoningModel,
1200
1902
  reasoningProbedAt: new Date().toISOString(),
1201
- probedModel: activeProvider.model
1903
+ probedModel: activeProvider.model,
1904
+ confirmedBaseUrl: activeProvider.baseUrl,
1905
+ confirmedAt: new Date().toISOString()
1202
1906
  })
1203
1907
  : p;
1204
1908
  });
1205
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1909
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
1206
1910
 
1207
1911
  storage.appendAudit({
1208
1912
  action: "auto_probe",
@@ -1239,7 +1943,7 @@ module.exports = function flowPilotRuntime(RED) {
1239
1943
  function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
1240
1944
  const settings = storage.getSettings();
1241
1945
  const activeProvider = storage.getActiveProvider(settings);
1242
- const described = describeSelectionContext(context, settings.redactionEnabled);
1946
+ const described = describeSelectionContext(context, settings);
1243
1947
  // Persona applies to the "explanation" field only (a real hand-off/
1244
1948
  // transition moment — "here's the flow I built for you") — never to
1245
1949
  // node names, ids, or any structural JSON, which stays exactly as each
@@ -1255,7 +1959,8 @@ module.exports = function flowPilotRuntime(RED) {
1255
1959
  // parsed envelope. Validated but non-critical — a malformed or missing
1256
1960
  // suggestion is just dropped (returns null), never an error, since chips
1257
1961
  // are an additive hint on top of the real response.
1258
- // { mode: "generate"|"document"|"modify"|"chat", prompt: "...", selectionHint?: "..." }
1962
+ // { mode: "generate"|"document"|"modify"|"chat", prompt: "...",
1963
+ // selectionHint?: "...", targetNodeIds?: "all"|string[] }
1259
1964
  // ---------------------------------------------------------------------
1260
1965
  function extractSuggestedAction(parsed) {
1261
1966
  const sa = parsed && parsed.suggestedAction;
@@ -1267,6 +1972,14 @@ module.exports = function flowPilotRuntime(RED) {
1267
1972
  if (typeof sa.selectionHint === "string" && sa.selectionHint.trim()) {
1268
1973
  result.selectionHint = sa.selectionHint.trim();
1269
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
+ }
1270
1983
  return result;
1271
1984
  }
1272
1985
 
@@ -1297,71 +2010,6 @@ module.exports = function flowPilotRuntime(RED) {
1297
2010
  // createChatDataStreamSplitter below so the marker/JSON are never flashed
1298
2011
  // to the user mid-stream.
1299
2012
  // ---------------------------------------------------------------------
1300
- const CHAT_DATA_MARKER = "<<<FLOWPILOT_DATA>>>";
1301
-
1302
- function splitChatDataBlock(content) {
1303
- const text = String(content || "");
1304
- const idx = text.indexOf(CHAT_DATA_MARKER);
1305
- if (idx === -1) { return { message: text, data: null }; }
1306
-
1307
- const message = text.slice(0, idx).replace(/\s+$/, "");
1308
- const jsonStr = text.slice(idx + CHAT_DATA_MARKER.length).trim();
1309
- let data = null;
1310
- try { data = JSON.parse(jsonStr); } catch (e) { data = null; }
1311
- return { message: message, data: data };
1312
- }
1313
-
1314
- // Streaming counterpart of splitChatDataBlock: buffers just enough of the
1315
- // tail to detect CHAT_DATA_MARKER even if it's split across provider
1316
- // chunks, without delaying normal text. push(delta) returns the portion of
1317
- // `delta` (plus any previously-held tail) that's safe to display now —
1318
- // possibly "". Once the marker is seen, all further input is buffered as
1319
- // the JSON data block instead of being displayed. finish() returns any
1320
- // held-back text that turned out NOT to be part of the marker (a false
1321
- // positive at end of stream) plus the parsed data block, if any.
1322
- // ---------------------------------------------------------------------
1323
- function createChatDataStreamSplitter() {
1324
- let held = "";
1325
- let inData = false;
1326
- let dataBuf = "";
1327
-
1328
- function push(delta) {
1329
- if (inData) { dataBuf += delta; return ""; }
1330
-
1331
- const combined = held + delta;
1332
- const idx = combined.indexOf(CHAT_DATA_MARKER);
1333
- if (idx !== -1) {
1334
- inData = true;
1335
- dataBuf = combined.slice(idx + CHAT_DATA_MARKER.length);
1336
- held = "";
1337
- return combined.slice(0, idx);
1338
- }
1339
-
1340
- // No full marker yet — check whether the tail of `combined` is a
1341
- // prefix of the marker (i.e. the marker may be split across chunks)
1342
- // and hold that part back.
1343
- const maxOverlap = Math.min(combined.length, CHAT_DATA_MARKER.length - 1);
1344
- let overlap = 0;
1345
- for (let len = maxOverlap; len >= 1; len--) {
1346
- if (combined.slice(-len) === CHAT_DATA_MARKER.slice(0, len)) { overlap = len; break; }
1347
- }
1348
- held = overlap ? combined.slice(-overlap) : "";
1349
- return overlap ? combined.slice(0, -overlap) : combined;
1350
- }
1351
-
1352
- function finish() {
1353
- const tail = held;
1354
- held = "";
1355
- let data = null;
1356
- if (inData) {
1357
- try { data = JSON.parse(dataBuf.trim()); } catch (e) { data = null; }
1358
- }
1359
- return { tail: tail, data: data };
1360
- }
1361
-
1362
- return { push: push, finish: finish };
1363
- }
1364
-
1365
2013
  // ---------------------------------------------------------------------
1366
2014
  // Shared helper: parse, validate and audit a completed provider response
1367
2015
  // for a generation-style request, returning { question } / { prose } /
@@ -1405,8 +2053,9 @@ module.exports = function flowPilotRuntime(RED) {
1405
2053
  });
1406
2054
  }
1407
2055
 
1408
- function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt) {
2056
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt, auditContext) {
1409
2057
  const perf = performanceAuditFields(messages, content, providerResult);
2058
+ const auditFields = auditContext || {};
1410
2059
 
1411
2060
  // Mode-mismatch redirect: the model may respond in plain prose —
1412
2061
  // addressing a request that doesn't belong in generate/document/modify —
@@ -1415,11 +2064,11 @@ module.exports = function flowPilotRuntime(RED) {
1415
2064
  // it would otherwise grab the "{" inside the data block and treat it as
1416
2065
  // a broken envelope.
1417
2066
  let envelopeParsed;
1418
- if (content.indexOf(CHAT_DATA_MARKER) !== -1) {
2067
+ if (findChatDataMarker(content)) {
1419
2068
  const preSplit = splitChatDataBlock(content);
1420
2069
  const proseMessage = preSplit.message.trim();
1421
2070
  if (proseMessage && proseMessage[0] !== "{") {
1422
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2071
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1423
2072
  const proseResult = { prose: proseMessage };
1424
2073
  if (preSplit.data) {
1425
2074
  const proseAction = extractSuggestedAction(preSplit.data);
@@ -1458,7 +2107,7 @@ module.exports = function flowPilotRuntime(RED) {
1458
2107
  // render it as a normal assistant message and keep the action armed.
1459
2108
  // Errors stay reserved for empty responses or a found-but-broken {...}.
1460
2109
  if (parseErr.noJsonFound && content.trim()) {
1461
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2110
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1462
2111
  // Mode-mismatch redirect: a prose reply may carry the same hidden
1463
2112
  // <<<FLOWPILOT_DATA>>> block as Chat, suggesting a mode switch (e.g.
1464
2113
  // "chat" when the request was actually a question, not a
@@ -1473,7 +2122,7 @@ module.exports = function flowPilotRuntime(RED) {
1473
2122
  }
1474
2123
  return proseResult;
1475
2124
  }
1476
- 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));
1477
2126
  const err = new Error("Could not parse a flow from the response: " + parseErr.message);
1478
2127
  err.status = 422;
1479
2128
  err.raw = content;
@@ -1499,10 +2148,18 @@ module.exports = function flowPilotRuntime(RED) {
1499
2148
  if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
1500
2149
  redirect.selectionHint = parsed.selectionHint.trim();
1501
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
+ }
1502
2159
  const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
1503
2160
  ? parsed.explanation.trim()
1504
2161
  : "This request belongs in " + parsed.mode + " mode.";
1505
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2162
+ storage.appendAudit(Object.assign({ action: auditAction + "_mode_redirect" }, auditFields, perf));
1506
2163
  return { prose: redirectProse, suggestedAction: redirect };
1507
2164
  }
1508
2165
 
@@ -1539,7 +2196,7 @@ module.exports = function flowPilotRuntime(RED) {
1539
2196
  // assistant message and keeps the Execute action armed for the answer.
1540
2197
  if (typeof parsed.question === "string" && parsed.question.trim() &&
1541
2198
  (!Array.isArray(parsed.flow) || parsed.flow.length === 0)) {
1542
- storage.appendAudit(Object.assign({ action: auditAction + "_question" }, perf));
2199
+ storage.appendAudit(Object.assign({ action: auditAction + "_question" }, auditFields, perf));
1543
2200
  const questionResult = { question: parsed.question, explanation: parsed.explanation || "" };
1544
2201
  const questionAction = extractSuggestedAction(parsed);
1545
2202
  if (questionAction) { questionResult.suggestedAction = questionAction; }
@@ -1565,6 +2222,24 @@ module.exports = function flowPilotRuntime(RED) {
1565
2222
  throw err;
1566
2223
  }
1567
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
+
1568
2243
  const changes = Array.isArray(parsed.changes) ? parsed.changes : [];
1569
2244
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1570
2245
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
@@ -1596,7 +2271,7 @@ module.exports = function flowPilotRuntime(RED) {
1596
2271
  newGroupCount: newGroups.length,
1597
2272
  contextNodeCount: described ? described.nodeCount : 0,
1598
2273
  contextConnectionCount: described ? described.connectionCount : 0
1599
- }, perf));
2274
+ }, auditFields, perf));
1600
2275
 
1601
2276
  const modifyResult = {
1602
2277
  explanation: parsed.explanation || "",
@@ -1606,6 +2281,12 @@ module.exports = function flowPilotRuntime(RED) {
1606
2281
  removeNodes: removeNodes,
1607
2282
  newGroups: newGroups
1608
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
+ }
1609
2290
  // Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
1610
2291
  const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
1611
2292
  if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
@@ -1630,7 +2311,7 @@ module.exports = function flowPilotRuntime(RED) {
1630
2311
  nodeCount: flow.length,
1631
2312
  contextNodeCount: described ? described.nodeCount : 0,
1632
2313
  contextConnectionCount: described ? described.connectionCount : 0
1633
- }, perf));
2314
+ }, auditFields, perf));
1634
2315
 
1635
2316
  const flowResult = {
1636
2317
  explanation: parsed.explanation || "",
@@ -1656,32 +2337,100 @@ module.exports = function flowPilotRuntime(RED) {
1656
2337
  // action name, and how the route validates its inputs beforehand. Throws
1657
2338
  // an Error with .status and (when applicable) .raw for the route to relay.
1658
2339
  // ---------------------------------------------------------------------
1659
- // 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
1660
2343
  // provider responds with tool_calls instead of a final envelope, returns
1661
2344
  // early with { toolCalls, messages, content, usage } — same shape as
1662
2345
  // runChat's early return — so the route can hand it to the frontend
1663
2346
  // without running processGenerationContent yet.
1664
- async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
2347
+ async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools, execution) {
1665
2348
  const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
1666
- const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, useTools);
1667
- const chatOptions = useTools
1668
- ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" }
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 }
1669
2360
  : (responseFormat ? { responseFormat: responseFormat } : undefined);
1670
- const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
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
+ }
1671
2372
  if (result.toolCalls) {
1672
- 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
+ };
1673
2411
  }
1674
2412
  const content = result.content || "";
1675
2413
  let parseOutcome = "unknown";
1676
2414
  try {
1677
- const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
2415
+ const generated = processGenerationContent(
2416
+ content, result, messages, auditAction, described, activeProvider, userPrompt, execution
2417
+ );
1678
2418
  parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1679
2419
  return generated;
1680
2420
  } catch (err) {
1681
2421
  parseOutcome = "parse_error:" + (err.message || "");
1682
2422
  throw err;
1683
2423
  } finally {
1684
- maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
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
+ });
1685
2434
  }
1686
2435
  }
1687
2436
 
@@ -1693,8 +2442,9 @@ module.exports = function flowPilotRuntime(RED) {
1693
2442
  // while the rest of the JSON (the "flow" array etc.) is buffered until
1694
2443
  // this resolves.
1695
2444
  // ---------------------------------------------------------------------
1696
- async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
2445
+ async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta, auditContext) {
1697
2446
  const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
2447
+ if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
1698
2448
  const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
1699
2449
  const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
1700
2450
  const result = await getProvider(activeProvider).chatStream(
@@ -1703,26 +2453,41 @@ module.exports = function flowPilotRuntime(RED) {
1703
2453
  const content = result.content || "";
1704
2454
  let parseOutcome = "unknown";
1705
2455
  try {
1706
- const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
2456
+ const generated = processGenerationContent(
2457
+ content, result, messages, auditAction, described, activeProvider, userPrompt, auditContext
2458
+ );
1707
2459
  parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1708
2460
  return generated;
1709
2461
  } catch (err) {
1710
2462
  parseOutcome = "parse_error:" + (err.message || "");
1711
2463
  throw err;
1712
2464
  } finally {
1713
- maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
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
+ });
1714
2475
  }
1715
2476
  }
1716
2477
 
1717
2478
  // Relays a runFlowGeneration error to the client with the right status,
1718
2479
  // falling back to 500 for anything that didn't set .status itself.
1719
- function sendGenerationError(res, auditAction, err) {
2480
+ function sendGenerationError(res, auditAction, err, auditContext) {
1720
2481
  if (err && err.status) {
1721
2482
  const body = { error: err.message };
2483
+ if (err.code) { body.code = err.code; }
1722
2484
  if (err.raw) { body.raw = err.raw; }
1723
2485
  return res.status(err.status).json(body);
1724
2486
  }
1725
- storage.appendAudit({ action: auditAction + "_error", error: err.message });
2487
+ storage.appendAudit(Object.assign(
2488
+ { action: auditAction + "_error", error: err.message },
2489
+ auditContext || {}
2490
+ ));
1726
2491
  res.status(500).json({ error: err.message });
1727
2492
  }
1728
2493
 
@@ -1760,7 +2525,7 @@ module.exports = function flowPilotRuntime(RED) {
1760
2525
  // the /flowpilot/modify route handler. Used by both the non-streaming and
1761
2526
  // streaming routes.
1762
2527
  // ---------------------------------------------------------------------
1763
- function finalizeModifyResult(result, originalNodes) {
2528
+ function finalizeModifyResult(result, originalNodes, auditContext) {
1764
2529
  if (result.question) {
1765
2530
  const questionBody = { explanation: result.explanation, question: result.question, flow: null };
1766
2531
  if (result.suggestedAction) { questionBody.suggestedAction = result.suggestedAction; }
@@ -1773,6 +2538,22 @@ module.exports = function flowPilotRuntime(RED) {
1773
2538
  if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1774
2539
  return { status: 200, body: proseBody };
1775
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
+ }
1776
2557
 
1777
2558
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1778
2559
 
@@ -1986,7 +2767,12 @@ module.exports = function flowPilotRuntime(RED) {
1986
2767
  }
1987
2768
  }
1988
2769
 
1989
- 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
+ }
1990
2776
 
1991
2777
  const verifySteps = [];
1992
2778
  validChanges.forEach(function (change) {
@@ -2045,7 +2831,7 @@ module.exports = function flowPilotRuntime(RED) {
2045
2831
  // `data: {"error": <body>, "status": <status>}`, since SSE responses can't
2046
2832
  // change their HTTP status after headers are sent.
2047
2833
  // ---------------------------------------------------------------------
2048
- 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) {
2049
2835
  res.writeHead(200, {
2050
2836
  "Content-Type": "text/event-stream; charset=utf-8",
2051
2837
  "Cache-Control": "no-cache, no-transform",
@@ -2058,12 +2844,18 @@ module.exports = function flowPilotRuntime(RED) {
2058
2844
  try {
2059
2845
  result = await runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, function (delta) {
2060
2846
  res.write("data: " + JSON.stringify({ delta: delta }) + "\n\n");
2061
- });
2847
+ }, auditContext);
2062
2848
  } catch (err) {
2063
2849
  const status = err && err.status ? err.status : 500;
2064
2850
  const body = { error: err.message };
2851
+ if (err && err.code) { body.code = err.code; }
2065
2852
  if (err && err.raw) { body.raw = err.raw; }
2066
- 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
+ }
2067
2859
  res.write("data: " + JSON.stringify({ error: body, status: status }) + "\n\n");
2068
2860
  res.write("data: [DONE]\n\n");
2069
2861
  res.end();
@@ -2102,7 +2894,13 @@ module.exports = function flowPilotRuntime(RED) {
2102
2894
  history, historyTruncated, useTools
2103
2895
  );
2104
2896
  if (generated.toolCalls) {
2105
- 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
+ });
2106
2904
  }
2107
2905
  recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
2108
2906
  const { status, body } = finalizeSimpleGeneration(generated);
@@ -2142,7 +2940,13 @@ module.exports = function flowPilotRuntime(RED) {
2142
2940
  history, historyTruncated, useTools
2143
2941
  );
2144
2942
  if (built.toolCalls) {
2145
- 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
+ });
2146
2950
  }
2147
2951
  recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
2148
2952
  const { status, body } = finalizeSimpleGeneration(built);
@@ -2154,7 +2958,7 @@ module.exports = function flowPilotRuntime(RED) {
2154
2958
 
2155
2959
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
2156
2960
  const context = req.body && req.body.context;
2157
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
2961
+ const described = describeSelectionContext(context, storage.getSettings());
2158
2962
 
2159
2963
  if (!described) {
2160
2964
  return res.status(400).json({ error: "Select the node(s) you want documented first." });
@@ -2182,7 +2986,13 @@ module.exports = function flowPilotRuntime(RED) {
2182
2986
  history, historyTruncated, useTools
2183
2987
  );
2184
2988
  if (documented.toolCalls) {
2185
- 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
+ });
2186
2996
  }
2187
2997
  recordTranscriptTurn(req.body.conversationId, "document", userPrompt, transcriptTextFromGenerationResult(documented));
2188
2998
  const { status, body } = finalizeSimpleGeneration(documented);
@@ -2193,8 +3003,13 @@ module.exports = function flowPilotRuntime(RED) {
2193
3003
  });
2194
3004
 
2195
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
+
2196
3011
  const context = req.body && req.body.context;
2197
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
3012
+ const described = describeSelectionContext(context, settings);
2198
3013
 
2199
3014
  if (!described) {
2200
3015
  return res.status(400).json({ error: "Select the node(s) you want to modify first." });
@@ -2212,15 +3027,20 @@ module.exports = function flowPilotRuntime(RED) {
2212
3027
  const history = sanitizeHistory(req.body.history);
2213
3028
  const historyTruncated = !!req.body.historyTruncated;
2214
3029
 
2215
- const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
3030
+ const finalize = function (result) { return finalizeModifyResult(result, originalNodes, execution); };
2216
3031
  const hasSwitch = Array.isArray(context && context.nodes) &&
2217
3032
  context.nodes.some(function (node) { return node && node.type === "switch"; });
2218
- const modifyPrompt = modifySystemPrompt({ hasSwitch: hasSwitch });
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
+ });
2219
3039
 
2220
3040
  if (req.body.stream) {
2221
3041
  return runExecuteStream(
2222
3042
  req, res, modifyPrompt, "modify", String(prompt).trim(), context,
2223
- history, historyTruncated, finalize, req.body.conversationId
3043
+ history, historyTruncated, finalize, req.body.conversationId, execution
2224
3044
  );
2225
3045
  }
2226
3046
 
@@ -2228,16 +3048,22 @@ module.exports = function flowPilotRuntime(RED) {
2228
3048
  const useTools = !!req.body.tools;
2229
3049
  const result = await runFlowGeneration(
2230
3050
  modifyPrompt, "modify", String(prompt).trim(), context,
2231
- history, historyTruncated, useTools
3051
+ history, historyTruncated, useTools, execution
2232
3052
  );
2233
3053
  if (result.toolCalls) {
2234
- 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
+ });
2235
3061
  }
2236
3062
  recordTranscriptTurn(req.body.conversationId, "modify", String(prompt).trim(), transcriptTextFromGenerationResult(result));
2237
3063
  const { status, body } = finalize(result);
2238
3064
  res.status(status).json(body);
2239
3065
  } catch (err) {
2240
- sendGenerationError(res, "modify", err);
3066
+ sendGenerationError(res, "modify", err, execution);
2241
3067
  }
2242
3068
  });
2243
3069
  };