@manny-est/node-red-flowpilot 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowpilot.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const http = require("http");
2
+ const https = require("https");
2
3
  const path = require("path");
4
+ const PACKAGE_VERSION = require("./package.json").version;
3
5
  const createStorage = require("./lib/storage");
4
6
  const openaiProvider = require("./lib/provider-openai-compatible");
5
7
  const anthropicProvider = require("./lib/provider-anthropic");
@@ -72,8 +74,120 @@ const modifySystemPrompt = require("./lib/modify-system-prompt");
72
74
  const buildSystemPrompt = require("./lib/build-system-prompt");
73
75
  const personaPrompt = require("./lib/persona-prompt");
74
76
  const { buildCoreScript } = require("./lib/build-core-script");
77
+ const {
78
+ createChatDataStreamSplitter,
79
+ findChatDataMarker,
80
+ splitChatDataBlock
81
+ } = require("./lib/chat-data");
75
82
  const { extractJsonObject } = require("./lib/envelope");
76
83
  const { repairEnvelope } = require("./lib/validator");
84
+ const { enforceAgentContract } = require("./lib/agent-contract");
85
+ const { isProviderShapedResponse } = require("./lib/provider-shape-check");
86
+ const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
87
+ const UPDATE_CHECK_URL = "https://registry.npmjs.org/-/package/@manny-est/node-red-flowpilot/dist-tags";
88
+ const UPDATE_CHECK_SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
89
+ const UPDATE_CHECK_FAILURE_TTL_MS = 15 * 60 * 1000;
90
+ const RUN_EVENTS_MAX_STORED = 50;
91
+ const runEventsStore = new Map();
92
+ let updateCheckCache = null;
93
+
94
+ function parseVersion(v) {
95
+ var parts = String(v).split("-");
96
+ var core = parts[0].split(".").map(Number);
97
+ var pre = parts.length > 1 ? parts.slice(1).join("-").split(".") : null;
98
+ return { core: core, pre: pre };
99
+ }
100
+
101
+ function isNewer(remoteVersion, localVersion) {
102
+ var r = parseVersion(remoteVersion), l = parseVersion(localVersion);
103
+ for (var i = 0; i < 3; i++) {
104
+ var rv = r.core[i] || 0, lv = l.core[i] || 0;
105
+ if (rv !== lv) { return rv > lv; }
106
+ }
107
+ // core version numbers are equal
108
+ if (!r.pre && l.pre) { return true; } // remote is a full release, local is a prerelease of the same core
109
+ if (r.pre && !l.pre) { return false; } // remote is a prerelease, local is already past it
110
+ if (!r.pre && !l.pre) { return false; } // identical plain releases
111
+ // both are prereleases of the same core version — compare the last dot segment numerically if possible
112
+ var rn = Number(r.pre[r.pre.length - 1]);
113
+ var ln = Number(l.pre[l.pre.length - 1]);
114
+ if (!isNaN(rn) && !isNaN(ln)) { return rn > ln; }
115
+ return r.pre.join(".") !== l.pre.join(".") && r.pre.join(".") > l.pre.join(".");
116
+ }
117
+
118
+ function updateCheckFallbackResponse() {
119
+ return { enabled: true, updateAvailable: false };
120
+ }
121
+
122
+ function fetchUpdateDistTags() {
123
+ return new Promise((resolve, reject) => {
124
+ let url;
125
+ try {
126
+ url = new URL(UPDATE_CHECK_URL);
127
+ } catch (err) {
128
+ reject(err);
129
+ return;
130
+ }
131
+
132
+ const req = https.request({
133
+ method: "GET",
134
+ hostname: url.hostname,
135
+ port: url.port || 443,
136
+ path: url.pathname + url.search,
137
+ headers: { Accept: "application/json" }
138
+ }, (res) => {
139
+ let data = "";
140
+ res.setEncoding("utf8");
141
+ res.on("data", chunk => { data += chunk; });
142
+ res.on("end", () => {
143
+ let parsed = null;
144
+ try {
145
+ parsed = data ? JSON.parse(data) : null;
146
+ } catch (err) {
147
+ reject(err);
148
+ return;
149
+ }
150
+ if (res.statusCode !== 200 || !parsed || typeof parsed !== "object") {
151
+ reject(new Error("Update check failed."));
152
+ return;
153
+ }
154
+ resolve(parsed);
155
+ });
156
+ });
157
+
158
+ req.on("error", reject);
159
+ req.setTimeout(5000, () => {
160
+ req.destroy(new Error("Timeout"));
161
+ });
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ async function performUpdateCheck() {
167
+ try {
168
+ const distTags = await fetchUpdateDistTags();
169
+ const tag = PACKAGE_VERSION.indexOf("-") !== -1 ? "beta" : "latest";
170
+ const tagVersion = distTags && distTags[tag];
171
+ if (typeof tagVersion !== "string" || !tagVersion) {
172
+ throw new Error("Missing dist-tag.");
173
+ }
174
+ return {
175
+ succeeded: true,
176
+ result: {
177
+ enabled: true,
178
+ updateAvailable: isNewer(tagVersion, PACKAGE_VERSION),
179
+ latestVersion: tagVersion,
180
+ currentVersion: PACKAGE_VERSION,
181
+ tag: tag
182
+ }
183
+ };
184
+ } catch (err) {
185
+ return {
186
+ succeeded: false,
187
+ result: updateCheckFallbackResponse()
188
+ };
189
+ }
190
+ }
77
191
 
78
192
  module.exports = function flowPilotRuntime(RED) {
79
193
  const storage = createStorage(RED.settings.userDir);
@@ -91,12 +205,11 @@ module.exports = function flowPilotRuntime(RED) {
91
205
  "that may have been said earlier, ask the user.";
92
206
 
93
207
  // ---------------------------------------------------------------------
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.
208
+ // Tier-1 READ tools the model may call autonomously. Their data
209
+ // (RED.nodes, live selection, debug buffer) lives only in the editor, so
210
+ // each call is executed CLIENT-SIDE and its result passed back through the
211
+ // same sanitizer as selection context — a tool result can never carry a
212
+ // raw secret.
100
213
  // ---------------------------------------------------------------------
101
214
  const AGENT_READ_TOOLS = [
102
215
  {
@@ -205,6 +318,250 @@ module.exports = function flowPilotRuntime(RED) {
205
318
  }
206
319
  ];
207
320
 
321
+ // W7 Round 1 WRITE tools. One call represents one plan/todo item: a small
322
+ // coherent bundle, never one call per field. `tier` is FlowPilot metadata,
323
+ // not part of either provider's tool schema; providerToolDefinitions()
324
+ // strips it before the request leaves the server. The client combines this
325
+ // registry tier with classifyFlowNodes-equivalent per-call node safety so a
326
+ // write-gated call only prompts when it actually touches an unsafe type.
327
+ const WRITE_TOOLS = [
328
+ {
329
+ tier: "write-gated",
330
+ type: "function",
331
+ function: {
332
+ name: "apply_step",
333
+ description: "Apply ONE plan item as a small bundle of sparse " +
334
+ "property patches, at most one new node, and its immediate wires. " +
335
+ "Use one call for the whole item, not one call per field. Existing " +
336
+ "wire removal/replacement is expressed as a sparse " +
337
+ "changes[].set.wires final value.",
338
+ parameters: {
339
+ type: "object",
340
+ properties: {
341
+ summary: {
342
+ type: "string",
343
+ description: "Short todo-item description of this step."
344
+ },
345
+ changes: {
346
+ type: "array",
347
+ description: "Sparse patches for existing nodes.",
348
+ items: {
349
+ type: "object",
350
+ properties: {
351
+ id: { type: "string" },
352
+ set: { type: "object", additionalProperties: true }
353
+ },
354
+ required: ["id", "set"],
355
+ additionalProperties: false
356
+ }
357
+ },
358
+ newNodes: {
359
+ type: "array",
360
+ description: "At most one new node for this step, using a temporary id.",
361
+ items: {
362
+ type: "object",
363
+ properties: {
364
+ id: { type: "string" },
365
+ type: { type: "string" }
366
+ },
367
+ required: ["id", "type"],
368
+ additionalProperties: true
369
+ }
370
+ },
371
+ newWires: {
372
+ type: "array",
373
+ description: "Immediate wires for this step; ids may refer to " +
374
+ "existing nodes or newNodes temporary ids.",
375
+ items: {
376
+ type: "object",
377
+ properties: {
378
+ from: { type: "string" },
379
+ fromPort: { type: "integer", minimum: 0 },
380
+ to: { type: "string" }
381
+ },
382
+ required: ["from", "fromPort", "to"],
383
+ additionalProperties: false
384
+ }
385
+ }
386
+ },
387
+ required: ["summary"],
388
+ additionalProperties: false
389
+ }
390
+ }
391
+ },
392
+ {
393
+ tier: "write-gated",
394
+ type: "function",
395
+ function: {
396
+ name: "remove_step",
397
+ description: "Remove one node as ONE plan item. Its connected wires " +
398
+ "are removed with it by the existing editor apply path.",
399
+ parameters: {
400
+ type: "object",
401
+ properties: {
402
+ summary: {
403
+ type: "string",
404
+ description: "Short todo-item description of this removal."
405
+ },
406
+ nodeId: { type: "string", description: "Existing node id to remove." }
407
+ },
408
+ required: ["summary", "nodeId"],
409
+ additionalProperties: false
410
+ }
411
+ }
412
+ },
413
+ {
414
+ tier: "write-gated",
415
+ type: "function",
416
+ function: {
417
+ name: "rename_node",
418
+ description: "Rename one existing node as ONE plan item.",
419
+ parameters: {
420
+ type: "object",
421
+ properties: {
422
+ summary: {
423
+ type: "string",
424
+ description: "Short todo-item description of this rename."
425
+ },
426
+ nodeId: { type: "string", description: "Existing node id to rename." },
427
+ name: { type: "string", description: "Exact final node name." }
428
+ },
429
+ required: ["summary", "nodeId", "name"],
430
+ additionalProperties: false
431
+ }
432
+ }
433
+ },
434
+ {
435
+ // ADR-003 R3a: grouping is visual-only, classified safe (auto-apply)
436
+ // regardless of which node types are grouped — "write-safe" here means
437
+ // "never eligible for consent-gating" (the mechanism
438
+ // writeToolCallNeedsConsent actually keys on), not "non-mutating" —
439
+ // this call does mutate (creates a group, pushes history), unlike
440
+ // ask_user, the tier's other current member.
441
+ tier: "write-safe",
442
+ type: "function",
443
+ function: {
444
+ name: "group_nodes",
445
+ description: "Create a new named group from existing, already-verified " +
446
+ "node ids. No nested groups (a group id among nodeIds) and no " +
447
+ "editing an existing group's membership — both are reported as an " +
448
+ "unsupported_operation instead of attempted.",
449
+ parameters: {
450
+ type: "object",
451
+ properties: {
452
+ name: { type: "string", description: "Name for the new group." },
453
+ nodeIds: {
454
+ type: "array",
455
+ description: "Existing node ids to include in the new group.",
456
+ items: { type: "string" },
457
+ minItems: 1
458
+ }
459
+ },
460
+ required: ["name", "nodeIds"],
461
+ additionalProperties: false
462
+ }
463
+ }
464
+ },
465
+ {
466
+ tier: "write-safe",
467
+ type: "function",
468
+ function: {
469
+ name: "redirect_mode",
470
+ description: "End this Modify agent turn without mutating anything " +
471
+ "and redirect the user to Generate, Document, or Chat instead.",
472
+ parameters: {
473
+ type: "object",
474
+ properties: {
475
+ mode: {
476
+ type: "string",
477
+ enum: ["generate", "document", "chat"],
478
+ description: "The mode this request actually belongs in."
479
+ },
480
+ prompt: {
481
+ type: "string",
482
+ description: "Ready-to-send prompt to prefill after switching modes."
483
+ },
484
+ explanation: {
485
+ type: "string",
486
+ description: "Visible reply shown to the user before the redirect chip."
487
+ },
488
+ selectionHint: {
489
+ type: "string",
490
+ description: "Optional selection guidance for Document redirects."
491
+ },
492
+ targetNodeIds: {
493
+ oneOf: [
494
+ { type: "string", enum: ["all", "instance"] },
495
+ {
496
+ type: "array",
497
+ items: { type: "string" },
498
+ minItems: 1
499
+ }
500
+ ],
501
+ description: "Optional resolved node target for Document redirects. " +
502
+ "\"all\" is the entire active flow/tab; \"instance\" is every flow " +
503
+ "tab in the whole Node-RED instance. Omit when the scope genuinely " +
504
+ "can't be resolved from context."
505
+ }
506
+ },
507
+ required: ["mode", "prompt", "explanation"],
508
+ additionalProperties: false
509
+ }
510
+ }
511
+ },
512
+ {
513
+ tier: "write-safe",
514
+ type: "function",
515
+ function: {
516
+ name: "ask_user",
517
+ description: "Ask the user a clarifying question, pause this loop, " +
518
+ "and resume with their answer. This does not mutate the flow.",
519
+ parameters: {
520
+ type: "object",
521
+ properties: {
522
+ question: { type: "string" },
523
+ options: {
524
+ type: "array",
525
+ items: { type: "string" },
526
+ description: "Optional short answer choices."
527
+ }
528
+ },
529
+ required: ["question"],
530
+ additionalProperties: false
531
+ }
532
+ }
533
+ }
534
+ ];
535
+
536
+ function providerToolDefinitions(tools) {
537
+ return tools.map(function (tool) {
538
+ return { type: tool.type, function: tool.function };
539
+ });
540
+ }
541
+
542
+ function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
543
+ const writesEnabled = settings.enableAgentWrite === true &&
544
+ (mode === "modify" || mode === "generate") && writesAllowed !== false &&
545
+ activeProvider && activeProvider.supportsTools === true;
546
+ return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
547
+ }
548
+
549
+ function toolTierMap(toolCalls) {
550
+ if (!Array.isArray(toolCalls) || !toolCalls.length) { return null; }
551
+ const tiersByName = {};
552
+ WRITE_TOOLS.forEach(function (tool) {
553
+ tiersByName[tool.function.name] = tool.tier;
554
+ });
555
+ const byCallId = {};
556
+ toolCalls.forEach(function (call) {
557
+ const name = call && call.function && call.function.name;
558
+ if (call && call.id && tiersByName[name]) {
559
+ byCallId[call.id] = tiersByName[name];
560
+ }
561
+ });
562
+ return Object.keys(byCallId).length ? byCallId : null;
563
+ }
564
+
208
565
  // Keep only well-formed { role: "user"|"assistant", content: <string> }
209
566
  // entries. Anything else (bad shapes, empty content, other roles) is
210
567
  // dropped rather than rejected outright — the history is advisory context,
@@ -548,27 +905,22 @@ module.exports = function flowPilotRuntime(RED) {
548
905
  return clean;
549
906
  }
550
907
 
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) {
908
+ // Append a provider-turn event to debug.log when debugLogging is enabled.
909
+ // Callers pass only post-redaction messages/content and non-secret provider
910
+ // metadata; auth keys and headers must never be included.
911
+ function maybeLogDebugEvent(type, fields) {
556
912
  const settings = storage.getSettings();
557
- if (!settings.logAssembledPrompts) { return; }
913
+ if (!settings.debugLogging) { return; }
914
+ fields = fields || {};
915
+ const messages = fields.messages || [];
558
916
  const promptChars = (messages || []).reduce(function (sum, m) {
559
917
  return sum + (m && typeof m.content === "string" ? m.content.length : 0);
560
918
  }, 0);
561
- storage.appendAssembledPromptLog({
562
- mode: mode,
563
- providerBaseUrl: activeProvider && activeProvider.baseUrl,
564
- model: activeProvider && activeProvider.model,
919
+ storage.appendDebugLog(Object.assign({
920
+ type: type,
565
921
  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
- });
922
+ messageCount: messages.length
923
+ }, fields));
572
924
  }
573
925
 
574
926
  // W0.1: warn when estimated prompt token count approaches the provider's
@@ -595,10 +947,78 @@ module.exports = function flowPilotRuntime(RED) {
595
947
  // Returns null when there's no selection — used by both /chat and
596
948
  // /generate so the two describe context identically and never drift.
597
949
  // ---------------------------------------------------------------------
598
- function describeSelectionContext(context, redactionEnabled) {
950
+ function maxSelectionContextChars(settings) {
951
+ const configured = Number(settings && settings.maxContextChars);
952
+ return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 12000;
953
+ }
954
+
955
+ function joinSelectionContextSections(sections) {
956
+ return sections
957
+ .filter(function (section) { return !!section && typeof section.content === "string" && section.content.length > 0; })
958
+ .map(function (section) { return section.content; })
959
+ .join("\n\n");
960
+ }
961
+
962
+ function selectionContextTruncationNote(maxChars, omittedSections, hardCutoff) {
963
+ let note = "\n\n[FlowPilot truncated selection context to fit maxContextChars=" + maxChars + ".";
964
+ if (omittedSections.length) {
965
+ note += " Omitted sections: " + omittedSections.join(", ") + ".";
966
+ }
967
+ if (hardCutoff) {
968
+ note += " Remaining content was hard-cut and may end mid-JSON.";
969
+ }
970
+ return note + "]";
971
+ }
972
+
973
+ function truncateSelectionContext(sections, maxChars) {
974
+ let activeSections = (sections || []).filter(function (section) { return !!section; });
975
+ let content = joinSelectionContextSections(activeSections);
976
+ if (!maxChars || maxChars <= 0 || content.length <= maxChars) {
977
+ return { content: content, truncated: false };
978
+ }
979
+
980
+ const omittedSections = [];
981
+ ["debug", "config", "perNode", "edges", "subflows"].forEach(function (key) {
982
+ if (content.length <= maxChars) { return; }
983
+ const nextSections = [];
984
+ let removed = false;
985
+ activeSections.forEach(function (section) {
986
+ if (!removed && section.key === key && section.required !== true) {
987
+ removed = true;
988
+ omittedSections.push(section.label);
989
+ return;
990
+ }
991
+ nextSections.push(section);
992
+ });
993
+ if (removed) {
994
+ activeSections = nextSections;
995
+ content = joinSelectionContextSections(activeSections);
996
+ }
997
+ });
998
+
999
+ if (content.length <= maxChars) {
1000
+ const note = selectionContextTruncationNote(maxChars, omittedSections, false);
1001
+ if (content.length + note.length <= maxChars) {
1002
+ content += note;
1003
+ }
1004
+ return { content: content, truncated: true };
1005
+ }
1006
+
1007
+ const hardCutNote = selectionContextTruncationNote(maxChars, omittedSections, true);
1008
+ if (hardCutNote.length >= maxChars) {
1009
+ return { content: content.slice(0, maxChars), truncated: true };
1010
+ }
1011
+ return {
1012
+ content: content.slice(0, maxChars - hardCutNote.length) + hardCutNote,
1013
+ truncated: true
1014
+ };
1015
+ }
1016
+
1017
+ function describeSelectionContext(context, settings) {
599
1018
  const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
600
1019
  const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
601
1020
  if (nodes.length === 0 && debugMessages.length === 0) { return null; }
1021
+ settings = settings || {};
602
1022
 
603
1023
  const connections = (context && context.connections) ? context.connections : {};
604
1024
  const edges = Array.isArray(connections.edges) ? connections.edges : [];
@@ -610,7 +1030,7 @@ module.exports = function flowPilotRuntime(RED) {
610
1030
  // changes. redactionEnabled only controls the SEPARATE secret-shaped-value
611
1031
  // scrubbing (password/token/apiKey-looking fields elsewhere in a node's
612
1032
  // config) — tell the model the truth about which protection is active.
613
- const credentialNote = redactionEnabled === false
1033
+ const credentialNote = settings.redactionEnabled === false
614
1034
  ? "Redaction is OFF for this session — context may contain sensitive " +
615
1035
  "values the user chose to share (e.g. embedded API keys or tokens); " +
616
1036
  "handle carefully and never volunteer them. Node-RED's separate " +
@@ -621,47 +1041,104 @@ module.exports = function flowPilotRuntime(RED) {
621
1041
  "(it requires re-confirming a type-to-confirm phrase, by design)."
622
1042
  : "This is sanitized configuration; credentials are redacted.";
623
1043
 
624
- let content = "";
1044
+ const sections = [];
625
1045
  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```";
1046
+ sections.push({
1047
+ key: "nodes",
1048
+ label: "selected nodes",
1049
+ required: true,
1050
+ content: "The user has selected the following Node-RED nodes as context. " +
1051
+ credentialNote + "\n\n" +
1052
+ "Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```"
1053
+ });
629
1054
  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```";
1055
+ sections.push({
1056
+ key: "edges",
1057
+ label: "connection edges",
1058
+ content: "Connections — directed edges by node id (a node's wires " +
1059
+ "describe its OUTPUTS; one edge per output port; fromId/toId refer " +
1060
+ "to the \"id\" fields in Nodes above):\n```json\n" +
1061
+ JSON.stringify(edges) + "\n```"
1062
+ });
1063
+ sections.push({
1064
+ key: "perNode",
1065
+ label: "per-node wiring summary",
1066
+ content: "Per-node wiring summary, with readable \"Name [type]\" " +
1067
+ "labels (inputs are reconstructed, since Node-RED nodes do not " +
1068
+ "store their own inputs; subFlow groups nodes into connected " +
1069
+ "sub-flows):\n```json\n" +
1070
+ JSON.stringify(perNode) + "\n```"
1071
+ });
639
1072
  }
640
1073
  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.";
1074
+ sections.push({
1075
+ key: "subflows",
1076
+ label: "sub-flow note",
1077
+ content: "Note: the selection contains " + subFlowCount + " separate, " +
1078
+ "unconnected sub-flows (see each node's subFlow number). Treat " +
1079
+ "them as distinct unless the user says otherwise."
1080
+ });
644
1081
  }
645
1082
  }
646
1083
 
647
- const configNodes = (context && Array.isArray(context.configNodes)) ? context.configNodes : [];
1084
+ const configNodes = settings.allowConfigContext === true &&
1085
+ context && Array.isArray(context.configNodes) ? context.configNodes : [];
648
1086
  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```";
1087
+ sections.push({
1088
+ key: "config",
1089
+ label: "config nodes",
1090
+ required: sections.length === 0,
1091
+ content: "Config nodes referenced by the selection (shared configuration " +
1092
+ "objects not shown on the canvas; credentials are redacted). " +
1093
+ "Use a config node's \"id\" to point an existing node at it via " +
1094
+ "a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
1095
+ JSON.stringify(configNodes) + "\n```"
1096
+ });
655
1097
  }
