@tbrandenburg/node-red-agents 0.1.3 → 0.1.5
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/nodes/agent/agent.js +401 -368
- package/nodes/agent/lib/agents/base.js +31 -31
- package/nodes/agent/lib/agents/opencode.js +127 -120
- package/nodes/agent/lib/agents/pi.js +195 -175
- package/nodes/agent/lib/execution/lifecycle.js +41 -41
- package/nodes/agent/lib/execution/scheduler.js +74 -73
- package/nodes/agent/lib/execution/status.js +10 -10
- package/nodes/agent/lib/mcp/normalize.js +16 -16
- package/nodes/agent/lib/runtimes/base.js +10 -10
- package/nodes/agent/lib/runtimes/direct.js +19 -19
- package/nodes/agent/lib/runtimes/process-exec.js +71 -71
- package/nodes/agent/lib/runtimes/srt.js +40 -40
- package/nodes/agent-server/agent-server.js +495 -458
- package/nodes/agent-server/lib/daemon.js +99 -84
- package/nodes/agent-server/lib/http.js +45 -43
- package/nodes/agent-server/lib/model.js +10 -8
- package/nodes/agent-server/lib/port.js +14 -14
- package/nodes/agent-server/lib/registry.js +54 -54
- package/nodes/agent-server/lib/status.js +6 -6
- package/nodes/gh/README.md +11 -11
- package/nodes/gh/examples/list-pull-requests.json +46 -46
- package/nodes/gh/examples/run-workflow.json +40 -40
- package/nodes/gh/gh.js +220 -211
- package/nodes/gh/lib/parse-args.js +43 -43
- package/package.json +1 -1
- package/shared/srt-settings.js +32 -29
package/nodes/agent/agent.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
const fs = require(
|
|
2
|
-
const { OpenCodeAdapter } = require(
|
|
3
|
-
const { PiAdapter } = require(
|
|
4
|
-
const { DirectRuntime } = require(
|
|
5
|
-
const { SrtRuntime } = require(
|
|
6
|
-
const { writeInlineSettingsFile } = require(
|
|
7
|
-
const { runAgent } = require(
|
|
8
|
-
const { ExecutionScheduler } = require(
|
|
9
|
-
const { computeNodeStatus } = require(
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { OpenCodeAdapter } = require("./lib/agents/opencode");
|
|
3
|
+
const { PiAdapter } = require("./lib/agents/pi");
|
|
4
|
+
const { DirectRuntime } = require("./lib/runtimes/direct");
|
|
5
|
+
const { SrtRuntime } = require("./lib/runtimes/srt");
|
|
6
|
+
const { writeInlineSettingsFile } = require("../../shared/srt-settings");
|
|
7
|
+
const { runAgent } = require("./lib/execution/lifecycle");
|
|
8
|
+
const { ExecutionScheduler } = require("./lib/execution/scheduler");
|
|
9
|
+
const { computeNodeStatus } = require("./lib/execution/status");
|
|
10
10
|
|
|
11
11
|
// Registries. Adding a future adapter/runtime is just one more entry here --
|
|
12
12
|
// nothing else in this file (or in lib/execution/lifecycle.js) needs to
|
|
@@ -14,383 +14,416 @@ const { computeNodeStatus } = require('./lib/execution/status');
|
|
|
14
14
|
// (lib/execution/scheduler.js) is likewise fully independent of both: it
|
|
15
15
|
// only ever sees opaque { executionId, ... } items.
|
|
16
16
|
const AGENTS = {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
opencode: () => new OpenCodeAdapter(),
|
|
18
|
+
pi: () => new PiAdapter(),
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
function buildRuntime(node) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
if (node.runtime === "srt") {
|
|
23
|
+
return new SrtRuntime({
|
|
24
|
+
binary: node.srtBinary || undefined,
|
|
25
|
+
settingsPath: node.resolvedSrtSettingsPath || undefined,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return new DirectRuntime();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
let executionCounter = 0;
|
|
32
32
|
function nextExecutionId() {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
executionCounter += 1;
|
|
34
|
+
return `exec-${Date.now()}-${executionCounter}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
module.exports = function (RED) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Default 1 = sequential, matching today's single-node-single-run
|
|
116
|
-
// mental model unless a flow author explicitly opts into more.
|
|
117
|
-
const concurrencyNum = parseInt(config.concurrency, 10);
|
|
118
|
-
node.concurrency = Number.isFinite(concurrencyNum) && concurrencyNum > 0 ? concurrencyNum : 1;
|
|
119
|
-
|
|
120
|
-
// Last terminal outcome, shown once active+queued both drop to 0.
|
|
121
|
-
node.lastTerminal = undefined;
|
|
122
|
-
node.lastText = undefined;
|
|
123
|
-
|
|
124
|
-
function updateStatus() {
|
|
125
|
-
node.status(
|
|
126
|
-
computeNodeStatus({
|
|
127
|
-
active: node.scheduler.activeCount,
|
|
128
|
-
queued: node.scheduler.queuedCount,
|
|
129
|
-
lastTerminal: node.lastTerminal,
|
|
130
|
-
lastText: node.lastText
|
|
131
|
-
})
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Resolves a typed-input field the same way for every field: an
|
|
136
|
-
// empty property path is only meaningful for type 'str' (falls
|
|
137
|
-
// through to `fallback`); for 'msg'/'flow'/'global'/'env' an empty
|
|
138
|
-
// path is invalid, so it's never even evaluated.
|
|
139
|
-
function resolveTyped(prop, type, msg, fallback) {
|
|
140
|
-
if (prop === '') return fallback;
|
|
141
|
-
try {
|
|
142
|
-
const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
|
|
143
|
-
return value === undefined || value === null ? fallback : value;
|
|
144
|
-
} catch (err) {
|
|
145
|
-
throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Common envelope for every message on output 2 (the lifecycle/event
|
|
150
|
-
// stream): correlation (topic, executionId), live scheduler counts
|
|
151
|
-
// (so a widget bound to this stream always has the current
|
|
152
|
-
// active/queued numbers, no separate polling needed) and a
|
|
153
|
-
// timestamp (so external aggregation/history doesn't have to rely
|
|
154
|
-
// on message-arrival time).
|
|
155
|
-
function lifecycleEnvelope(msg, executionId, payload) {
|
|
156
|
-
return {
|
|
157
|
-
_msgid: msg._msgid,
|
|
158
|
-
topic: msg.topic,
|
|
159
|
-
payload,
|
|
160
|
-
agent: node.agent,
|
|
161
|
-
runtime: node.runtime,
|
|
162
|
-
executionId,
|
|
163
|
-
active: node.scheduler.activeCount,
|
|
164
|
-
queued: node.scheduler.queuedCount,
|
|
165
|
-
timestamp: Date.now()
|
|
166
|
-
};
|
|
38
|
+
"use strict";
|
|
39
|
+
|
|
40
|
+
function AgentNode(config) {
|
|
41
|
+
RED.nodes.createNode(this, config);
|
|
42
|
+
const node = this;
|
|
43
|
+
|
|
44
|
+
node.agent = config.agent || "opencode";
|
|
45
|
+
node.runtime = config.runtime || "direct";
|
|
46
|
+
node.invocation = config.invocation || "prompt";
|
|
47
|
+
|
|
48
|
+
node.model = config.model || "";
|
|
49
|
+
node.modelType = config.modelType || "str";
|
|
50
|
+
|
|
51
|
+
node.prompt = config.prompt !== undefined ? config.prompt : "payload";
|
|
52
|
+
node.promptType = config.promptType || "msg";
|
|
53
|
+
|
|
54
|
+
// Same name/default/type as the agent-server node's sessionIdProp:
|
|
55
|
+
// absent/blank on a run -> a brand-new session is started (today's
|
|
56
|
+
// only behavior); present -> that session is resumed instead (only
|
|
57
|
+
// the OpenCode adapter supports this -- see lib/agents/opencode.js
|
|
58
|
+
// and lib/agents/pi.js).
|
|
59
|
+
node.sessionIdProp = config.sessionIdProp !== undefined ? config.sessionIdProp : "sessionID";
|
|
60
|
+
node.sessionIdPropType = config.sessionIdPropType || "msg";
|
|
61
|
+
|
|
62
|
+
node.invocationName = config.invocationName || "";
|
|
63
|
+
node.invocationNameType = config.invocationNameType || "str";
|
|
64
|
+
|
|
65
|
+
node.arguments_ = config.arguments !== undefined ? config.arguments : "payload";
|
|
66
|
+
node.argumentsType = config.argumentsType || "msg";
|
|
67
|
+
|
|
68
|
+
node.cwd = config.cwd !== undefined ? config.cwd : "cwd";
|
|
69
|
+
node.cwdType = config.cwdType || "msg";
|
|
70
|
+
|
|
71
|
+
node.auto = config.auto === true;
|
|
72
|
+
|
|
73
|
+
node.timeout = config.timeout !== undefined ? config.timeout : "";
|
|
74
|
+
node.timeoutType = config.timeoutType || "num";
|
|
75
|
+
|
|
76
|
+
node.mcpServers = Array.isArray(config.mcpServers) ? config.mcpServers : [];
|
|
77
|
+
|
|
78
|
+
node.srtBinary = config.srtBinary || "";
|
|
79
|
+
node.srtSettingsMode = config.srtSettingsMode || "file";
|
|
80
|
+
node.srtSettingsPath = config.srtSettingsPath || "";
|
|
81
|
+
node.srtAllowedDomains = Array.isArray(config.srtAllowedDomains)
|
|
82
|
+
? config.srtAllowedDomains
|
|
83
|
+
: [];
|
|
84
|
+
node.srtAllowedWriteDirs = Array.isArray(config.srtAllowedWriteDirs)
|
|
85
|
+
? config.srtAllowedWriteDirs
|
|
86
|
+
: [];
|
|
87
|
+
node.srtStrictAllowlist = config.srtStrictAllowlist !== false;
|
|
88
|
+
node.srtAdvancedJson = config.srtAdvancedJson || "";
|
|
89
|
+
|
|
90
|
+
// Resolved once at construction time (not per-execution -- these
|
|
91
|
+
// settings don't change without a redeploy). For 'file' mode this
|
|
92
|
+
// is just srtSettingsPath itself; for 'inline' mode it's a
|
|
93
|
+
// generated temp settings file. node.resolvedSrtSettingsPath stays
|
|
94
|
+
// undefined (srt falls back to its own default) if unset/failed.
|
|
95
|
+
node.resolvedSrtSettingsPath = undefined;
|
|
96
|
+
node.srtTempSettingsFile = undefined;
|
|
97
|
+
node.srtSettingsError = undefined;
|
|
98
|
+
|
|
99
|
+
if (node.runtime === "srt") {
|
|
100
|
+
if (node.srtSettingsMode === "inline") {
|
|
101
|
+
try {
|
|
102
|
+
node.resolvedSrtSettingsPath = writeInlineSettingsFile(node.id, {
|
|
103
|
+
allowedDomains: node.srtAllowedDomains,
|
|
104
|
+
allowedWriteDirs: node.srtAllowedWriteDirs,
|
|
105
|
+
strictAllowlist: node.srtStrictAllowlist,
|
|
106
|
+
advancedJson: node.srtAdvancedJson,
|
|
107
|
+
});
|
|
108
|
+
node.srtTempSettingsFile = node.resolvedSrtSettingsPath;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
node.srtSettingsError = `invalid inline SRT settings JSON: ${err.message}`;
|
|
111
|
+
node.error(`agent: ${node.srtSettingsError}`);
|
|
112
|
+
node.status({ fill: "red", shape: "ring", text: "bad srt settings" });
|
|
167
113
|
}
|
|
114
|
+
} else {
|
|
115
|
+
node.resolvedSrtSettingsPath = node.srtSettingsPath || undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
168
118
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
119
|
+
// Default 1 = sequential, matching today's single-node-single-run
|
|
120
|
+
// mental model unless a flow author explicitly opts into more.
|
|
121
|
+
const concurrencyNum = parseInt(config.concurrency, 10);
|
|
122
|
+
node.concurrency = Number.isFinite(concurrencyNum) && concurrencyNum > 0 ? concurrencyNum : 1;
|
|
123
|
+
|
|
124
|
+
// Last terminal outcome, shown once active+queued both drop to 0.
|
|
125
|
+
node.lastTerminal = undefined;
|
|
126
|
+
node.lastText = undefined;
|
|
127
|
+
|
|
128
|
+
function updateStatus() {
|
|
129
|
+
node.status(
|
|
130
|
+
computeNodeStatus({
|
|
131
|
+
active: node.scheduler.activeCount,
|
|
132
|
+
queued: node.scheduler.queuedCount,
|
|
133
|
+
lastTerminal: node.lastTerminal,
|
|
134
|
+
lastText: node.lastText,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
172
138
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
onEvent: (event) => {
|
|
187
|
-
send([null, lifecycleEnvelope(msg, executionId, event)]);
|
|
188
|
-
},
|
|
189
|
-
onStatus: (status) => {
|
|
190
|
-
if (status === 'running') {
|
|
191
|
-
emitEvent(send, msg, executionId, 'running');
|
|
192
|
-
} else {
|
|
193
|
-
// Terminal (completed/failed/timeout): stash rather
|
|
194
|
-
// than emit immediately -- the scheduler hasn't
|
|
195
|
-
// removed this execution from `active` yet at this
|
|
196
|
-
// point, so the active/queued counts on the
|
|
197
|
-
// envelope would be stale by one. onSettled (below)
|
|
198
|
-
// emits it once the scheduler's own bookkeeping,
|
|
199
|
-
// including any newly-started queued item, is
|
|
200
|
-
// fully settled.
|
|
201
|
-
item.finalStatus = status;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
})
|
|
205
|
-
.then((result) => {
|
|
206
|
-
node.lastTerminal = result.status;
|
|
207
|
-
node.lastText = undefined;
|
|
208
|
-
|
|
209
|
-
const resultMsg = Object.assign({}, msg, {
|
|
210
|
-
payload: result.payload,
|
|
211
|
-
agent: node.agent,
|
|
212
|
-
runtime: node.runtime,
|
|
213
|
-
// Top-level, in addition to agentExecution.sessionID
|
|
214
|
-
// below: matches the agent-server node's convention
|
|
215
|
-
// so this output can be fed straight back into the
|
|
216
|
-
// (default) Session ID field -- msg.sessionID -- of
|
|
217
|
-
// this or another agent node with no extra wiring.
|
|
218
|
-
sessionID: result.sessionID,
|
|
219
|
-
agentExecution: {
|
|
220
|
-
id: executionId,
|
|
221
|
-
status: result.status,
|
|
222
|
-
exitCode: result.exitCode,
|
|
223
|
-
signal: result.signal,
|
|
224
|
-
timedOut: result.timedOut,
|
|
225
|
-
durationMs: result.durationMs,
|
|
226
|
-
sessionID: result.sessionID
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
send([resultMsg, null]);
|
|
230
|
-
|
|
231
|
-
if (result.status === 'failed' || result.status === 'timeout') {
|
|
232
|
-
done(
|
|
233
|
-
`agent (${node.agent}/${node.runtime}): ${result.status}` +
|
|
234
|
-
(result.errorMessage ? ` -- ${result.errorMessage}` : '') +
|
|
235
|
-
` [executionId=${executionId} cwd=${resolved.cwd || '(default)'} exitCode=${result.exitCode}]`
|
|
236
|
-
);
|
|
237
|
-
} else {
|
|
238
|
-
done();
|
|
239
|
-
}
|
|
240
|
-
})
|
|
241
|
-
.catch((err) => {
|
|
242
|
-
node.lastTerminal = 'failed';
|
|
243
|
-
node.lastText = 'error';
|
|
244
|
-
// Covers e.g. adapter.validate() throwing synchronously,
|
|
245
|
-
// before onStatus('running') ever fires -- still needs a
|
|
246
|
-
// terminal lifecycle event for anything tracking this
|
|
247
|
-
// execution by executionId/topic.
|
|
248
|
-
item.finalStatus = 'failed';
|
|
249
|
-
done(new Error(`agent (${node.agent}/${node.runtime}) [executionId=${executionId}]: ${err.message}`));
|
|
250
|
-
});
|
|
251
|
-
}
|
|
139
|
+
// Resolves a typed-input field the same way for every field: an
|
|
140
|
+
// empty property path is only meaningful for type 'str' (falls
|
|
141
|
+
// through to `fallback`); for 'msg'/'flow'/'global'/'env' an empty
|
|
142
|
+
// path is invalid, so it's never even evaluated.
|
|
143
|
+
function resolveTyped(prop, type, msg, fallback) {
|
|
144
|
+
if (prop === "") return fallback;
|
|
145
|
+
try {
|
|
146
|
+
const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
|
|
147
|
+
return value === undefined || value === null ? fallback : value;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
252
152
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
153
|
+
// Common envelope for every message on output 2 (the lifecycle/event
|
|
154
|
+
// stream): correlation (topic, executionId), live scheduler counts
|
|
155
|
+
// (so a widget bound to this stream always has the current
|
|
156
|
+
// active/queued numbers, no separate polling needed) and a
|
|
157
|
+
// timestamp (so external aggregation/history doesn't have to rely
|
|
158
|
+
// on message-arrival time).
|
|
159
|
+
function lifecycleEnvelope(msg, executionId, payload) {
|
|
160
|
+
return {
|
|
161
|
+
_msgid: msg._msgid,
|
|
162
|
+
topic: msg.topic,
|
|
163
|
+
payload,
|
|
164
|
+
agent: node.agent,
|
|
165
|
+
runtime: node.runtime,
|
|
166
|
+
executionId,
|
|
167
|
+
active: node.scheduler.activeCount,
|
|
168
|
+
queued: node.scheduler.queuedCount,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
266
172
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// executionId). A separate msg from the one being terminated -- the
|
|
271
|
-
// terminated execution's own original done()/outputs still fire on
|
|
272
|
-
// their own once the process actually exits (status 'failed' with a
|
|
273
|
-
// SIGTERM/SIGKILL signal, same as any other non-zero exit), this
|
|
274
|
-
// handler's output/done is only the immediate "kill requested" ack.
|
|
275
|
-
function handleTerminateOperation(msg, send, done) {
|
|
276
|
-
const executionId = typeof msg.executionId === 'string' ? msg.executionId.trim() : '';
|
|
277
|
-
if (!executionId) {
|
|
278
|
-
done(new Error('agent: terminate requires msg.executionId'));
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const result = node.scheduler.cancel(executionId);
|
|
283
|
-
if (!result) {
|
|
284
|
-
done(new Error(`agent: unknown or already-finished executionId "${executionId}"`));
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (result.status === 'queued') {
|
|
289
|
-
const queuedItem = result.item;
|
|
290
|
-
emitEvent(queuedItem.send, queuedItem.msg, executionId, 'cancelled');
|
|
291
|
-
queuedItem.done(new Error('agent: execution cancelled before it started (terminate requested)'));
|
|
292
|
-
updateStatus();
|
|
293
|
-
send([Object.assign({}, msg, { payload: { executionId, terminated: true, status: 'cancelled' } }), null]);
|
|
294
|
-
done();
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Active: send SIGTERM (escalating to SIGKILL) to the whole
|
|
299
|
-
// process group. Works identically for both the direct and srt
|
|
300
|
-
// runtimes -- see process-exec.js's killProcessGroup -- so no
|
|
301
|
-
// per-runtime branching is needed here.
|
|
302
|
-
const runtime = buildRuntime(node);
|
|
303
|
-
Promise.resolve(runtime.terminate(executionId))
|
|
304
|
-
.then(() => {
|
|
305
|
-
send([Object.assign({}, msg, { payload: { executionId, terminated: true, status: 'terminating' } }), null]);
|
|
306
|
-
done();
|
|
307
|
-
})
|
|
308
|
-
.catch((err) => done(err));
|
|
309
|
-
}
|
|
173
|
+
function emitEvent(send, msg, executionId, type) {
|
|
174
|
+
send([null, lifecycleEnvelope(msg, executionId, { type })]);
|
|
175
|
+
}
|
|
310
176
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
177
|
+
// The actual work for one execution. Only ever invoked by the
|
|
178
|
+
// scheduler once a concurrency slot is free -- never called
|
|
179
|
+
// directly from the input handler.
|
|
180
|
+
function startExecution(item) {
|
|
181
|
+
const { executionId, msg, send, done, resolved } = item;
|
|
182
|
+
const adapter = AGENTS[node.agent]();
|
|
183
|
+
const runtime = buildRuntime(node);
|
|
184
|
+
|
|
185
|
+
return runAgent({
|
|
186
|
+
adapter,
|
|
187
|
+
runtime,
|
|
188
|
+
resolved,
|
|
189
|
+
executionId,
|
|
190
|
+
onEvent: (event) => {
|
|
191
|
+
send([null, lifecycleEnvelope(msg, executionId, event)]);
|
|
192
|
+
},
|
|
193
|
+
onStatus: (status) => {
|
|
194
|
+
if (status === "running") {
|
|
195
|
+
emitEvent(send, msg, executionId, "running");
|
|
196
|
+
} else {
|
|
197
|
+
// Terminal (completed/failed/timeout): stash rather
|
|
198
|
+
// than emit immediately -- the scheduler hasn't
|
|
199
|
+
// removed this execution from `active` yet at this
|
|
200
|
+
// point, so the active/queued counts on the
|
|
201
|
+
// envelope would be stale by one. onSettled (below)
|
|
202
|
+
// emits it once the scheduler's own bookkeeping,
|
|
203
|
+
// including any newly-started queued item, is
|
|
204
|
+
// fully settled.
|
|
205
|
+
item.finalStatus = status;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
.then((result) => {
|
|
210
|
+
node.lastTerminal = result.status;
|
|
211
|
+
node.lastText = undefined;
|
|
212
|
+
|
|
213
|
+
const resultMsg = Object.assign({}, msg, {
|
|
214
|
+
payload: result.payload,
|
|
215
|
+
agent: node.agent,
|
|
216
|
+
runtime: node.runtime,
|
|
217
|
+
// Top-level, in addition to agentExecution.sessionID
|
|
218
|
+
// below: matches the agent-server node's convention
|
|
219
|
+
// so this output can be fed straight back into the
|
|
220
|
+
// (default) Session ID field -- msg.sessionID -- of
|
|
221
|
+
// this or another agent node with no extra wiring.
|
|
222
|
+
sessionID: result.sessionID,
|
|
223
|
+
agentExecution: {
|
|
224
|
+
id: executionId,
|
|
225
|
+
status: result.status,
|
|
226
|
+
exitCode: result.exitCode,
|
|
227
|
+
signal: result.signal,
|
|
228
|
+
timedOut: result.timedOut,
|
|
229
|
+
durationMs: result.durationMs,
|
|
230
|
+
sessionID: result.sessionID,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
send([resultMsg, null]);
|
|
234
|
+
|
|
235
|
+
if (result.status === "failed" || result.status === "timeout") {
|
|
236
|
+
done(
|
|
237
|
+
`agent (${node.agent}/${node.runtime}): ${result.status}` +
|
|
238
|
+
(result.errorMessage ? ` -- ${result.errorMessage}` : "") +
|
|
239
|
+
` [executionId=${executionId} cwd=${resolved.cwd || "(default)"} exitCode=${result.exitCode}]`,
|
|
240
|
+
);
|
|
241
|
+
} else {
|
|
242
|
+
done();
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.catch((err) => {
|
|
246
|
+
node.lastTerminal = "failed";
|
|
247
|
+
node.lastText = "error";
|
|
248
|
+
// Covers e.g. adapter.validate() throwing synchronously,
|
|
249
|
+
// before onStatus('running') ever fires -- still needs a
|
|
250
|
+
// terminal lifecycle event for anything tracking this
|
|
251
|
+
// execution by executionId/topic.
|
|
252
|
+
item.finalStatus = "failed";
|
|
253
|
+
done(
|
|
254
|
+
new Error(
|
|
255
|
+
`agent (${node.agent}/${node.runtime}) [executionId=${executionId}]: ${err.message}`,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
371
258
|
});
|
|
259
|
+
}
|
|
372
260
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
261
|
+
node.scheduler = new ExecutionScheduler({
|
|
262
|
+
concurrency: node.concurrency,
|
|
263
|
+
onStart: startExecution,
|
|
264
|
+
onQueued: (item) => emitEvent(item.send, item.msg, item.executionId, "queued"),
|
|
265
|
+
// Runs after this item is removed from `active` and any newly-
|
|
266
|
+
// eligible queued item has already been started, so the terminal
|
|
267
|
+
// event's active/queued counts are accurate (see the onStatus
|
|
268
|
+
// comment in startExecution for why it's deferred to here).
|
|
269
|
+
onSettled: (item) => {
|
|
270
|
+
if (item.finalStatus) emitEvent(item.send, item.msg, item.executionId, item.finalStatus);
|
|
271
|
+
updateStatus();
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// On-demand termination of one in-flight (or still-queued) execution
|
|
276
|
+
// of *this* node instance, addressed by the executionId a previous
|
|
277
|
+
// trigger returned (msg.agentExecution.id / the events envelope's
|
|
278
|
+
// executionId). A separate msg from the one being terminated -- the
|
|
279
|
+
// terminated execution's own original done()/outputs still fire on
|
|
280
|
+
// their own once the process actually exits (status 'failed' with a
|
|
281
|
+
// SIGTERM/SIGKILL signal, same as any other non-zero exit), this
|
|
282
|
+
// handler's output/done is only the immediate "kill requested" ack.
|
|
283
|
+
function handleTerminateOperation(msg, send, done) {
|
|
284
|
+
const executionId = typeof msg.executionId === "string" ? msg.executionId.trim() : "";
|
|
285
|
+
if (!executionId) {
|
|
286
|
+
done(new Error("agent: terminate requires msg.executionId"));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = node.scheduler.cancel(executionId);
|
|
291
|
+
if (!result) {
|
|
292
|
+
done(new Error(`agent: unknown or already-finished executionId "${executionId}"`));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (result.status === "queued") {
|
|
297
|
+
const queuedItem = result.item;
|
|
298
|
+
emitEvent(queuedItem.send, queuedItem.msg, executionId, "cancelled");
|
|
299
|
+
queuedItem.done(
|
|
300
|
+
new Error("agent: execution cancelled before it started (terminate requested)"),
|
|
301
|
+
);
|
|
302
|
+
updateStatus();
|
|
303
|
+
send([
|
|
304
|
+
Object.assign({}, msg, {
|
|
305
|
+
payload: { executionId, terminated: true, status: "cancelled" },
|
|
306
|
+
}),
|
|
307
|
+
null,
|
|
308
|
+
]);
|
|
309
|
+
done();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Active: send SIGTERM (escalating to SIGKILL) to the whole
|
|
314
|
+
// process group. Works identically for both the direct and srt
|
|
315
|
+
// runtimes -- see process-exec.js's killProcessGroup -- so no
|
|
316
|
+
// per-runtime branching is needed here.
|
|
317
|
+
const runtime = buildRuntime(node);
|
|
318
|
+
Promise.resolve(runtime.terminate(executionId))
|
|
319
|
+
.then(() => {
|
|
320
|
+
send([
|
|
321
|
+
Object.assign({}, msg, {
|
|
322
|
+
payload: { executionId, terminated: true, status: "terminating" },
|
|
323
|
+
}),
|
|
324
|
+
null,
|
|
325
|
+
]);
|
|
326
|
+
done();
|
|
327
|
+
})
|
|
328
|
+
.catch((err) => done(err));
|
|
393
329
|
}
|
|
394
330
|
|
|
395
|
-
|
|
331
|
+
node.on("input", function (msg, send, done) {
|
|
332
|
+
if (node.srtSettingsError) {
|
|
333
|
+
node.lastTerminal = "failed";
|
|
334
|
+
node.lastText = "bad srt settings";
|
|
335
|
+
updateStatus();
|
|
336
|
+
done(new Error(`agent: ${node.srtSettingsError}`));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (msg.operation === "terminate") {
|
|
341
|
+
handleTerminateOperation(msg, send, done);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let resolved;
|
|
346
|
+
try {
|
|
347
|
+
resolved = {
|
|
348
|
+
invocation: node.invocation,
|
|
349
|
+
prompt:
|
|
350
|
+
node.invocation === "prompt"
|
|
351
|
+
? resolveTyped(node.prompt, node.promptType, msg, msg.payload)
|
|
352
|
+
: undefined,
|
|
353
|
+
invocationName:
|
|
354
|
+
node.invocation !== "prompt"
|
|
355
|
+
? resolveTyped(node.invocationName, node.invocationNameType, msg, "")
|
|
356
|
+
: undefined,
|
|
357
|
+
args:
|
|
358
|
+
node.invocation !== "prompt"
|
|
359
|
+
? resolveTyped(node.arguments_, node.argumentsType, msg, msg.payload)
|
|
360
|
+
: undefined,
|
|
361
|
+
cwd: (() => {
|
|
362
|
+
const v = resolveTyped(node.cwd, node.cwdType, msg, "");
|
|
363
|
+
return v === undefined || v === null ? "" : String(v).trim();
|
|
364
|
+
})(),
|
|
365
|
+
sessionID: (() => {
|
|
366
|
+
const v = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, "");
|
|
367
|
+
return v === undefined || v === null ? "" : String(v).trim();
|
|
368
|
+
})(),
|
|
369
|
+
model: (() => {
|
|
370
|
+
const v = resolveTyped(node.model, node.modelType, msg, "");
|
|
371
|
+
return v === undefined || v === null ? "" : String(v).trim();
|
|
372
|
+
})(),
|
|
373
|
+
auto: node.auto,
|
|
374
|
+
timeoutMs: (() => {
|
|
375
|
+
const v = resolveTyped(node.timeout, node.timeoutType, msg, undefined);
|
|
376
|
+
const num = Number(v);
|
|
377
|
+
return v === undefined || v === "" || !Number.isFinite(num) || num <= 0
|
|
378
|
+
? undefined
|
|
379
|
+
: num * 1000;
|
|
380
|
+
})(),
|
|
381
|
+
mcpServers: node.mcpServers,
|
|
382
|
+
};
|
|
383
|
+
} catch (err) {
|
|
384
|
+
node.lastTerminal = "failed";
|
|
385
|
+
node.lastText = "bad config";
|
|
386
|
+
updateStatus();
|
|
387
|
+
done(err);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!AGENTS[node.agent]) {
|
|
392
|
+
node.lastTerminal = "failed";
|
|
393
|
+
node.lastText = "unknown agent";
|
|
394
|
+
updateStatus();
|
|
395
|
+
done(new Error(`agent: unknown agent "${node.agent}"`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const executionId = nextExecutionId();
|
|
400
|
+
node.scheduler.submit({ executionId, msg, send, done, resolved });
|
|
401
|
+
updateStatus();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
node.on("close", function (done) {
|
|
405
|
+
// Stop accepting further work first: drop anything still
|
|
406
|
+
// waiting in the queue with a clean done()/cancelled event,
|
|
407
|
+
// then terminate whatever's still actively running. No child
|
|
408
|
+
// process should be orphaned by a redeploy or node removal.
|
|
409
|
+
node.scheduler.drainQueue((item) => {
|
|
410
|
+
emitEvent(item.send, item.msg, item.executionId, "cancelled");
|
|
411
|
+
item.done(new Error("agent: node closing, execution cancelled before it started"));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const runtime = buildRuntime(node);
|
|
415
|
+
const activeIds = node.scheduler.activeIds();
|
|
416
|
+
Promise.all(
|
|
417
|
+
activeIds.map((id) => Promise.resolve(runtime.terminate(id)).catch(() => {})),
|
|
418
|
+
).then(() => {
|
|
419
|
+
if (node.srtTempSettingsFile) {
|
|
420
|
+
fs.unlink(node.srtTempSettingsFile, () => {});
|
|
421
|
+
}
|
|
422
|
+
node.status({});
|
|
423
|
+
done();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
RED.nodes.registerType("agent", AgentNode);
|
|
396
429
|
};
|