@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/lib/core/main.js
CHANGED
|
@@ -40,6 +40,29 @@
|
|
|
40
40
|
var messageRecords = [];
|
|
41
41
|
var _nextRecordId = 0;
|
|
42
42
|
|
|
43
|
+
// P10-D1 (ADR-001 R5): per-conversation map of opId -> already-applied
|
|
44
|
+
// WRITE tool result. opId = runId + ":" + call.id (runId minted per
|
|
45
|
+
// runAgentLoop run, modes.js). A repeat opId (duplicate delivery, a
|
|
46
|
+
// retry, or the model repeating a call) returns the SAME result without
|
|
47
|
+
// re-invoking the executor — the graph is mutated at most once per
|
|
48
|
+
// opId. Keyed first by conversationId so switching conversations
|
|
49
|
+
// (clearChat / loading a saved transcript) can't cross-contaminate;
|
|
50
|
+
// cleared for the outgoing conversationId at those same points. In-
|
|
51
|
+
// memory only, matching messageRecords/conversationHistory's existing
|
|
52
|
+
// volatility (ADR-001: "persistence is minimal and schema-ready this
|
|
53
|
+
// phase" — full state-machine persistence is deferred to phase close).
|
|
54
|
+
var appliedOpsByConversation = {};
|
|
55
|
+
|
|
56
|
+
function getAppliedOp(convId, opId) {
|
|
57
|
+
var ops = appliedOpsByConversation[convId];
|
|
58
|
+
return ops ? ops[opId] : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recordAppliedOp(convId, opId, result) {
|
|
62
|
+
if (!appliedOpsByConversation[convId]) { appliedOpsByConversation[convId] = {}; }
|
|
63
|
+
appliedOpsByConversation[convId][opId] = result;
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
function addRecord(kind, payload) {
|
|
44
67
|
var rec = { id: _nextRecordId++, ts: Date.now(), kind: kind };
|
|
45
68
|
if (payload) {
|
|
@@ -83,6 +106,10 @@
|
|
|
83
106
|
renderLoopCheckpoint(activeBuildLoop);
|
|
84
107
|
} else if (rec.buildConsentGate) {
|
|
85
108
|
renderBuildConsentGate(rec);
|
|
109
|
+
} else if (rec.agentToolConsent) {
|
|
110
|
+
renderAgentToolConsentGate(rec);
|
|
111
|
+
} else if (rec.askUserTool) {
|
|
112
|
+
renderAskUserQuestion(rec);
|
|
86
113
|
} else {
|
|
87
114
|
renderClarifyingQuestion(rec.options || []);
|
|
88
115
|
}
|
|
@@ -223,7 +250,7 @@
|
|
|
223
250
|
// side-effecting node's own real id here (status events report the
|
|
224
251
|
// node itself, not a debug tap wired to it).
|
|
225
252
|
if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
|
|
226
|
-
if (
|
|
253
|
+
if (freshBuildLoopEvidence(activeBuildLoop).length > 0) { return; }
|
|
227
254
|
|
|
228
255
|
// Skip in-progress ("blue") statuses — e.g. http-request emits
|
|
229
256
|
// fill:"blue" text:"requesting" before any response arrives. Locking
|
|
@@ -263,6 +290,27 @@
|
|
|
263
290
|
}, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
|
|
264
291
|
}
|
|
265
292
|
|
|
293
|
+
// CLAUDE-025: attachedDebugMessages is deliberately STICKY across turns
|
|
294
|
+
// (see the block comment above — "sticky, like conversationHistory,
|
|
295
|
+
// until removed or Clear Chat"), which is exactly right for ordinary
|
|
296
|
+
// chat/generate/modify context. But the /build loop's own verification
|
|
297
|
+
// step must NOT inherit that stickiness: reviewing against a debug
|
|
298
|
+
// message left over from an earlier Build attempt (or one attached
|
|
299
|
+
// manually via the popout, or a still-running unrelated flow) as if it
|
|
300
|
+
// were evidence for THIS attempt's own goal is how a later attempt can
|
|
301
|
+
// declare Touchdown without ever having deployed or checked its own
|
|
302
|
+
// output. loop.deployedAt is stamped (see init.js's "deploy" listener)
|
|
303
|
+
// the moment THIS attempt's own "apply"->"attach" transition fires, so
|
|
304
|
+
// filtering on it scopes evidence to messages that arrived at or after
|
|
305
|
+
// that specific redeploy — never null (build review only runs once the
|
|
306
|
+
// loop has reached "attach", which requires deployedAt to be set).
|
|
307
|
+
function freshBuildLoopEvidence(loop) {
|
|
308
|
+
if (!loop || typeof loop.deployedAt !== "number") { return []; }
|
|
309
|
+
return attachedDebugMessages.filter(function (m) {
|
|
310
|
+
return m.timestamp >= loop.deployedAt;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
266
314
|
// The exact shape sent to the backend (and shown by "Preview debug") —
|
|
267
315
|
// excludes previewValue, which exists only for the debug-log list.
|
|
268
316
|
function buildDebugMessagesForSend() {
|
|
@@ -627,7 +675,21 @@
|
|
|
627
675
|
|
|
628
676
|
// ---- View switching -------------------------------------------------
|
|
629
677
|
|
|
678
|
+
// CLAUDE-024: every mode-tab switch disarms whatever Execute action
|
|
679
|
+
// (Generate/Document/Modify) or Query intent was armed before the
|
|
680
|
+
// switch. Without this, arming Modify then tabbing away and back to
|
|
681
|
+
// Chat left armedExecuteAction pointing at the stale mutating action,
|
|
682
|
+
// so a plain question typed on what LOOKS like a fresh Chat tab still
|
|
683
|
+
// dispatched through Modify/Generate/Document on Send — a real
|
|
684
|
+
// unintended-mutation bug (reproduced: an unwanted node deletion).
|
|
685
|
+
// Send must always route to whatever is actually visible.
|
|
686
|
+
function disarmForModeSwitch() {
|
|
687
|
+
disarmExecuteAction();
|
|
688
|
+
disarmQueryIntent();
|
|
689
|
+
}
|
|
690
|
+
|
|
630
691
|
function showChat() {
|
|
692
|
+
disarmForModeSwitch();
|
|
631
693
|
el("#fp-chat-panel").removeClass("fp-hidden");
|
|
632
694
|
el("#fp-settings-panel").addClass("fp-hidden");
|
|
633
695
|
el("#fp-history-panel").addClass("fp-hidden");
|
|
@@ -637,6 +699,7 @@
|
|
|
637
699
|
}
|
|
638
700
|
|
|
639
701
|
function showSettings() {
|
|
702
|
+
disarmForModeSwitch();
|
|
640
703
|
el("#fp-settings-panel").removeClass("fp-hidden");
|
|
641
704
|
el("#fp-chat-panel").addClass("fp-hidden");
|
|
642
705
|
el("#fp-history-panel").addClass("fp-hidden");
|
|
@@ -646,6 +709,7 @@
|
|
|
646
709
|
}
|
|
647
710
|
|
|
648
711
|
function showHistory() {
|
|
712
|
+
disarmForModeSwitch();
|
|
649
713
|
el("#fp-history-panel").removeClass("fp-hidden");
|
|
650
714
|
el("#fp-chat-panel").addClass("fp-hidden");
|
|
651
715
|
el("#fp-settings-panel").addClass("fp-hidden");
|
|
@@ -665,6 +729,7 @@
|
|
|
665
729
|
attachedDebugMessages = [];
|
|
666
730
|
activeBuildLoop = null;
|
|
667
731
|
disarmExecuteAction(); // also clears pinnedSelectionIds
|
|
732
|
+
delete appliedOpsByConversation[conversationId];
|
|
668
733
|
conversationId = newConversationId();
|
|
669
734
|
fpChatSnappedToBottom = true;
|
|
670
735
|
updateSelectionStatus();
|
|
@@ -778,8 +843,12 @@
|
|
|
778
843
|
// Switches to a past conversation: rebuilds conversationHistory and the
|
|
779
844
|
// visible chat from its saved transcript, and continues using its
|
|
780
845
|
// conversationId so new turns append to the same transcript file.
|
|
781
|
-
|
|
846
|
+
// onError: optional — CLAUDE-029's page-load rehydration passes one to
|
|
847
|
+
// stay quiet (no chat error bubble) and clear a stale sessionStorage
|
|
848
|
+
// entry on 404, instead of the ajaxJson default of addMessage("error", …).
|
|
849
|
+
function loadConversation(id, onError) {
|
|
782
850
|
ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
|
|
851
|
+
delete appliedOpsByConversation[conversationId];
|
|
783
852
|
conversationId = id;
|
|
784
853
|
try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
|
|
785
854
|
|
|
@@ -796,7 +865,7 @@
|
|
|
796
865
|
pinnedSelectionIds = null;
|
|
797
866
|
updateSelectionStatus();
|
|
798
867
|
showChat();
|
|
799
|
-
});
|
|
868
|
+
}, onError);
|
|
800
869
|
}
|
|
801
870
|
|
|
802
871
|
// Recall — searches OTHER past conversations' transcripts for the
|
|
@@ -982,17 +1051,52 @@
|
|
|
982
1051
|
// removed in both the success and error paths so it can't get stuck.
|
|
983
1052
|
// showStop adds a "Stop" button, used by the agent loop
|
|
984
1053
|
// (runAgentChat) so the user can interrupt a multi-step tool-call run.
|
|
1054
|
+
//
|
|
1055
|
+
// fpPendingStartedAt / fpPendingElapsedInterval drive the live elapsed-time
|
|
1056
|
+
// counter (.fp-typing-elapsed). Real timing data (corpus + live browser
|
|
1057
|
+
// testing, ~150+ requests) is bimodal: the overwhelming majority finish in
|
|
1058
|
+
// 2-13s, with rare genuine outliers around 73-77s and nothing observed in
|
|
1059
|
+
// between. FP_PENDING_SLOW_MS picks 25s as the "this is a slow one"
|
|
1060
|
+
// threshold — comfortably past every normal request, comfortably before
|
|
1061
|
+
// the real outliers, so the reassurance text only ever appears when it's
|
|
1062
|
+
// actually warranted.
|
|
1063
|
+
var fpPendingStartedAt = null;
|
|
1064
|
+
var fpPendingElapsedInterval = null;
|
|
1065
|
+
var FP_PENDING_SLOW_MS = 25000;
|
|
1066
|
+
|
|
1067
|
+
function fpFormatElapsed(ms) {
|
|
1068
|
+
var totalSeconds = Math.floor(ms / 1000);
|
|
1069
|
+
var text;
|
|
1070
|
+
if (totalSeconds < 60) {
|
|
1071
|
+
text = totalSeconds + "s";
|
|
1072
|
+
} else {
|
|
1073
|
+
var mins = Math.floor(totalSeconds / 60);
|
|
1074
|
+
var secs = totalSeconds % 60;
|
|
1075
|
+
text = mins + "m " + secs + "s";
|
|
1076
|
+
}
|
|
1077
|
+
if (ms >= FP_PENDING_SLOW_MS) {
|
|
1078
|
+
text += " — still working, some requests take a minute or more";
|
|
1079
|
+
}
|
|
1080
|
+
return text;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
985
1083
|
function showPending(showStop) {
|
|
986
1084
|
var $box = el("#fp-messages");
|
|
987
1085
|
if (!$box.length) { return; }
|
|
988
1086
|
// Guard against duplicates (e.g. fast double-send).
|
|
989
1087
|
$box.find("#fp-pending").remove();
|
|
1088
|
+
if (fpPendingElapsedInterval) {
|
|
1089
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1090
|
+
fpPendingElapsedInterval = null;
|
|
1091
|
+
}
|
|
990
1092
|
|
|
991
1093
|
var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
|
|
992
1094
|
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
993
1095
|
var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
|
|
994
1096
|
$dots.append($("<span>"), $("<span>"), $("<span>"));
|
|
995
1097
|
$dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
|
|
1098
|
+
var $elapsed = $("<span>").addClass("fp-typing-elapsed");
|
|
1099
|
+
$dots.append($elapsed);
|
|
996
1100
|
if (showStop) {
|
|
997
1101
|
$dots.append($("<button>")
|
|
998
1102
|
.addClass("fp-agent-stop red-ui-button red-ui-button-small")
|
|
@@ -1000,6 +1104,7 @@
|
|
|
1000
1104
|
.text("Stop")
|
|
1001
1105
|
.on("click", function () {
|
|
1002
1106
|
fpAgentStopRequested = true;
|
|
1107
|
+
if (fpCurrentAgentRequest) { fpCurrentAgentRequest.abort(); }
|
|
1003
1108
|
$(this).prop("disabled", true).text("Stopping…");
|
|
1004
1109
|
}));
|
|
1005
1110
|
}
|
|
@@ -1007,10 +1112,28 @@
|
|
|
1007
1112
|
|
|
1008
1113
|
$box.append($msg);
|
|
1009
1114
|
scrollMessagesToBottom();
|
|
1115
|
+
|
|
1116
|
+
fpPendingStartedAt = Date.now();
|
|
1117
|
+
fpPendingElapsedInterval = setInterval(function () {
|
|
1118
|
+
var $label = el("#fp-pending .fp-typing-elapsed");
|
|
1119
|
+
if (!$label.length) {
|
|
1120
|
+
// Panel closed / #fp-pending gone without hidePending firing;
|
|
1121
|
+
// stop polling instead of leaking the interval forever.
|
|
1122
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1123
|
+
fpPendingElapsedInterval = null;
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
$label.text(" · " + fpFormatElapsed(Date.now() - fpPendingStartedAt));
|
|
1127
|
+
}, 1000);
|
|
1010
1128
|
}
|
|
1011
1129
|
|
|
1012
1130
|
function hidePending() {
|
|
1013
1131
|
el("#fp-messages").find("#fp-pending").remove();
|
|
1132
|
+
if (fpPendingElapsedInterval) {
|
|
1133
|
+
clearInterval(fpPendingElapsedInterval);
|
|
1134
|
+
fpPendingElapsedInterval = null;
|
|
1135
|
+
}
|
|
1136
|
+
fpPendingStartedAt = null;
|
|
1014
1137
|
}
|
|
1015
1138
|
|
|
1016
1139
|
// Updates the narration text shown in the pending indicator while the
|
|
@@ -1071,7 +1194,7 @@
|
|
|
1071
1194
|
}
|
|
1072
1195
|
|
|
1073
1196
|
function ajaxJson(method, url, payload, onSuccess, onError) {
|
|
1074
|
-
$.ajax({
|
|
1197
|
+
return $.ajax({
|
|
1075
1198
|
url: flowpilotUrl(url),
|
|
1076
1199
|
method: method,
|
|
1077
1200
|
contentType: "application/json",
|
|
@@ -1084,7 +1207,14 @@
|
|
|
1084
1207
|
},
|
|
1085
1208
|
success: onSuccess,
|
|
1086
1209
|
error: function (xhr) {
|
|
1087
|
-
|
|
1210
|
+
// Prefer .message (human-readable text) over .error (a
|
|
1211
|
+
// machine code like "provider_unconfirmed" or
|
|
1212
|
+
// "agent_strategy_unavailable") when both are present — a
|
|
1213
|
+
// plain-string-only error response (the common case,
|
|
1214
|
+
// {error:"..."} with no .message) is untouched, since
|
|
1215
|
+
// .message is simply absent there.
|
|
1216
|
+
var msg = (xhr.responseJSON && xhr.responseJSON.message) ||
|
|
1217
|
+
(xhr.responseJSON && xhr.responseJSON.error) ||
|
|
1088
1218
|
xhr.responseText || xhr.statusText || "Unknown error";
|
|
1089
1219
|
if (onError) { onError(msg, xhr); }
|
|
1090
1220
|
else { addMessage("error", msg); }
|
|
@@ -1125,7 +1255,11 @@
|
|
|
1125
1255
|
el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
|
|
1126
1256
|
}
|
|
1127
1257
|
|
|
1128
|
-
// Write the form fields from a given provider profile.
|
|
1258
|
+
// Write the form fields from a given provider profile. p.apiKey is
|
|
1259
|
+
// never a real key here — the server masks it to a sentinel/"" on every
|
|
1260
|
+
// response (see maskProviderSecrets, flowpilot.js) — so leaving the
|
|
1261
|
+
// field untouched on save round-trips harmlessly (reconcileProviderSecrets,
|
|
1262
|
+
// lib/storage.js, keeps the real stored key).
|
|
1129
1263
|
function fillProviderFields(p) {
|
|
1130
1264
|
p = p || {};
|
|
1131
1265
|
el("#fp-provider-name").val(p.providerName || "");
|
|
@@ -1134,20 +1268,23 @@
|
|
|
1134
1268
|
toggleAnthropicHint(type);
|
|
1135
1269
|
el("#fp-base-url").val(p.baseUrl || "");
|
|
1136
1270
|
el("#fp-api-key").val(p.apiKey || "");
|
|
1271
|
+
el("#fp-api-key").attr("placeholder", p.hasApiKey ? "Saved — leave to keep, retype to change" : "Optional");
|
|
1137
1272
|
el("#fp-model").val(p.model || "");
|
|
1138
1273
|
el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
|
|
1139
1274
|
// Test provider is disabled until this provider has a model.
|
|
1140
1275
|
el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
|
|
1141
1276
|
}
|
|
1142
1277
|
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1278
|
+
// CLAUDE-032: 5 discrete levels (was 1-10 with sparse interpolated
|
|
1279
|
+
// anchors — live testing found no discernible voice difference across
|
|
1280
|
+
// the old scale). Labels match lib/persona-prompt.js's PERSONA_LEVELS
|
|
1281
|
+
// exactly, one label per slider position, no interpolation.
|
|
1145
1282
|
function personaLabelFor(n) {
|
|
1146
1283
|
n = Number(n);
|
|
1147
1284
|
if (n <= 1) { return "Plain engineer — no aviation language at all."; }
|
|
1148
|
-
if (n
|
|
1149
|
-
if (n
|
|
1150
|
-
if (n
|
|
1285
|
+
if (n === 2) { return "Subtle co-pilot (default) — light, occasional flavor."; }
|
|
1286
|
+
if (n === 3) { return "Noticeable captain energy — a sentence or two, every time."; }
|
|
1287
|
+
if (n === 4) { return "Heavy captain energy — leans hard into the bit."; }
|
|
1151
1288
|
return "Full captain — comically over-the-top.";
|
|
1152
1289
|
}
|
|
1153
1290
|
|
|
@@ -1165,17 +1302,19 @@
|
|
|
1165
1302
|
fillProviderFields(activeProvider());
|
|
1166
1303
|
|
|
1167
1304
|
el("#fp-system-prompt").val(settings.systemPrompt || "");
|
|
1168
|
-
el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity :
|
|
1305
|
+
el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 2);
|
|
1169
1306
|
updatePersonaLabel();
|
|
1170
1307
|
el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
|
|
1171
1308
|
el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
|
|
1172
1309
|
el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
|
|
1173
1310
|
el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
|
|
1174
|
-
el("#fp-request-timeout").val(
|
|
1311
|
+
el("#fp-request-timeout").val(settings.requestTimeoutMs !== undefined ? (settings.requestTimeoutMs / 1000) : 180);
|
|
1312
|
+
el("#fp-agent-turn-max-tokens").val(settings.agentTurnMaxTokens !== undefined ? settings.agentTurnMaxTokens : 4096);
|
|
1175
1313
|
el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
|
|
1176
1314
|
el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
|
|
1177
1315
|
el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
|
|
1178
1316
|
el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
|
|
1317
|
+
el("#fp-debug-logging").prop("checked", !!settings.debugLogging);
|
|
1179
1318
|
|
|
1180
1319
|
// The dev/test banner is part of the warning set the user can silence
|
|
1181
1320
|
// via the type-to-confirm acknowledgement.
|
|
@@ -1250,14 +1389,15 @@
|
|
|
1250
1389
|
if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
|
|
1251
1390
|
|
|
1252
1391
|
var requestTimeoutSec = Number(el("#fp-request-timeout").val());
|
|
1253
|
-
if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
|
|
1254
1392
|
|
|
1255
1393
|
var personaIntensity = Number(el("#fp-persona-intensity").val());
|
|
1256
|
-
if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity >
|
|
1394
|
+
if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 5) { personaIntensity = 2; }
|
|
1257
1395
|
|
|
1258
1396
|
var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
|
|
1259
1397
|
if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
|
|
1260
1398
|
|
|
1399
|
+
var agentTurnMaxTokens = Number(el("#fp-agent-turn-max-tokens").val());
|
|
1400
|
+
|
|
1261
1401
|
return {
|
|
1262
1402
|
providers: providersList(),
|
|
1263
1403
|
activeProviderId: currentSettings.activeProviderId,
|
|
@@ -1268,10 +1408,12 @@
|
|
|
1268
1408
|
historyMaxExchanges: historyMax,
|
|
1269
1409
|
streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
|
|
1270
1410
|
requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
|
|
1411
|
+
agentTurnMaxTokens: agentTurnMaxTokens,
|
|
1271
1412
|
agentLoopMaxIterations: agentLoopMaxIterations,
|
|
1272
1413
|
loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
|
|
1273
1414
|
suppressContextWarnings: suppress,
|
|
1274
1415
|
redactionEnabled: redactionEnabled,
|
|
1416
|
+
debugLogging: el("#fp-debug-logging").prop("checked"),
|
|
1275
1417
|
customIntents: Array.isArray(currentSettings.customIntents)
|
|
1276
1418
|
? currentSettings.customIntents : []
|
|
1277
1419
|
};
|
|
@@ -1417,6 +1559,21 @@
|
|
|
1417
1559
|
var payload = collectSettings();
|
|
1418
1560
|
var list = payload.providers || [];
|
|
1419
1561
|
|
|
1562
|
+
if (!isFinite(payload.requestTimeoutMs) || payload.requestTimeoutMs <= 0) {
|
|
1563
|
+
var timeoutMsg = "Cannot save: request timeout must be a positive number of seconds.";
|
|
1564
|
+
addMessage("error", timeoutMsg);
|
|
1565
|
+
if (announce) { showSaveStatus(timeoutMsg, true); }
|
|
1566
|
+
showSettings();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (!isFinite(payload.agentTurnMaxTokens) || payload.agentTurnMaxTokens <= 0 || Math.floor(payload.agentTurnMaxTokens) !== payload.agentTurnMaxTokens) {
|
|
1570
|
+
var tokenMsg = "Cannot save: max tokens per agent turn must be a positive whole number.";
|
|
1571
|
+
addMessage("error", tokenMsg);
|
|
1572
|
+
if (announce) { showSaveStatus(tokenMsg, true); }
|
|
1573
|
+
showSettings();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1420
1577
|
// Validation 1: every non-Anthropic provider needs a base URL.
|
|
1421
1578
|
// Anthropic providers default to api.anthropic.com when baseUrl is blank.
|
|
1422
1579
|
var noUrl = list.filter(function (p) {
|
|
@@ -1649,6 +1806,470 @@
|
|
|
1649
1806
|
return Object.assign({ selected: true }, context);
|
|
1650
1807
|
}
|
|
1651
1808
|
|
|
1809
|
+
// ---------------------------------------------------------------------
|
|
1810
|
+
// W7 — WRITE tools (§16 of FlowPilot-Phase10-Rescope-Scoping.md).
|
|
1811
|
+
//
|
|
1812
|
+
// Exact argument schemas as landed by CODEX-005 (coordinated via
|
|
1813
|
+
// mailbox, 2026-07-26 19:28/19:37 UTC — these are NOT a client-side
|
|
1814
|
+
// guess): CODEX-005 deliberately reused the EXISTING Modify-envelope
|
|
1815
|
+
// field names/shapes so the client applies WRITE tool calls through
|
|
1816
|
+
// the SAME pipeline as the ordinary (non-agentic) Modify apply path —
|
|
1817
|
+
// apply_step: { summary, changes?: [{id, set}], newNodes?: [{id,
|
|
1818
|
+
// type, ...}], newWires?: [{from, fromPort, to}] }
|
|
1819
|
+
// `changes[].set` is a sparse property patch, `newNodes`/`newWires`
|
|
1820
|
+
// are byte-identical in shape to the top-level Modify envelope's
|
|
1821
|
+
// own fields (applyInsertions already accepts them directly).
|
|
1822
|
+
// "One call is one todo item even when these arrays contain a
|
|
1823
|
+
// small bundle" (§16 point 2) — arrays may hold more than one
|
|
1824
|
+
// entry in a single call.
|
|
1825
|
+
// remove_step: { summary, nodeId }
|
|
1826
|
+
// rename_node: { summary, nodeId, name }
|
|
1827
|
+
// ask_user: { question, options? } — tier "write-safe", non-mutating,
|
|
1828
|
+
// handled entirely in the agent loop (modes.js), never reaches
|
|
1829
|
+
// here in normal flow.
|
|
1830
|
+
//
|
|
1831
|
+
// Tier ("write-gated" vs "write-safe") arrives OUT OF BAND per call as
|
|
1832
|
+
// data.toolTiers[call.id] (modes.js reads this), not by tool name — a
|
|
1833
|
+
// write-gated tier means the call is ELIGIBLE for consent-gating, not
|
|
1834
|
+
// that every call of that tool prompts (CODEX-005's own clarification):
|
|
1835
|
+
// §16 point 4 still requires classifying the ACTUAL touched node
|
|
1836
|
+
// type(s) against SAFE_NODE_TYPES before deciding to gate.
|
|
1837
|
+
//
|
|
1838
|
+
// tool_result shape (§16 point 3 — mirrors the existing verifySteps
|
|
1839
|
+
// check vocabulary, scoped strictly to this call's own touched ids):
|
|
1840
|
+
// { requested, checks: [{check, nodeId|fromId/toId, prop?, expected?,
|
|
1841
|
+
// pass}], allPass, error? }
|
|
1842
|
+
// ---------------------------------------------------------------------
|
|
1843
|
+
|
|
1844
|
+
// Mirrors flowpilot.js's SAFE_NODE_TYPES verbatim (§16 point 4: "reuse
|
|
1845
|
+
// SAFE_NODE_TYPES/classifyFlowNodes verbatim"). This is a client-side
|
|
1846
|
+
// COPY, not a shared module — lib/core/*.js has no require/import, and
|
|
1847
|
+
// the server's set lives in a separate Node.js process. Must be kept in
|
|
1848
|
+
// sync by hand; flagged in the mailbox report as a drift risk to watch
|
|
1849
|
+
// if the server-side list ever changes.
|
|
1850
|
+
var WRITE_GATE_SAFE_NODE_TYPES = new Set([
|
|
1851
|
+
"inject", "function", "change", "switch", "filter", "json", "xml", "csv",
|
|
1852
|
+
"base64", "html", "split", "join", "sort", "batch", "debug", "status",
|
|
1853
|
+
"comment", "link in", "link out", "link call", "junction"
|
|
1854
|
+
]);
|
|
1855
|
+
|
|
1856
|
+
// Collects the node type(s) a WRITE tool call would touch — a NEW
|
|
1857
|
+
// node's own declared type, or an EXISTING node's live type looked up
|
|
1858
|
+
// via findLiveNode (apply-review.js, same closure). Used by
|
|
1859
|
+
// writeToolCallNeedsConsent (modes.js) to decide whether to gate.
|
|
1860
|
+
function collectWriteToolTouchedTypes(name, args) {
|
|
1861
|
+
args = args || {};
|
|
1862
|
+
var types = [];
|
|
1863
|
+
function addLiveType(id) {
|
|
1864
|
+
if (!id) { return; }
|
|
1865
|
+
var live = findLiveNode(id);
|
|
1866
|
+
if (live) { types.push(live.type); }
|
|
1867
|
+
}
|
|
1868
|
+
switch (name) {
|
|
1869
|
+
case "apply_step":
|
|
1870
|
+
(Array.isArray(args.newNodes) ? args.newNodes : []).forEach(function (n) {
|
|
1871
|
+
if (n && n.type) { types.push(n.type); }
|
|
1872
|
+
});
|
|
1873
|
+
(Array.isArray(args.newWires) ? args.newWires : []).forEach(function (w) {
|
|
1874
|
+
if (!w) { return; }
|
|
1875
|
+
addLiveType(w.from);
|
|
1876
|
+
addLiveType(w.to);
|
|
1877
|
+
});
|
|
1878
|
+
(Array.isArray(args.changes) ? args.changes : []).forEach(function (c) {
|
|
1879
|
+
if (c) { addLiveType(c.id); }
|
|
1880
|
+
});
|
|
1881
|
+
break;
|
|
1882
|
+
case "remove_step":
|
|
1883
|
+
addLiveType(args.nodeId);
|
|
1884
|
+
break;
|
|
1885
|
+
case "rename_node":
|
|
1886
|
+
addLiveType(args.nodeId);
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
return types;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// §16 point 4: gate ONLY when the call's tier is "write-gated" AND it
|
|
1893
|
+
// touches a node type outside SAFE_NODE_TYPES; autonomously apply
|
|
1894
|
+
// everything else (including every "write-safe" call, e.g. ask_user,
|
|
1895
|
+
// which never reaches this function at all in practice — see
|
|
1896
|
+
// handleStep). An id that doesn't resolve to a live node at all
|
|
1897
|
+
// (hallucinated id, or a brand-new node referenced by a wire before it
|
|
1898
|
+
// exists) can't be proven safe, so it's treated conservatively as
|
|
1899
|
+
// side-effecting — mirrors WS4's classifyFlowNodes default (unknown =>
|
|
1900
|
+
// sideEffecting).
|
|
1901
|
+
function writeToolCallNeedsConsent(tier, name, args) {
|
|
1902
|
+
if (tier !== "write-gated") { return false; }
|
|
1903
|
+
var types = collectWriteToolTouchedTypes(name, args);
|
|
1904
|
+
if (!types.length) { return true; }
|
|
1905
|
+
return types.some(function (t) { return !WRITE_GATE_SAFE_NODE_TYPES.has(t); });
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Builds the tool_result envelope from a checks array already carrying
|
|
1909
|
+
// .pass — shared tail for all three WRITE tool executors below.
|
|
1910
|
+
function buildWriteToolResult(requestedArgs, checks, extra) {
|
|
1911
|
+
var allPass = checks.length > 0 && checks.every(function (c) { return c.pass; });
|
|
1912
|
+
return Object.assign({ requested: requestedArgs, checks: checks, allPass: allPass }, extra || {});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// Runs the verifySteps-style check vocabulary (runSingleVerifyCheck,
|
|
1916
|
+
// modes.js, same closure) against a list of steps in this call's own
|
|
1917
|
+
// shape, scoped strictly to the touched id(s) — never a flow-wide
|
|
1918
|
+
// snapshot (§16 point 3).
|
|
1919
|
+
function runChecksForToolResult(steps, idMap) {
|
|
1920
|
+
var checks = [];
|
|
1921
|
+
(steps || []).forEach(function (step) {
|
|
1922
|
+
var result = runSingleVerifyCheck(step, idMap);
|
|
1923
|
+
if (!result) { return; }
|
|
1924
|
+
checks.push(Object.assign({}, step, { pass: result.ok }));
|
|
1925
|
+
});
|
|
1926
|
+
return checks;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// apply_step: newNodes/newWires go through applyInsertions — the SAME
|
|
1930
|
+
// layout/collision-avoidance/wiring path Generate/Modify insertions
|
|
1931
|
+
// already use, since CODEX-005 matched that exact field shape — then
|
|
1932
|
+
// changes[].set (sparse property patches on EXISTING nodes) goes
|
|
1933
|
+
// through applyModifications's Tier 1, using the resulting idMap so a
|
|
1934
|
+
// change value can reference a placeholder id from newNodes in the
|
|
1935
|
+
// SAME call (mirrors the existing "insertions run first so their
|
|
1936
|
+
// placeholder→real-id map is available" ordering in
|
|
1937
|
+
// addModifyReview's apply button). `changes[].set.wires` is the ONE
|
|
1938
|
+
// exception: CLAUDE-009-fix — an existing node's wiring is never a
|
|
1939
|
+
// generic property (mirrors computeNodeDiff's `if (k === "wires") {
|
|
1940
|
+
// wiresChanged = true; return; }`), so it's split out and routed
|
|
1941
|
+
// through computeWireDiff/Tier 3 (RED.nodes.addLink/removeLink,
|
|
1942
|
+
// canWire port validation) instead of a raw `liveNode.wires = val`
|
|
1943
|
+
// assignment, which would "succeed" without ever updating the link
|
|
1944
|
+
// registry the canvas and RED.nodes.eachLink actually read from.
|
|
1945
|
+
function executeApplyStepTool(args, runIdMap, runHistoryEvents) {
|
|
1946
|
+
args = args || {};
|
|
1947
|
+
var changes = Array.isArray(args.changes) ? args.changes : [];
|
|
1948
|
+
var newNodes = Array.isArray(args.newNodes) ? args.newNodes : [];
|
|
1949
|
+
var newWires = Array.isArray(args.newWires) ? args.newWires : [];
|
|
1950
|
+
|
|
1951
|
+
var idMap = {};
|
|
1952
|
+
var steps = [];
|
|
1953
|
+
|
|
1954
|
+
// CLAUDE-013: resolves a placeholder id (e.g. "fp-new-2") through
|
|
1955
|
+
// this call's own idMap first (ids it just created via
|
|
1956
|
+
// applyInsertions), then through runIdMap (ids created by an
|
|
1957
|
+
// EARLIER WRITE-tool call in the same agent run) — so referencing a
|
|
1958
|
+
// node created two tool calls ago works the same as referencing one
|
|
1959
|
+
// created in this call.
|
|
1960
|
+
function resolveId(id) {
|
|
1961
|
+
if (typeof id !== "string") { return id; }
|
|
1962
|
+
if (idMap && idMap[id]) { return idMap[id]; }
|
|
1963
|
+
if (runIdMap && runIdMap[id]) { return runIdMap[id]; }
|
|
1964
|
+
return id;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (newNodes.length || newWires.length) {
|
|
1968
|
+
idMap = applyInsertions(newNodes, newWires, [], runHistoryEvents) || {};
|
|
1969
|
+
newNodes.forEach(function (n) {
|
|
1970
|
+
if (n && n.id) { steps.push({ check: "exists", nodeId: n.id }); }
|
|
1971
|
+
});
|
|
1972
|
+
newWires.forEach(function (w) {
|
|
1973
|
+
if (w && w.from && w.to) { steps.push({ check: "wire", fromId: w.from, fromPort: w.fromPort || 0, toId: w.to }); }
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (changes.length) {
|
|
1978
|
+
var propDiffs = changes.filter(function (c) { return c && c.id && c.set && typeof c.set === "object"; })
|
|
1979
|
+
.map(function (c) {
|
|
1980
|
+
var nodeId = resolveId(c.id);
|
|
1981
|
+
var live = findLiveNode(nodeId);
|
|
1982
|
+
var hasWireChange = Object.prototype.hasOwnProperty.call(c.set, "wires");
|
|
1983
|
+
var propertyChanges = Object.keys(c.set).filter(function (k) { return k !== "wires"; })
|
|
1984
|
+
.map(function (k) {
|
|
1985
|
+
return { key: k, oldVal: live ? live[k] : undefined, newVal: c.set[k] };
|
|
1986
|
+
});
|
|
1987
|
+
var wiresDiff = { toAdd: [], toRemove: [] };
|
|
1988
|
+
if (hasWireChange && live) {
|
|
1989
|
+
// The tool-call path has no "flow the model had in
|
|
1990
|
+
// context" boundary the way the classic envelope's
|
|
1991
|
+
// validTargetIds does (scoped to the returned "flow"
|
|
1992
|
+
// array) — the model only sends this one node's
|
|
1993
|
+
// desired wires, not a full flow. Per sr-dev's
|
|
1994
|
+
// guidance, treat every id the diff could actually
|
|
1995
|
+
// flag for removal as valid: computeWireDiff only
|
|
1996
|
+
// ever consults validTargetIds for ids already found
|
|
1997
|
+
// among this node's CURRENT live targets, so seeding
|
|
1998
|
+
// validTargetIds from those current targets is
|
|
1999
|
+
// equivalent to "always valid" without needing a
|
|
2000
|
+
// magic always-true object.
|
|
2001
|
+
var validTargetIds = {};
|
|
2002
|
+
RED.nodes.eachLink(function (l) {
|
|
2003
|
+
if (l.source && l.source.id === nodeId && l.target) { validTargetIds[l.target.id] = true; }
|
|
2004
|
+
});
|
|
2005
|
+
wiresDiff = computeWireDiff(nodeId, c.set.wires, validTargetIds);
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
modNode: { id: nodeId },
|
|
2009
|
+
propertyChanges: propertyChanges,
|
|
2010
|
+
wiresChanged: hasWireChange && !!live,
|
|
2011
|
+
wiresDiff: wiresDiff
|
|
2012
|
+
};
|
|
2013
|
+
});
|
|
2014
|
+
// CLAUDE-013: applyModifications's own Tier 1 substitution
|
|
2015
|
+
// (apply-review.js) resolves c.set[k] placeholder values through
|
|
2016
|
+
// whatever idMap it's given — passing the call-local idMap alone
|
|
2017
|
+
// would WRITE the raw unresolved placeholder string onto the live
|
|
2018
|
+
// node (e.g. an mqtt-out's "broker" left as "fp-new-broker")
|
|
2019
|
+
// whenever the referenced node was created by an EARLIER call
|
|
2020
|
+
// this run, even though the verify step below correctly expects
|
|
2021
|
+
// the real id — a live-value/verify-step mismatch caught by
|
|
2022
|
+
// testing, not named explicitly in the ticket's cited line
|
|
2023
|
+
// numbers. Merge runIdMap in so the actual mutation and the
|
|
2024
|
+
// verify step agree.
|
|
2025
|
+
if (propDiffs.length) { applyModifications(propDiffs, [], null, Object.assign({}, runIdMap, idMap), runHistoryEvents); }
|
|
2026
|
+
changes.forEach(function (c) {
|
|
2027
|
+
if (!c || !c.id || !c.set || typeof c.set !== "object") { return; }
|
|
2028
|
+
var nodeId = resolveId(c.id);
|
|
2029
|
+
Object.keys(c.set).filter(function (k) { return k !== "wires"; }).forEach(function (k) {
|
|
2030
|
+
// Resolve through idMap/runIdMap the SAME way the diff
|
|
2031
|
+
// above just did, so a set value referencing a
|
|
2032
|
+
// placeholder from THIS call's own newNodes (e.g. an
|
|
2033
|
+
// mqtt-out's "broker" pointing at a new mqtt-broker) OR
|
|
2034
|
+
// from an EARLIER call this run is checked against what
|
|
2035
|
+
// actually landed, not the raw unresolved placeholder
|
|
2036
|
+
// string.
|
|
2037
|
+
var expected = resolveId(c.set[k]);
|
|
2038
|
+
steps.push({ check: "property", nodeId: nodeId, prop: k, expected: expected });
|
|
2039
|
+
});
|
|
2040
|
+
if (Object.prototype.hasOwnProperty.call(c.set, "wires")) {
|
|
2041
|
+
// Emit one "wire" check per desired (port, target) pair —
|
|
2042
|
+
// the same check shape/granularity newWires already uses
|
|
2043
|
+
// above — so the model's requested final wiring state is
|
|
2044
|
+
// verified against the live graph, not a property key.
|
|
2045
|
+
var desiredWires = Array.isArray(c.set.wires) ? c.set.wires : [];
|
|
2046
|
+
desiredWires.forEach(function (targets, port) {
|
|
2047
|
+
(Array.isArray(targets) ? targets : []).forEach(function (targetId) {
|
|
2048
|
+
steps.push({ check: "wire", fromId: nodeId, fromPort: port, toId: resolveId(targetId) });
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!steps.length) {
|
|
2056
|
+
return buildWriteToolResult(args, [], { error: "apply_step call had nothing to apply" });
|
|
2057
|
+
}
|
|
2058
|
+
var checks = runChecksForToolResult(steps, idMap);
|
|
2059
|
+
var extra = {};
|
|
2060
|
+
if (idMap && Object.keys(idMap).length) { extra.idMap = idMap; }
|
|
2061
|
+
return buildWriteToolResult(args, checks, extra);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function executeRemoveStepTool(args, runIdMap, runHistoryEvents) {
|
|
2065
|
+
args = args || {};
|
|
2066
|
+
if (!args.nodeId) {
|
|
2067
|
+
return buildWriteToolResult(args, [], { error: "remove_step requires nodeId" });
|
|
2068
|
+
}
|
|
2069
|
+
// CLAUDE-013: args.nodeId may be a placeholder minted by an EARLIER
|
|
2070
|
+
// WRITE-tool call this run (e.g. "insert node, then remove it").
|
|
2071
|
+
var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
|
|
2072
|
+
applyModifications([], [nodeId], null, {}, runHistoryEvents);
|
|
2073
|
+
var checks = runChecksForToolResult([{ check: "absent", nodeId: nodeId }], {});
|
|
2074
|
+
return buildWriteToolResult(args, checks);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function executeRenameNodeTool(args, runIdMap, runHistoryEvents) {
|
|
2078
|
+
args = args || {};
|
|
2079
|
+
if (!args.nodeId || typeof args.name !== "string") {
|
|
2080
|
+
return buildWriteToolResult(args, [], { error: "rename_node requires nodeId and name" });
|
|
2081
|
+
}
|
|
2082
|
+
// CLAUDE-013: same placeholder resolution as executeRemoveStepTool.
|
|
2083
|
+
var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
|
|
2084
|
+
var live = findLiveNode(nodeId);
|
|
2085
|
+
var diff = [{
|
|
2086
|
+
modNode: { id: nodeId },
|
|
2087
|
+
propertyChanges: [{ key: "name", oldVal: live ? live.name : undefined, newVal: args.name }],
|
|
2088
|
+
wiresChanged: false,
|
|
2089
|
+
wiresDiff: { toAdd: [], toRemove: [] }
|
|
2090
|
+
}];
|
|
2091
|
+
applyModifications(diff, [], null, {}, runHistoryEvents);
|
|
2092
|
+
var checks = runChecksForToolResult([{ check: "property", nodeId: nodeId, prop: "name", expected: args.name }], {});
|
|
2093
|
+
return buildWriteToolResult(args, checks);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function executeRedirectModeTool(args) {
|
|
2097
|
+
args = args || {};
|
|
2098
|
+
if (["generate", "document", "chat"].indexOf(args.mode) === -1) {
|
|
2099
|
+
return { error: "redirect_mode requires mode = generate, document, or chat" };
|
|
2100
|
+
}
|
|
2101
|
+
if (typeof args.prompt !== "string" || !args.prompt.trim()) {
|
|
2102
|
+
return { error: "redirect_mode requires prompt" };
|
|
2103
|
+
}
|
|
2104
|
+
if (typeof args.explanation !== "string" || !args.explanation.trim()) {
|
|
2105
|
+
return { error: "redirect_mode requires explanation" };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
var result = {
|
|
2109
|
+
redirected: true,
|
|
2110
|
+
explanation: args.explanation.trim(),
|
|
2111
|
+
suggestedAction: {
|
|
2112
|
+
mode: args.mode,
|
|
2113
|
+
prompt: args.prompt.trim()
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
if (typeof args.selectionHint === "string" && args.selectionHint.trim()) {
|
|
2117
|
+
result.suggestedAction.selectionHint = args.selectionHint.trim();
|
|
2118
|
+
}
|
|
2119
|
+
if (args.targetNodeIds === "all") {
|
|
2120
|
+
result.suggestedAction.targetNodeIds = "all";
|
|
2121
|
+
} else if (Array.isArray(args.targetNodeIds)) {
|
|
2122
|
+
var ids = args.targetNodeIds
|
|
2123
|
+
.filter(function (id) { return typeof id === "string" && id.trim(); })
|
|
2124
|
+
.map(function (id) { return id.trim(); });
|
|
2125
|
+
if (ids.length) { result.suggestedAction.targetNodeIds = ids; }
|
|
2126
|
+
}
|
|
2127
|
+
return result;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// group_nodes (ADR-003 R3a / P10-B3): wraps the exact
|
|
2131
|
+
// RED.group.createGroup path already proven in apply-review.js's
|
|
2132
|
+
// applyGroupChanges create branch (~1356-1362) — no new group logic.
|
|
2133
|
+
// Pre-validates every id via findLiveNode (the established resolver
|
|
2134
|
+
// used by the other WRITE tool executors above, a superset of
|
|
2135
|
+
// RED.nodes.node that also resolves groups/junctions — needed here
|
|
2136
|
+
// specifically to detect a group id among nodeIds). "No partial
|
|
2137
|
+
// group": any missing id is an error, not a partial create. A
|
|
2138
|
+
// resolved id that's itself a group (nesting) or already belongs to a
|
|
2139
|
+
// group (would require editing that group's membership) is out of
|
|
2140
|
+
// scope for this minimal tool per ADR-003 and reported as
|
|
2141
|
+
// unsupported_operation instead of attempted.
|
|
2142
|
+
function executeGroupNodesTool(args, runIdMap, runHistoryEvents) {
|
|
2143
|
+
args = args || {};
|
|
2144
|
+
var name = typeof args.name === "string" ? args.name : "";
|
|
2145
|
+
var nodeIds = Array.isArray(args.nodeIds) ? args.nodeIds : [];
|
|
2146
|
+
if (!name || !nodeIds.length) {
|
|
2147
|
+
return buildWriteToolResult(args, [], { error: "group_nodes requires name and at least one nodeId" });
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// CLAUDE-013: this is the exact call shape that surfaced the "node
|
|
2151
|
+
// not found: fp-new-2" error — a node created by an EARLIER
|
|
2152
|
+
// apply_step call in the same run, referenced here by its
|
|
2153
|
+
// placeholder id, with no idMap of its own to resolve against.
|
|
2154
|
+
var resolvedIds = nodeIds.map(function (id) {
|
|
2155
|
+
return (runIdMap && typeof id === "string" && runIdMap[id]) || id;
|
|
2156
|
+
});
|
|
2157
|
+
var uniqueIds = resolvedIds.filter(function (id, i) { return resolvedIds.indexOf(id) === i; });
|
|
2158
|
+
var missing = [], nested = [], alreadyGrouped = [], resolved = [];
|
|
2159
|
+
uniqueIds.forEach(function (id) {
|
|
2160
|
+
var live = findLiveNode(id);
|
|
2161
|
+
if (!live) { missing.push(id); return; }
|
|
2162
|
+
if (live.type === "group") { nested.push(id); return; }
|
|
2163
|
+
if (live.g) { alreadyGrouped.push(id); return; }
|
|
2164
|
+
resolved.push(live);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
if (missing.length) {
|
|
2168
|
+
return buildWriteToolResult(args, [], { error: "group_nodes: node id(s) not found: " + missing.join(", ") });
|
|
2169
|
+
}
|
|
2170
|
+
if (nested.length || alreadyGrouped.length) {
|
|
2171
|
+
var reasonParts = [];
|
|
2172
|
+
if (nested.length) { reasonParts.push("already a group: " + nested.join(", ")); }
|
|
2173
|
+
if (alreadyGrouped.length) { reasonParts.push("already in another group: " + alreadyGrouped.join(", ")); }
|
|
2174
|
+
return buildWriteToolResult(args, [], {
|
|
2175
|
+
unsupported: true,
|
|
2176
|
+
operation: "group_nodes",
|
|
2177
|
+
reason: "Nested groups and existing-group membership edits aren't supported (" + reasonParts.join("; ") + ").",
|
|
2178
|
+
available: ["apply_step", "remove_step", "rename_node", "group_nodes"]
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// CLAUDE-026: all resolved nodes must share one tab. Node-RED core's
|
|
2183
|
+
// own RED.group.createGroup (confirmed via source) registers an
|
|
2184
|
+
// EMPTY group via RED.nodes.addGroup() FIRST, using resolved[0].z,
|
|
2185
|
+
// and only THEN populates it via addToGroup — which validates every
|
|
2186
|
+
// node's .z matches and throws if not. createGroup catches that
|
|
2187
|
+
// throw itself, RED.notifies it, and returns undefined — but never
|
|
2188
|
+
// undoes the addGroup() call, so a mismatched-z request leaves a
|
|
2189
|
+
// real, empty, orphaned group on resolved[0]'s tab every time.
|
|
2190
|
+
// Checking up front avoids ever creating that orphan for this
|
|
2191
|
+
// (deterministic, reproducible-by-inspection) cause.
|
|
2192
|
+
var groupZ = resolved.length ? resolved[0].z : null;
|
|
2193
|
+
var mismatchedZ = resolved.some(function (n) { return n.z !== groupZ; });
|
|
2194
|
+
if (mismatchedZ) {
|
|
2195
|
+
return buildWriteToolResult(args, [], { error: "group_nodes: all nodes must be on the same tab to be grouped" });
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Belt-and-suspenders for any OTHER way createGroup can fail after
|
|
2199
|
+
// already registering that empty shell (a live QA run hit one:
|
|
2200
|
+
// "Node type not installed: group" on 1 of 3 attempts, whose exact
|
|
2201
|
+
// trigger wasn't pinned down) — snapshot the groups already on this
|
|
2202
|
+
// tab before each attempt, and if createGroup comes back falsy,
|
|
2203
|
+
// diff RED.nodes.groups(z) against the snapshot to find and remove
|
|
2204
|
+
// (via RED.group.ungroup — the same full-removal path
|
|
2205
|
+
// applyGroupChanges' disband branch already uses, confirmed safe on
|
|
2206
|
+
// a zero-member group) exactly what OUR call just orphaned, never
|
|
2207
|
+
// anything the user made themselves. One retry after cleanup in
|
|
2208
|
+
// case the underlying cause was transient.
|
|
2209
|
+
function attemptCreateGroup() {
|
|
2210
|
+
var before = {};
|
|
2211
|
+
RED.nodes.groups(groupZ).forEach(function (g) { before[g.id] = true; });
|
|
2212
|
+
var group;
|
|
2213
|
+
var caught = null;
|
|
2214
|
+
try {
|
|
2215
|
+
group = RED.group.createGroup(resolved);
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
caught = e;
|
|
2218
|
+
}
|
|
2219
|
+
if (!group) {
|
|
2220
|
+
RED.nodes.groups(groupZ).forEach(function (g) {
|
|
2221
|
+
if (!before[g.id]) { RED.group.ungroup(g); }
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
return { group: group, error: caught };
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
var attempt = attemptCreateGroup();
|
|
2228
|
+
if (!attempt.group) { attempt = attemptCreateGroup(); }
|
|
2229
|
+
if (!attempt.group) {
|
|
2230
|
+
var errMsg = (attempt.error && attempt.error.message) || attempt.error || "createGroup returned nothing";
|
|
2231
|
+
return buildWriteToolResult(args, [], { error: "Failed to create group: " + errMsg });
|
|
2232
|
+
}
|
|
2233
|
+
var newGroup = attempt.group;
|
|
2234
|
+
newGroup.name = name;
|
|
2235
|
+
RED.group.markDirty(newGroup);
|
|
2236
|
+
// CLAUDE-027: collected into this run's shared accumulator (same
|
|
2237
|
+
// mechanism as applyInsertions/applyModifications above) rather than
|
|
2238
|
+
// pushed straight to RED.history, so this run's flush can fold it
|
|
2239
|
+
// together with the rest of this run's WRITE-tool calls into ONE
|
|
2240
|
+
// undo entry via t:"multi".
|
|
2241
|
+
var createGroupHistoryEvent = { t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() };
|
|
2242
|
+
if (runHistoryEvents) { runHistoryEvents.push(createGroupHistoryEvent); } else { RED.history.push(createGroupHistoryEvent); }
|
|
2243
|
+
// markDirty alone doesn't force the editor to recompute the new
|
|
2244
|
+
// group's visible boundary immediately (apply-review.js's
|
|
2245
|
+
// applyInsertions already redraws after every insertion for the
|
|
2246
|
+
// same reason) — without this the group stays invisible until some
|
|
2247
|
+
// unrelated user action (zoom, node move) triggers a real redraw.
|
|
2248
|
+
RED.view.redraw(true);
|
|
2249
|
+
|
|
2250
|
+
// CLAUDE-020: group_nodes succeeded silently — no chat confirmation,
|
|
2251
|
+
// unlike its sibling WRITE-tool executor (apply-review.js's
|
|
2252
|
+
// applyInsertions), which reports a "Touchdown" note on every
|
|
2253
|
+
// successful insertion. Mirror that here so a Build-plan step that
|
|
2254
|
+
// groups nodes is actually confirmed in the chat, not just visible
|
|
2255
|
+
// on the canvas.
|
|
2256
|
+
var groupedNote = "Touchdown — created group \"" + name + "\" (" +
|
|
2257
|
+
resolved.length + " node(s)). Ctrl+Z to undo.";
|
|
2258
|
+
addMessage("assistant", groupedNote);
|
|
2259
|
+
pushHistory("assistant", groupedNote);
|
|
2260
|
+
updateSelectionStatus();
|
|
2261
|
+
|
|
2262
|
+
var steps = [
|
|
2263
|
+
{ check: "exists", nodeId: newGroup.id },
|
|
2264
|
+
{ check: "property", nodeId: newGroup.id, prop: "name", expected: name }
|
|
2265
|
+
];
|
|
2266
|
+
resolved.forEach(function (n) {
|
|
2267
|
+
steps.push({ check: "property", nodeId: n.id, prop: "g", expected: newGroup.id });
|
|
2268
|
+
});
|
|
2269
|
+
var checks = runChecksForToolResult(steps, {});
|
|
2270
|
+
return buildWriteToolResult(args, checks, { groupId: newGroup.id });
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1652
2273
|
// Per-step narration: a short human-readable description of what a tool
|
|
1653
2274
|
// call is about to do, shown in the pending indicator (see runAgentChat).
|
|
1654
2275
|
function describeAgentToolCall(name, args) {
|
|
@@ -1666,6 +2287,18 @@
|
|
|
1666
2287
|
return "Checking the debug log…";
|
|
1667
2288
|
case "get_selection":
|
|
1668
2289
|
return "Checking the current selection…";
|
|
2290
|
+
case "apply_step":
|
|
2291
|
+
return "Applying step" + (args.summary ? ": " + args.summary : "") + "…";
|
|
2292
|
+
case "remove_step":
|
|
2293
|
+
return "Removing node" + (args.summary ? ": " + args.summary : " " + JSON.stringify(args.nodeId || "?")) + "…";
|
|
2294
|
+
case "rename_node":
|
|
2295
|
+
return "Renaming node to " + JSON.stringify(args.name || "?") + "…";
|
|
2296
|
+
case "group_nodes":
|
|
2297
|
+
return "Creating group " + JSON.stringify(args.name || "?") + "…";
|
|
2298
|
+
case "redirect_mode":
|
|
2299
|
+
return "Redirecting to " + JSON.stringify(args.mode || "?") + " mode…";
|
|
2300
|
+
case "ask_user":
|
|
2301
|
+
return "Asking a clarifying question…";
|
|
1669
2302
|
default:
|
|
1670
2303
|
return "Running " + (name || "a tool") + "…";
|
|
1671
2304
|
}
|
|
@@ -1679,7 +2312,7 @@
|
|
|
1679
2312
|
catch (e) { return {}; }
|
|
1680
2313
|
}
|
|
1681
2314
|
|
|
1682
|
-
function executeAgentToolCall(call) {
|
|
2315
|
+
function executeAgentToolCall(call, runIdMap, runHistoryEvents) {
|
|
1683
2316
|
var name = call && call.function && call.function.name;
|
|
1684
2317
|
var args = parseToolCallArgs(call);
|
|
1685
2318
|
switch (name) {
|
|
@@ -1695,6 +2328,21 @@
|
|
|
1695
2328
|
return executeReadDebugTool(args);
|
|
1696
2329
|
case "get_selection":
|
|
1697
2330
|
return executeGetSelectionTool();
|
|
2331
|
+
case "apply_step":
|
|
2332
|
+
return executeApplyStepTool(args, runIdMap, runHistoryEvents);
|
|
2333
|
+
case "remove_step":
|
|
2334
|
+
return executeRemoveStepTool(args, runIdMap, runHistoryEvents);
|
|
2335
|
+
case "rename_node":
|
|
2336
|
+
return executeRenameNodeTool(args, runIdMap, runHistoryEvents);
|
|
2337
|
+
case "group_nodes":
|
|
2338
|
+
return executeGroupNodesTool(args, runIdMap, runHistoryEvents);
|
|
2339
|
+
case "redirect_mode":
|
|
2340
|
+
return executeRedirectModeTool(args);
|
|
2341
|
+
case "ask_user":
|
|
2342
|
+
// Non-mutating and always intercepted by the agent loop
|
|
2343
|
+
// (modes.js's handleStep) before reaching here — this is a
|
|
2344
|
+
// safe fallback only, never expected in normal operation.
|
|
2345
|
+
return { error: "ask_user must be answered via the loop's question UI, not executed directly" };
|
|
1698
2346
|
default:
|
|
1699
2347
|
return { error: "Unknown tool: " + name };
|
|
1700
2348
|
}
|