656
1098
 
657
1099
  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```";
1100
+ sections.push({
1101
+ key: "debug",
1102
+ label: "debug messages",
1103
+ required: sections.length === 0,
1104
+ content: "The user attached recent Node-RED Debug sidebar output for " +
1105
+ "troubleshooting (runtime data, may be truncated):\n```json\n" +
1106
+ JSON.stringify(debugMessages) + "\n```"
1107
+ });
662
1108
  }
663
1109
 
664
- return { content: content, nodeCount: nodes.length, connectionCount: edges.length, debugMessageCount: debugMessages.length };
1110
+ const limited = truncateSelectionContext(sections, maxSelectionContextChars(settings));
1111
+ return {
1112
+ content: limited.content,
1113
+ nodeCount: nodes.length,
1114
+ connectionCount: edges.length,
1115
+ debugMessageCount: debugMessages.length,
1116
+ truncated: limited.truncated
1117
+ };
1118
+ }
1119
+
1120
+ const AGENT_TRUNCATION_NUDGE =
1121
+ "your last reply was cut off — emit ONLY the tool call";
1122
+
1123
+ function agentTurnOptions(settings, options) {
1124
+ const configured = Number(settings.agentTurnMaxTokens);
1125
+ return Object.assign({}, options || {}, {
1126
+ maxTokens: Number.isInteger(configured) && configured > 0 ? configured : 4096
1127
+ });
1128
+ }
1129
+
1130
+ async function chatWithAgentCap(provider, activeProvider, messages, options) {
1131
+ const first = await provider.chat(activeProvider, messages, options);
1132
+ if (first.finishReason !== "length") { return first; }
1133
+
1134
+ const retryMessages = messages.concat([
1135
+ { role: "system", content: AGENT_TRUNCATION_NUDGE }
1136
+ ]);
1137
+ const retry = await provider.chat(activeProvider, retryMessages, options);
1138
+ if (retry.finishReason === "length") {
1139
+ retry.fallbackToClassic = true;
1140
+ }
1141
+ return retry;
665
1142
  }
