@tbrandenburg/node-red-agents 0.1.3 → 0.1.4

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.
@@ -1,12 +1,12 @@
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');
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
- opencode: () => new OpenCodeAdapter(),
18
- pi: () => new PiAdapter()
17
+ opencode: () => new OpenCodeAdapter(),
18
+ pi: () => new PiAdapter(),
19
19
  };
20
20
 
21
21
  function buildRuntime(node) {
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();
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
- executionCounter += 1;
34
- return `exec-${Date.now()}-${executionCounter}`;
33
+ executionCounter += 1;
34
+ return `exec-${Date.now()}-${executionCounter}`;
35
35
  }
36
36
 
37
37
  module.exports = function (RED) {
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) ? config.srtAllowedDomains : [];
82
- node.srtAllowedWriteDirs = Array.isArray(config.srtAllowedWriteDirs) ? config.srtAllowedWriteDirs : [];
83
- node.srtStrictAllowlist = config.srtStrictAllowlist !== false;
84
- node.srtAdvancedJson = config.srtAdvancedJson || '';
85
-
86
- // Resolved once at construction time (not per-execution -- these
87
- // settings don't change without a redeploy). For 'file' mode this
88
- // is just srtSettingsPath itself; for 'inline' mode it's a
89
- // generated temp settings file. node.resolvedSrtSettingsPath stays
90
- // undefined (srt falls back to its own default) if unset/failed.
91
- node.resolvedSrtSettingsPath = undefined;
92
- node.srtTempSettingsFile = undefined;
93
- node.srtSettingsError = undefined;
94
-
95
- if (node.runtime === 'srt') {
96
- if (node.srtSettingsMode === 'inline') {
97
- try {
98
- node.resolvedSrtSettingsPath = writeInlineSettingsFile(node.id, {
99
- allowedDomains: node.srtAllowedDomains,
100
- allowedWriteDirs: node.srtAllowedWriteDirs,
101
- strictAllowlist: node.srtStrictAllowlist,
102
- advancedJson: node.srtAdvancedJson
103
- });
104
- node.srtTempSettingsFile = node.resolvedSrtSettingsPath;
105
- } catch (err) {
106
- node.srtSettingsError = `invalid inline SRT settings JSON: ${err.message}`;
107
- node.error(`agent: ${node.srtSettingsError}`);
108
- node.status({ fill: 'red', shape: 'ring', text: 'bad srt settings' });
109
- }
110
- } else {
111
- node.resolvedSrtSettingsPath = node.srtSettingsPath || undefined;
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
- function emitEvent(send, msg, executionId, type) {
170
- send([null, lifecycleEnvelope(msg, executionId, { type })]);
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
- // The actual work for one execution. Only ever invoked by the
174
- // scheduler once a concurrency slot is free -- never called
175
- // directly from the input handler.
176
- function startExecution(item) {
177
- const { executionId, msg, send, done, resolved } = item;
178
- const adapter = AGENTS[node.agent]();
179
- const runtime = buildRuntime(node);
180
-
181
- return runAgent({
182
- adapter,
183
- runtime,
184
- resolved,
185
- executionId,
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
- node.scheduler = new ExecutionScheduler({
254
- concurrency: node.concurrency,
255
- onStart: startExecution,
256
- onQueued: (item) => emitEvent(item.send, item.msg, item.executionId, 'queued'),
257
- // Runs after this item is removed from `active` and any newly-
258
- // eligible queued item has already been started, so the terminal
259
- // event's active/queued counts are accurate (see the onStatus
260
- // comment in startExecution for why it's deferred to here).
261
- onSettled: (item) => {
262
- if (item.finalStatus) emitEvent(item.send, item.msg, item.executionId, item.finalStatus);
263
- updateStatus();
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
- // On-demand termination of one in-flight (or still-queued) execution
268
- // of *this* node instance, addressed by the executionId a previous
269
- // trigger returned (msg.agentExecution.id / the events envelope's
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
- node.on('input', function (msg, send, done) {
312
- if (node.srtSettingsError) {
313
- node.lastTerminal = 'failed';
314
- node.lastText = 'bad srt settings';
315
- updateStatus();
316
- done(new Error(`agent: ${node.srtSettingsError}`));
317
- return;
318
- }
319
-
320
- if (msg.operation === 'terminate') {
321
- handleTerminateOperation(msg, send, done);
322
- return;
323
- }
324
-
325
- let resolved;
326
- try {
327
- resolved = {
328
- invocation: node.invocation,
329
- prompt: node.invocation === 'prompt' ? resolveTyped(node.prompt, node.promptType, msg, msg.payload) : undefined,
330
- invocationName: node.invocation !== 'prompt' ? resolveTyped(node.invocationName, node.invocationNameType, msg, '') : undefined,
331
- args: node.invocation !== 'prompt' ? resolveTyped(node.arguments_, node.argumentsType, msg, msg.payload) : undefined,
332
- cwd: (() => {
333
- const v = resolveTyped(node.cwd, node.cwdType, msg, '');
334
- return v === undefined || v === null ? '' : String(v).trim();
335
- })(),
336
- sessionID: (() => {
337
- const v = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, '');
338
- return v === undefined || v === null ? '' : String(v).trim();
339
- })(),
340
- model: (() => {
341
- const v = resolveTyped(node.model, node.modelType, msg, '');
342
- return v === undefined || v === null ? '' : String(v).trim();
343
- })(),
344
- auto: node.auto,
345
- timeoutMs: (() => {
346
- const v = resolveTyped(node.timeout, node.timeoutType, msg, undefined);
347
- const num = Number(v);
348
- return v === undefined || v === '' || !Number.isFinite(num) || num <= 0 ? undefined : num * 1000;
349
- })(),
350
- mcpServers: node.mcpServers
351
- };
352
- } catch (err) {
353
- node.lastTerminal = 'failed';
354
- node.lastText = 'bad config';
355
- updateStatus();
356
- done(err);
357
- return;
358
- }
359
-
360
- if (!AGENTS[node.agent]) {
361
- node.lastTerminal = 'failed';
362
- node.lastText = 'unknown agent';
363
- updateStatus();
364
- done(new Error(`agent: unknown agent "${node.agent}"`));
365
- return;
366
- }
367
-
368
- const executionId = nextExecutionId();
369
- node.scheduler.submit({ executionId, msg, send, done, resolved });
370
- updateStatus();
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
- node.on('close', function (done) {
374
- // Stop accepting further work first: drop anything still
375
- // waiting in the queue with a clean done()/cancelled event,
376
- // then terminate whatever's still actively running. No child
377
- // process should be orphaned by a redeploy or node removal.
378
- node.scheduler.drainQueue((item) => {
379
- emitEvent(item.send, item.msg, item.executionId, 'cancelled');
380
- item.done(new Error('agent: node closing, execution cancelled before it started'));
381
- });
382
-
383
- const runtime = buildRuntime(node);
384
- const activeIds = node.scheduler.activeIds();
385
- Promise.all(activeIds.map((id) => Promise.resolve(runtime.terminate(id)).catch(() => {}))).then(() => {
386
- if (node.srtTempSettingsFile) {
387
- fs.unlink(node.srtTempSettingsFile, () => {});
388
- }
389
- node.status({});
390
- done();
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
- RED.nodes.registerType('agent', AgentNode);
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
  };