@manny-est/node-red-flowpilot 0.6.0-beta.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -13
- package/README.md +10 -1
- package/flowpilot-core.css +15 -0
- package/flowpilot-node-entry.js +15 -0
- package/flowpilot.js +215 -21
- package/lib/agent-contract.js +8 -3
- package/lib/core/history.js +151 -9
- package/lib/core/init.js +90 -21
- package/lib/core/main.js +120 -26
- package/lib/core/modes.js +222 -18
- package/lib/core/selection-context.js +17 -0
- package/lib/default-system-prompt.js +2 -2
- package/lib/document-system-prompt.js +4 -2
- package/lib/generation-system-prompt.js +2 -2
- package/lib/modify-system-prompt.js +1 -1
- package/lib/prompt-fragments.js +10 -6
- package/lib/provider-anthropic.js +12 -2
- package/lib/provider-openai-compatible.js +14 -2
- package/lib/provider-shape-check.js +1 -1
- package/lib/storage.js +6 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,34 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to FlowPilot are documented here.
|
|
4
4
|
|
|
5
|
-
## [0.6.0
|
|
5
|
+
## [0.6.0] - 2026-09-01
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Promoted from `0.6.0-beta.1` to the stable `latest` npm tag.
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
|
-
- **Agentic WRITE-tool loop for Modify** (behind the
|
|
11
|
-
setting, default off): step-by-step tool calls
|
|
12
|
-
(`apply_step`, `remove_step`, `rename_node`,
|
|
13
|
-
write-gated by a per-step consent prompt (Proceed
|
|
14
|
-
it touches the canvas. Multi-item requests are
|
|
15
|
-
separate, individually-verified steps rather
|
|
16
|
-
envelope; an injected mid-run failure (e.g. a
|
|
17
|
-
exists) fails only that item, with the rest
|
|
10
|
+
- **Agentic WRITE-tool loop for Modify and Generate** (behind the
|
|
11
|
+
`enableAgentWrite` setting, default off): step-by-step tool calls
|
|
12
|
+
against the live flow (`apply_step`, `remove_step`, `rename_node`,
|
|
13
|
+
`group_nodes`), each write-gated by a per-step consent prompt (Proceed
|
|
14
|
+
/ Skip this step) before it touches the canvas. Multi-item requests are
|
|
15
|
+
tracked and executed as separate, individually-verified steps rather
|
|
16
|
+
than one all-or-nothing envelope; an injected mid-run failure (e.g. a
|
|
17
|
+
referenced node no longer exists) fails only that item, with the rest
|
|
18
|
+
completing normally. Generate joined the same agentic runtime Modify
|
|
19
|
+
already used, closing the last place a mutation could land through the
|
|
20
|
+
older, less-verified classic JSON path while write-tool mode is on.
|
|
18
21
|
- **`ask_user` clarifying-question tool**: an agent-strategy turn can pause
|
|
19
22
|
mid-run to ask a single focused question (with optional quick-reply
|
|
20
23
|
buttons) instead of guessing, and resumes exactly where it left off once
|
|
21
24
|
answered.
|
|
25
|
+
- **Deterministic run summaries**: when a Modify or Generate run actually
|
|
26
|
+
applied WRITE-tool steps, the per-item outcome you see (✓ applied and
|
|
27
|
+
verified / ✗ failed, with why) now comes straight from what the tools
|
|
28
|
+
actually reported — not from the model's own retelling. The model's own
|
|
29
|
+
wrap-up explanation still shows, underneath, clearly secondary — useful
|
|
30
|
+
context, never the thing you have to trust to know what really
|
|
31
|
+
happened.
|
|
22
32
|
- **Contract-exclusivity enforcement**: a server-side safety net
|
|
23
33
|
(`enforceAgentContract`) strips any classic-style mutation fields
|
|
24
|
-
(`changes`/`newNodes`/`newWires`/`removeNodes`/`newGroups`) that
|
|
34
|
+
(`changes`/`newNodes`/`newWires`/`removeNodes`/`newGroups`/`flow`) that
|
|
25
35
|
accidentally appear on an agent-strategy turn with no tool calls, before
|
|
26
36
|
they ever reach the client — the two mutation paths (classic envelope vs.
|
|
27
|
-
agentic WRITE tools) stay mutually exclusive per turn
|
|
37
|
+
agentic WRITE tools) stay mutually exclusive per turn, for every mode
|
|
38
|
+
that can run agentically.
|
|
28
39
|
- **Run identity and honest interruption**: agent-strategy runs now carry a
|
|
29
40
|
stable run/operation id so a duplicate tool-call delivery (a retry, or the
|
|
30
41
|
model repeating itself) is applied at most once; a run that's stopped or
|
|
31
42
|
hits its step/token ceiling reports an honest "interrupted after N steps"
|
|
32
|
-
instead of silently truncating.
|
|
43
|
+
instead of silently truncating. That step-by-step record is also now
|
|
44
|
+
visible to the server (previously client-side only), the foundation for
|
|
45
|
+
the deterministic run summaries above.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- **The FlowPilot sidebar's entire client script was loading twice on
|
|
49
|
+
every single editor page load** — a packaging issue present since the
|
|
50
|
+
plugin's first release, only now found and fixed. In practice this
|
|
51
|
+
meant a page reload silently lost the current conversation (a second,
|
|
52
|
+
redundant script execution would stomp the first one's restored state)
|
|
53
|
+
and, more subtly, any other one-time setup code in the client ran
|
|
54
|
+
twice per load. Reloading the Node-RED editor now correctly picks the
|
|
55
|
+
conversation back up where you left off.
|
|
56
|
+
- Anthropic (Claude) responses now get the exact same deterministic
|
|
57
|
+
run-summary and contract-exclusivity treatment as every other
|
|
58
|
+
provider — verified directly against Claude Sonnet, not just the
|
|
59
|
+
OpenAI-compatible path.
|
|
33
60
|
|
|
34
61
|
### Security
|
|
35
62
|
- **API keys are now write-only over HTTP** (previously exposed on
|
|
@@ -60,6 +87,59 @@ Prerelease — published to the `beta` npm tag only. `latest` stays on `0.5.2`.
|
|
|
60
87
|
`dev-docs/current/Phase10-Build-Progress.md` and
|
|
61
88
|
`dev-docs/current/Phase10-Gate-Closeout-Final.md`.
|
|
62
89
|
|
|
90
|
+
## [0.5.2] - 2026-08-04
|
|
91
|
+
|
|
92
|
+
This release continues the 0.5.x line. Phase 10's agentic Modify redesign
|
|
93
|
+
(WRITE-tool loop, per-call consent gates, `ask_user`) is developed
|
|
94
|
+
separately and is not included here — it ships under its own beta line
|
|
95
|
+
until it's ready to replace this one.
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
- **Anthropic provider support**: FlowPilot can now talk directly to the
|
|
99
|
+
Anthropic API (Claude models) alongside any OpenAI-compatible endpoint.
|
|
100
|
+
A new Provider Type dropdown in Settings switches between them; message
|
|
101
|
+
format, tool-call translation, and streaming (SSE) are handled
|
|
102
|
+
transparently by a dedicated adapter. Leave Base URL blank to use
|
|
103
|
+
`api.anthropic.com`.
|
|
104
|
+
- **Reasoning model support**: FlowPilot detects reasoning models
|
|
105
|
+
(Nemotron, DeepSeek, QwQ, and any model returning `reasoning_content` or
|
|
106
|
+
`<think>` blocks) and shows a live collapsing "Thinking…" block during
|
|
107
|
+
streaming, or a pre-collapsed one on the agent-loop path.
|
|
108
|
+
- **Auto-preflight on model change**: switching models and sending a
|
|
109
|
+
message silently probes capabilities first — no more manual "Test
|
|
110
|
+
Provider" click after every model swap.
|
|
111
|
+
- **`/refresh` command**: re-renders the message panel from the in-memory
|
|
112
|
+
record store without losing conversation history — restores interactive
|
|
113
|
+
Apply buttons and review panels that went stale after a long session.
|
|
114
|
+
- **Build loop — consent gates for side-effecting steps, checkpoint
|
|
115
|
+
questions, context-aware start from a selection, and an explicit
|
|
116
|
+
done/fail confirmation** instead of silently stopping.
|
|
117
|
+
- **Modify — todo/plan checklist rendering**: multi-item Modify requests
|
|
118
|
+
now show a checklist that resolves as verification confirms each item
|
|
119
|
+
landed, instead of a single opaque pass/fail.
|
|
120
|
+
- **Config node support**: config nodes (MQTT brokers, TLS configs, etc.)
|
|
121
|
+
are now exposed in context and can be created/connected by Modify.
|
|
122
|
+
- **Server-side validator/repair layer**: malformed model output is
|
|
123
|
+
caught and, where possible, automatically repaired before it reaches
|
|
124
|
+
the canvas.
|
|
125
|
+
|
|
126
|
+
### Fixed
|
|
127
|
+
- Redaction round-trip poisoning, token-credential redaction gaps, and
|
|
128
|
+
several redaction echo/false-positive fixes across Modify and the build
|
|
129
|
+
loop's review step.
|
|
130
|
+
- Group data corruption from a stray `changes` patch touching a group's
|
|
131
|
+
membership array directly; mixed-membership group create/extend
|
|
132
|
+
corruption.
|
|
133
|
+
- Invalid port wiring (wiring to/from a port index that doesn't exist)
|
|
134
|
+
now guarded instead of silently applied.
|
|
135
|
+
- New-node insertion collision avoidance tuned for faster, more reliable
|
|
136
|
+
separation on dense flows.
|
|
137
|
+
- Several build-loop review false positives (metadata-field echoes,
|
|
138
|
+
debug/function/mqtt node misclassification, stale in-progress status
|
|
139
|
+
read as a real error).
|
|
140
|
+
- Partial id-validation no longer discards an entire valid Modify batch
|
|
141
|
+
over one bad node id.
|
|
142
|
+
|
|
63
143
|
## [0.5.1] - 2026-07-24
|
|
64
144
|
|
|
65
145
|
### Added
|
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ installation, the sidebar UI, and a chapter on every feature.
|
|
|
26
26
|
- **User-initiated only** — FlowPilot never changes your flow without an
|
|
27
27
|
explicit request.
|
|
28
28
|
- **Complete visibility** — every proposed change is shown as a diff/review
|
|
29
|
-
before
|
|
29
|
+
before it's applied; in the opt-in agentic mode (below), each step is
|
|
30
|
+
gated by its own Proceed/Skip prompt instead, so you're still in control
|
|
31
|
+
of what actually lands on the canvas.
|
|
30
32
|
- **Undo first** — every change goes through Node-RED's native undo
|
|
31
33
|
(Ctrl+Z), including multi-part changes (insertions + rewires + new nodes)
|
|
32
34
|
as a single step.
|
|
@@ -73,6 +75,13 @@ See the [User Guide](USER-GUIDE.md#privacy-and-safety) for the full details.
|
|
|
73
75
|
Document, with a clear notice when older messages are truncated.
|
|
74
76
|
- **Clarifying questions** — if an instruction is too vague to act on
|
|
75
77
|
safely, FlowPilot asks ONE question instead of guessing.
|
|
78
|
+
- **Agentic mode (opt-in, Settings)** — Generate and Modify can work
|
|
79
|
+
step by step instead of one big review-then-apply diff: each step
|
|
80
|
+
(insert, rewire, rename, remove, group) lands on the canvas as it's
|
|
81
|
+
proposed, gated by its own Proceed/Skip consent prompt for anything
|
|
82
|
+
beyond the safest node types. The summary you see when a run finishes
|
|
83
|
+
is built from what the tools actually reported happened, not just the
|
|
84
|
+
model's own retelling — if a step failed or was skipped, it says so.
|
|
76
85
|
- **Streaming replies** — optional SSE streaming for chat responses.
|
|
77
86
|
- **`/build`** — describe a goal and FlowPilot plans it, proposes a first
|
|
78
87
|
flow, then walks an interactive build → deploy → debug → review → fix loop
|
package/flowpilot-core.css
CHANGED
|
@@ -108,6 +108,21 @@
|
|
|
108
108
|
border-color: rgba(80, 130, 255, 0.35);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/* Subordinate to the deterministic run summary above it (C1): same shape,
|
|
112
|
+
visibly de-emphasized so a model's free-text recap can't be mistaken for
|
|
113
|
+
the authoritative per-item outcome. */
|
|
114
|
+
.fp-secondary {
|
|
115
|
+
padding: 8px 14px;
|
|
116
|
+
background: transparent;
|
|
117
|
+
border-style: dashed;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
color: var(--red-ui-secondary-text-color, #888);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.fp-secondary .fp-label {
|
|
123
|
+
opacity: 0.7;
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
/* Reasoning / thinking block — shown for models that emit reasoning_content
|
|
112
127
|
(e.g. Nemotron, DeepSeek-R1). Sits above the assistant response bubble.
|
|
113
128
|
Expanded while the model thinks; collapses automatically when content starts. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Node-RED's "nodes" registration entry needs its own .js file, separate
|
|
2
|
+
// from flowpilot.js — Node-RED derives an editor-template path for every
|
|
3
|
+
// declared "nodes" entry by replacing the .js extension with .html
|
|
4
|
+
// (@node-red/registry/lib/loader.js loadNodeConfig: `file.replace(/\.c?js$/,".html")`),
|
|
5
|
+
// with NO awareness of the "plugins" entry that already explicitly declares
|
|
6
|
+
// flowpilot.html. If this entry pointed at flowpilot.js directly, Node-RED
|
|
7
|
+
// would derive "flowpilot.html" as this node's own template, find the real
|
|
8
|
+
// file (same basename), and load its content a SECOND time — the plugin's
|
|
9
|
+
// entire client bundle (flowpilot/core.js and its inline init script)
|
|
10
|
+
// ends up in the editor page twice, executing every module-level
|
|
11
|
+
// side effect (including sessionStorage init) twice per page load.
|
|
12
|
+
// This shim's own basename ("flowpilot-node-entry") has no matching .html,
|
|
13
|
+
// so Node-RED's template lookup misses (ENOENT) and loads no content for
|
|
14
|
+
// it — exactly what a route-registration-only, no-editor-UI node needs.
|
|
15
|
+
module.exports = require("./flowpilot.js");
|
package/flowpilot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
|
+
const https = require("https");
|
|
2
3
|
const path = require("path");
|
|
4
|
+
const PACKAGE_VERSION = require("./package.json").version;
|
|
3
5
|
const createStorage = require("./lib/storage");
|
|
4
6
|
const openaiProvider = require("./lib/provider-openai-compatible");
|
|
5
7
|
const anthropicProvider = require("./lib/provider-anthropic");
|
|
@@ -82,6 +84,110 @@ const { repairEnvelope } = require("./lib/validator");
|
|
|
82
84
|
const { enforceAgentContract } = require("./lib/agent-contract");
|
|
83
85
|
const { isProviderShapedResponse } = require("./lib/provider-shape-check");
|
|
84
86
|
const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
|
|
87
|
+
const UPDATE_CHECK_URL = "https://registry.npmjs.org/-/package/@manny-est/node-red-flowpilot/dist-tags";
|
|
88
|
+
const UPDATE_CHECK_SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
|
|
89
|
+
const UPDATE_CHECK_FAILURE_TTL_MS = 15 * 60 * 1000;
|
|
90
|
+
const RUN_EVENTS_MAX_STORED = 50;
|
|
91
|
+
const runEventsStore = new Map();
|
|
92
|
+
let updateCheckCache = null;
|
|
93
|
+
|
|
94
|
+
function parseVersion(v) {
|
|
95
|
+
var parts = String(v).split("-");
|
|
96
|
+
var core = parts[0].split(".").map(Number);
|
|
97
|
+
var pre = parts.length > 1 ? parts.slice(1).join("-").split(".") : null;
|
|
98
|
+
return { core: core, pre: pre };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isNewer(remoteVersion, localVersion) {
|
|
102
|
+
var r = parseVersion(remoteVersion), l = parseVersion(localVersion);
|
|
103
|
+
for (var i = 0; i < 3; i++) {
|
|
104
|
+
var rv = r.core[i] || 0, lv = l.core[i] || 0;
|
|
105
|
+
if (rv !== lv) { return rv > lv; }
|
|
106
|
+
}
|
|
107
|
+
// core version numbers are equal
|
|
108
|
+
if (!r.pre && l.pre) { return true; } // remote is a full release, local is a prerelease of the same core
|
|
109
|
+
if (r.pre && !l.pre) { return false; } // remote is a prerelease, local is already past it
|
|
110
|
+
if (!r.pre && !l.pre) { return false; } // identical plain releases
|
|
111
|
+
// both are prereleases of the same core version — compare the last dot segment numerically if possible
|
|
112
|
+
var rn = Number(r.pre[r.pre.length - 1]);
|
|
113
|
+
var ln = Number(l.pre[l.pre.length - 1]);
|
|
114
|
+
if (!isNaN(rn) && !isNaN(ln)) { return rn > ln; }
|
|
115
|
+
return r.pre.join(".") !== l.pre.join(".") && r.pre.join(".") > l.pre.join(".");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function updateCheckFallbackResponse() {
|
|
119
|
+
return { enabled: true, updateAvailable: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fetchUpdateDistTags() {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
let url;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL(UPDATE_CHECK_URL);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
reject(err);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const req = https.request({
|
|
133
|
+
method: "GET",
|
|
134
|
+
hostname: url.hostname,
|
|
135
|
+
port: url.port || 443,
|
|
136
|
+
path: url.pathname + url.search,
|
|
137
|
+
headers: { Accept: "application/json" }
|
|
138
|
+
}, (res) => {
|
|
139
|
+
let data = "";
|
|
140
|
+
res.setEncoding("utf8");
|
|
141
|
+
res.on("data", chunk => { data += chunk; });
|
|
142
|
+
res.on("end", () => {
|
|
143
|
+
let parsed = null;
|
|
144
|
+
try {
|
|
145
|
+
parsed = data ? JSON.parse(data) : null;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
reject(err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (res.statusCode !== 200 || !parsed || typeof parsed !== "object") {
|
|
151
|
+
reject(new Error("Update check failed."));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
resolve(parsed);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
req.on("error", reject);
|
|
159
|
+
req.setTimeout(5000, () => {
|
|
160
|
+
req.destroy(new Error("Timeout"));
|
|
161
|
+
});
|
|
162
|
+
req.end();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function performUpdateCheck() {
|
|
167
|
+
try {
|
|
168
|
+
const distTags = await fetchUpdateDistTags();
|
|
169
|
+
const tag = PACKAGE_VERSION.indexOf("-") !== -1 ? "beta" : "latest";
|
|
170
|
+
const tagVersion = distTags && distTags[tag];
|
|
171
|
+
if (typeof tagVersion !== "string" || !tagVersion) {
|
|
172
|
+
throw new Error("Missing dist-tag.");
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
succeeded: true,
|
|
176
|
+
result: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
updateAvailable: isNewer(tagVersion, PACKAGE_VERSION),
|
|
179
|
+
latestVersion: tagVersion,
|
|
180
|
+
currentVersion: PACKAGE_VERSION,
|
|
181
|
+
tag: tag
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return {
|
|
186
|
+
succeeded: false,
|
|
187
|
+
result: updateCheckFallbackResponse()
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
85
191
|
|
|
86
192
|
module.exports = function flowPilotRuntime(RED) {
|
|
87
193
|
const storage = createStorage(RED.settings.userDir);
|
|
@@ -385,14 +491,17 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
385
491
|
},
|
|
386
492
|
targetNodeIds: {
|
|
387
493
|
oneOf: [
|
|
388
|
-
{ type: "string", enum: ["all"] },
|
|
494
|
+
{ type: "string", enum: ["all", "instance"] },
|
|
389
495
|
{
|
|
390
496
|
type: "array",
|
|
391
497
|
items: { type: "string" },
|
|
392
498
|
minItems: 1
|
|
393
499
|
}
|
|
394
500
|
],
|
|
395
|
-
description: "Optional resolved node target for Document redirects."
|
|
501
|
+
description: "Optional resolved node target for Document redirects. " +
|
|
502
|
+
"\"all\" is the entire active flow/tab; \"instance\" is every flow " +
|
|
503
|
+
"tab in the whole Node-RED instance. Omit when the scope genuinely " +
|
|
504
|
+
"can't be resolved from context."
|
|
396
505
|
}
|
|
397
506
|
},
|
|
398
507
|
required: ["mode", "prompt", "explanation"],
|
|
@@ -432,7 +541,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
432
541
|
|
|
433
542
|
function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
|
|
434
543
|
const writesEnabled = settings.enableAgentWrite === true &&
|
|
435
|
-
mode === "modify" && writesAllowed !== false &&
|
|
544
|
+
(mode === "modify" || mode === "generate") && writesAllowed !== false &&
|
|
436
545
|
activeProvider && activeProvider.supportsTools === true;
|
|
437
546
|
return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
|
|
438
547
|
}
|
|
@@ -1233,12 +1342,43 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1233
1342
|
|
|
1234
1343
|
RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
1235
1344
|
try {
|
|
1236
|
-
|
|
1345
|
+
const responseBody = maskProviderSecrets(storage.getSettings());
|
|
1346
|
+
responseBody.flowpilotVersion = PACKAGE_VERSION;
|
|
1347
|
+
res.json(responseBody);
|
|
1237
1348
|
} catch (err) {
|
|
1238
1349
|
res.status(500).json({ error: err.message });
|
|
1239
1350
|
}
|
|
1240
1351
|
});
|
|
1241
1352
|
|
|
1353
|
+
RED.httpAdmin.get("/flowpilot/update-check", RED.auth.needsPermission("settings.read"), async function (req, res) {
|
|
1354
|
+
const settings = storage.getSettings();
|
|
1355
|
+
if (settings.checkForUpdates === false) {
|
|
1356
|
+
res.json({ enabled: false });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const now = Date.now();
|
|
1361
|
+
if (updateCheckCache) {
|
|
1362
|
+
const ageMs = now - updateCheckCache.checkedAt;
|
|
1363
|
+
if (updateCheckCache.succeeded && ageMs < UPDATE_CHECK_SUCCESS_TTL_MS) {
|
|
1364
|
+
res.json(updateCheckCache.result);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (!updateCheckCache.succeeded && ageMs < UPDATE_CHECK_FAILURE_TTL_MS) {
|
|
1368
|
+
res.json(updateCheckFallbackResponse());
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const check = await performUpdateCheck();
|
|
1374
|
+
updateCheckCache = {
|
|
1375
|
+
checkedAt: now,
|
|
1376
|
+
succeeded: check.succeeded,
|
|
1377
|
+
result: check.result
|
|
1378
|
+
};
|
|
1379
|
+
res.json(check.result);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1242
1382
|
// ---- Settings: default system prompt (for "Reset to default") -------
|
|
1243
1383
|
|
|
1244
1384
|
RED.httpAdmin.get("/flowpilot/default-system-prompt", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
@@ -1249,6 +1389,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1249
1389
|
}
|
|
1250
1390
|
});
|
|
1251
1391
|
|
|
1392
|
+
RED.httpAdmin.get("/flowpilot/run-events/:runId", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
1393
|
+
const runId = req.params.runId;
|
|
1394
|
+
res.json({ runId: runId, events: runEventsStore.get(runId) || [] });
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1252
1397
|
// ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
|
|
1253
1398
|
// Serves the shared renderer (flowpilot-core.js, the same script
|
|
1254
1399
|
// flowpilot.html loads for the sidebar) plus its stylesheet and the
|
|
@@ -1307,13 +1452,21 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1307
1452
|
// a known limitation, not fixed by this ticket.
|
|
1308
1453
|
|
|
1309
1454
|
// ---- Provider confirmation gate (ADR-007, SSRF mitigation) -----------
|
|
1310
|
-
// No operational request (chat/generate/modify/document/build/agent-step
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1455
|
+
// No operational request (chat/generate/modify/document/build/agent-step)
|
|
1456
|
+
// touches a provider's baseUrl until that exact URL has passed a real
|
|
1457
|
+
// FlowPilot provider check — see isProviderShapedResponse
|
|
1313
1458
|
// (lib/provider-shape-check.js) below
|
|
1314
|
-
// and /flowpilot/test, /flowpilot/probe, the only
|
|
1315
|
-
// contact an unconfirmed URL.
|
|
1316
|
-
//
|
|
1459
|
+
// and /flowpilot/test, /flowpilot/probe, /flowpilot/models — the only
|
|
1460
|
+
// three routes allowed to contact an unconfirmed URL. The first two
|
|
1461
|
+
// WRITE confirmedBaseUrl/confirmedAt on a passing check (the deliberate
|
|
1462
|
+
// confirming action); /flowpilot/models never does — it's read-only
|
|
1463
|
+
// reconnaissance (listing available models), safe to allow pre-confirmation
|
|
1464
|
+
// the same way /probe is, but not itself a confirmation. All three stay
|
|
1465
|
+
// safe against an unconfirmed/malicious target the same way: a strict
|
|
1466
|
+
// shape check on any "success" response (isProviderShapedResponse for
|
|
1467
|
+
// chat-shaped, isModelsListShaped for a models list) and a generic,
|
|
1468
|
+
// never-reflects-the-body error on failure (enforced at the shared HTTP
|
|
1469
|
+
// client layer in lib/provider-*.js). lib/storage.js's
|
|
1317
1470
|
// reconcileProviderSecrets is the other half — it strips any
|
|
1318
1471
|
// client-supplied confirmedBaseUrl/confirmedAt on save and clears
|
|
1319
1472
|
// confirmation whenever baseUrl or apiKey actually changes, so
|
|
@@ -1399,16 +1552,22 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1399
1552
|
// ---- Models: list models via the active provider's /v1/models -------
|
|
1400
1553
|
// Always acts on the SAVED active provider (the frontend saves the form
|
|
1401
1554
|
// first, mirroring Pre-flight check), and never errors out for a provider
|
|
1402
|
-
// that doesn't support /v1/models — see listModels().
|
|
1555
|
+
// that doesn't support /v1/models — see listModels(). No request body is
|
|
1556
|
+
// read here, so no body-presence check applies (unlike /flowpilot/settings,
|
|
1557
|
+
// which does act on its body).
|
|
1558
|
+
//
|
|
1559
|
+
// This is the THIRD route allowed to touch an unconfirmed baseUrl (ADR-007)
|
|
1560
|
+
// — users need to see a model list before ever running Pre-flight check,
|
|
1561
|
+
// and the confirmation gate would otherwise block that. Kept safe the same
|
|
1562
|
+
// way /flowpilot/test and /flowpilot/probe are: listModels() only trusts a
|
|
1563
|
+
// genuinely provider-shaped response (isModelsListShaped) and never
|
|
1564
|
+
// reflects a raw failure body. Does not itself write confirmedBaseUrl/
|
|
1565
|
+
// confirmedAt — only the deliberate /flowpilot/test action confirms.
|
|
1403
1566
|
|
|
1404
1567
|
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
|
-
}
|
|
1408
1568
|
try {
|
|
1409
1569
|
const settings = storage.getSettings();
|
|
1410
1570
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1411
|
-
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
1412
1571
|
const result = await getProvider(activeProvider).listModels(activeProvider);
|
|
1413
1572
|
storage.appendAudit({
|
|
1414
1573
|
action: "list_models",
|
|
@@ -1562,6 +1721,17 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1562
1721
|
const activeProvider = storage.getActiveProvider(settings);
|
|
1563
1722
|
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
1564
1723
|
if (!execution) { return; }
|
|
1724
|
+
if (Array.isArray(req.body.events) && typeof execution.runId === "string" && execution.runId) {
|
|
1725
|
+
runEventsStore.set(execution.runId, req.body.events);
|
|
1726
|
+
if (runEventsStore.size > RUN_EVENTS_MAX_STORED) {
|
|
1727
|
+
runEventsStore.delete(runEventsStore.keys().next().value);
|
|
1728
|
+
}
|
|
1729
|
+
maybeLogDebugEvent("run_events", {
|
|
1730
|
+
mode: req.body.mode || "chat",
|
|
1731
|
+
runId: execution.runId,
|
|
1732
|
+
events: req.body.events
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1565
1735
|
if (!requireConfirmedProvider(res, activeProvider)) { return; }
|
|
1566
1736
|
|
|
1567
1737
|
const messages = req.body && req.body.messages;
|
|
@@ -1650,7 +1820,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1650
1820
|
r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
|
|
1651
1821
|
);
|
|
1652
1822
|
}
|
|
1653
|
-
: finalizeSimpleGeneration;
|
|
1823
|
+
: function (r) { return finalizeSimpleGeneration(r, execution); };
|
|
1654
1824
|
const { status, body } = finalize(generated);
|
|
1655
1825
|
return res.status(status).json(body);
|
|
1656
1826
|
}
|
|
@@ -2295,6 +2465,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2295
2465
|
return modifyResult;
|
|
2296
2466
|
}
|
|
2297
2467
|
|
|
2468
|
+
enforceAgentContract(parsed, auditContext, false);
|
|
2469
|
+
|
|
2298
2470
|
const flow = Array.isArray(parsed.flow) ? parsed.flow : null;
|
|
2299
2471
|
if (!flow) {
|
|
2300
2472
|
const err = new Error("The response did not contain a 'flow' array.");
|
|
@@ -2319,6 +2491,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2319
2491
|
newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
|
|
2320
2492
|
newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
|
|
2321
2493
|
};
|
|
2494
|
+
if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
|
|
2495
|
+
flowResult.strippedFields = parsed.strippedFields.slice();
|
|
2496
|
+
}
|
|
2322
2497
|
if (auditAction === "build") {
|
|
2323
2498
|
const fpUidManifest = buildFpUidManifest(flow);
|
|
2324
2499
|
if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
|
|
@@ -2353,7 +2528,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2353
2528
|
? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
|
|
2354
2529
|
: [];
|
|
2355
2530
|
const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
|
|
2356
|
-
const toolChoice = auditAction === "modify" && execution &&
|
|
2531
|
+
const toolChoice = (auditAction === "modify" || auditAction === "generate") && execution &&
|
|
2357
2532
|
execution.strategy === "agent" ? "required" : "auto";
|
|
2358
2533
|
let chatOptions = toolsEnabled
|
|
2359
2534
|
? { tools: offeredTools, toolChoice: toolChoice }
|
|
@@ -2498,7 +2673,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2498
2673
|
// Used by both the non-streaming route (res.status(status).json(body)) and
|
|
2499
2674
|
// the streaming route (relayed as the final SSE event).
|
|
2500
2675
|
// ---------------------------------------------------------------------
|
|
2501
|
-
function finalizeSimpleGeneration(result) {
|
|
2676
|
+
function finalizeSimpleGeneration(result, execution) {
|
|
2502
2677
|
if (result.question) {
|
|
2503
2678
|
const body = { explanation: result.explanation, question: result.question, flow: null };
|
|
2504
2679
|
if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
|
|
@@ -2511,6 +2686,19 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2511
2686
|
if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
|
|
2512
2687
|
return { status: 200, body: proseBody };
|
|
2513
2688
|
}
|
|
2689
|
+
if (execution && execution.strategy === "agent") {
|
|
2690
|
+
const agentBody = {
|
|
2691
|
+
explanation: result.explanation || "",
|
|
2692
|
+
prose: true,
|
|
2693
|
+
flow: null
|
|
2694
|
+
};
|
|
2695
|
+
if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
|
|
2696
|
+
agentBody.strippedFields = result.strippedFields.slice();
|
|
2697
|
+
}
|
|
2698
|
+
if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
|
|
2699
|
+
if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
|
|
2700
|
+
return { status: 200, body: agentBody };
|
|
2701
|
+
}
|
|
2514
2702
|
return { status: 200, body: result };
|
|
2515
2703
|
}
|
|
2516
2704
|
|
|
@@ -2871,6 +3059,11 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2871
3059
|
}
|
|
2872
3060
|
|
|
2873
3061
|
RED.httpAdmin.post("/flowpilot/generate", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
3062
|
+
const settings = storage.getSettings();
|
|
3063
|
+
const activeProvider = storage.getActiveProvider(settings);
|
|
3064
|
+
const execution = requireExecutionContract(req, res, settings, activeProvider);
|
|
3065
|
+
if (!execution) { return; }
|
|
3066
|
+
|
|
2874
3067
|
const prompt = req.body && req.body.prompt;
|
|
2875
3068
|
|
|
2876
3069
|
if (!prompt || !String(prompt).trim()) {
|
|
@@ -2879,11 +3072,12 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2879
3072
|
|
|
2880
3073
|
const history = sanitizeHistory(req.body.history);
|
|
2881
3074
|
const historyTruncated = !!req.body.historyTruncated;
|
|
3075
|
+
const finalize = function (result) { return finalizeSimpleGeneration(result, execution); };
|
|
2882
3076
|
|
|
2883
3077
|
if (req.body.stream) {
|
|
2884
3078
|
return runExecuteStream(
|
|
2885
3079
|
req, res, generationSystemPrompt, "generate", prompt, req.body && req.body.context,
|
|
2886
|
-
history, historyTruncated,
|
|
3080
|
+
history, historyTruncated, finalize, req.body.conversationId, execution
|
|
2887
3081
|
);
|
|
2888
3082
|
}
|
|
2889
3083
|
|
|
@@ -2891,7 +3085,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2891
3085
|
const useTools = !!req.body.tools;
|
|
2892
3086
|
const generated = await runFlowGeneration(
|
|
2893
3087
|
generationSystemPrompt, "generate", prompt, req.body && req.body.context,
|
|
2894
|
-
history, historyTruncated, useTools
|
|
3088
|
+
history, historyTruncated, useTools, execution
|
|
2895
3089
|
);
|
|
2896
3090
|
if (generated.toolCalls) {
|
|
2897
3091
|
return res.json({
|
|
@@ -2903,7 +3097,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
2903
3097
|
});
|
|
2904
3098
|
}
|
|
2905
3099
|
recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
|
|
2906
|
-
const { status, body } =
|
|
3100
|
+
const { status, body } = finalize(generated);
|
|
2907
3101
|
res.status(status).json(body);
|
|
2908
3102
|
} catch (err) {
|
|
2909
3103
|
sendGenerationError(res, "generate", err);
|
package/lib/agent-contract.js
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// The agent-strategy contract: a "strategy":"agent" turn is only ever
|
|
5
5
|
// allowed to mutate the flow via a WRITE tool call, never via the classic
|
|
6
6
|
// JSON-envelope mutation fields (changes/newNodes/newWires/removeNodes/
|
|
7
|
-
// newGroups
|
|
8
|
-
//
|
|
7
|
+
// newGroups for Modify; flow for Generate/Document/Build — Generate's own
|
|
8
|
+
// envelope shape uses "flow", not "newNodes"/"newWires", found live during
|
|
9
|
+
// the 0.6.0 FINISH-list pass: the field was missing from this list entirely,
|
|
10
|
+
// so an agent-strategy Generate final turn with no tool calls could emit a
|
|
11
|
+
// full flow array completely unprotected). If a model still emits those
|
|
12
|
+
// fields on a turn that made no tool calls, strip them before the response
|
|
13
|
+
// reaches the client and log
|
|
9
14
|
// what was stripped — the two mutation code paths (classic envelope vs.
|
|
10
15
|
// agentic WRITE tools) must stay mutually exclusive per agent turn.
|
|
11
16
|
// Classic-strategy turns and turns that DID make tool calls are untouched
|
|
@@ -13,7 +18,7 @@
|
|
|
13
18
|
// violating shape it exists to catch.
|
|
14
19
|
// ---------------------------------------------------------------------
|
|
15
20
|
|
|
16
|
-
const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
|
|
21
|
+
const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "flow"];
|
|
17
22
|
|
|
18
23
|
function enforceAgentContract(result, execution, hasToolCalls) {
|
|
19
24
|
if (!result || !execution || execution.strategy !== "agent" || hasToolCalls) {
|