666
1143
 
667
1144
  // ---------------------------------------------------------------------
@@ -669,28 +1146,56 @@ module.exports = function flowPilotRuntime(RED) {
669
1146
  // log it, and return the result. Used by both /chat and /test so the two
670
1147
  // never drift apart. contextMode is recorded for the audit trail.
671
1148
  // ---------------------------------------------------------------------
672
- // useTools: when true, the request offers AGENT_READ_TOOLS
1149
+ // useTools: when true and the active provider supports native tools, the
1150
+ // request offers the mode-appropriate agent tools
673
1151
  // with tool_choice "auto". If the provider responds with tool_calls instead
674
1152
  // of a final message, we return early with `toolCalls` + the `messages`
675
1153
  // array built so far (so the caller/frontend can append the tool results
676
1154
  // and continue via /flowpilot/agent-step) — nothing is recorded to the
677
1155
  // transcript yet, since this isn't the final answer for the turn.
678
- async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools) {
1156
+ async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools, strategy) {
679
1157
  const settings = storage.getSettings();
680
1158
  const activeProvider = storage.getActiveProvider(settings);
681
1159
 
682
- const described = describeSelectionContext(context, settings.redactionEnabled);
1160
+ // Shared with /flowpilot/test (contextMode === "connectivity-test"),
1161
+ // which IS the confirming check itself and is exempt — every other
1162
+ // caller (/flowpilot/chat) is a real operational request and gated.
1163
+ if (contextMode !== "connectivity-test" && !isProviderConfirmed(activeProvider)) {
1164
+ throw providerUnconfirmedError();
1165
+ }
1166
+
1167
+ const described = describeSelectionContext(context, settings);
683
1168
  const messages = buildMessages(
684
1169
  buildChatSystemPrompt(settings),
685
1170
  history, historyTruncated, described, prompt
686
1171
  );
687
1172
  warnNumCtxOverflow(messages, activeProvider, "chat");
688
1173
 
689
- const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
690
- const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
1174
+ const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
1175
+ let chatOptions = toolsEnabled
1176
+ ? { tools: agentToolsFor(settings, activeProvider, "chat"), toolChoice: "auto" }
1177
+ : undefined;
1178
+ const provider = getProvider(activeProvider);
1179
+ let result;
1180
+ if (strategy === "agent") {
1181
+ chatOptions = agentTurnOptions(settings, chatOptions);
1182
+ result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
1183
+ } else {
1184
+ result = await provider.chat(activeProvider, messages, chatOptions);
1185
+ }
691
1186
 
692
- if (result.toolCalls) {
1187
+ if (result.toolCalls || result.fallbackToClassic) {
693
1188
  const perf = performanceAuditFields(messages, result.content, result);
1189
+ if (result.toolCalls) {
1190
+ maybeLogDebugEvent("tool_call", {
1191
+ mode: "chat",
1192
+ providerBaseUrl: activeProvider.baseUrl,
1193
+ model: activeProvider.model,
1194
+ messages: messages,
1195
+ toolCalls: result.toolCalls,
1196
+ responseContent: result.content || null
1197
+ });
1198
+ }
694
1199
  return { settings, activeProvider, result, perf, messages, toolCalls: result.toolCalls };
695
1200
  }
696
1201
 
@@ -700,6 +1205,15 @@ module.exports = function flowPilotRuntime(RED) {
700
1205
  const split = splitChatDataBlock(result.content || "");
701
1206
 
702
1207
  recordTranscriptTurn(conversationId, "chat", prompt, split.message);
1208
+ maybeLogDebugEvent("assistant_reply", {
1209
+ mode: "chat",
1210
+ providerBaseUrl: activeProvider.baseUrl,
1211
+ model: activeProvider.model,
1212
+ messages: messages,
1213
+ responseChars: typeof result.content === "string" ? result.content.length : 0,
1214
+ responseContent: result.content || "",
1215
+ parseOutcome: "received"
1216
+ });
703
1217
 
704
1218
  const perf = performanceAuditFields(messages, result.content, result);
705
1219
 
@@ -717,7 +1231,13 @@ module.exports = function flowPilotRuntime(RED) {
717
1231
  const settings = storage.getSettings();
718
1232
  const activeProvider = storage.getActiveProvider(settings);
719
1233
 
720
- const described = describeSelectionContext(context, settings.redactionEnabled);
1234
+ // Checked before any SSE headers are written, so the caller's catch
1235
+ // block can still send a normal JSON 409 (res.headersSent is false).
1236
+ if (!isProviderConfirmed(activeProvider)) {
1237
+ throw providerUnconfirmedError();
1238
+ }
1239
+
1240
+ const described = describeSelectionContext(context, settings);
721
1241
  const messages = buildMessages(
722
1242
  buildChatSystemPrompt(settings),
723
1243
  history, historyTruncated, described, prompt
@@ -788,18 +1308,77 @@ module.exports = function flowPilotRuntime(RED) {
788
1308
  }, performanceAuditFields(messages, full, streamResult)));
789
1309
 
790
1310
  recordTranscriptTurn(conversationId, "chat", prompt, visibleText);
1311
+ maybeLogDebugEvent("assistant_reply", {
1312
+ mode: "chat-stream",
1313
+ providerBaseUrl: activeProvider.baseUrl,
1314
+ model: activeProvider.model,
1315
+ messages: messages,
1316
+ responseChars: typeof full === "string" ? full.length : 0,
1317
+ responseContent: full || "",
1318
+ parseOutcome: "received"
1319
+ });
791
1320
  }
792
1321
 
793
1322
  // ---- Settings: read --------------------------------------------------
794
1323
 
1324
+ // Never let a real provider apiKey reach an HTTP response — storage's own
1325
+ // getSettings()/saveSettings() still return the real key internally
1326
+ // (getActiveProvider -> provider.chat depends on it), this masks ONLY the
1327
+ // two client-facing routes below. hasApiKey lets the UI show "a key is
1328
+ // saved" without ever receiving it; API_KEY_UNCHANGED is what the client
1329
+ // echoes back on save to mean "leave it alone" (see
1330
+ // reconcileProviderSecrets in lib/storage.js, the other half of this).
1331
+ function maskProviderSecrets(settings) {
1332
+ const masked = Object.assign({}, settings);
1333
+ masked.providers = (Array.isArray(settings.providers) ? settings.providers : []).map(function (p) {
1334
+ const hasApiKey = !!(p && p.apiKey && String(p.apiKey).trim());
1335
+ const next = Object.assign({}, p);
1336
+ next.apiKey = hasApiKey ? API_KEY_UNCHANGED : "";
1337
+ next.hasApiKey = hasApiKey;
1338
+ return next;
1339
+ });
1340
+ return masked;
1341
+ }
1342
+
795
1343
  RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
796
1344
  try {
797
- res.json(storage.getSettings());
1345
+ const responseBody = maskProviderSecrets(storage.getSettings());
1346
+ responseBody.flowpilotVersion = PACKAGE_VERSION;
1347
+ res.json(responseBody);
798
1348
  } catch (err) {
799
1349
  res.status(500).json({ error: err.message });
800
1350
  }
801
1351
  });
802
1352
 
1353
+ RED.httpAdmin.get("/flowpilot/update-check", RED.auth.needsPermission("settings.read"), async function (req, res) {
1354
+ const settings = storage.getSettings();
1355
+ if (settings.checkForUpdates === false) {
1356
+ res.json({ enabled: false });
1357
+ return;
1358
+ }
1359
+
1360
+ const now = Date.now();
1361
+ if (updateCheckCache) {
1362
+ const ageMs = now - updateCheckCache.checkedAt;
1363
+ if (updateCheckCache.succeeded && ageMs < UPDATE_CHECK_SUCCESS_TTL_MS) {
1364
+ res.json(updateCheckCache.result);
1365
+ return;
1366
+ }
1367
+ if (!updateCheckCache.succeeded && ageMs < UPDATE_CHECK_FAILURE_TTL_MS) {
1368
+ res.json(updateCheckFallbackResponse());
1369
+ return;
1370
+ }
1371
+ }
1372
+
1373
+ const check = await performUpdateCheck();
1374
+ updateCheckCache = {
1375
+ checkedAt: now,
1376
+ succeeded: check.succeeded,
1377
+ result: check.result
1378
+ };
1379
+ res.json(check.result);
1380
+ });
1381
+
803
1382
  // ---- Settings: default system prompt (for "Reset to default") -------
804
1383
 
805
1384
  RED.httpAdmin.get("/flowpilot/default-system-prompt", RED.auth.needsPermission("settings.read"), function (req, res) {
@@ -810,6 +1389,11 @@ module.exports = function flowPilotRuntime(RED) {
810
1389
  }
811
1390
  });
812
1391
 
