@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/CHANGELOG.md +85 -26
- package/README.md +10 -1
- package/USER-GUIDE.md +5 -1
- package/flowpilot-core.css +53 -10
- package/flowpilot-node-entry.js +15 -0
- package/flowpilot.js +1207 -187
- package/lib/agent-contract.js +50 -0
- package/lib/build-core-script.js +1 -0
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +34 -11
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +157 -6
- package/lib/core/init.js +223 -21
- package/lib/core/main.js +780 -38
- package/lib/core/modes.js +1098 -112
- package/lib/core/selection-context.js +44 -24
- package/lib/default-system-prompt.js +4 -3
- package/lib/document-system-prompt.js +7 -4
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +5 -4
- package/lib/modify-system-prompt.js +64 -12
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +17 -2
- package/lib/provider-anthropic.js +23 -10
- package/lib/provider-openai-compatible.js +51 -11
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +134 -21
- package/package.json +3 -2
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
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
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.
|
|
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.
|
|
562
|
-
|
|
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:
|
|
567
|
-
|
|
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
|
|
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
|
-
|
|
1044
|
+
const sections = [];
|
|
625
1045
|
if (nodes.length > 0) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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 =
|
|
1084
|
+
const configNodes = settings.allowConfigContext === true &&
|
|
1085
|
+
context && Array.isArray(context.configNodes) ? context.configNodes : [];
|
|
648
1086
|
if (configNodes.length > 0) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
984
|
-
const
|
|
985
|
-
|
|
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
|
-
|
|
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
|
|
1003
|
-
const generated = processGenerationContent(
|
|
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) {
|
|
1007
|
-
|
|
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
|
|
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: "...",
|
|
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
|
|
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 + "
|
|
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
|
|
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
|
-
|
|
1667
|
-
const
|
|
1668
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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) {
|
|
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) {
|
|
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,
|
|
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({
|
|
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 } =
|
|
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({
|
|
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()
|
|
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({
|
|
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,
|
|
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
|
-
|
|
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({
|
|
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
|
};
|