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