1392
+ RED.httpAdmin.get("/flowpilot/run-events/:runId", RED.auth.needsPermission("settings.read"), function (req, res) {
1393
+ const runId = req.params.runId;
1394
+ res.json({ runId: runId, events: runEventsStore.get(runId) || [] });
1395
+ });
1396
+
813
1397
  // ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
814
1398
  // Serves the shared renderer (flowpilot-core.js, the same script
815
1399
  // flowpilot.html loads for the sidebar) plus its stylesheet and the
@@ -848,18 +1432,118 @@ module.exports = function flowPilotRuntime(RED) {
848
1432
  res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
849
1433
  });
850
1434
 
1435
+ // Scoped to /flowpilot/* specifically — RED.httpAdmin is Node-RED's own
1436
+ // shared admin app, so an unscoped .use() here would also intercept
1437
+ // malformed-JSON errors on core Node-RED admin routes (flow deploy, node
1438
+ // install, etc.), well beyond this ticket's intent to harden FlowPilot's
1439
+ // own endpoints.
1440
+ // CODEX-027 follow-up: a scoped RED.httpAdmin.use(errorHandler) here
1441
+ // (tried both with and without a "/flowpilot" path prefix) never actually
1442
+ // intercepted a malformed-JSON body-parse error live — Express's own
1443
+ // default HTML error page still won, for every /flowpilot/* route tested,
1444
+ // meaning Node-RED's core httpAdmin setup already fully resolves that
1445
+ // error (parser -> its own handler -> response sent) before a route
1446
+ // registered by a loaded plugin ever gets a chance to react, regardless
1447
+ // of where among the plugin's own routes it's positioned. Reverted rather
1448
+ // than ship a handler that silently never fires. The two higher-value
1449
+ // parts of this ticket (empty-body validation, 404s for missing
1450
+ // conversations) are real and verified working; malformed-JSON responses
1451
+ // still return Express's default HTML page, not clean JSON — flagged as
1452
+ // a known limitation, not fixed by this ticket.
1453
+
1454
+ // ---- Provider confirmation gate (ADR-007, SSRF mitigation) -----------
1455
+ // No operational request (chat/generate/modify/document/build/agent-step)
1456
+ // touches a provider's baseUrl until that exact URL has passed a real
1457
+ // FlowPilot provider check — see isProviderShapedResponse
1458
+ // (lib/provider-shape-check.js) below
1459
+ // and /flowpilot/test, /flowpilot/probe, /flowpilot/models — the only
1460
+ // three routes allowed to contact an unconfirmed URL. The first two
1461
+ // WRITE confirmedBaseUrl/confirmedAt on a passing check (the deliberate
1462
+ // confirming action); /flowpilot/models never does — it's read-only
1463
+ // reconnaissance (listing available models), safe to allow pre-confirmation
1464
+ // the same way /probe is, but not itself a confirmation. All three stay
1465
+ // safe against an unconfirmed/malicious target the same way: a strict
1466
+ // shape check on any "success" response (isProviderShapedResponse for
1467
+ // chat-shaped, isModelsListShaped for a models list) and a generic,
1468
+ // never-reflects-the-body error on failure (enforced at the shared HTTP
1469
+ // client layer in lib/provider-*.js). lib/storage.js's
1470
+ // reconcileProviderSecrets is the other half — it strips any
1471
+ // client-supplied confirmedBaseUrl/confirmedAt on save and clears
1472
+ // confirmation whenever baseUrl or apiKey actually changes, so
1473
+ // confirmation can never be forged or silently carried over to a
1474
+ // different URL.
1475
+
1476
+ function isProviderConfirmed(provider) {
1477
+ // typeof check, not truthiness: baseUrl "" is a real, documented,
1478
+ // supported value (Anthropic's "leave blank for api.anthropic.com"
1479
+ // convention) — a provider CAN be legitimately confirmed with an empty
1480
+ // confirmedBaseUrl, and `"" && ...` would silently evaluate false,
1481
+ // locking that configuration out of confirmation forever. Only an
1482
+ // actually-absent (never confirmed) field should fail this check.
1483
+ return !!provider && typeof provider.confirmedBaseUrl === "string" &&
1484
+ provider.confirmedBaseUrl === provider.baseUrl;
1485
+ }
1486
+
1487
+ const PROVIDER_UNCONFIRMED_MESSAGE = "This provider hasn't passed a connection test yet — run Test Provider first.";
1488
+
1489
+ // For routes that resolve activeProvider themselves and can respond
1490
+ // directly (models, agent-step, chat, chat-stream) — matches the
1491
+ // {error:"code", message:"text"} shape requireExecutionContract already
1492
+ // uses elsewhere in this file.
1493
+ function requireConfirmedProvider(res, activeProvider) {
1494
+ if (isProviderConfirmed(activeProvider)) { return true; }
1495
+ res.status(409).json({ error: "provider_unconfirmed", message: PROVIDER_UNCONFIRMED_MESSAGE });
1496
+ return false;
1497
+ }
1498
+
1499
+ // For the generation-family helpers (runFlowGeneration/
1500
+ // runFlowGenerationStream), which don't have direct access to `res` — they
1501
+ // throw, and the route's existing sendGenerationError/stream-error path
1502
+ // turns .status/.code into the actual response.
1503
+ function providerUnconfirmedError() {
1504
+ const err = new Error(PROVIDER_UNCONFIRMED_MESSAGE);
1505
+ err.status = 409;
1506
+ err.code = "provider_unconfirmed";
1507
+ return err;
1508
+ }
1509
+
1510
+ // The confirming check's own pass/fail criterion (B3) — extracted to its
1511
+ // own module (lib/provider-shape-check.js) so it has a real, re-executable
1512
+ // unit test rather than only code-review-level evidence.
1513
+
1514
+ function hasRequestBody(body) {
1515
+ return !!body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length > 0;
1516
+ }
1517
+
1518
+ function validateSettingsSaveBody(body) {
1519
+ if (!hasRequestBody(body)) {
1520
+ return "Settings payload is required.";
1521
+ }
1522
+ if (!Array.isArray(body.providers) || body.providers.length === 0) {
1523
+ return "Settings payload must include at least one provider.";
1524
+ }
1525
+ if (!body.activeProviderId || !String(body.activeProviderId).trim()) {
1526
+ return "Settings payload must include an activeProviderId.";
1527
+ }
1528
+ return null;
1529
+ }
1530
+
851
1531
  // ---- Settings: write -------------------------------------------------
852
1532
 
853
1533
  RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
1534
+ const validationError = validateSettingsSaveBody(req.body);
1535
+ if (validationError) {
1536
+ return res.status(400).json({ error: validationError });
1537
+ }
854
1538
  try {
855
- const saved = storage.saveSettings(req.body || {});
1539
+ const saved = storage.saveSettings(req.body);
856
1540
  storage.appendAudit({
857
1541
  action: "settings_saved",
858
1542
  providerName: saved.providerName,
859
1543
  baseUrl: saved.baseUrl,
860
1544
  model: saved.model
861
1545
  });
862
- res.json(saved);
1546
+ res.json(maskProviderSecrets(saved));
863
1547
  } catch (err) {
864
1548
  res.status(500).json({ error: err.message });
865
1549
  }
