@manny-est/node-red-flowpilot 0.5.2 → 0.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -74
- package/USER-GUIDE.md +5 -1
- package/flowpilot-core.css +38 -10
- package/flowpilot.js +1007 -181
- package/lib/agent-contract.js +45 -0
- package/lib/build-core-script.js +1 -0
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +34 -11
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +10 -1
- package/lib/core/init.js +138 -5
- package/lib/core/main.js +664 -16
- package/lib/core/modes.js +882 -100
- package/lib/core/selection-context.js +27 -24
- package/lib/default-system-prompt.js +4 -3
- package/lib/document-system-prompt.js +3 -2
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +5 -4
- package/lib/modify-system-prompt.js +64 -12
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +13 -2
- package/lib/provider-anthropic.js +11 -8
- package/lib/provider-openai-compatible.js +37 -9
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +128 -21
- package/package.json +1 -1
package/flowpilot.js
CHANGED
|
@@ -72,8 +72,16 @@ const modifySystemPrompt = require("./lib/modify-system-prompt");
|
|
|
72
72
|
const buildSystemPrompt = require("./lib/build-system-prompt");
|
|
73
73
|
const personaPrompt = require("./lib/persona-prompt");
|
|
74
74
|
const { buildCoreScript } = require("./lib/build-core-script");
|
|
75
|
+
const {
|
|
76
|
+
createChatDataStreamSplitter,
|
|
77
|
+
findChatDataMarker,
|
|
78
|
+
splitChatDataBlock
|
|
79
|
+
} = require("./lib/chat-data");
|
|
75
80
|
const { extractJsonObject } = require("./lib/envelope");
|
|
76
81
|
const { repairEnvelope } = require("./lib/validator");
|
|
82
|
+
const { enforceAgentContract } = require("./lib/agent-contract");
|
|
83
|
+
const { isProviderShapedResponse } = require("./lib/provider-shape-check");
|
|
84
|
+
const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
|
|
77
85
|
|
|
78
86
|
module.exports = function flowPilotRuntime(RED) {
|
|
79
87
|
const storage = createStorage(RED.settings.userDir);
|
|
@@ -91,12 +99,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
91
99
|
"that may have been said earlier, ask the user.";
|
|
92
100
|
|
|
93
101
|
// ---------------------------------------------------------------------
|
|
94
|
-
// Tier-1 READ tools the model may call autonomously
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// tools; they stay on the existing diff/review/apply envelope.
|
|
102
|
+
// Tier-1 READ tools the model may call autonomously. Their data
|
|
103
|
+
// (RED.nodes, live selection, debug buffer) lives only in the editor, so
|
|
104
|
+
// each call is executed CLIENT-SIDE and its result passed back through the
|
|
105
|
+
// same sanitizer as selection context — a tool result can never carry a
|
|
106
|
+
// raw secret.
|
|
100
107
|
// ---------------------------------------------------------------------
|
|
101
108
|
const AGENT_READ_TOOLS = [
|
|
102
109
|
{
|
|
@@ -205,6 +212,247 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
205
212
|
}
|
|
206
213
|
];
|
|
207
214
|
|
|
215
|
+
// W7 Round 1 WRITE tools. One call represents one plan/todo item: a small
|
|
216
|
+
// coherent bundle, never one call per field. `tier` is FlowPilot metadata,
|
|
217
|
+
// not part of either provider's tool schema; providerToolDefinitions()
|
|
218
|
+
// strips it before the request leaves the server. The client combines this
|
|
219
|
+
// registry tier with classifyFlowNodes-equivalent per-call node safety so a
|
|
220
|
+
// write-gated call only prompts when it actually touches an unsafe type.
|
|
221
|
+
const WRITE_TOOLS = [
|
|
222
|
+
{
|
|
223
|
+
tier: "write-gated",
|
|
224
|
+
type: "function",
|
|
225
|
+
function: {
|
|
226
|
+
name: "apply_step",
|
|
227
|
+
description: "Apply ONE plan item as a small bundle of sparse " +
|
|
228
|
+
"property patches, at most one new node, and its immediate wires. " +
|
|
229
|
+
"Use one call for the whole item, not one call per field. Existing " +
|
|
230
|
+
"wire removal/replacement is expressed as a sparse " +
|
|
231
|
+
"changes[].set.wires final value.",
|
|
232
|
+
parameters: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: {
|
|
235
|
+
summary: {
|
|
236
|
+
type: "string",
|
|
237
|
+
description: "Short todo-item description of this step."
|
|
238
|
+
},
|
|
239
|
+
changes: {
|
|
240
|
+
type: "array",
|
|
241
|
+
description: "Sparse patches for existing nodes.",
|
|
242
|
+
items: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
id: { type: "string" },
|
|
246
|
+
set: { type: "object", additionalProperties: true }
|
|
247
|
+
},
|
|
248
|
+
required: ["id", "set"],
|
|
249
|
+
additionalProperties: false
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
newNodes: {
|
|
253
|
+
type: "array",
|
|
254
|
+
description: "At most one new node for this step, using a temporary id.",
|
|
255
|
+
items: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
id: { type: "string" },
|
|
259
|
+
type: { type: "string" }
|
|
260
|
+
},
|
|
261
|
+
required: ["id", "type"],
|
|
262
|
+
additionalProperties: true
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
newWires: {
|
|
266
|
+
type: "array",
|
|
267
|
+
description: "Immediate wires for this step; ids may refer to " +
|
|
268
|
+
"existing nodes or newNodes temporary ids.",
|
|
269
|
+
items: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
from: { type: "string" },
|
|
273
|
+
fromPort: { type: "integer", minimum: 0 },
|
|
274
|
+
to: { type: "string" }
|
|
275
|
+
},
|
|
276
|
+
required: ["from", "fromPort", "to"],
|
|
277
|
+
additionalProperties: false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
required: ["summary"],
|
|
282
|
+
additionalProperties: false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
tier: "write-gated",
|
|
288
|
+
type: "function",
|
|
289
|
+
function: {
|
|
290
|
+
name: "remove_step",
|
|
291
|
+
description: "Remove one node as ONE plan item. Its connected wires " +
|
|
292
|
+
"are removed with it by the existing editor apply path.",
|
|
293
|
+
parameters: {
|
|
294
|
+
type: "object",
|
|
295
|
+
properties: {
|
|
296
|
+
summary: {
|
|
297
|
+
type: "string",
|
|
298
|
+
description: "Short todo-item description of this removal."
|
|
299
|
+
},
|
|
300
|
+
nodeId: { type: "string", description: "Existing node id to remove." }
|
|
301
|
+
},
|
|
302
|
+
required: ["summary", "nodeId"],
|
|
303
|
+
additionalProperties: false
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
tier: "write-gated",
|
|
309
|
+
type: "function",
|
|
310
|
+
function: {
|
|
311
|
+
name: "rename_node",
|
|
312
|
+
description: "Rename one existing node as ONE plan item.",
|
|
313
|
+
parameters: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {
|
|
316
|
+
summary: {
|
|
317
|
+
type: "string",
|
|
318
|
+
description: "Short todo-item description of this rename."
|
|
319
|
+
},
|
|
320
|
+
nodeId: { type: "string", description: "Existing node id to rename." },
|
|
321
|
+
name: { type: "string", description: "Exact final node name." }
|
|
322
|
+
},
|
|
323
|
+
required: ["summary", "nodeId", "name"],
|
|
324
|
+
additionalProperties: false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
// ADR-003 R3a: grouping is visual-only, classified safe (auto-apply)
|
|
330
|
+
// regardless of which node types are grouped — "write-safe" here means
|
|
331
|
+
// "never eligible for consent-gating" (the mechanism
|
|
332
|
+
// writeToolCallNeedsConsent actually keys on), not "non-mutating" —
|
|
333
|
+
// this call does mutate (creates a group, pushes history), unlike
|
|
334
|
+
// ask_user, the tier's other current member.
|
|
335
|
+
tier: "write-safe",
|
|
336
|
+
type: "function",
|
|
337
|
+
function: {
|
|
338
|
+
name: "group_nodes",
|
|
339
|
+
description: "Create a new named group from existing, already-verified " +
|
|
340
|
+
"node ids. No nested groups (a group id among nodeIds) and no " +
|
|
341
|
+
"editing an existing group's membership — both are reported as an " +
|
|
342
|
+
"unsupported_operation instead of attempted.",
|
|
343
|
+
parameters: {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: {
|
|
346
|
+
name: { type: "string", description: "Name for the new group." },
|
|
347
|
+
nodeIds: {
|
|
348
|
+
type: "array",
|
|
349
|
+
description: "Existing node ids to include in the new group.",
|
|
350
|
+
items: { type: "string" },
|
|
351
|
+
minItems: 1
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
required: ["name", "nodeIds"],
|
|
355
|
+
additionalProperties: false
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
tier: "write-safe",
|
|
361
|
+
type: "function",
|
|
362
|
+
function: {
|
|
363
|
+
name: "redirect_mode",
|
|
364
|
+
description: "End this Modify agent turn without mutating anything " +
|
|
365
|
+
"and redirect the user to Generate, Document, or Chat instead.",
|
|
366
|
+
parameters: {
|
|
367
|
+
type: "object",
|
|
368
|
+
properties: {
|
|
369
|
+
mode: {
|
|
370
|
+
type: "string",
|
|
371
|
+
enum: ["generate", "document", "chat"],
|
|
372
|
+
description: "The mode this request actually belongs in."
|
|
373
|
+
},
|
|
374
|
+
prompt: {
|
|
375
|
+
type: "string",
|
|
376
|
+
description: "Ready-to-send prompt to prefill after switching modes."
|
|
377
|
+
},
|
|
378
|
+
explanation: {
|
|
379
|
+
type: "string",
|
|
380
|
+
description: "Visible reply shown to the user before the redirect chip."
|
|
381
|
+
},
|
|
382
|
+
selectionHint: {
|
|
383
|
+
type: "string",
|
|
384
|
+
description: "Optional selection guidance for Document redirects."
|
|
385
|
+
},
|
|
386
|
+
targetNodeIds: {
|
|
387
|
+
oneOf: [
|
|
388
|
+
{ type: "string", enum: ["all"] },
|
|
389
|
+
{
|
|
390
|
+
type: "array",
|
|
391
|
+
items: { type: "string" },
|
|
392
|
+
minItems: 1
|
|
393
|
+
}
|
|
394
|
+
],
|
|
395
|
+
description: "Optional resolved node target for Document redirects."
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
required: ["mode", "prompt", "explanation"],
|
|
399
|
+
additionalProperties: false
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
tier: "write-safe",
|
|
405
|
+
type: "function",
|
|
406
|
+
function: {
|
|
407
|
+
name: "ask_user",
|
|
408
|
+
description: "Ask the user a clarifying question, pause this loop, " +
|
|
409
|
+
"and resume with their answer. This does not mutate the flow.",
|
|
410
|
+
parameters: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: {
|
|
413
|
+
question: { type: "string" },
|
|
414
|
+
options: {
|
|
415
|
+
type: "array",
|
|
416
|
+
items: { type: "string" },
|
|
417
|
+
description: "Optional short answer choices."
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
required: ["question"],
|
|
421
|
+
additionalProperties: false
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
function providerToolDefinitions(tools) {
|
|
428
|
+
return tools.map(function (tool) {
|
|
429
|
+
return { type: tool.type, function: tool.function };
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
|
|
434
|
+
const writesEnabled = settings.enableAgentWrite === true &&
|
|
435
|
+
mode === "modify" && writesAllowed !== false &&
|
|
436
|
+
activeProvider && activeProvider.supportsTools === true;
|
|
437
|
+
return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function toolTierMap(toolCalls) {
|
|
441
|
+
if (!Array.isArray(toolCalls) || !toolCalls.length) { return null; }
|
|
442
|
+
const tiersByName = {};
|
|
443
|
+
WRITE_TOOLS.forEach(function (tool) {
|
|
444
|
+
tiersByName[tool.function.name] = tool.tier;
|
|
445
|
+
});
|
|
446
|
+
const byCallId = {};
|
|
447
|
+
toolCalls.forEach(function (call) {
|
|
448
|
+
const name = call && call.function && call.function.name;
|
|
449
|
+
if (call && call.id && tiersByName[name]) {
|
|
450
|
+
byCallId[call.id] = tiersByName[name];
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
return Object.keys(byCallId).length ? byCallId : null;
|
|
454
|
+
}
|
|
455
|
+
|
|
208
456
|
// Keep only well-formed { role: "user"|"assistant", content: <string> }
|
|
209
457
|
// entries. Anything else (bad shapes, empty content, other roles) is
|
|
210
458
|
// dropped rather than rejected outright — the history is advisory context,
|
|
@@ -548,27 +796,22 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
548
796
|
return clean;
|
|
549
797
|
}
|
|
550
798
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
function maybeLogAssembledPrompt(mode, messages, responseContent, parseOutcome, activeProvider) {
|
|
799
|
+
// Append a provider-turn event to debug.log when debugLogging is enabled.
|
|
800
|
+
// Callers pass only post-redaction messages/content and non-secret provider
|
|
801
|
+
// metadata; auth keys and headers must never be included.
|
|
802
|
+
function maybeLogDebugEvent(type, fields) {
|
|
556
803
|
const settings = storage.getSettings();
|
|
557
|
-
if (!settings.
|
|
804
|
+
if (!settings.debugLogging) { return; }
|
|
805
|
+
fields = fields || {};
|
|
806
|
+
const messages = fields.messages || [];
|
|
558
807
|
const promptChars = (messages || []).reduce(function (sum, m) {
|
|
559
808
|
return sum + (m && typeof m.content === "string" ? m.content.length : 0);
|
|
560
809
|
}, 0);
|
|
561
|
-
storage.
|
|
562
|
-
|
|
563
|
-
providerBaseUrl: activeProvider && activeProvider.baseUrl,
|
|
564
|
-
model: activeProvider && activeProvider.model,
|
|
810
|
+
storage.appendDebugLog(Object.assign({
|
|
811
|
+
type: type,
|
|
565
812
|
promptTokenEst: Math.round(promptChars / 4),
|
|
566
|
-
messageCount:
|
|
567
|
-
|
|
568
|
-
responseChars: typeof responseContent === "string" ? responseContent.length : 0,
|
|
569
|
-
responseContent: responseContent,
|
|
570
|
-
parseOutcome: parseOutcome
|
|
571
|
-
});
|
|
813
|
+
messageCount: messages.length
|
|
814
|
+
}, fields));
|
|
572
815
|
}
|
|
573
816
|
|
|
574
817
|
// W0.1: warn when estimated prompt token count approaches the provider's
|
|
@@ -595,10 +838,78 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
595
838
|
// Returns null when there's no selection — used by both /chat and
|
|
596
839
|
// /generate so the two describe context identically and never drift.
|
|
597
840
|
// ---------------------------------------------------------------------
|
|
598
|
-
function
|
|
841
|
+
function maxSelectionContextChars(settings) {
|
|
842
|
+
const configured = Number(settings && settings.maxContextChars);
|
|
843
|
+
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 12000;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function joinSelectionContextSections(sections) {
|
|
847
|
+
return sections
|
|
848
|
+
.filter(function (section) { return !!section && typeof section.content === "string" && section.content.length > 0; })
|
|
849
|
+
.map(function (section) { return section.content; })
|
|
850
|
+
.join("\n\n");
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function selectionContextTruncationNote(maxChars, omittedSections, hardCutoff) {
|
|
854
|
+
let note = "\n\n[FlowPilot truncated selection context to fit maxContextChars=" + maxChars + ".";
|
|
855
|
+
if (omittedSections.length) {
|
|
856
|
+
note += " Omitted sections: " + omittedSections.join(", ") + ".";
|
|
857
|
+
}
|
|
858
|
+
if (hardCutoff) {
|
|
859
|
+
note += " Remaining content was hard-cut and may end mid-JSON.";
|
|
860
|
+
}
|
|
861
|
+
return note + "]";
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function truncateSelectionContext(sections, maxChars) {
|
|
865
|
+
let activeSections = (sections || []).filter(function (section) { return !!section; });
|
|
866
|
+
let content = joinSelectionContextSections(activeSections);
|
|
867
|
+
if (!maxChars || maxChars <= 0 || content.length <= maxChars) {
|
|
868
|
+
return { content: content, truncated: false };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const omittedSections = [];
|
|
872
|
+
["debug", "config", "perNode", "edges", "subflows"].forEach(function (key) {
|
|
873
|
+
if (content.length <= maxChars) { return; }
|
|
874
|
+
const nextSections = [];
|
|
875
|
+
let removed = false;
|
|
876
|
+
activeSections.forEach(function (section) {
|
|
877
|
+
if (!removed && section.key === key && section.required !== true) {
|
|
878
|
+
removed = true;
|
|
879
|
+
omittedSections.push(section.label);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
nextSections.push(section);
|
|
883
|
+
});
|
|
884
|
+
if (removed) {
|
|
885
|
+
activeSections = nextSections;
|
|
886
|
+
content = joinSelectionContextSections(activeSections);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
if (content.length <= maxChars) {
|
|
891
|
+
const note = selectionContextTruncationNote(maxChars, omittedSections, false);
|
|
892
|
+
if (content.length + note.length <= maxChars) {
|
|
893
|
+
content += note;
|
|
894
|
+
}
|
|
895
|
+
return { content: content, truncated: true };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const hardCutNote = selectionContextTruncationNote(maxChars, omittedSections, true);
|
|
899
|
+
if (hardCutNote.length >= maxChars) {
|
|
900
|
+
return { content: content.slice(0, maxChars), truncated: true };
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
content: content.slice(0, maxChars - hardCutNote.length) + hardCutNote,
|
|
904
|
+
truncated: true
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function describeSelectionContext(context, settings) {
|
|
599
909
|
const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
|
|
600
910
|
const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
|
|
601
911
|
if (nodes.length === 0 && debugMessages.length === 0) { return null; }
|
|
912
|
+
settings = settings || {};
|
|
602
913
|
|
|
603
914
|
const connections = (context && context.connections) ? context.connections : {};
|
|
604
915
|
const edges = Array.isArray(connections.edges) ? connections.edges : [];
|
|
@@ -610,7 +921,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
610
921
|
// changes. redactionEnabled only controls the SEPARATE secret-shaped-value
|
|
611
922
|
// scrubbing (password/token/apiKey-looking fields elsewhere in a node's
|
|
612
923
|
// config) — tell the model the truth about which protection is active.
|
|
613
|
-
const credentialNote = redactionEnabled === false
|
|
924
|
+
const credentialNote = settings.redactionEnabled === false
|
|
614
925
|
? "Redaction is OFF for this session — context may contain sensitive " +
|
|
615
926
|
"values the user chose to share (e.g. embedded API keys or tokens); " +
|
|
616
927
|
"handle carefully and never volunteer them. Node-RED's separate " +
|
|
@@ -621,47 +932,104 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
621
932
|
"(it requires re-confirming a type-to-confirm phrase, by design)."
|
|
622
933
|
: "This is sanitized configuration; credentials are redacted.";
|
|
623
934
|
|
|
624
|
-
|
|
935
|
+
const sections = [];
|
|
625
936
|
if (nodes.length > 0) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
937
|
+
sections.push({
|
|
938
|
+
key: "nodes",
|
|
939
|
+
label: "selected nodes",
|
|
940
|
+
required: true,
|
|
941
|
+
content: "The user has selected the following Node-RED nodes as context. " +
|
|
942
|
+
credentialNote + "\n\n" +
|
|
943
|
+
"Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```"
|
|
944
|
+
});
|
|
629
945
|
if (edges.length > 0) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
946
|
+
sections.push({
|
|
947
|
+
key: "edges",
|
|
948
|
+
label: "connection edges",
|
|
949
|
+
content: "Connections — directed edges by node id (a node's wires " +
|
|
950
|
+
"describe its OUTPUTS; one edge per output port; fromId/toId refer " +
|
|
951
|
+
"to the \"id\" fields in Nodes above):\n```json\n" +
|
|
952
|
+
JSON.stringify(edges) + "\n```"
|
|
953
|
+
});
|
|
954
|
+
sections.push({
|
|
955
|
+
key: "perNode",
|
|
956
|
+
label: "per-node wiring summary",
|
|
957
|
+
content: "Per-node wiring summary, with readable \"Name [type]\" " +
|
|
958
|
+
"labels (inputs are reconstructed, since Node-RED nodes do not " +
|
|
959
|
+
"store their own inputs; subFlow groups nodes into connected " +
|
|
960
|
+
"sub-flows):\n```json\n" +
|
|
961
|
+
JSON.stringify(perNode) + "\n```"
|
|
962
|
+
});
|
|
639
963
|
}
|
|
640
964
|
if (subFlowCount > 1) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
965
|
+
sections.push({
|
|
966
|
+
key: "subflows",
|
|
967
|
+
label: "sub-flow note",
|
|
968
|
+
content: "Note: the selection contains " + subFlowCount + " separate, " +
|
|
969
|
+
"unconnected sub-flows (see each node's subFlow number). Treat " +
|
|
970
|
+
"them as distinct unless the user says otherwise."
|
|
971
|
+
});
|
|
644
972
|
}
|
|
645
973
|
}
|
|
646
974
|
|
|
647
|
-
const configNodes =
|
|
975
|
+
const configNodes = settings.allowConfigContext === true &&
|
|
976
|
+
context && Array.isArray(context.configNodes) ? context.configNodes : [];
|
|
648
977
|
if (configNodes.length > 0) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
978
|
+
sections.push({
|
|
979
|
+
key: "config",
|
|
980
|
+
label: "config nodes",
|
|
981
|
+
required: sections.length === 0,
|
|
982
|
+
content: "Config nodes referenced by the selection (shared configuration " +
|
|
983
|
+
"objects not shown on the canvas; credentials are redacted). " +
|
|
984
|
+
"Use a config node's \"id\" to point an existing node at it via " +
|
|
985
|
+
"a \"changes\" patch, or create a new one via \"newNodes\":\n```json\n" +
|
|
986
|
+
JSON.stringify(configNodes) + "\n```"
|
|
987
|
+
});
|
|
655
988
|
}
|
|
656
989
|
|
|
657
990
|
if (debugMessages.length > 0) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
991
|
+
sections.push({
|
|
992
|
+
key: "debug",
|
|
993
|
+
label: "debug messages",
|
|
994
|
+
required: sections.length === 0,
|
|
995
|
+
content: "The user attached recent Node-RED Debug sidebar output for " +
|
|
996
|
+
"troubleshooting (runtime data, may be truncated):\n```json\n" +
|
|
997
|
+
JSON.stringify(debugMessages) + "\n```"
|
|
998
|
+
});
|
|
662
999
|
}
|
|
663
1000
|
|
|
664
|
-
|
|
1001
|
+
const limited = truncateSelectionContext(sections, maxSelectionContextChars(settings));
|
|
1002
|
+
return {
|
|
1003
|
+
content: limited.content,
|
|
1004
|
+
nodeCount: nodes.length,
|
|
1005
|
+
connectionCount: edges.length,
|
|
1006
|
+
debugMessageCount: debugMessages.length,
|
|
1007
|
+
truncated: limited.truncated
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const AGENT_TRUNCATION_NUDGE =
|
|
1012
|
+
"your last reply was cut off — emit ONLY the tool call";
|
|
1013
|
+
|
|
1014
|
+
function agentTurnOptions(settings, options) {
|
|
1015
|
+
const configured = Number(settings.agentTurnMaxTokens);
|
|
1016
|
+
return Object.assign({}, options || {}, {
|
|
1017
|
+
maxTokens: Number.isInteger(configured) && configured > 0 ? configured : 4096
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function chatWithAgentCap(provider, activeProvider, messages, options) {
|
|
1022
|
+
const first = await provider.chat(activeProvider, messages, options);
|
|
1023
|
+
if (first.finishReason !== "length") { return first; }
|
|
1024
|
+
|
|
1025
|
+
const retryMessages = messages.concat([
|
|
1026
|
+
{ role: "system", content: AGENT_TRUNCATION_NUDGE }
|
|
1027
|
+
]);
|
|
1028
|
+
const retry = await provider.chat(activeProvider, retryMessages, options);
|
|
1029
|
+
if (retry.finishReason === "length") {
|
|
1030
|
+
retry.fallbackToClassic = true;
|
|
1031
|
+
}
|
|
1032
|
+
return retry;
|
|
665
1033
|
}
|
|
666
1034
|
|
|
667
1035
|
// ---------------------------------------------------------------------
|
|
@@ -669,28 +1037,56 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
669
1037
|
// log it, and return the result. Used by both /chat and /test so the two
|
|
670
1038
|
// never drift apart. contextMode is recorded for the audit trail.
|
|
671
1039
|
// ---------------------------------------------------------------------
|
|
672
|
-
// useTools: when true
|
|
1040
|
+
// useTools: when true and the active provider supports native tools, the
|
|
1041
|
+
// request offers the mode-appropriate agent tools
|
|
673
1042
|
// with tool_choice "auto". If the provider responds with tool_calls instead
|
|
674
1043
|
// of a final message, we return early with `toolCalls` + the `messages`
|
|
675
1044
|
// array built so far (so the caller/frontend can append the tool results
|
|
676
1045
|
// and continue via /flowpilot/agent-step) — nothing is recorded to the
|
|
677
1046
|
// transcript yet, since this isn't the final answer for the turn.
|
|
678
|
-
async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools) {
|
|
1047
|
+
async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools, strategy) {
|
|
679
1048
|
const settings = storage.getSettings();
|
|
680
1049
|
const activeProvider = storage.getActiveProvider(settings);
|
|
681
1050
|
|
|
682
|
-
|
|
1051
|
+
// Shared with /flowpilot/test (contextMode === "connectivity-test"),
|
|
1052
|
+
// which IS the confirming check itself and is exempt — every other
|
|
1053
|
+
// caller (/flowpilot/chat) is a real operational request and gated.
|
|
1054
|
+
if (contextMode !== "connectivity-test" && !isProviderConfirmed(activeProvider)) {
|
|
1055
|
+
throw providerUnconfirmedError();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const described = describeSelectionContext(context, settings);
|
|
683
1059
|
const messages = buildMessages(
|
|
684
1060
|
buildChatSystemPrompt(settings),
|
|
685
1061
|
history, historyTruncated, described, prompt
|
|
686
1062
|
);
|
|
687
1063
|
warnNumCtxOverflow(messages, activeProvider, "chat");
|
|
688
1064
|
|
|
689
|
-
const
|
|
690
|
-
|
|
1065
|
+
const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
|
|
1066
|
+
let chatOptions = toolsEnabled
|
|
1067
|
+
? { tools: agentToolsFor(settings, activeProvider, "chat"), toolChoice: "auto" }
|
|
1068
|
+
: undefined;
|
|
1069
|
+
const provider = getProvider(activeProvider);
|
|
1070
|
+
let result;
|
|
1071
|
+
if (strategy === "agent") {
|
|
1072
|
+
chatOptions = agentTurnOptions(settings, chatOptions);
|
|
1073
|
+
result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
|
|
1074
|
+
} else {
|
|
1075
|
+
result = await provider.chat(activeProvider, messages, chatOptions);
|
|
1076
|
+
}
|
|
691
1077
|
|
|
692
|
-
if (result.toolCalls) {
|
|
1078
|
+
if (result.toolCalls || result.fallbackToClassic) {
|
|
693
1079
|
const perf = performanceAuditFields(messages, result.content, result);
|
|
1080
|
+
if (result.toolCalls) {
|
|
1081
|
+
maybeLogDebugEvent("tool_call", {
|
|
1082
|
+
mode: "chat",
|
|
1083
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
1084
|
+
model: activeProvider.model,
|
|
1085
|
+
messages: messages,
|
|
1086
|
+
toolCalls: result.toolCalls,
|
|
1087
|
+
responseContent: result.content || null
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
694
1090
|
return { settings, activeProvider, result, perf, messages, toolCalls: result.toolCalls };
|
|
695
1091
|
}
|
|
696
1092
|
|
|
@@ -700,6 +1096,15 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
700
1096
|
const split = splitChatDataBlock(result.content || "");
|
|
701
1097
|
|
|
702
1098
|
recordTranscriptTurn(conversationId, "chat", prompt, split.message);
|
|
1099
|
+
maybeLogDebugEvent("assistant_reply", {
|
|
1100
|
+
mode: "chat",
|
|
1101
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
1102
|
+
model: activeProvider.model,
|
|
1103
|
+
messages: messages,
|
|
1104
|
+
responseChars: typeof result.content === "string" ? result.content.length : 0,
|
|
1105
|
+
responseContent: result.content || "",
|
|
1106
|
+
parseOutcome: "received"
|
|
1107
|
+
});
|
|
703
1108
|
|
|
704
1109
|
const perf = performanceAuditFields(messages, result.content, result);
|
|
705
1110
|
|
|
@@ -717,7 +1122,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
717
1122
|
const settings = storage.getSettings();
|
|
718
1123
|
const activeProvider = storage.getActiveProvider(settings);
|
|
719
1124
|
|
|
720
|
-
|
|
1125
|
+
// Checked before any SSE headers are written, so the caller's catch
|
|
1126
|
+
// block can still send a normal JSON 409 (res.headersSent is false).
|
|
1127
|
+
if (!isProviderConfirmed(activeProvider)) {
|
|
1128
|
+
throw providerUnconfirmedError();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const described = describeSelectionContext(context, settings);
|
|
721
1132
|
const messages = buildMessages(
|
|
722
1133
|
buildChatSystemPrompt(settings),
|
|
723
1134
|
history, historyTruncated, described, prompt
|
|
@@ -788,13 +1199,41 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
788
1199
|
}, performanceAuditFields(messages, full, streamResult)));
|
|
789
1200
|
|
|
790
1201
|
recordTranscriptTurn(conversationId, "chat", prompt, visibleText);
|
|
1202
|
+
maybeLogDebugEvent("assistant_reply", {
|
|
1203
|
+
mode: "chat-stream",
|
|
1204
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
1205
|
+
model: activeProvider.model,
|
|
1206
|
+
messages: messages,
|
|
1207
|
+
responseChars: typeof full === "string" ? full.length : 0,
|
|
1208
|
+
responseContent: full || "",
|
|
1209
|
+
parseOutcome: "received"
|
|
1210
|
+
});
|
|
791
1211
|
}
|
|
792
1212
|
|
|
793
1213
|
// ---- Settings: read --------------------------------------------------
|
|
794
1214
|
|
|
1215
|
+
// Never let a real provider apiKey reach an HTTP response — storage's own
|
|
1216
|
+
// getSettings()/saveSettings() still return the real key internally
|
|
1217
|
+
// (getActiveProvider -> provider.chat depends on it), this masks ONLY the
|
|
1218
|
+
// two client-facing routes below. hasApiKey lets the UI show "a key is
|
|
1219
|
+
// saved" without ever receiving it; API_KEY_UNCHANGED is what the client
|
|
1220
|
+
// echoes back on save to mean "leave it alone" (see
|
|
1221
|
+
// reconcileProviderSecrets in lib/storage.js, the other half of this).
|
|
1222
|
+
function maskProviderSecrets(settings) {
|
|
1223
|
+
const masked = Object.assign({}, settings);
|
|
1224
|
+
masked.providers = (Array.isArray(settings.providers) ? settings.providers : []).map(function (p) {
|
|
1225
|
+
const hasApiKey = !!(p && p.apiKey && String(p.apiKey).trim());
|
|
1226
|
+
const next = Object.assign({}, p);
|
|
1227
|
+
next.apiKey = hasApiKey ? API_KEY_UNCHANGED : "";
|
|
1228
|
+
next.hasApiKey = hasApiKey;
|
|
1229
|
+
return next;
|
|
1230
|
+
});
|
|
1231
|
+
return masked;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
795
1234
|
RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
796
1235
|
try {
|
|
797
|
-
res.json(storage.getSettings());
|
|
1236
|
+
res.json(maskProviderSecrets(storage.getSettings()));
|
|
798
1237
|
} catch (err) {
|
|
799
1238
|
res.status(500).json({ error: err.message });
|
|
800
1239
|
}
|
|
@@ -848,18 +1287,110 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
848
1287
|
res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
|
|
849
1288
|
});
|
|
850
1289
|
|
|
1290
|
+
// Scoped to /flowpilot/* specifically — RED.httpAdmin is Node-RED's own
|
|
1291
|
+
// shared admin app, so an unscoped .use() here would also intercept
|
|
1292
|
+
// malformed-JSON errors on core Node-RED admin routes (flow deploy, node
|
|
1293
|
+
// install, etc.), well beyond this ticket's intent to harden FlowPilot's
|
|
1294
|
+
// own endpoints.
|
|
1295
|
+
// CODEX-027 follow-up: a scoped RED.httpAdmin.use(errorHandler) here
|
|
1296
|
+
// (tried both with and without a "/flowpilot" path prefix) never actually
|
|
1297
|
+
// intercepted a malformed-JSON body-parse error live — Express's own
|
|
1298
|
+
// default HTML error page still won, for every /flowpilot/* route tested,
|
|
1299
|
+
// meaning Node-RED's core httpAdmin setup already fully resolves that
|
|
1300
|
+
// error (parser -> its own handler -> response sent) before a route
|
|
1301
|
+
// registered by a loaded plugin ever gets a chance to react, regardless
|
|
1302
|
+
// of where among the plugin's own routes it's positioned. Reverted rather
|
|
1303
|
+
// than ship a handler that silently never fires. The two higher-value
|
|
1304
|
+
// parts of this ticket (empty-body validation, 404s for missing
|
|
1305
|
+
// conversations) are real and verified working; malformed-JSON responses
|
|
1306
|
+
// still return Express's default HTML page, not clean JSON — flagged as
|
|
1307
|
+
// a known limitation, not fixed by this ticket.
|
|
1308
|
+
|
|
1309
|
+
// ---- Provider confirmation gate (ADR-007, SSRF mitigation) -----------
|
|
1310
|
+
// No operational request (chat/generate/modify/document/build/agent-step/
|
|
1311
|
+
// models) touches a provider's baseUrl until that exact URL has passed a
|
|
1312
|
+
// real FlowPilot provider check — see isProviderShapedResponse
|
|
1313
|
+
// (lib/provider-shape-check.js) below
|
|
1314
|
+
// and /flowpilot/test, /flowpilot/probe, the only two routes allowed to
|
|
1315
|
+
// contact an unconfirmed URL. confirmedBaseUrl/confirmedAt are written
|
|
1316
|
+
// ONLY by those two routes on a passing check; lib/storage.js's
|
|
1317
|
+
// reconcileProviderSecrets is the other half — it strips any
|
|
1318
|
+
// client-supplied confirmedBaseUrl/confirmedAt on save and clears
|
|
1319
|
+
// confirmation whenever baseUrl or apiKey actually changes, so
|
|
1320
|
+
// confirmation can never be forged or silently carried over to a
|
|
1321
|
+
// different URL.
|
|
1322
|
+
|
|
1323
|
+
function isProviderConfirmed(provider) {
|
|
1324
|
+
// typeof check, not truthiness: baseUrl "" is a real, documented,
|
|
1325
|
+
// supported value (Anthropic's "leave blank for api.anthropic.com"
|
|
1326
|
+
// convention) — a provider CAN be legitimately confirmed with an empty
|
|
1327
|
+
// confirmedBaseUrl, and `"" && ...` would silently evaluate false,
|
|
1328
|
+
// locking that configuration out of confirmation forever. Only an
|
|
1329
|
+
// actually-absent (never confirmed) field should fail this check.
|
|
1330
|
+
return !!provider && typeof provider.confirmedBaseUrl === "string" &&
|
|
1331
|
+
provider.confirmedBaseUrl === provider.baseUrl;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const PROVIDER_UNCONFIRMED_MESSAGE = "This provider hasn't passed a connection test yet — run Test Provider first.";
|
|
1335
|
+
|
|
1336
|
+
// For routes that resolve activeProvider themselves and can respond
|
|
1337
|
+
// directly (models, agent-step, chat, chat-stream) — matches the
|
|
1338
|
+
// {error:"code", message:"text"} shape requireExecutionContract already
|
|
1339
|
+
// uses elsewhere in this file.
|
|
1340
|
+
function requireConfirmedProvider(res, activeProvider) {
|
|
1341
|
+
if (isProviderConfirmed(activeProvider)) { return true; }
|
|
1342
|
+
res.status(409).json({ error: "provider_unconfirmed", message: PROVIDER_UNCONFIRMED_MESSAGE });
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// For the generation-family helpers (runFlowGeneration/
|
|
1347
|
+
// runFlowGenerationStream), which don't have direct access to `res` — they
|
|
1348
|
+
// throw, and the route's existing sendGenerationError/stream-error path
|
|
1349
|
+
// turns .status/.code into the actual response.
|
|
1350
|
+
function providerUnconfirmedError() {
|
|
1351
|
+
const err = new Error(PROVIDER_UNCONFIRMED_MESSAGE);
|
|
1352
|
+
err.status = 409;
|
|
1353
|
+
err.code = "provider_unconfirmed";
|
|
1354
|
+
return err;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// The confirming check's own pass/fail criterion (B3) — extracted to its
|
|
1358
|
+
// own module (lib/provider-shape-check.js) so it has a real, re-executable
|
|
1359
|
+
// unit test rather than only code-review-level evidence.
|
|
1360
|
+
|
|
1361
|
+
function hasRequestBody(body) {
|
|
1362
|
+
return !!body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length > 0;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function validateSettingsSaveBody(body) {
|
|
1366
|
+
if (!hasRequestBody(body)) {
|
|
1367
|
+
return "Settings payload is required.";
|
|
1368
|
+
}
|
|
1369
|
+
if (!Array.isArray(body.providers) || body.providers.length === 0) {
|
|
1370
|
+
return "Settings payload must include at least one provider.";
|
|
1371
|
+
}
|
|
1372
|
+
if (!body.activeProviderId || !String(body.activeProviderId).trim()) {
|
|
1373
|
+
return "Settings payload must include an activeProviderId.";
|
|
1374
|
+
}
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
851
1378
|
// ---- Settings: write -------------------------------------------------
|
|
852
1379
|
|
|
853
1380
|
RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
|
|
1381
|
+
const validationError = validateSettingsSaveBody(req.body);
|
|
1382
|
+
if (validationError) {
|
|
1383
|
+
return res.status(400).json({ error: validationError });
|
|
1384
|
+
}
|
|
854
1385
|
try {
|
|
855
|
-
const saved = storage.saveSettings(req.body
|
|
1386
|
+
const saved = storage.saveSettings(req.body);
|
|
856
1387
|
storage.appendAudit({
|
|
857
1388
|
action: "settings_saved",
|
|
858
1389
|
providerName: saved.providerName,
|
|
859
1390
|
baseUrl: saved.baseUrl,
|
|
860
1391
|
model: saved.model
|
|
861
1392
|
});
|
|
862
|
-
res.json(saved);
|
|
1393
|
+
res.json(maskProviderSecrets(saved));
|
|
863
1394
|
} catch (err) {
|
|
864
1395
|
res.status(500).json({ error: err.message });
|
|
865
1396
|
}
|
|
@@ -871,9 +1402,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
871
1402
|
// that doesn't support /v1/models — see listModels().
|
|
872
1403
|
|
|
873
1404
|
RED.httpAdmin.post("/flowpilot/models", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1405
|
+
if (!hasRequestBody(req.body)) {
|
|
1406
|
+
return res.status(400).json({ error: "Request body is required." });
|
|
1407
|
+
}
|
|
874
1408
|
try {
|
|
875
1409
|
const settings = storage.getSettings();
|
|
876
1410
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1411
|
+
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
877
1412
|
const result = await getProvider(activeProvider).listModels(activeProvider);
|
|
878
1413
|
storage.appendAudit({
|
|
879
1414
|
action: "list_models",
|
|
@@ -906,6 +1441,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
906
1441
|
try {
|
|
907
1442
|
await runChatStream(req, res, prompt, req.body.context, history, historyTruncated, req.body.conversationId);
|
|
908
1443
|
} catch (err) {
|
|
1444
|
+
if (!res.headersSent && err && err.status) {
|
|
1445
|
+
const errBody = { error: err.code || err.message };
|
|
1446
|
+
if (err.code) { errBody.message = err.message; }
|
|
1447
|
+
return res.status(err.status).json(errBody);
|
|
1448
|
+
}
|
|
909
1449
|
storage.appendAudit({ action: "chat_stream_error", error: err.message });
|
|
910
1450
|
if (!res.headersSent) {
|
|
911
1451
|
res.status(500).json({ error: err.message });
|
|
@@ -919,7 +1459,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
919
1459
|
try {
|
|
920
1460
|
const useTools = !!req.body.tools;
|
|
921
1461
|
const { activeProvider, result, perf, chatMessage, chatData, messages, toolCalls } =
|
|
922
|
-
await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated,
|
|
1462
|
+
await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated,
|
|
1463
|
+
req.body.conversationId, useTools, req.body.strategy);
|
|
923
1464
|
|
|
924
1465
|
storage.appendAudit(Object.assign({
|
|
925
1466
|
action: "chat",
|
|
@@ -929,6 +1470,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
929
1470
|
toolCallCount: toolCalls ? toolCalls.length : 0
|
|
930
1471
|
}, perf));
|
|
931
1472
|
|
|
1473
|
+
if (result.fallbackToClassic) {
|
|
1474
|
+
return res.json({ fallbackToClassic: true, usage: result.usage || null });
|
|
1475
|
+
}
|
|
932
1476
|
if (toolCalls) {
|
|
933
1477
|
return res.json({ toolCalls: toolCalls, messages: messages, content: result.content || null, usage: result.usage || null });
|
|
934
1478
|
}
|
|
@@ -948,6 +1492,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
948
1492
|
if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
|
|
949
1493
|
res.json(body);
|
|
950
1494
|
} catch (err) {
|
|
1495
|
+
if (err && err.status) {
|
|
1496
|
+
const errBody = { error: err.code || err.message };
|
|
1497
|
+
if (err.code) { errBody.message = err.message; }
|
|
1498
|
+
return res.status(err.status).json(errBody);
|
|
1499
|
+
}
|
|
951
1500
|
storage.appendAudit({ action: "chat_error", error: err.message });
|
|
952
1501
|
res.status(500).json({ error: err.message });
|
|
953
1502
|
}
|
|
@@ -972,7 +1521,49 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
972
1521
|
// `context` (for describeSelectionContext / modify's originalNodes) and
|
|
973
1522
|
// `prompt` (for transcript recording) are passed through from the
|
|
974
1523
|
// initial request.
|
|
1524
|
+
const EXECUTION_STRATEGIES = new Set(["agent", "classic"]);
|
|
1525
|
+
const EXECUTION_ENTRIES = new Set([
|
|
1526
|
+
"chat", "document", "generate", "build", "modify", "build-review", "build-existing"
|
|
1527
|
+
]);
|
|
1528
|
+
|
|
1529
|
+
function requireExecutionContract(req, res, settings, activeProvider) {
|
|
1530
|
+
const body = (req && req.body) || {};
|
|
1531
|
+
if (!EXECUTION_STRATEGIES.has(body.strategy) || !EXECUTION_ENTRIES.has(body.entry)) {
|
|
1532
|
+
res.status(400).json({
|
|
1533
|
+
error: "strategy_required",
|
|
1534
|
+
message: "strategy and entry are required and must be recognized values."
|
|
1535
|
+
});
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
1538
|
+
if (body.strategy === "agent" &&
|
|
1539
|
+
!(settings.enableAgentWrite === true && activeProvider && activeProvider.supportsTools === true)) {
|
|
1540
|
+
res.status(409).json({
|
|
1541
|
+
error: "agent_strategy_unavailable",
|
|
1542
|
+
message: "The agent strategy requires enableAgentWrite and a tool-capable provider."
|
|
1543
|
+
});
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
return {
|
|
1547
|
+
strategy: body.strategy,
|
|
1548
|
+
entry: body.entry,
|
|
1549
|
+
conversationId: body.conversationId || null,
|
|
1550
|
+
runId: body.runId || null,
|
|
1551
|
+
// CLAUDE-014: plain-language note for the client-side decision (consent
|
|
1552
|
+
// gate / ask_user / loop-checkpoint) that triggered this request, only
|
|
1553
|
+
// sent when settings.debugLogging is on — threaded into
|
|
1554
|
+
// maybeLogDebugEvent calls below so debug.log shows what the user
|
|
1555
|
+
// actually decided instead of only raw tool-result JSON.
|
|
1556
|
+
debugNote: body.debugNote || null
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
975
1560
|
RED.httpAdmin.post("/flowpilot/agent-step", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1561
|
+
const settings = storage.getSettings();
|
|
1562
|
+
const activeProvider = storage.getActiveProvider(settings);
|
|
1563
|
+
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
1564
|
+
if (!execution) { return; }
|
|
1565
|
+
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
1566
|
+
|
|
976
1567
|
const messages = req.body && req.body.messages;
|
|
977
1568
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
978
1569
|
return res.status(400).json({ error: "messages array is required." });
|
|
@@ -980,13 +1571,36 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
980
1571
|
const mode = req.body.mode || "chat";
|
|
981
1572
|
|
|
982
1573
|
try {
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
|
|
1574
|
+
const toolsEnabled = activeProvider.supportsTools === true;
|
|
1575
|
+
const offeredTools = toolsEnabled
|
|
1576
|
+
? agentToolsFor(settings, activeProvider, mode, execution.strategy === "agent")
|
|
1577
|
+
: [];
|
|
1578
|
+
let chatOptions = toolsEnabled
|
|
1579
|
+
? { tools: offeredTools, toolChoice: "auto" }
|
|
1580
|
+
: undefined;
|
|
1581
|
+
if (execution.strategy === "agent") {
|
|
1582
|
+
chatOptions = agentTurnOptions(settings, chatOptions);
|
|
1583
|
+
}
|
|
1584
|
+
// Messages include client-produced role:"tool" results. Pass them
|
|
1585
|
+
// straight to the adapter; OpenAI-compatible providers receive them
|
|
1586
|
+
// unchanged and Anthropic converts only the outer message envelope to
|
|
1587
|
+
// native tool_result blocks.
|
|
1588
|
+
const provider = getProvider(activeProvider);
|
|
1589
|
+
const result = execution.strategy === "agent"
|
|
1590
|
+
? await chatWithAgentCap(provider, activeProvider, messages, chatOptions)
|
|
1591
|
+
: await provider.chat(activeProvider, messages, chatOptions);
|
|
1592
|
+
|
|
1593
|
+
if (result.fallbackToClassic) {
|
|
1594
|
+
return res.json({ fallbackToClassic: true, usage: result.usage || null });
|
|
1595
|
+
}
|
|
986
1596
|
|
|
987
1597
|
storage.appendAudit(Object.assign({
|
|
988
1598
|
action: "agent_step",
|
|
989
1599
|
mode: mode,
|
|
1600
|
+
strategy: execution.strategy,
|
|
1601
|
+
entry: execution.entry,
|
|
1602
|
+
conversationId: execution.conversationId,
|
|
1603
|
+
runId: execution.runId,
|
|
990
1604
|
providerName: activeProvider.providerName,
|
|
991
1605
|
baseUrl: activeProvider.baseUrl,
|
|
992
1606
|
model: activeProvider.model,
|
|
@@ -994,16 +1608,48 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
994
1608
|
}, performanceAuditFields(messages, result.content, result)));
|
|
995
1609
|
|
|
996
1610
|
if (result.toolCalls) {
|
|
997
|
-
|
|
1611
|
+
maybeLogDebugEvent("tool_call", {
|
|
1612
|
+
mode: mode,
|
|
1613
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
1614
|
+
model: activeProvider.model,
|
|
1615
|
+
messages: messages,
|
|
1616
|
+
toolCalls: result.toolCalls,
|
|
1617
|
+
responseContent: result.content || null,
|
|
1618
|
+
debugNote: execution.debugNote || undefined
|
|
1619
|
+
});
|
|
1620
|
+
return res.json({
|
|
1621
|
+
toolCalls: result.toolCalls,
|
|
1622
|
+
toolTiers: toolTierMap(result.toolCalls),
|
|
1623
|
+
content: result.content || null,
|
|
1624
|
+
usage: result.usage || null
|
|
1625
|
+
});
|
|
998
1626
|
}
|
|
999
1627
|
|
|
1628
|
+
maybeLogDebugEvent("assistant_reply", {
|
|
1629
|
+
mode: mode,
|
|
1630
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
1631
|
+
model: activeProvider.model,
|
|
1632
|
+
messages: messages,
|
|
1633
|
+
responseChars: typeof result.content === "string" ? result.content.length : 0,
|
|
1634
|
+
responseContent: result.content || "",
|
|
1635
|
+
parseOutcome: "received",
|
|
1636
|
+
debugNote: execution.debugNote || undefined
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1000
1639
|
if (mode !== "chat") {
|
|
1001
1640
|
const context = req.body.context;
|
|
1002
|
-
const described = describeSelectionContext(context, settings
|
|
1003
|
-
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
|
+
);
|
|
1004
1646
|
recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
|
|
1005
1647
|
const finalize = (mode === "modify")
|
|
1006
|
-
? function (r) {
|
|
1648
|
+
? function (r) {
|
|
1649
|
+
return finalizeModifyResult(
|
|
1650
|
+
r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1007
1653
|
: finalizeSimpleGeneration;
|
|
1008
1654
|
const { status, body } = finalize(generated);
|
|
1009
1655
|
return res.status(status).json(body);
|
|
@@ -1019,7 +1665,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1019
1665
|
if (questionOptions) { body.questionOptions = questionOptions; }
|
|
1020
1666
|
res.json(body);
|
|
1021
1667
|
} catch (err) {
|
|
1022
|
-
sendGenerationError(res, mode + "_agent_step", err);
|
|
1668
|
+
sendGenerationError(res, mode + "_agent_step", err, execution);
|
|
1023
1669
|
}
|
|
1024
1670
|
});
|
|
1025
1671
|
|
|
@@ -1077,6 +1723,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1077
1723
|
RED.httpAdmin.get("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
1078
1724
|
const id = sanitizeConversationId(req.params.id);
|
|
1079
1725
|
if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
|
|
1726
|
+
if (storage.listConversationIds().indexOf(id) === -1) {
|
|
1727
|
+
return res.status(404).json({ error: "Conversation not found." });
|
|
1728
|
+
}
|
|
1080
1729
|
try {
|
|
1081
1730
|
res.json({ id: id, messages: storage.readTranscript(id) });
|
|
1082
1731
|
} catch (err) {
|
|
@@ -1087,6 +1736,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1087
1736
|
RED.httpAdmin.delete("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.write"), function (req, res) {
|
|
1088
1737
|
const id = sanitizeConversationId(req.params.id);
|
|
1089
1738
|
if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
|
|
1739
|
+
if (storage.listConversationIds().indexOf(id) === -1) {
|
|
1740
|
+
return res.status(404).json({ error: "Conversation not found." });
|
|
1741
|
+
}
|
|
1090
1742
|
try {
|
|
1091
1743
|
storage.deleteTranscript(id);
|
|
1092
1744
|
storage.appendAudit({ action: "conversation_delete", conversationId: id });
|
|
@@ -1112,11 +1764,37 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1112
1764
|
// reply at all." Never depends on chat history or flow context.
|
|
1113
1765
|
|
|
1114
1766
|
RED.httpAdmin.post("/flowpilot/test", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1767
|
+
if (!hasRequestBody(req.body)) {
|
|
1768
|
+
return res.status(400).json({ error: "Request body is required." });
|
|
1769
|
+
}
|
|
1115
1770
|
const prompt = (req.body && req.body.prompt) || "Say hello from FlowPilot.";
|
|
1116
1771
|
|
|
1117
1772
|
try {
|
|
1773
|
+
// This IS the provider-confirmation check (ADR-007) — the one request
|
|
1774
|
+
// allowed to touch a not-yet-confirmed baseUrl. runChat("connectivity-
|
|
1775
|
+
// test") skips the confirmation gate for exactly this call.
|
|
1118
1776
|
const { settings, activeProvider, result, perf, chatMessage } = await runChat(prompt, "connectivity-test");
|
|
1119
1777
|
|
|
1778
|
+
// Strict pass criterion (B3): a 200 with SOME JSON body is not enough
|
|
1779
|
+
// — an internal service or a cloud metadata endpoint can return that
|
|
1780
|
+
// trivially. Require an actually provider-shaped chat-completion
|
|
1781
|
+
// response, or the check fails and the provider stays unconfirmed.
|
|
1782
|
+
// The error returned to the client is deliberately generic — never
|
|
1783
|
+
// the upstream body — so a probe against a non-provider target
|
|
1784
|
+
// yields nothing readable (the actual SSRF seal).
|
|
1785
|
+
if (!isProviderShapedResponse(activeProvider.type, result.raw)) {
|
|
1786
|
+
storage.appendAudit({
|
|
1787
|
+
action: "chat_test_error",
|
|
1788
|
+
providerName: activeProvider.providerName,
|
|
1789
|
+
baseUrl: activeProvider.baseUrl,
|
|
1790
|
+
error: "not_provider_shaped"
|
|
1791
|
+
});
|
|
1792
|
+
return res.status(422).json({
|
|
1793
|
+
error: "provider_check_failed",
|
|
1794
|
+
message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1120
1798
|
storage.appendAudit(Object.assign({
|
|
1121
1799
|
action: "chat_test",
|
|
1122
1800
|
providerName: activeProvider.providerName,
|
|
@@ -1145,11 +1823,16 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1145
1823
|
toolsProbedAt: new Date().toISOString(),
|
|
1146
1824
|
isReasoningModel: reasoning.isReasoningModel,
|
|
1147
1825
|
reasoningProbedAt: new Date().toISOString(),
|
|
1148
|
-
probedModel: activeProvider.model
|
|
1826
|
+
probedModel: activeProvider.model,
|
|
1827
|
+
// The check above passed — this exact baseUrl is now
|
|
1828
|
+
// confirmed. Cleared automatically (reconcileProviderSecrets,
|
|
1829
|
+
// lib/storage.js) the moment baseUrl or apiKey changes.
|
|
1830
|
+
confirmedBaseUrl: activeProvider.baseUrl,
|
|
1831
|
+
confirmedAt: new Date().toISOString()
|
|
1149
1832
|
})
|
|
1150
1833
|
: p;
|
|
1151
1834
|
});
|
|
1152
|
-
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
|
|
1835
|
+
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
|
|
1153
1836
|
|
|
1154
1837
|
const toolLabel = probe.supportsTools
|
|
1155
1838
|
? "✓ Connected · ✓ Supports tools"
|
|
@@ -1180,15 +1863,34 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1180
1863
|
// probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
|
|
1181
1864
|
|
|
1182
1865
|
RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1866
|
+
if (!hasRequestBody(req.body)) {
|
|
1867
|
+
return res.status(400).json({ error: "Request body is required." });
|
|
1868
|
+
}
|
|
1183
1869
|
try {
|
|
1184
1870
|
const settings = storage.getSettings();
|
|
1185
1871
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1186
1872
|
|
|
1873
|
+
// This is the OTHER route allowed to touch an unconfirmed baseUrl
|
|
1874
|
+
// (ADR-007) — same confirming-check treatment as /flowpilot/test.
|
|
1187
1875
|
const probe = await getProvider(activeProvider).probeTools(activeProvider);
|
|
1188
1876
|
const chatResult = await getProvider(activeProvider).chat(activeProvider, [
|
|
1189
1877
|
{ role: "system", content: "You are a helpful assistant." },
|
|
1190
1878
|
{ role: "user", content: "Say hello." }
|
|
1191
1879
|
]);
|
|
1880
|
+
|
|
1881
|
+
if (!isProviderShapedResponse(activeProvider.type, chatResult.raw)) {
|
|
1882
|
+
storage.appendAudit({
|
|
1883
|
+
action: "auto_probe_error",
|
|
1884
|
+
providerName: activeProvider.providerName,
|
|
1885
|
+
baseUrl: activeProvider.baseUrl,
|
|
1886
|
+
error: "not_provider_shaped"
|
|
1887
|
+
});
|
|
1888
|
+
return res.status(422).json({
|
|
1889
|
+
error: "provider_check_failed",
|
|
1890
|
+
message: "Not a valid provider endpoint (no FlowPilot-compatible response)."
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1192
1894
|
const reasoning = getProvider(activeProvider).detectReasoning(chatResult.raw);
|
|
1193
1895
|
|
|
1194
1896
|
const updatedProviders = (settings.providers || []).map(function (p) {
|
|
@@ -1198,11 +1900,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1198
1900
|
toolsProbedAt: new Date().toISOString(),
|
|
1199
1901
|
isReasoningModel: reasoning.isReasoningModel,
|
|
1200
1902
|
reasoningProbedAt: new Date().toISOString(),
|
|
1201
|
-
probedModel: activeProvider.model
|
|
1903
|
+
probedModel: activeProvider.model,
|
|
1904
|
+
confirmedBaseUrl: activeProvider.baseUrl,
|
|
1905
|
+
confirmedAt: new Date().toISOString()
|
|
1202
1906
|
})
|
|
1203
1907
|
: p;
|
|
1204
1908
|
});
|
|
1205
|
-
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
|
|
1909
|
+
storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }), { trustConfirmation: true });
|
|
1206
1910
|
|
|
1207
1911
|
storage.appendAudit({
|
|
1208
1912
|
action: "auto_probe",
|
|
@@ -1239,7 +1943,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1239
1943
|
function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction) {
|
|
1240
1944
|
const settings = storage.getSettings();
|
|
1241
1945
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1242
|
-
const described = describeSelectionContext(context, settings
|
|
1946
|
+
const described = describeSelectionContext(context, settings);
|
|
1243
1947
|
// Persona applies to the "explanation" field only (a real hand-off/
|
|
1244
1948
|
// transition moment — "here's the flow I built for you") — never to
|
|
1245
1949
|
// node names, ids, or any structural JSON, which stays exactly as each
|
|
@@ -1255,7 +1959,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1255
1959
|
// parsed envelope. Validated but non-critical — a malformed or missing
|
|
1256
1960
|
// suggestion is just dropped (returns null), never an error, since chips
|
|
1257
1961
|
// are an additive hint on top of the real response.
|
|
1258
|
-
// { mode: "generate"|"document"|"modify"|"chat", prompt: "...",
|
|
1962
|
+
// { mode: "generate"|"document"|"modify"|"chat", prompt: "...",
|
|
1963
|
+
// selectionHint?: "...", targetNodeIds?: "all"|string[] }
|
|
1259
1964
|
// ---------------------------------------------------------------------
|
|
1260
1965
|
function extractSuggestedAction(parsed) {
|
|
1261
1966
|
const sa = parsed && parsed.suggestedAction;
|
|
@@ -1267,6 +1972,14 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1267
1972
|
if (typeof sa.selectionHint === "string" && sa.selectionHint.trim()) {
|
|
1268
1973
|
result.selectionHint = sa.selectionHint.trim();
|
|
1269
1974
|
}
|
|
1975
|
+
if (sa.targetNodeIds === "all") {
|
|
1976
|
+
result.targetNodeIds = "all";
|
|
1977
|
+
} else if (Array.isArray(sa.targetNodeIds)) {
|
|
1978
|
+
const targetNodeIds = sa.targetNodeIds
|
|
1979
|
+
.filter(function (id) { return typeof id === "string" && id.trim(); })
|
|
1980
|
+
.map(function (id) { return id.trim(); });
|
|
1981
|
+
if (targetNodeIds.length) { result.targetNodeIds = targetNodeIds; }
|
|
1982
|
+
}
|
|
1270
1983
|
return result;
|
|
1271
1984
|
}
|
|
1272
1985
|
|
|
@@ -1297,71 +2010,6 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1297
2010
|
// createChatDataStreamSplitter below so the marker/JSON are never flashed
|
|
1298
2011
|
// to the user mid-stream.
|
|
1299
2012
|
// ---------------------------------------------------------------------
|
|
1300
|
-
const CHAT_DATA_MARKER = "<<<FLOWPILOT_DATA>>>";
|
|
1301
|
-
|
|
1302
|
-
function splitChatDataBlock(content) {
|
|
1303
|
-
const text = String(content || "");
|
|
1304
|
-
const idx = text.indexOf(CHAT_DATA_MARKER);
|
|
1305
|
-
if (idx === -1) { return { message: text, data: null }; }
|
|
1306
|
-
|
|
1307
|
-
const message = text.slice(0, idx).replace(/\s+$/, "");
|
|
1308
|
-
const jsonStr = text.slice(idx + CHAT_DATA_MARKER.length).trim();
|
|
1309
|
-
let data = null;
|
|
1310
|
-
try { data = JSON.parse(jsonStr); } catch (e) { data = null; }
|
|
1311
|
-
return { message: message, data: data };
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// Streaming counterpart of splitChatDataBlock: buffers just enough of the
|
|
1315
|
-
// tail to detect CHAT_DATA_MARKER even if it's split across provider
|
|
1316
|
-
// chunks, without delaying normal text. push(delta) returns the portion of
|
|
1317
|
-
// `delta` (plus any previously-held tail) that's safe to display now —
|
|
1318
|
-
// possibly "". Once the marker is seen, all further input is buffered as
|
|
1319
|
-
// the JSON data block instead of being displayed. finish() returns any
|
|
1320
|
-
// held-back text that turned out NOT to be part of the marker (a false
|
|
1321
|
-
// positive at end of stream) plus the parsed data block, if any.
|
|
1322
|
-
// ---------------------------------------------------------------------
|
|
1323
|
-
function createChatDataStreamSplitter() {
|
|
1324
|
-
let held = "";
|
|
1325
|
-
let inData = false;
|
|
1326
|
-
let dataBuf = "";
|
|
1327
|
-
|
|
1328
|
-
function push(delta) {
|
|
1329
|
-
if (inData) { dataBuf += delta; return ""; }
|
|
1330
|
-
|
|
1331
|
-
const combined = held + delta;
|
|
1332
|
-
const idx = combined.indexOf(CHAT_DATA_MARKER);
|
|
1333
|
-
if (idx !== -1) {
|
|
1334
|
-
inData = true;
|
|
1335
|
-
dataBuf = combined.slice(idx + CHAT_DATA_MARKER.length);
|
|
1336
|
-
held = "";
|
|
1337
|
-
return combined.slice(0, idx);
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
// No full marker yet — check whether the tail of `combined` is a
|
|
1341
|
-
// prefix of the marker (i.e. the marker may be split across chunks)
|
|
1342
|
-
// and hold that part back.
|
|
1343
|
-
const maxOverlap = Math.min(combined.length, CHAT_DATA_MARKER.length - 1);
|
|
1344
|
-
let overlap = 0;
|
|
1345
|
-
for (let len = maxOverlap; len >= 1; len--) {
|
|
1346
|
-
if (combined.slice(-len) === CHAT_DATA_MARKER.slice(0, len)) { overlap = len; break; }
|
|
1347
|
-
}
|
|
1348
|
-
held = overlap ? combined.slice(-overlap) : "";
|
|
1349
|
-
return overlap ? combined.slice(0, -overlap) : combined;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
function finish() {
|
|
1353
|
-
const tail = held;
|
|
1354
|
-
held = "";
|
|
1355
|
-
let data = null;
|
|
1356
|
-
if (inData) {
|
|
1357
|
-
try { data = JSON.parse(dataBuf.trim()); } catch (e) { data = null; }
|
|
1358
|
-
}
|
|
1359
|
-
return { tail: tail, data: data };
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
return { push: push, finish: finish };
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
2013
|
// ---------------------------------------------------------------------
|
|
1366
2014
|
// Shared helper: parse, validate and audit a completed provider response
|
|
1367
2015
|
// for a generation-style request, returning { question } / { prose } /
|
|
@@ -1405,8 +2053,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1405
2053
|
});
|
|
1406
2054
|
}
|
|
1407
2055
|
|
|
1408
|
-
function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt) {
|
|
2056
|
+
function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider, userPrompt, auditContext) {
|
|
1409
2057
|
const perf = performanceAuditFields(messages, content, providerResult);
|
|
2058
|
+
const auditFields = auditContext || {};
|
|
1410
2059
|
|
|
1411
2060
|
// Mode-mismatch redirect: the model may respond in plain prose —
|
|
1412
2061
|
// addressing a request that doesn't belong in generate/document/modify —
|
|
@@ -1415,11 +2064,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1415
2064
|
// it would otherwise grab the "{" inside the data block and treat it as
|
|
1416
2065
|
// a broken envelope.
|
|
1417
2066
|
let envelopeParsed;
|
|
1418
|
-
if (content
|
|
2067
|
+
if (findChatDataMarker(content)) {
|
|
1419
2068
|
const preSplit = splitChatDataBlock(content);
|
|
1420
2069
|
const proseMessage = preSplit.message.trim();
|
|
1421
2070
|
if (proseMessage && proseMessage[0] !== "{") {
|
|
1422
|
-
storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
|
|
2071
|
+
storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
|
|
1423
2072
|
const proseResult = { prose: proseMessage };
|
|
1424
2073
|
if (preSplit.data) {
|
|
1425
2074
|
const proseAction = extractSuggestedAction(preSplit.data);
|
|
@@ -1458,7 +2107,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1458
2107
|
// render it as a normal assistant message and keep the action armed.
|
|
1459
2108
|
// Errors stay reserved for empty responses or a found-but-broken {...}.
|
|
1460
2109
|
if (parseErr.noJsonFound && content.trim()) {
|
|
1461
|
-
storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
|
|
2110
|
+
storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, auditFields, perf));
|
|
1462
2111
|
// Mode-mismatch redirect: a prose reply may carry the same hidden
|
|
1463
2112
|
// <<<FLOWPILOT_DATA>>> block as Chat, suggesting a mode switch (e.g.
|
|
1464
2113
|
// "chat" when the request was actually a question, not a
|
|
@@ -1473,7 +2122,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1473
2122
|
}
|
|
1474
2123
|
return proseResult;
|
|
1475
2124
|
}
|
|
1476
|
-
storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, perf));
|
|
2125
|
+
storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, auditFields, perf));
|
|
1477
2126
|
const err = new Error("Could not parse a flow from the response: " + parseErr.message);
|
|
1478
2127
|
err.status = 422;
|
|
1479
2128
|
err.raw = content;
|
|
@@ -1499,10 +2148,18 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1499
2148
|
if (typeof parsed.selectionHint === "string" && parsed.selectionHint.trim()) {
|
|
1500
2149
|
redirect.selectionHint = parsed.selectionHint.trim();
|
|
1501
2150
|
}
|
|
2151
|
+
if (parsed.targetNodeIds === "all") {
|
|
2152
|
+
redirect.targetNodeIds = "all";
|
|
2153
|
+
} else if (Array.isArray(parsed.targetNodeIds)) {
|
|
2154
|
+
const targetNodeIds = parsed.targetNodeIds
|
|
2155
|
+
.filter(function (id) { return typeof id === "string" && id.trim(); })
|
|
2156
|
+
.map(function (id) { return id.trim(); });
|
|
2157
|
+
if (targetNodeIds.length) { redirect.targetNodeIds = targetNodeIds; }
|
|
2158
|
+
}
|
|
1502
2159
|
const redirectProse = (typeof parsed.explanation === "string" && parsed.explanation.trim())
|
|
1503
2160
|
? parsed.explanation.trim()
|
|
1504
2161
|
: "This request belongs in " + parsed.mode + " mode.";
|
|
1505
|
-
storage.appendAudit(Object.assign({ action: auditAction + "
|
|
2162
|
+
storage.appendAudit(Object.assign({ action: auditAction + "_mode_redirect" }, auditFields, perf));
|
|
1506
2163
|
return { prose: redirectProse, suggestedAction: redirect };
|
|
1507
2164
|
}
|
|
1508
2165
|
|
|
@@ -1539,7 +2196,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1539
2196
|
// assistant message and keeps the Execute action armed for the answer.
|
|
1540
2197
|
if (typeof parsed.question === "string" && parsed.question.trim() &&
|
|
1541
2198
|
(!Array.isArray(parsed.flow) || parsed.flow.length === 0)) {
|
|
1542
|
-
storage.appendAudit(Object.assign({ action: auditAction + "_question" }, perf));
|
|
2199
|
+
storage.appendAudit(Object.assign({ action: auditAction + "_question" }, auditFields, perf));
|
|
1543
2200
|
const questionResult = { question: parsed.question, explanation: parsed.explanation || "" };
|
|
1544
2201
|
const questionAction = extractSuggestedAction(parsed);
|
|
1545
2202
|
if (questionAction) { questionResult.suggestedAction = questionAction; }
|
|
@@ -1565,6 +2222,24 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1565
2222
|
throw err;
|
|
1566
2223
|
}
|
|
1567
2224
|
|
|
2225
|
+
// Do not silently turn malformed structured fields into empty arrays.
|
|
2226
|
+
// A model reply such as {"changes": {...}} is plainly an attempted
|
|
2227
|
+
// Modify envelope, not prose or a legitimate no-op. Treat wrong field
|
|
2228
|
+
// types like other envelope parse failures so the client uses its safe
|
|
2229
|
+
// 422 handling and never presents raw JSON as a trusted assistant reply.
|
|
2230
|
+
const modifyArrayFields = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
|
|
2231
|
+
const invalidArrayFields = modifyArrayFields.filter(function (field) {
|
|
2232
|
+
return field in parsed && !Array.isArray(parsed[field]);
|
|
2233
|
+
});
|
|
2234
|
+
if (invalidArrayFields.length) {
|
|
2235
|
+
const err = new Error("The response contained non-array modify field(s): " + invalidArrayFields.join(", ") + ".");
|
|
2236
|
+
err.status = 422;
|
|
2237
|
+
err.raw = content;
|
|
2238
|
+
throw err;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
enforceAgentContract(parsed, auditContext, false);
|
|
2242
|
+
|
|
1568
2243
|
const changes = Array.isArray(parsed.changes) ? parsed.changes : [];
|
|
1569
2244
|
const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
|
|
1570
2245
|
const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
|
|
@@ -1596,7 +2271,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1596
2271
|
newGroupCount: newGroups.length,
|
|
1597
2272
|
contextNodeCount: described ? described.nodeCount : 0,
|
|
1598
2273
|
contextConnectionCount: described ? described.connectionCount : 0
|
|
1599
|
-
}, perf));
|
|
2274
|
+
}, auditFields, perf));
|
|
1600
2275
|
|
|
1601
2276
|
const modifyResult = {
|
|
1602
2277
|
explanation: parsed.explanation || "",
|
|
@@ -1606,6 +2281,12 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1606
2281
|
removeNodes: removeNodes,
|
|
1607
2282
|
newGroups: newGroups
|
|
1608
2283
|
};
|
|
2284
|
+
if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
|
|
2285
|
+
modifyResult.strippedFields = parsed.strippedFields.slice();
|
|
2286
|
+
}
|
|
2287
|
+
if (Array.isArray(parsed.verifySteps) && parsed.verifySteps.length) {
|
|
2288
|
+
modifyResult.verifySteps = parsed.verifySteps.slice();
|
|
2289
|
+
}
|
|
1609
2290
|
// Combine skipped-note sources: redaction (W0.2) and switch mismatch (W2).
|
|
1610
2291
|
const skippedNotes = [redactionSkippedNote, parsed._switchMismatchNote].filter(Boolean);
|
|
1611
2292
|
if (skippedNotes.length) { modifyResult.skippedNote = skippedNotes.join(" "); }
|
|
@@ -1630,7 +2311,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1630
2311
|
nodeCount: flow.length,
|
|
1631
2312
|
contextNodeCount: described ? described.nodeCount : 0,
|
|
1632
2313
|
contextConnectionCount: described ? described.connectionCount : 0
|
|
1633
|
-
}, perf));
|
|
2314
|
+
}, auditFields, perf));
|
|
1634
2315
|
|
|
1635
2316
|
const flowResult = {
|
|
1636
2317
|
explanation: parsed.explanation || "",
|
|
@@ -1656,32 +2337,100 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1656
2337
|
// action name, and how the route validates its inputs beforehand. Throws
|
|
1657
2338
|
// an Error with .status and (when applicable) .raw for the route to relay.
|
|
1658
2339
|
// ---------------------------------------------------------------------
|
|
1659
|
-
// Step 4: useTools offers
|
|
2340
|
+
// Step 4: useTools offers the mode-appropriate agent tools. WRITE tools
|
|
2341
|
+
// are Modify-only and require enableAgentWrite plus a tool-capable
|
|
2342
|
+
// provider. If the
|
|
1660
2343
|
// provider responds with tool_calls instead of a final envelope, returns
|
|
1661
2344
|
// early with { toolCalls, messages, content, usage } — same shape as
|
|
1662
2345
|
// runChat's early return — so the route can hand it to the frontend
|
|
1663
2346
|
// without running processGenerationContent yet.
|
|
1664
|
-
async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
|
|
2347
|
+
async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools, execution) {
|
|
1665
2348
|
const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
|
|
1666
|
-
|
|
1667
|
-
const
|
|
1668
|
-
|
|
2349
|
+
if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
|
|
2350
|
+
const settings = storage.getSettings();
|
|
2351
|
+
const toolsEnabled = !!useTools && activeProvider.supportsTools === true;
|
|
2352
|
+
const offeredTools = toolsEnabled
|
|
2353
|
+
? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
|
|
2354
|
+
: [];
|
|
2355
|
+
const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
|
|
2356
|
+
const toolChoice = auditAction === "modify" && execution &&
|
|
2357
|
+
execution.strategy === "agent" ? "required" : "auto";
|
|
2358
|
+
let chatOptions = toolsEnabled
|
|
2359
|
+
? { tools: offeredTools, toolChoice: toolChoice }
|
|
1669
2360
|
: (responseFormat ? { responseFormat: responseFormat } : undefined);
|
|
1670
|
-
const
|
|
2361
|
+
const provider = getProvider(activeProvider);
|
|
2362
|
+
let result;
|
|
2363
|
+
if (execution && execution.strategy === "agent") {
|
|
2364
|
+
chatOptions = agentTurnOptions(settings, chatOptions);
|
|
2365
|
+
result = await chatWithAgentCap(provider, activeProvider, messages, chatOptions);
|
|
2366
|
+
} else {
|
|
2367
|
+
result = await provider.chat(activeProvider, messages, chatOptions);
|
|
2368
|
+
}
|
|
2369
|
+
if (result.fallbackToClassic) {
|
|
2370
|
+
return { fallbackToClassic: true, usage: result.usage || null };
|
|
2371
|
+
}
|
|
1671
2372
|
if (result.toolCalls) {
|
|
1672
|
-
|
|
2373
|
+
// F-5: this is the FIRST turn of an agent-strategy request (routed
|
|
2374
|
+
// through /flowpilot/{generate,build,document,modify}, not the
|
|
2375
|
+
// /flowpilot/agent-step continuation endpoint below, which already
|
|
2376
|
+
// audits unconditionally) — it can carry a real WRITE tool call, so
|
|
2377
|
+
// it must not be the one turn that leaves zero record. Previously
|
|
2378
|
+
// this path returned before any storage.appendAudit call; a request
|
|
2379
|
+
// whose very first turn was a tool call (e.g. an immediate ask_user,
|
|
2380
|
+
// or — as here — the opening WRITE call of a multi-item Modify)
|
|
2381
|
+
// vanished from audit.log entirely. maybeLogDebugEvent below is
|
|
2382
|
+
// additive (only fires when settings.debugLogging is on); this
|
|
2383
|
+
// appendAudit call is unconditional, matching /flowpilot/agent-step.
|
|
2384
|
+
storage.appendAudit({
|
|
2385
|
+
action: "first_turn_tool_call",
|
|
2386
|
+
mode: auditAction,
|
|
2387
|
+
strategy: (execution && execution.strategy) || null,
|
|
2388
|
+
entry: (execution && execution.entry) || null,
|
|
2389
|
+
conversationId: (execution && execution.conversationId) || null,
|
|
2390
|
+
runId: (execution && execution.runId) || null,
|
|
2391
|
+
providerName: activeProvider.providerName,
|
|
2392
|
+
baseUrl: activeProvider.baseUrl,
|
|
2393
|
+
model: activeProvider.model,
|
|
2394
|
+
toolCallCount: result.toolCalls.length
|
|
2395
|
+
});
|
|
2396
|
+
maybeLogDebugEvent("tool_call", {
|
|
2397
|
+
mode: auditAction,
|
|
2398
|
+
providerBaseUrl: activeProvider.baseUrl,
|
|
2399
|
+
model: activeProvider.model,
|
|
2400
|
+
messages: messages,
|
|
2401
|
+
toolCalls: result.toolCalls,
|
|
2402
|
+
responseContent: result.content || null
|
|
2403
|
+
});
|
|
2404
|
+
return {
|
|
2405
|
+
toolCalls: result.toolCalls,
|
|
2406
|
+
toolTiers: toolTierMap(result.toolCalls),
|
|
2407
|
+
messages: messages,
|
|
2408
|
+
content: result.content || null,
|
|
2409
|
+
usage: result.usage || null
|
|
2410
|
+
};
|
|
1673
2411
|
}
|
|
1674
2412
|
const content = result.content || "";
|
|
1675
2413
|
let parseOutcome = "unknown";
|
|
1676
2414
|
try {
|
|
1677
|
-
const generated = processGenerationContent(
|
|
2415
|
+
const generated = processGenerationContent(
|
|
2416
|
+
content, result, messages, auditAction, described, activeProvider, userPrompt, execution
|
|
2417
|
+
);
|
|
1678
2418
|
parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
|
|
1679
2419
|
return generated;
|
|
1680
2420
|
} catch (err) {
|
|
1681
2421
|
parseOutcome = "parse_error:" + (err.message || "");
|
|
1682
2422
|
throw err;
|
|
1683
2423
|
} finally {
|
|
1684
|
-
|
|
2424
|
+
maybeLogDebugEvent("assistant_reply", {
|
|
2425
|
+
mode: auditAction,
|
|
2426
|
+
providerBaseUrl: activeProvider && activeProvider.baseUrl,
|
|
2427
|
+
model: activeProvider && activeProvider.model,
|
|
2428
|
+
messages: messages,
|
|
2429
|
+
responseChars: typeof content === "string" ? content.length : 0,
|
|
2430
|
+
responseContent: content,
|
|
2431
|
+
parseOutcome: parseOutcome,
|
|
2432
|
+
debugNote: (execution && execution.debugNote) || undefined
|
|
2433
|
+
});
|
|
1685
2434
|
}
|
|
1686
2435
|
}
|
|
1687
2436
|
|
|
@@ -1693,8 +2442,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1693
2442
|
// while the rest of the JSON (the "flow" array etc.) is buffered until
|
|
1694
2443
|
// this resolves.
|
|
1695
2444
|
// ---------------------------------------------------------------------
|
|
1696
|
-
async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
|
|
2445
|
+
async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta, auditContext) {
|
|
1697
2446
|
const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated, auditAction);
|
|
2447
|
+
if (!isProviderConfirmed(activeProvider)) { throw providerUnconfirmedError(); }
|
|
1698
2448
|
const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, false);
|
|
1699
2449
|
const streamOptions = responseFormat ? { responseFormat: responseFormat } : undefined;
|
|
1700
2450
|
const result = await getProvider(activeProvider).chatStream(
|
|
@@ -1703,26 +2453,41 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1703
2453
|
const content = result.content || "";
|
|
1704
2454
|
let parseOutcome = "unknown";
|
|
1705
2455
|
try {
|
|
1706
|
-
const generated = processGenerationContent(
|
|
2456
|
+
const generated = processGenerationContent(
|
|
2457
|
+
content, result, messages, auditAction, described, activeProvider, userPrompt, auditContext
|
|
2458
|
+
);
|
|
1707
2459
|
parseOutcome = generated.prose ? "prose" : generated.question ? "question" : "success";
|
|
1708
2460
|
return generated;
|
|
1709
2461
|
} catch (err) {
|
|
1710
2462
|
parseOutcome = "parse_error:" + (err.message || "");
|
|
1711
2463
|
throw err;
|
|
1712
2464
|
} finally {
|
|
1713
|
-
|
|
2465
|
+
maybeLogDebugEvent("assistant_reply", {
|
|
2466
|
+
mode: auditAction,
|
|
2467
|
+
providerBaseUrl: activeProvider && activeProvider.baseUrl,
|
|
2468
|
+
model: activeProvider && activeProvider.model,
|
|
2469
|
+
messages: messages,
|
|
2470
|
+
responseChars: typeof content === "string" ? content.length : 0,
|
|
2471
|
+
responseContent: content,
|
|
2472
|
+
parseOutcome: parseOutcome,
|
|
2473
|
+
debugNote: (auditContext && auditContext.debugNote) || undefined
|
|
2474
|
+
});
|
|
1714
2475
|
}
|
|
1715
2476
|
}
|
|
1716
2477
|
|
|
1717
2478
|
// Relays a runFlowGeneration error to the client with the right status,
|
|
1718
2479
|
// falling back to 500 for anything that didn't set .status itself.
|
|
1719
|
-
function sendGenerationError(res, auditAction, err) {
|
|
2480
|
+
function sendGenerationError(res, auditAction, err, auditContext) {
|
|
1720
2481
|
if (err && err.status) {
|
|
1721
2482
|
const body = { error: err.message };
|
|
2483
|
+
if (err.code) { body.code = err.code; }
|
|
1722
2484
|
if (err.raw) { body.raw = err.raw; }
|
|
1723
2485
|
return res.status(err.status).json(body);
|
|
1724
2486
|
}
|
|
1725
|
-
storage.appendAudit(
|
|
2487
|
+
storage.appendAudit(Object.assign(
|
|
2488
|
+
{ action: auditAction + "_error", error: err.message },
|
|
2489
|
+
auditContext || {}
|
|
2490
|
+
));
|
|
1726
2491
|
res.status(500).json({ error: err.message });
|
|
1727
2492
|
}
|
|
1728
2493
|
|
|
@@ -1760,7 +2525,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1760
2525
|
// the /flowpilot/modify route handler. Used by both the non-streaming and
|
|
1761
2526
|
// streaming routes.
|
|
1762
2527
|
// ---------------------------------------------------------------------
|
|
1763
|
-
function finalizeModifyResult(result, originalNodes) {
|
|
2528
|
+
function finalizeModifyResult(result, originalNodes, auditContext) {
|
|
1764
2529
|
if (result.question) {
|
|
1765
2530
|
const questionBody = { explanation: result.explanation, question: result.question, flow: null };
|
|
1766
2531
|
if (result.suggestedAction) { questionBody.suggestedAction = result.suggestedAction; }
|
|
@@ -1773,6 +2538,22 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1773
2538
|
if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
|
|
1774
2539
|
return { status: 200, body: proseBody };
|
|
1775
2540
|
}
|
|
2541
|
+
if (auditContext && auditContext.strategy === "agent") {
|
|
2542
|
+
const agentBody = {
|
|
2543
|
+
explanation: result.explanation || "",
|
|
2544
|
+
prose: true,
|
|
2545
|
+
flow: null
|
|
2546
|
+
};
|
|
2547
|
+
if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
|
|
2548
|
+
agentBody.strippedFields = result.strippedFields.slice();
|
|
2549
|
+
}
|
|
2550
|
+
if (Array.isArray(result.verifySteps) && result.verifySteps.length) {
|
|
2551
|
+
agentBody.verifySteps = result.verifySteps.slice();
|
|
2552
|
+
}
|
|
2553
|
+
if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
|
|
2554
|
+
if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
|
|
2555
|
+
return { status: 200, body: agentBody };
|
|
2556
|
+
}
|
|
1776
2557
|
|
|
1777
2558
|
const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
|
|
1778
2559
|
|
|
@@ -1986,7 +2767,12 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1986
2767
|
}
|
|
1987
2768
|
}
|
|
1988
2769
|
|
|
1989
|
-
if (newGroups.length > 0) {
|
|
2770
|
+
if (newGroups.length > 0) {
|
|
2771
|
+
storage.appendAudit(Object.assign(
|
|
2772
|
+
{ action: "modify_groups", count: newGroups.length },
|
|
2773
|
+
auditContext || {}
|
|
2774
|
+
));
|
|
2775
|
+
}
|
|
1990
2776
|
|
|
1991
2777
|
const verifySteps = [];
|
|
1992
2778
|
validChanges.forEach(function (change) {
|
|
@@ -2045,7 +2831,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2045
2831
|
// `data: {"error": <body>, "status": <status>}`, since SSE responses can't
|
|
2046
2832
|
// change their HTTP status after headers are sent.
|
|
2047
2833
|
// ---------------------------------------------------------------------
|
|
2048
|
-
async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId) {
|
|
2834
|
+
async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId, auditContext) {
|
|
2049
2835
|
res.writeHead(200, {
|
|
2050
2836
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2051
2837
|
"Cache-Control": "no-cache, no-transform",
|
|
@@ -2058,12 +2844,18 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2058
2844
|
try {
|
|
2059
2845
|
result = await runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, function (delta) {
|
|
2060
2846
|
res.write("data: " + JSON.stringify({ delta: delta }) + "\n\n");
|
|
2061
|
-
});
|
|
2847
|
+
}, auditContext);
|
|
2062
2848
|
} catch (err) {
|
|
2063
2849
|
const status = err && err.status ? err.status : 500;
|
|
2064
2850
|
const body = { error: err.message };
|
|
2851
|
+
if (err && err.code) { body.code = err.code; }
|
|
2065
2852
|
if (err && err.raw) { body.raw = err.raw; }
|
|
2066
|
-
if (!err || !err.status) {
|
|
2853
|
+
if (!err || !err.status) {
|
|
2854
|
+
storage.appendAudit(Object.assign(
|
|
2855
|
+
{ action: auditAction + "_error", error: err.message },
|
|
2856
|
+
auditContext || {}
|
|
2857
|
+
));
|
|
2858
|
+
}
|
|
2067
2859
|
res.write("data: " + JSON.stringify({ error: body, status: status }) + "\n\n");
|
|
2068
2860
|
res.write("data: [DONE]\n\n");
|
|
2069
2861
|
res.end();
|
|
@@ -2102,7 +2894,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2102
2894
|
history, historyTruncated, useTools
|
|
2103
2895
|
);
|
|
2104
2896
|
if (generated.toolCalls) {
|
|
2105
|
-
return res.json({
|
|
2897
|
+
return res.json({
|
|
2898
|
+
toolCalls: generated.toolCalls,
|
|
2899
|
+
toolTiers: generated.toolTiers,
|
|
2900
|
+
messages: generated.messages,
|
|
2901
|
+
content: generated.content,
|
|
2902
|
+
usage: generated.usage
|
|
2903
|
+
});
|
|
2106
2904
|
}
|
|
2107
2905
|
recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
|
|
2108
2906
|
const { status, body } = finalizeSimpleGeneration(generated);
|
|
@@ -2142,7 +2940,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2142
2940
|
history, historyTruncated, useTools
|
|
2143
2941
|
);
|
|
2144
2942
|
if (built.toolCalls) {
|
|
2145
|
-
return res.json({
|
|
2943
|
+
return res.json({
|
|
2944
|
+
toolCalls: built.toolCalls,
|
|
2945
|
+
toolTiers: built.toolTiers,
|
|
2946
|
+
messages: built.messages,
|
|
2947
|
+
content: built.content,
|
|
2948
|
+
usage: built.usage
|
|
2949
|
+
});
|
|
2146
2950
|
}
|
|
2147
2951
|
recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
|
|
2148
2952
|
const { status, body } = finalizeSimpleGeneration(built);
|
|
@@ -2154,7 +2958,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2154
2958
|
|
|
2155
2959
|
RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
2156
2960
|
const context = req.body && req.body.context;
|
|
2157
|
-
const described = describeSelectionContext(context, storage.getSettings()
|
|
2961
|
+
const described = describeSelectionContext(context, storage.getSettings());
|
|
2158
2962
|
|
|
2159
2963
|
if (!described) {
|
|
2160
2964
|
return res.status(400).json({ error: "Select the node(s) you want documented first." });
|
|
@@ -2182,7 +2986,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2182
2986
|
history, historyTruncated, useTools
|
|
2183
2987
|
);
|
|
2184
2988
|
if (documented.toolCalls) {
|
|
2185
|
-
return res.json({
|
|
2989
|
+
return res.json({
|
|
2990
|
+
toolCalls: documented.toolCalls,
|
|
2991
|
+
toolTiers: documented.toolTiers,
|
|
2992
|
+
messages: documented.messages,
|
|
2993
|
+
content: documented.content,
|
|
2994
|
+
usage: documented.usage
|
|
2995
|
+
});
|
|
2186
2996
|
}
|
|
2187
2997
|
recordTranscriptTurn(req.body.conversationId, "document", userPrompt, transcriptTextFromGenerationResult(documented));
|
|
2188
2998
|
const { status, body } = finalizeSimpleGeneration(documented);
|
|
@@ -2193,8 +3003,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2193
3003
|
});
|
|
2194
3004
|
|
|
2195
3005
|
RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
3006
|
+
const settings = storage.getSettings();
|
|
3007
|
+
const activeProvider = storage.getActiveProvider(settings);
|
|
3008
|
+
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
3009
|
+
if (!execution) { return; }
|
|
3010
|
+
|
|
2196
3011
|
const context = req.body && req.body.context;
|
|
2197
|
-
const described = describeSelectionContext(context,
|
|
3012
|
+
const described = describeSelectionContext(context, settings);
|
|
2198
3013
|
|
|
2199
3014
|
if (!described) {
|
|
2200
3015
|
return res.status(400).json({ error: "Select the node(s) you want to modify first." });
|
|
@@ -2212,15 +3027,20 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2212
3027
|
const history = sanitizeHistory(req.body.history);
|
|
2213
3028
|
const historyTruncated = !!req.body.historyTruncated;
|
|
2214
3029
|
|
|
2215
|
-
const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
|
|
3030
|
+
const finalize = function (result) { return finalizeModifyResult(result, originalNodes, execution); };
|
|
2216
3031
|
const hasSwitch = Array.isArray(context && context.nodes) &&
|
|
2217
3032
|
context.nodes.some(function (node) { return node && node.type === "switch"; });
|
|
2218
|
-
|
|
3033
|
+
// Keep the legacy prompt byte-identical for the explicit classic
|
|
3034
|
+
// strategy. Agent prompt behavior keys only off the propagated strategy.
|
|
3035
|
+
const modifyPrompt = modifySystemPrompt({
|
|
3036
|
+
hasSwitch: hasSwitch,
|
|
3037
|
+
agentWriteEnabled: execution.strategy === "agent"
|
|
3038
|
+
});
|
|
2219
3039
|
|
|
2220
3040
|
if (req.body.stream) {
|
|
2221
3041
|
return runExecuteStream(
|
|
2222
3042
|
req, res, modifyPrompt, "modify", String(prompt).trim(), context,
|
|
2223
|
-
history, historyTruncated, finalize, req.body.conversationId
|
|
3043
|
+
history, historyTruncated, finalize, req.body.conversationId, execution
|
|
2224
3044
|
);
|
|
2225
3045
|
}
|
|
2226
3046
|
|
|
@@ -2228,16 +3048,22 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2228
3048
|
const useTools = !!req.body.tools;
|
|
2229
3049
|
const result = await runFlowGeneration(
|
|
2230
3050
|
modifyPrompt, "modify", String(prompt).trim(), context,
|
|
2231
|
-
history, historyTruncated, useTools
|
|
3051
|
+
history, historyTruncated, useTools, execution
|
|
2232
3052
|
);
|
|
2233
3053
|
if (result.toolCalls) {
|
|
2234
|
-
return res.json({
|
|
3054
|
+
return res.json({
|
|
3055
|
+
toolCalls: result.toolCalls,
|
|
3056
|
+
toolTiers: result.toolTiers,
|
|
3057
|
+
messages: result.messages,
|
|
3058
|
+
content: result.content,
|
|
3059
|
+
usage: result.usage
|
|
3060
|
+
});
|
|
2235
3061
|
}
|
|
2236
3062
|
recordTranscriptTurn(req.body.conversationId, "modify", String(prompt).trim(), transcriptTextFromGenerationResult(result));
|
|
2237
3063
|
const { status, body } = finalize(result);
|
|
2238
3064
|
res.status(status).json(body);
|
|
2239
3065
|
} catch (err) {
|
|
2240
|
-
sendGenerationError(res, "modify", err);
|
|
3066
|
+
sendGenerationError(res, "modify", err, execution);
|
|
2241
3067
|
}
|
|
2242
3068
|
});
|
|
2243
3069
|
};
|