@@ -868,7 +1552,17 @@ module.exports = function flowPilotRuntime(RED) {
868
1552
  // ---- Models: list models via the active provider's /v1/models -------
869
1553
  // Always acts on the SAVED active provider (the frontend saves the form
870
1554
  // first, mirroring Pre-flight check), and never errors out for a provider
871
- // that doesn't support /v1/models — see listModels().
1555
+ // that doesn't support /v1/models — see listModels(). No request body is
1556
+ // read here, so no body-presence check applies (unlike /flowpilot/settings,
1557
+ // which does act on its body).
1558
+ //
1559
+ // This is the THIRD route allowed to touch an unconfirmed baseUrl (ADR-007)
1560
+ // — users need to see a model list before ever running Pre-flight check,
1561
+ // and the confirmation gate would otherwise block that. Kept safe the same
1562
+ // way /flowpilot/test and /flowpilot/probe are: listModels() only trusts a
1563
+ // genuinely provider-shaped response (isModelsListShaped) and never
1564
+ // reflects a raw failure body. Does not itself write confirmedBaseUrl/
1565
+ // confirmedAt — only the deliberate /flowpilot/test action confirms.
872
1566
 
873
1567
  RED.httpAdmin.post("/flowpilot/models", RED.auth.needsPermission("settings.write"), async function (req, res) {
874
1568
  try {
@@ -906,6 +1600,11 @@ module.exports = function flowPilotRuntime(RED) {
906
1600
  try {
907
1601
  await runChatStream(req, res, prompt, req.body.context, history, historyTruncated, req.body.conversationId);
908
1602
  } catch (err) {
1603
+ if (!res.headersSent && err && err.status) {
1604
+ const errBody = { error: err.code || err.message };
1605
+ if (err.code) { errBody.message = err.message; }
1606
+ return res.status(err.status).json(errBody);
1607
+ }
909
1608
  storage.appendAudit({ action: "chat_stream_error", error: err.message });
910
1609
  if (!res.headersSent) {
911
1610
  res.status(500).json({ error: err.message });
@@ -919,7 +1618,8 @@ module.exports = function flowPilotRuntime(RED) {
919
1618
  try {
920
1619
  const useTools = !!req.body.tools;
921
1620
  const { activeProvider, result, perf, chatMessage, chatData, messages, toolCalls } =
922
- await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated, req.body.conversationId, useTools);
1621
+ await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated,
1622
+ req.body.conversationId, useTools, req.body.strategy);
923
1623
 
924
1624
  storage.appendAudit(Object.assign({
925
1625
  action: "chat",
@@ -929,6 +1629,9 @@ module.exports = function flowPilotRuntime(RED) {
929
1629
  toolCallCount: toolCalls ? toolCalls.length : 0
930
1630
  }, perf));
931
1631
 
1632
+ if (result.fallbackToClassic) {
1633
+ return res.json({ fallbackToClassic: true, usage: result.usage || null });
1634
+ }
932
1635
  if (toolCalls) {
933
1636
  return res.json({ toolCalls: toolCalls, messages: messages, content: result.content || null, usage: result.usage || null });
934
1637
  }
@@ -948,6 +1651,11 @@ module.exports = function flowPilotRuntime(RED) {
948
1651
  if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
949
1652
  res.json(body);
950
1653
  } catch (err) {
1654
+ if (err && err.status) {
1655
+ const errBody = { error: err.code || err.message };
1656
+ if (err.code) { errBody.message = err.message; }
1657
+ return res.status(err.status).json(errBody);
1658
+ }
951
1659
  storage.appendAudit({ action: "chat_error", error: err.message });
952
1660
  res.status(500).json({ error: err.message });
953
1661
  }
@@ -972,7 +1680,60 @@ module.exports = function flowPilotRuntime(RED) {
972
1680
  // `context` (for describeSelectionContext / modify's originalNodes) and
973
1681
  // `prompt` (for transcript recording) are passed through from the
974
1682
  // initial request.
1683
+ const EXECUTION_STRATEGIES = new Set(["agent", "classic"]);
1684
+ const EXECUTION_ENTRIES = new Set([
1685
+ "chat", "document", "generate", "build", "modify", "build-review", "build-existing"
1686
+ ]);
1687
+
1688
+ function requireExecutionContract(req, res, settings, activeProvider) {
1689
+ const body = (req && req.body) || {};
1690
+ if (!EXECUTION_STRATEGIES.has(body.strategy) || !EXECUTION_ENTRIES.has(body.entry)) {
1691
+ res.status(400).json({
1692
+ error: "strategy_required",
1693
+ message: "strategy and entry are required and must be recognized values."
1694
+ });
1695
+ return null;
1696
+ }
1697
+ if (body.strategy === "agent" &&
1698
+ !(settings.enableAgentWrite === true && activeProvider && activeProvider.supportsTools === true)) {
1699
+ res.status(409).json({
1700
+ error: "agent_strategy_unavailable",
1701
+ message: "The agent strategy requires enableAgentWrite and a tool-capable provider."
1702
+ });
1703
+ return null;
1704
+ }
1705
+ return {
1706
+ strategy: body.strategy,
1707
+ entry: body.entry,
1708
+ conversationId: body.conversationId || null,
1709
+ runId: body.runId || null,
1710
+ // CLAUDE-014: plain-language note for the client-side decision (consent
1711
+ // gate / ask_user / loop-checkpoint) that triggered this request, only
1712
+ // sent when settings.debugLogging is on — threaded into
1713
+ // maybeLogDebugEvent calls below so debug.log shows what the user
1714
+ // actually decided instead of only raw tool-result JSON.
1715
+ debugNote: body.debugNote || null
1716
+ };
1717
+ }
1718
+
975
1719
  RED.httpAdmin.post("/flowpilot/agent-step", RED.auth.needsPermission("settings.write"), async function (req, res) {
1720
+ const settings = storage.getSettings();
1721
+ const activeProvider = storage.getActiveProvider(settings);
1722
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
1723
+ if (!execution) { return; }
1724
+ if (Array.isArray(req.body.events) && typeof execution.runId === "string" && execution.runId) {
1725
+ runEventsStore.set(execution.runId, req.body.events);
1726
+ if (runEventsStore.size > RUN_EVENTS_MAX_STORED) {
1727
+ runEventsStore.delete(runEventsStore.keys().next().value);
1728
+ }
1729
+ maybeLogDebugEvent("run_events", {
1730
+ mode: req.body.mode || "chat",
1731
+ runId: execution.runId,
1732
+ events: req.body.events
1733
+ });
1734
+ }
1735
+ if (!requireConfirmedProvider(res, activeProvider)) { return; }
1736
+
976
1737
  const messages = req.body && req.body.messages;
977
1738
  if (!Array.isArray(messages) || messages.length === 0) {
978
1739
  return res.status(400).json({ error: "messages array is required." });
@@ -980,13 +1741,36 @@ module.exports = function flowPilotRuntime(RED) {
980
1741
  const mode = req.body.mode || "chat";
981
1742
 
982
1743
  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" });
1744
+ const toolsEnabled = activeProvider.supportsTools === true;
1745
+ const offeredTools = toolsEnabled
1746
+ ? agentToolsFor(settings, activeProvider, mode, execution.strategy === "agent")
1747
+ : [];
1748
+ let chatOptions = toolsEnabled
1749
+ ? { tools: offeredTools, toolChoice: "auto" }
1750
+ : undefined;
1751
+ if (execution.strategy === "agent") {
1752
+ chatOptions = agentTurnOptions(settings, chatOptions);
1753
+ }
1754
+ // Messages include client-produced role:"tool" results. Pass them
1755
+ // straight to the adapter; OpenAI-compatible providers receive them
1756
+ // unchanged and Anthropic converts only the outer message envelope to
1757
+ // native tool_result blocks.
1758
+ const provider = getProvider(activeProvider);
1759
+ const result = execution.strategy === "agent"
1760
+ ? await chatWithAgentCap(provider, activeProvider, messages, chatOptions)
1761
+ : await provider.chat(activeProvider, messages, chatOptions);
1762
+
1763
+ if (result.fallbackToClassic) {
1764
+ return res.json({ fallbackToClassic: true, usage: result.usage || null });
1765
+ }
986
1766
 
987
1767
  storage.appendAudit(Object.assign({
988
1768
  action: "agent_step",
989
1769
  mode: mode,
1770
+ strategy: execution.strategy,
1771
+ entry: execution.entry,
1772
+ conversationId: execution.conversationId,
1773
+ runId: execution.runId,
990
1774
  providerName: activeProvider.providerName,
991
1775
  baseUrl: activeProvider.baseUrl,
992
1776
  model: activeProvider.model,
@@ -994,17 +1778,49 @@ module.exports = function flowPilotRuntime(RED) {
994
1778
  }, performanceAuditFields(messages, result.content, result)));
995
1779
 
996
1780
  if (result.toolCalls) {
997
- return res.json({ toolCalls: result.toolCalls, content: result.content || null, usage: result.usage || null });
1781
+ maybeLogDebugEvent("tool_call", {
1782
+ mode: mode,
1783
+ providerBaseUrl: activeProvider.baseUrl,
1784
+ model: activeProvider.model,
1785
+ messages: messages,
1786
+ toolCalls: result.toolCalls,
1787
+ responseContent: result.content || null,
1788
+ debugNote: execution.debugNote || undefined
1789
+ });
1790
+ return res.json({
1791
+ toolCalls: result.toolCalls,
1792
+ toolTiers: toolTierMap(result.toolCalls),
1793
+ content: result.content || null,
1794
+ usage: result.usage || null
1795
+ });
998
1796
  }
999
1797
 
1798
+ maybeLogDebugEvent("assistant_reply", {
1799
+ mode: mode,
1800
+ providerBaseUrl: activeProvider.baseUrl,
1801
+ model: activeProvider.model,
1802
+ messages: messages,
1803
+ responseChars: typeof result.content === "string" ? result.content.length : 0,
1804
+ responseContent: result.content || "",
1805
+ parseOutcome: "received",
1806
+ debugNote: execution.debugNote || undefined
1807
+ });
1808
+
1000
1809
  if (mode !== "chat") {
1001
1810
  const context = req.body.context;
1002
- const described = describeSelectionContext(context, settings.redactionEnabled);
1003
- const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
1811
+ const described = describeSelectionContext(context, settings);
1812
+ const generated = processGenerationContent(
1813
+ result.content || "", result, messages, mode, described, activeProvider,
1814
+ req.body.prompt, execution
1815
+ );
1004
1816
  recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
1005
1817
  const finalize = (mode === "modify")
1006
- ? function (r) { return finalizeModifyResult(r, (context && Array.isArray(context.nodes)) ? context.nodes : []); }
1007
- : finalizeSimpleGeneration;
1818
+ ? function (r) {
1819
+ return finalizeModifyResult(
1820
+ r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
1821
+ );
1822
+ }
1823
+ : function (r) { return finalizeSimpleGeneration(r, execution); };
1008
1824
  const { status, body } = finalize(generated);
1009
1825
  return res.status(status).json(body);
1010
1826
  }
@@ -1019,7 +1835,7 @@ module.exports = function flowPilotRuntime(RED) {
1019
1835
  if (questionOptions) { body.questionOptions = questionOptions; }
1020
1836
  res.json(body);
1021
1837
  } catch (err) {
1022
- sendGenerationError(res, mode + "_agent_step", err);
1838
+ sendGenerationError(res, mode + "_agent_step", err, execution);
1023
1839
  }
1024
1840
  });
1025
1841
 
@@ -1077,6 +1893,9 @@ module.exports = function flowPilotRuntime(RED) {
1077
1893
  RED.httpAdmin.get("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.read"), function (req, res) {
1078
1894
  const id = sanitizeConversationId(req.params.id);
1079
1895
  if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
1896
+ if (storage.listConversationIds().indexOf(id) === -1) {
1897
+ return res.status(404).json({ error: "Conversation not found." });
1898
+ }
1080
1899
  try {
1081
1900
  res.json({ id: id, messages: storage.readTranscript(id) });
1082
1901
  } catch (err) {
@@ -1087,6 +1906,9 @@ module.exports = function flowPilotRuntime(RED) {
1087
1906
  RED.httpAdmin.delete("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.write"), function (req, res) {
1088
1907
  const id = sanitizeConversationId(req.params.id);
1089
1908
  if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
1909
+ if (storage.listConversationIds().indexOf(id) === -1) {
1910
+ return res.status(404).json({ error: "Conversation not found." });
1911
+ }
1090
1912
  try {
1091
1913
  storage.deleteTranscript(id);
1092
1914
  storage.appendAudit({ action: "conversation_delete", conversationId: id });
@@ -1112,11 +1934,37 @@ module.exports = function flowPilotRuntime(RED) {
1112
1934
  // reply at all." Never depends on chat history or flow context.
1113
1935
 
1114
1936
  RED.httpAdmin.post("/flowpilot/test", RED.auth.needsPermission("settings.write"), async function (req, res) {
1937
+ if (!hasRequestBody(req.body)) {
1938
+ return res.status(400).json({ error: "Request body is required." });
1939
+ }
1115
1940
  const prompt = (req.body && req.body.prompt) || "Say hello from FlowPilot.";
1116
1941
 
1117
1942
  try {
1943
+ // This IS the provider-confirmation check (ADR-007) — the one request
1944
+ // allowed to touch a not-yet-confirmed baseUrl. runChat("connectivity-
1945
+ // test") skips the confirmation gate for exactly this call.
1118
1946
  const { settings, activeProvider, result, perf, chatMessage } = await runChat(prompt, "connectivity-test");
1119
1947
 
1948
+ // Strict pass criterion (B3): a 200 with SOME JSON body is not enough
1949
+ // — an internal service or a cloud metadata endpoint can return that
1950
+ // trivially. Require an actually provider-shaped chat-completion
1951
+ // response, or the check fails and the provider stays unconfirmed.
1952
+ // The error returned to the client is deliberately generic — never
1953
+ // the upstream body — so a probe against a non-provider target
1954
+ // yields nothing readable (the actual SSRF seal).
1955
+ if (!isProviderShapedResponse(activeProvider.type, result.raw)) {
1956
+ storage.appendAudit({
1957
+ action: "chat_test_error",
1958
+ providerName: activeProvider.providerName,
1959
+ baseUrl: activeProvider.baseUrl,
1960
+ error: "not_provider_shaped"
1961
+ });
1962
+ return res.status(422).json({
1963
+ error: "provider_check_failed",
1964
+ message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
1965
+ });
1966
+ }
1967
+
1120
1968
  storage.appendAudit(Object.assign({
1121
1969
  action: "chat_test",
1122
1970
  providerName: activeProvider.providerName,
@@ -1145,11 +1993,16 @@ module.exports = function flowPilotRuntime(RED) {
1145
1993
  toolsProbedAt: new Date().toISOString(),
1146
1994
  isReasoningModel: reasoning.isReasoningModel,
1147
1995
  reasoningProbedAt: new Date().toISOString(),
1148
- probedModel: activeProvider.model
1996
+ probedModel: activeProvider.model,
1997
+ // The check above passed — this exact baseUrl is now
1998
+ // confirmed. Cleared automatically (reconcileProviderSecrets,
1999
+ // lib/storage.js) the moment baseUrl or apiKey changes.
2000
+ confirmedBaseUrl: activeProvider.baseUrl,
2001
+ confirmedAt: new Date().toISOString()
1149
2002
  })
1150
2003
  : p;
1151
2004
  });
1152
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
2005
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
1153
2006
 
1154
2007
  const toolLabel = probe.supportsTools
1155
2008
  ? "✓ Connected · ✓ Supports tools"
@@ -1180,15 +2033,34 @@ module.exports = function flowPilotRuntime(RED) {
1180
2033
  // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
1181
2034
 
1182
2035
  RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
2036
+ if (!hasRequestBody(req.body)) {
2037
+ return res.status(400).json({ error: "Request body is required." });
2038
+ }
1183
2039
  try {
1184
2040
  const settings = storage.getSettings();
1185
2041
  const activeProvider = storage.getActiveProvider(settings);
1186
2042
 
2043
+ // This is the OTHER route allowed to touch an unconfirmed baseUrl
2044
+ // (ADR-007) — same confirming-check treatment as /flowpilot/test.
1187
2045
  const probe = await getProvider(activeProvider).probeTools(activeProvider);
1188
2046
  const chatResult = await getProvider(activeProvider).chat(activeProvider, [
1189
2047
  { role: "system", content: "You are a helpful assistant." },
1190
2048
  { role: "user", content: "Say hello." }
1191
2049
  ]);
2050
+
2051
+ if (!isProviderShapedResponse(activeProvider.type, chatResult.raw)) {
2052
+ storage.appendAudit({
2053
+ action: "auto_probe_error",
2054
+ providerName: activeProvider.providerName,
2055
+ baseUrl: activeProvider.baseUrl,
2056
+ error: "not_provider_shaped"
2057
+ });
2058
+ return res.status(422).json({
2059
+ error: "provider_check_failed",
2060
+ message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
2061
+ });
2062
+ }
2063
+
1192
2064
  const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
1193
2065
 
1194
2066
  const updatedProviders = (settings.providers || []).map(function (p) {
@@ -1198,11 +2070,13 @@ module.exports = function flowPilotRuntime(RED) {
1198
2070
  toolsProbedAt: new Date().toISOString(),
1199
2071
  isReasoningModel: reasoning.isReasoningModel,
1200
2072
  reasoningProbedAt: new Date().toISOString(),
1201
- probedModel: activeProvider.model
2073
+ probedModel: activeProvider.model,
2074
+ confirmedBaseUrl: activeProvider.baseUrl,
2075
+ confirmedAt: new Date().toISOString()
1202
2076
  })
1203
2077
  : p;
1204
2078
  });
1205
- storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
2079
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
1206
2080
 
1207
2081
  storage.appendAudit({
1208
2082
  action: "auto_probe",
@@ -1239,7 +2113,7 @@ module.exports = function flowPilotRuntime(RED) {
1239
2113
  function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
1240
2114
  const settings = storage.getSettings();
1241
2115
  const activeProvider = storage.getActiveProvider(settings);
1242
- const described = describeSelectionContext(context, settings.redactionEnabled);
2116
+ const described = describeSelectionContext(context, settings);
1243
2117
  // Persona applies to the "explanation" field only (a real hand-off/
1244
2118
  // transition moment — "here's the flow I built for you") — never to
1245
2119
  // node names, ids, or any structural JSON, which stays exactly as each
@@ -1255,7 +2129,8 @@ module.exports = function flowPilotRuntime(RED) {
1255
2129
  // parsed envelope. Validated but non-critical — a malformed or missing
1256
2130
  // suggestion is just dropped (returns null), never an error, since chips
1257
2131
  // are an additive hint on top of the real response.
1258
- // { mode: "generate"|"document"|"modify"|"chat", prompt: "...", selectionHint?: "..." }
2132
+ // { mode: "generate"|"document"|"modify"|"chat", prompt: "...",
2133
+ // selectionHint?: "...", targetNodeIds?: "all"|string[] }
1259
2134
  // ---------------------------------------------------------------------
1260
2135
  function extractSuggestedAction(parsed) {
1261
2136
  const sa = parsed && parsed.suggestedAction;
@@ -1267,6 +2142,14 @@ module.exports = function flowPilotRuntime(RED) {
1267
2142
  if (typeof sa.selectionHint === "string" && sa.selectionHint.trim()) {
1268
2143
  result.selectionHint = sa.selectionHint.trim();
1269
2144
  }
2145
+ if (sa.targetNodeIds === "all") {
2146
+ result.targetNodeIds = "all";
2147
+ } else if (Array.isArray(sa.targetNodeIds)) {
2148
+ const targetNodeIds = sa.targetNodeIds
2149
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
2150
+ .map(function (id) { return id.trim(); });
2151
+ if (targetNodeIds.length) { result.targetNodeIds = targetNodeIds; }
2152
+ }
1270
2153
  return result;
1271
2154
  }
1272
2155
 
@@ -1297,71 +2180,6 @@ module.exports = function flowPilotRuntime(RED) {
1297
2180
  // createChatDataStreamSplitter below so the marker/JSON are never flashed
1298
2181
  // to the user mid-stream.
1299
2182
  // ---------------------------------------------------------------------
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
2183
  // ---------------------------------------------------------------------
1366
2184
  // Shared helper: parse, validate and audit a completed provider response
1367
2185
  // for a generation-style request, returning { question } / { prose } /
@@ -1405,8 +2223,9 @@ module.exports = function flowPilotRuntime(RED) {
1405
2223
  });
1406
2224
  }
1407
2225
 
1408
- function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt) {
2226
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt, auditContext) {
1409
2227
  const perf = performanceAuditFields(messages, content, providerResult);
2228
+ const auditFields = auditContext || {};
1410
2229
 
1411
2230
  // Mode-mismatch redirect: the model may respond in plain prose —
1412
2231
  // addressing a request that doesn't belong in generate/document/modify —
@@ -1415,11 +2234,11 @@ module.exports = function flowPilotRuntime(RED) {
1415
2234
  // it would otherwise grab the "{" inside the data block and treat it as
1416
2235
  // a broken envelope.
1417
2236
  let envelopeParsed;
1418
- if (content.indexOf(CHAT_DATA_MARKER) !== -1) {
2237
+ if (findChatDataMarker(content)) {
1419
2238
  const preSplit = splitChatDataBlock(content);
1420
2239
  const proseMessage = preSplit.message.trim();
1421
2240
  if (proseMessage && proseMessage[0] !== "{") {
1422
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2241
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1423
2242
  const proseResult = { prose: proseMessage };
1424
2243
  if (preSplit.data) {
1425
2244
  const proseAction = extractSuggestedAction(preSplit.data);
@@ -1458,7 +2277,7 @@ module.exports = function flowPilotRuntime(RED) {
1458
2277
  // render it as a normal assistant message and keep the action armed.
1459
2278
  // Errors stay reserved for empty responses or a found-but-broken {...}.
1460
2279
  if (parseErr.noJsonFound && content.trim()) {
1461
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2280
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
1462
2281
  // Mode-mismatch redirect: a prose reply may carry the same hidden
1463
2282
  // <<<FLOWPILOT_DATA>>> block as Chat, suggesting a mode switch (e.g.
1464
2283
  // "chat" when the request was actually a question, not a
@@ -1473,7 +2292,7 @@ module.exports = function flowPilotRuntime(RED) {
1473
2292
  }
1474
2293
  return proseResult;
1475
2294
  }
1476
- storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, perf));
2295
+ storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, auditFields, perf));
1477
2296
  const err = new Error("Could not parse a flow from the response: " + parseErr.message);
1478
2297
  err.status = 422;
1479
2298
  err.raw = content;
@@ -1499,10 +2318,18 @@ module.exports = function flowPilotRuntime(RED) {
1499
2318
  if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
1500
2319
  redirect.selectionHint = parsed.selectionHint.trim();
1501
2320
  }
2321
+ if (parsed.targetNodeIds === "all") {
2322
+ redirect.targetNodeIds = "all";
2323
+ } else if (Array.isArray(parsed.targetNodeIds)) {
2324
+ const targetNodeIds = parsed.targetNodeIds
2325
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
2326
+ .map(function (id) { return id.trim(); });
2327
+ if (targetNodeIds.length) { redirect.targetNodeIds = targetNodeIds; }
2328
+ }
1502
2329
  const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
1503
2330
  ? parsed.explanation.trim()
1504
2331
  : "This request belongs in " + parsed.mode + " mode.";
1505
- storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
2332
+ storage.appendAudit(Object.assign({ action: auditAction + "_mode_redirect" }, auditFields, perf));
1506
2333
  return { prose: redirectProse, suggestedAction: redirect };
1507
2334
  }
1508
2335
 
@@ -1539,7 +2366,7 @@ module.exports = function flowPilotRuntime(RED) {
1539
2366
  // assistant message and keeps the Execute action armed for the answer.
1540
2367
  if (typeof parsed.question === "string" && parsed.question.trim() &&
1541
2368
  (!Array.isArray(parsed.flow) || parsed.flow.length === 0)) {
1542
- storage.appendAudit(Object.assign({ action: auditAction + "_question" }, perf));
2369
+ storage.appendAudit(Object.assign({ action: auditAction + "_question" }, auditFields, perf));
1543
2370
  const questionResult = { question: parsed.question, explanation: parsed.explanation || "" };
1544
2371
  const questionAction = extractSuggestedAction(parsed);
1545
2372
  if (questionAction) { questionResult.suggestedAction = questionAction; }
@@ -1565,6 +2392,24 @@ module.exports = function flowPilotRuntime(RED) {
1565
2392
  throw err;
1566
2393
  }
1567
2394
 
2395
+ // Do not silently turn malformed structured fields into empty arrays.
2396
+ // A model reply such as {"changes": {...}} is plainly an attempted
2397
+ // Modify envelope, not prose or a legitimate no-op. Treat wrong field
2398
+ // types like other envelope parse failures so the client uses its safe
2399
+ // 422 handling and never presents raw JSON as a trusted assistant reply.
2400
+ const modifyArrayFields = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
2401
+ const invalidArrayFields = modifyArrayFields.filter(function (field) {
2402
+ return field in parsed && !Array.isArray(parsed[field]);
2403
+ });
2404
+ if (invalidArrayFields.length) {
2405
+ const err = new Error("The response contained non-array modify field(s): " + invalidArrayFields.join(", ") + ".");
2406
+ err.status = 422;
2407
+ err.raw = content;
2408
+ throw err;
2409
+ }
2410
+
2411
+ enforceAgentContract(parsed, auditContext, false);
2412
+
1568
2413
  const changes = Array.isArray(parsed.changes) ? parsed.changes : [];
1569
2414
  const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1570
2415
  const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
@@ -1596,7 +2441,7 @@ module.exports = function flowPilotRuntime(RED) {
1596
2441
  newGroupCount: newGroups.length,
1597
2442
  contextNodeCount: described ? described.nodeCount : 0,
1598
2443
  contextConnectionCount: described ? described.connectionCount : 0
1599
- }, perf));
2444
+ }, auditFields, perf));
1600
2445
 
1601
2446
  const modifyResult = {
1602
2447
  explanation: parsed.explanation || "",
@@ -1606,6 +2451,12 @@ module.exports = function flowPilotRuntime(RED) {
1606
2451
  removeNodes: removeNodes,
1607
2452
  newGroups: newGroups
1608
2453
  };
2454
+ if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
2455
+ modifyResult.strippedFields = parsed.strippedFields.slice();
2456
+ }
2457
+ if (Array.isArray(parsed.verifySteps) && parsed.verifySteps.length) {
2458
+ modifyResult.verifySteps = parsed.verifySteps.slice();
2459
+ }
1609
2460
  // Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
1610
2461
  const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
1611
2462
  if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
@@ -1614,6 +2465,8 @@ module.exports = function flowPilotRuntime(RED) {
1614
2465
  return modifyResult;
1615
2466
  }
1616
2467
 
2468
+ enforceAgentContract(parsed, auditContext, false);
2469
+
1617
2470
  const flow = Array.isArray(parsed.flow) ? parsed.flow : null;
1618
2471
  if (!flow) {
1619
2472
  const err = new Error("The response did not contain a 'flow' array.");
@@ -1630,7 +2483,7 @@ module.exports = function flowPilotRuntime(RED) {
1630
2483
  nodeCount: flow.length,
1631
2484
  contextNodeCount: described ? described.nodeCount : 0,
1632
2485
  contextConnectionCount: described ? described.connectionCount : 0
1633
- }, perf));
2486
+ }, auditFields, perf));
1634
2487
 
1635
2488
  const flowResult = {
1636
2489
  explanation: parsed.explanation || "",
@@ -1638,6 +2491,9 @@ module.exports = function flowPilotRuntime(RED) {
1638
2491
  newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
1639
2492
  newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
1640
2493
  };
2494
+ if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
2495
+ flowResult.strippedFields = parsed.strippedFields.slice();
2496
+ }
1641
2497
  if (auditAction === "build") {
1642
2498
  const fpUidManifest = buildFpUidManifest(flow);
1643
2499
  if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
@@ -1656,32 +2512,100 @@ module.exports = function flowPilotRuntime(RED) {
1656
2512
  // action name, and how the route validates its inputs beforehand. Throws
1657
2513
  // an Error with .status and (when applicable) .raw for the route to relay.
1658
2514
  // ---------------------------------------------------------------------
1659
- // Step 4: useTools offers AGENT_READ_TOOLS (explore-then-propose). If the
2515
+ // Step 4: useTools offers the mode-appropriate agent tools. WRITE tools
2516
+ // are Modify-only and require enableAgentWrite plus a tool-capable
2517
+ // provider. If the
1660
2518
  // provider responds with tool_calls instead of a final envelope, returns
1661
2519
  // early with { toolCalls, messages, content, usage } — same shape as
1662
2520
  // runChat's early return — so the route can hand it to the frontend
1663
2521
  // without running processGenerationContent yet.
1664
- async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
2522
+ async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools, execution) {
1665
2523
  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" }
2524
+ if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
2525
+ const settings = storage.getSettings();
2526
+ const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
2527
+ const offeredTools = toolsEnabled
2528
+ ? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
2529
+ : [];
2530
+ const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
2531
+ const toolChoice = (auditAction === "modify" || auditAction === "generate") && execution &&
2532
+ execution.strategy === "agent" ? "required" : "auto";
2533
+ let chatOptions = toolsEnabled
2534
+ ? { tools: offeredTools, toolChoice: toolChoice }
1669
2535
  : (responseFormat ? { responseFormat: responseFormat } : undefined);
1670
- const result = await getProvider(activeProvider).chat(activeProvider, messages, chatOptions);
2536
+ const provider = getProvider(activeProvider);
2537
+ let result;
2538
+ if (execution && execution.strategy === "agent") {
2539
+ chatOptions = agentTurnOptions(settings, chatOptions);
2540
+ result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
2541
+ } else {
2542
+ result = await provider.chat(activeProvider, messages, chatOptions);
2543
+ }
2544
+ if (result.fallbackToClassic) {
2545
+ return { fallbackToClassic: true, usage: result.usage || null };
2546
+ }
1671
2547
  if (result.toolCalls) {
1672
- return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
2548
+ // F-5: this is the FIRST turn of an agent-strategy request (routed
2549
+ // through /flowpilot/{generate,build,document,modify}, not the
2550
+ // /flowpilot/agent-step continuation endpoint below, which already
2551
+ // audits unconditionally) — it can carry a real WRITE tool call, so
2552
+ // it must not be the one turn that leaves zero record. Previously
2553
+ // this path returned before any storage.appendAudit call; a request
2554
+ // whose very first turn was a tool call (e.g. an immediate ask_user,
2555
+ // or — as here — the opening WRITE call of a multi-item Modify)
2556
+ // vanished from audit.log entirely. maybeLogDebugEvent below is
2557
+ // additive (only fires when settings.debugLogging is on); this
2558
+ // appendAudit call is unconditional, matching /flowpilot/agent-step.
2559
+ storage.appendAudit({
2560
+ action: "first_turn_tool_call",
2561
+ mode: auditAction,
2562
+ strategy: (execution && execution.strategy) || null,
2563
+ entry: (execution && execution.entry) || null,
2564
+ conversationId: (execution && execution.conversationId) || null,
2565
+ runId: (execution && execution.runId) || null,
2566
+ providerName: activeProvider.providerName,
2567
+ baseUrl: activeProvider.baseUrl,
2568
+ model: activeProvider.model,
2569
+ toolCallCount: result.toolCalls.length
2570
+ });
2571
+ maybeLogDebugEvent("tool_call", {
2572
+ mode: auditAction,
2573
+ providerBaseUrl: activeProvider.baseUrl,
2574
+ model: activeProvider.model,
2575
+ messages: messages,
2576
+ toolCalls: result.toolCalls,
2577
+ responseContent: result.content || null
2578
+ });
2579
+ return {
2580
+ toolCalls: result.toolCalls,
2581
+ toolTiers: toolTierMap(result.toolCalls),
2582
+ messages: messages,
2583
+ content: result.content || null,
2584
+ usage: result.usage || null
2585
+ };
1673
2586
  }
1674
2587
  const content = result.content || "";
1675
2588
  let parseOutcome = "unknown";
1676
2589
  try {
1677
- const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
2590
+ const generated = processGenerationContent(
2591
+ content, result, messages, auditAction, described, activeProvider, userPrompt, execution
2592
+ );
1678
2593
  parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1679
2594
  return generated;
1680
2595
  } catch (err) {
1681
2596
  parseOutcome = "parse_error:" + (err.message || "");
1682
2597
  throw err;
1683
2598
  } finally {
1684
- maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
2599
+ maybeLogDebugEvent("assistant_reply", {
2600
+ mode: auditAction,
2601
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
2602
+ model: activeProvider && activeProvider.model,
2603
+ messages: messages,
2604
+ responseChars: typeof content === "string" ? content.length : 0,
2605
+ responseContent: content,
2606
+ parseOutcome: parseOutcome,
2607
+ debugNote: (execution && execution.debugNote) || undefined
2608
+ });
1685
2609
  }
1686
2610
  }
1687
2611
 
@@ -1693,8 +2617,9 @@ module.exports = function flowPilotRuntime(RED) {
1693
2617
  // while the rest of the JSON (the "flow" array etc.) is buffered until
1694
2618
  // this resolves.
1695
2619
  // ---------------------------------------------------------------------
1696
- async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
2620
+ async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta, auditContext) {
1697
2621
  const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
2622
+ if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
1698
2623
  const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
1699
2624
  const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
1700
2625
  const result = await getProvider(activeProvider).chatStream(
@@ -1703,26 +2628,41 @@ module.exports = function flowPilotRuntime(RED) {
1703
2628
  const content = result.content || "";
1704
2629
  let parseOutcome = "unknown";
1705
2630
  try {
1706
- const generated = processGenerationContent(content, result, messages, auditAction, described, activeProvider, userPrompt);
2631
+ const generated = processGenerationContent(
2632
+ content, result, messages, auditAction, described, activeProvider, userPrompt, auditContext
2633
+ );
1707
2634
  parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
1708
2635
  return generated;
1709
2636
  } catch (err) {
1710
2637
  parseOutcome = "parse_error:" + (err.message || "");
1711
2638
  throw err;
1712
2639
  } finally {
1713
- maybeLogAssembledPrompt(auditAction, messages, content, parseOutcome, activeProvider);
2640
+ maybeLogDebugEvent("assistant_reply", {
2641
+ mode: auditAction,
2642
+ providerBaseUrl: activeProvider && activeProvider.baseUrl,
2643
+ model: activeProvider && activeProvider.model,
2644
+ messages: messages,
2645
+ responseChars: typeof content === "string" ? content.length : 0,
2646
+ responseContent: content,
2647
+ parseOutcome: parseOutcome,
2648
+ debugNote: (auditContext && auditContext.debugNote) || undefined
2649
+ });
1714
2650
  }
1715
2651
  }
1716
2652
 
1717
2653
  // Relays a runFlowGeneration error to the client with the right status,
1718
2654
  // falling back to 500 for anything that didn't set .status itself.
1719
- function sendGenerationError(res, auditAction, err) {
2655
+ function sendGenerationError(res, auditAction, err, auditContext) {
1720
2656
  if (err && err.status) {
1721
2657
  const body = { error: err.message };
2658
+ if (err.code) { body.code = err.code; }
1722
2659
  if (err.raw) { body.raw = err.raw; }
1723
2660
  return res.status(err.status).json(body);
1724
2661
  }
1725
- storage.appendAudit({ action: auditAction + "_error", error: err.message });
2662
+ storage.appendAudit(Object.assign(
2663
+ { action: auditAction + "_error", error: err.message },
2664
+ auditContext || {}
2665
+ ));
1726
2666
  res.status(500).json({ error: err.message });
1727
2667
  }
1728
2668
 
@@ -1733,7 +2673,7 @@ module.exports = function flowPilotRuntime(RED) {
1733
2673
  // Used by both the non-streaming route (res.status(status).json(body)) and
1734
2674
  // the streaming route (relayed as the final SSE event).
1735
2675
  // ---------------------------------------------------------------------
1736
- function finalizeSimpleGeneration(result) {
2676
+ function finalizeSimpleGeneration(result, execution) {
1737
2677
  if (result.question) {
1738
2678
  const body = { explanation: result.explanation, question: result.question, flow: null };
1739
2679
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
@@ -1746,6 +2686,19 @@ module.exports = function flowPilotRuntime(RED) {
1746
2686
  if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1747
2687
  return { status: 200, body: proseBody };
1748
2688
  }
2689
+ if (execution && execution.strategy === "agent") {
2690
+ const agentBody = {
2691
+ explanation: result.explanation || "",
2692
+ prose: true,
2693
+ flow: null
2694
+ };
2695
+ if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
2696
+ agentBody.strippedFields = result.strippedFields.slice();
2697
+ }
2698
+ if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
2699
+ if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
2700
+ return { status: 200, body: agentBody };
2701
+ }
1749
2702
  return { status: 200, body: result };
1750
2703
  }
1751
2704
 
@@ -1760,7 +2713,7 @@ module.exports = function flowPilotRuntime(RED) {
1760
2713
  // the /flowpilot/modify route handler. Used by both the non-streaming and
1761
2714
  // streaming routes.
1762
2715
  // ---------------------------------------------------------------------
1763
- function finalizeModifyResult(result, originalNodes) {
2716
+ function finalizeModifyResult(result, originalNodes, auditContext) {
1764
2717
  if (result.question) {
1765
2718
  const questionBody = { explanation: result.explanation, question: result.question, flow: null };
1766
2719
  if (result.suggestedAction) { questionBody.suggestedAction = result.suggestedAction; }
@@ -1773,6 +2726,22 @@ module.exports = function flowPilotRuntime(RED) {
1773
2726
  if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1774
2727
  return { status: 200, body: proseBody };
1775
2728
  }
2729
+ if (auditContext && auditContext.strategy === "agent") {
2730
+ const agentBody = {
2731
+ explanation: result.explanation || "",
2732
+ prose: true,
2733
+ flow: null
2734
+ };
2735
+ if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
2736
+ agentBody.strippedFields = result.strippedFields.slice();
2737
+ }
2738
+ if (Array.isArray(result.verifySteps) && result.verifySteps.length) {
2739
+ agentBody.verifySteps = result.verifySteps.slice();
2740
+ }
2741
+ if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
2742
+ if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
2743
+ return { status: 200, body: agentBody };
2744
+ }
1776
2745
 
1777
2746
  const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1778
2747
 
@@ -1986,7 +2955,12 @@ module.exports = function flowPilotRuntime(RED) {
1986
2955
  }
1987
2956
  }
1988
2957
 
1989
- if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
2958
+ if (newGroups.length > 0) {
2959
+ storage.appendAudit(Object.assign(
2960
+ { action: "modify_groups", count: newGroups.length },
2961
+ auditContext || {}
2962
+ ));
2963
+ }
1990
2964
 
1991
2965
  const verifySteps = [];
1992
2966
  validChanges.forEach(function (change) {
@@ -2045,7 +3019,7 @@ module.exports = function flowPilotRuntime(RED) {
2045
3019
  // `data: {"error": <body>, "status": <status>}`, since SSE responses can't
2046
3020
  // change their HTTP status after headers are sent.
2047
3021
  // ---------------------------------------------------------------------
2048
- async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId) {
3022
+ async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId, auditContext) {
2049
3023
  res.writeHead(200, {
2050
3024
  "Content-Type": "text/event-stream; charset=utf-8",
2051
3025
  "Cache-Control": "no-cache, no-transform",
@@ -2058,12 +3032,18 @@ module.exports = function flowPilotRuntime(RED) {
2058
3032
  try {
2059
3033
  result = await runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, function (delta) {
2060
3034
  res.write("data: " + JSON.stringify({ delta: delta }) + "\n\n");
2061
- });
3035
+ }, auditContext);
2062
3036
  } catch (err) {
2063
3037
  const status = err && err.status ? err.status : 500;
2064
3038
  const body = { error: err.message };
3039
+ if (err && err.code) { body.code = err.code; }
2065
3040
  if (err && err.raw) { body.raw = err.raw; }
2066
- if (!err || !err.status) { storage.appendAudit({ action: auditAction + "_error", error: err.message }); }
3041
+ if (!err || !err.status) {
3042
+ storage.appendAudit(Object.assign(
3043
+ { action: auditAction + "_error", error: err.message },
3044
+ auditContext || {}
3045
+ ));
3046
+ }
2067
3047
  res.write("data: " + JSON.stringify({ error: body, status: status }) + "\n\n");
2068
3048
  res.write("data: [DONE]\n\n");
2069
3049
  res.end();
@@ -2079,6 +3059,11 @@ module.exports = function flowPilotRuntime(RED) {
2079
3059
  }
2080
3060
 
2081
3061
  RED.httpAdmin.post("/flowpilot/generate", RED.auth.needsPermission("settings.write"), async function (req, res) {
3062
+ const settings = storage.getSettings();
3063
+ const activeProvider = storage.getActiveProvider(settings);
3064
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
3065
+ if (!execution) { return; }
3066
+
2082
3067
  const prompt = req.body && req.body.prompt;
2083
3068
 
2084
3069
  if (!prompt || !String(prompt).trim()) {
@@ -2087,11 +3072,12 @@ module.exports = function flowPilotRuntime(RED) {
2087
3072
 
2088
3073
  const history = sanitizeHistory(req.body.history);
2089
3074
  const historyTruncated = !!req.body.historyTruncated;
3075
+ const finalize = function (result) { return finalizeSimpleGeneration(result, execution); };
2090
3076
 
2091
3077
  if (req.body.stream) {
2092
3078
  return runExecuteStream(
2093
3079
  req, res, generationSystemPrompt, "generate", prompt, req.body && req.body.context,
2094
- history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
3080
+ history, historyTruncated, finalize, req.body.conversationId, execution
2095
3081
  );
2096
3082
  }
2097
3083
 
@@ -2099,13 +3085,19 @@ module.exports = function flowPilotRuntime(RED) {
2099
3085
  const useTools = !!req.body.tools;
2100
3086
  const generated = await runFlowGeneration(
2101
3087
  generationSystemPrompt, "generate", prompt, req.body && req.body.context,
2102
- history, historyTruncated, useTools
3088
+ history, historyTruncated, useTools, execution
2103
3089
  );
2104
3090
  if (generated.toolCalls) {
2105
- return res.json({ toolCalls: generated.toolCalls, messages: generated.messages, content: generated.content, usage: generated.usage });
3091
+ return res.json({
3092
+ toolCalls: generated.toolCalls,
3093
+ toolTiers: generated.toolTiers,
3094
+ messages: generated.messages,
3095
+ content: generated.content,
3096
+ usage: generated.usage
3097
+ });
2106
3098
  }
2107
3099
  recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
2108
- const { status, body } = finalizeSimpleGeneration(generated);
3100
+ const { status, body } = finalize(generated);
2109
3101
  res.status(status).json(body);
2110
3102
  } catch (err) {
2111
3103
  sendGenerationError(res, "generate", err);
@@ -2142,7 +3134,13 @@ module.exports = function flowPilotRuntime(RED) {
2142
3134
  history, historyTruncated, useTools
2143
3135
  );
2144
3136
  if (built.toolCalls) {
2145
- return res.json({ toolCalls: built.toolCalls, messages: built.messages, content: built.content, usage: built.usage });
3137
+ return res.json({
3138
+ toolCalls: built.toolCalls,
3139
+ toolTiers: built.toolTiers,
3140
+ messages: built.messages,
3141
+ content: built.content,
3142
+ usage: built.usage
3143
+ });
2146
3144
  }
2147
3145
  recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
2148
3146
  const { status, body } = finalizeSimpleGeneration(built);
@@ -2154,7 +3152,7 @@ module.exports = function flowPilotRuntime(RED) {
2154
3152
 
2155
3153
  RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
2156
3154
  const context = req.body && req.body.context;
2157
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
3155
+ const described = describeSelectionContext(context, storage.getSettings());
2158
3156
 
2159
3157
  if (!described) {
2160
3158
  return res.status(400).json({ error: "Select the node(s) you want documented first." });
@@ -2182,7 +3180,13 @@ module.exports = function flowPilotRuntime(RED) {
2182
3180
  history, historyTruncated, useTools
2183
3181
  );
2184
3182
  if (documented.toolCalls) {
2185
- return res.json({ toolCalls: documented.toolCalls, messages: documented.messages, content: documented.content, usage: documented.usage });
3183
+ return res.json({
3184
+ toolCalls: documented.toolCalls,
3185
+ toolTiers: documented.toolTiers,
3186
+ messages: documented.messages,
3187
+ content: documented.content,
3188
+ usage: documented.usage
3189
+ });
2186
3190
  }
2187
3191
  recordTranscriptTurn(req.body.conversationId, "document", userPrompt, transcriptTextFromGenerationResult(documented));
2188
3192
  const { status, body } = finalizeSimpleGeneration(documented);
@@ -2193,8 +3197,13 @@ module.exports = function flowPilotRuntime(RED) {
2193
3197
  });
2194
3198
 
2195
3199
  RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
3200
+ const settings = storage.getSettings();
3201
+ const activeProvider = storage.getActiveProvider(settings);
3202
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
3203
+ if (!execution) { return; }
3204
+
2196
3205
  const context = req.body && req.body.context;
2197
- const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
3206
+ const described = describeSelectionContext(context, settings);
2198
3207
 
2199
3208
  if (!described) {
2200
3209
  return res.status(400).json({ error: "Select the node(s) you want to modify first." });
@@ -2212,15 +3221,20 @@ module.exports = function flowPilotRuntime(RED) {
2212
3221
  const history = sanitizeHistory(req.body.history);
2213
3222
  const historyTruncated = !!req.body.historyTruncated;
2214
3223
 
2215
- const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
3224
+ const finalize = function (result) { return finalizeModifyResult(result, originalNodes, execution); };
2216
3225
  const hasSwitch = Array.isArray(context && context.nodes) &&
2217
3226
  context.nodes.some(function (node) { return node && node.type === "switch"; });
2218
- const modifyPrompt = modifySystemPrompt({ hasSwitch: hasSwitch });
3227
+ // Keep the legacy prompt byte-identical for the explicit classic
3228
+ // strategy. Agent prompt behavior keys only off the propagated strategy.
3229
+ const modifyPrompt = modifySystemPrompt({
3230
+ hasSwitch: hasSwitch,
3231
+ agentWriteEnabled: execution.strategy === "agent"
3232
+ });
2219
3233
 
2220
3234
  if (req.body.stream) {
2221
3235
  return runExecuteStream(
2222
3236
  req, res, modifyPrompt, "modify", String(prompt).trim(), context,
2223
- history, historyTruncated, finalize, req.body.conversationId
3237
+ history, historyTruncated, finalize, req.body.conversationId, execution
2224
3238
  );
2225
3239
  }
2226
3240
 
@@ -2228,16 +3242,22 @@ module.exports = function flowPilotRuntime(RED) {
2228
3242
  const useTools = !!req.body.tools;
2229
3243
  const result = await runFlowGeneration(
2230
3244
  modifyPrompt, "modify", String(prompt).trim(), context,
2231
- history, historyTruncated, useTools
3245
+ history, historyTruncated, useTools, execution
2232
3246
  );
2233
3247
  if (result.toolCalls) {
2234
- return res.json({ toolCalls: result.toolCalls, messages: result.messages, content: result.content, usage: result.usage });
3248
+ return res.json({
3249
+ toolCalls: result.toolCalls,
3250
+ toolTiers: result.toolTiers,
3251
+ messages: result.messages,
3252
+ content: result.content,
3253
+ usage: result.usage
3254
+ });
2235
3255
  }
2236
3256
  recordTranscriptTurn(req.body.conversationId, "modify", String(prompt).trim(), transcriptTextFromGenerationResult(result));
2237
3257
  const { status, body } = finalize(result);
2238
3258
  res.status(status).json(body);
2239
3259
  } catch (err) {
2240
- sendGenerationError(res, "modify", err);
3260
+ sendGenerationError(res, "modify", err, execution);
2241
3261
  }
2242
3262
  });
2243
3263
  };