@tbrandenburg/node-red-agents 0.1.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/LICENSE +21 -0
- package/README.md +34 -0
- package/nodes/agent/agent.html +550 -0
- package/nodes/agent/agent.js +396 -0
- package/nodes/agent/icons/agent.svg +27 -0
- package/nodes/agent/lib/agents/base.js +42 -0
- package/nodes/agent/lib/agents/opencode.js +141 -0
- package/nodes/agent/lib/agents/pi.js +220 -0
- package/nodes/agent/lib/execution/lifecycle.js +69 -0
- package/nodes/agent/lib/execution/scheduler.js +97 -0
- package/nodes/agent/lib/execution/status.js +24 -0
- package/nodes/agent/lib/mcp/normalize.js +31 -0
- package/nodes/agent/lib/runtimes/base.js +21 -0
- package/nodes/agent/lib/runtimes/direct.js +28 -0
- package/nodes/agent/lib/runtimes/process-exec.js +105 -0
- package/nodes/agent/lib/runtimes/srt.js +63 -0
- package/nodes/agent-server/agent-server.html +365 -0
- package/nodes/agent-server/agent-server.js +481 -0
- package/nodes/agent-server/icons/agent.svg +27 -0
- package/nodes/agent-server/lib/daemon.js +149 -0
- package/nodes/agent-server/lib/http.js +60 -0
- package/nodes/agent-server/lib/model.js +20 -0
- package/nodes/agent-server/lib/port.js +31 -0
- package/nodes/agent-server/lib/registry.js +77 -0
- package/nodes/agent-server/lib/status.js +15 -0
- package/nodes/gh/README.md +75 -0
- package/nodes/gh/examples/list-pull-requests.json +48 -0
- package/nodes/gh/examples/run-workflow.json +42 -0
- package/nodes/gh/gh.html +146 -0
- package/nodes/gh/gh.js +237 -0
- package/nodes/gh/icons/gh.svg +15 -0
- package/nodes/gh/lib/parse-args.js +67 -0
- package/package.json +60 -0
- package/shared/srt-settings.js +71 -0
|
@@ -0,0 +1,396 @@
|
|
|
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
|
+
|
|
11
|
+
// Registries. Adding a future adapter/runtime is just one more entry here --
|
|
12
|
+
// nothing else in this file (or in lib/execution/lifecycle.js) needs to
|
|
13
|
+
// change, per the spec's adapter-independence requirement. Concurrency
|
|
14
|
+
// (lib/execution/scheduler.js) is likewise fully independent of both: it
|
|
15
|
+
// only ever sees opaque { executionId, ... } items.
|
|
16
|
+
const AGENTS = {
|
|
17
|
+
opencode: () => new OpenCodeAdapter(),
|
|
18
|
+
pi: () => new PiAdapter()
|
|
19
|
+
};
|
|
20
|
+
|
|
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();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let executionCounter = 0;
|
|
32
|
+
function nextExecutionId() {
|
|
33
|
+
executionCounter += 1;
|
|
34
|
+
return `exec-${Date.now()}-${executionCounter}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function emitEvent(send, msg, executionId, type) {
|
|
170
|
+
send([null, lifecycleEnvelope(msg, executionId, { type })]);
|
|
171
|
+
}
|
|
172
|
+
|
|
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
|
+
}
|
|
252
|
+
|
|
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
|
+
});
|
|
266
|
+
|
|
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
|
+
}
|
|
310
|
+
|
|
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();
|
|
371
|
+
});
|
|
372
|
+
|
|
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
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
RED.nodes.registerType('agent', AgentNode);
|
|
396
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 60">
|
|
2
|
+
<!--
|
|
3
|
+
Node-RED custom node icon requirements (docs/creating-nodes/appearance.md):
|
|
4
|
+
white on transparent background, 2:3 aspect ratio, >= 40x60px.
|
|
5
|
+
This viewBox is exactly 40x60 (2:3). Eyes/mouth are cut as transparent
|
|
6
|
+
holes out of the solid white silhouette via a mask, so the shape reads
|
|
7
|
+
correctly against any node background color.
|
|
8
|
+
-->
|
|
9
|
+
<defs>
|
|
10
|
+
<mask id="agent-face-mask">
|
|
11
|
+
<rect x="0" y="0" width="40" height="60" fill="#ffffff"/>
|
|
12
|
+
<circle cx="14" cy="30" r="3.2" fill="#000000"/>
|
|
13
|
+
<circle cx="26" cy="30" r="3.2" fill="#000000"/>
|
|
14
|
+
<rect x="13" y="38" width="14" height="4" rx="2" fill="#000000"/>
|
|
15
|
+
</mask>
|
|
16
|
+
</defs>
|
|
17
|
+
<g fill="#ffffff" mask="url(#agent-face-mask)">
|
|
18
|
+
<!-- antenna -->
|
|
19
|
+
<circle cx="20" cy="6" r="3"/>
|
|
20
|
+
<rect x="18.5" y="9" width="3" height="6" rx="1.5"/>
|
|
21
|
+
<!-- head -->
|
|
22
|
+
<rect x="4" y="15" width="32" height="34" rx="8"/>
|
|
23
|
+
<!-- side ears -->
|
|
24
|
+
<rect x="0" y="26" width="4" height="10" rx="2"/>
|
|
25
|
+
<rect x="36" y="26" width="4" height="10" rx="2"/>
|
|
26
|
+
</g>
|
|
27
|
+
</svg>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Generic interface every agent harness adapter (OpenCode, and later
|
|
4
|
+
// Pi/Claude Code) must implement. The Agent node core only ever talks to
|
|
5
|
+
// this interface -- it must never know about a specific agent's CLI flags.
|
|
6
|
+
class AgentAdapter {
|
|
7
|
+
// Throws a descriptive Error if `resolved` (the already-typed-input
|
|
8
|
+
// -resolved config, see lib/execution/lifecycle.js) is not runnable.
|
|
9
|
+
// Must not have side effects beyond validation.
|
|
10
|
+
//
|
|
11
|
+
// `resolved.sessionID` (string, may be '' or undefined) is an optional
|
|
12
|
+
// continuation id: '' / undefined means "start a new session" (today's
|
|
13
|
+
// only behavior); a non-empty value asks the adapter to resume that
|
|
14
|
+
// session instead. An adapter that has no way to honor this (no
|
|
15
|
+
// underlying CLI/session concept, or one that's deliberately disabled
|
|
16
|
+
// -- see the Pi adapter) must throw here rather than silently ignoring
|
|
17
|
+
// it or starting a new session anyway.
|
|
18
|
+
validate(_resolved) {}
|
|
19
|
+
|
|
20
|
+
// Returns { command, args, env } -- a normalized, adapter-specific
|
|
21
|
+
// execution request. `args` must always be a plain array of strings;
|
|
22
|
+
// never a shell command string (see spec: spawn over exec).
|
|
23
|
+
buildExecution(_resolved) {
|
|
24
|
+
throw new Error('AgentAdapter.buildExecution() not implemented');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Parses one line of stdout. Returns a normalized event object
|
|
28
|
+
// { type, sessionID, data } or null if the line should be ignored
|
|
29
|
+
// (blank, or malformed -- must never throw).
|
|
30
|
+
parseEvent(_line) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Given all accumulated parsed events plus the process exit info,
|
|
35
|
+
// returns { payload, sessionID, status, errorMessage? }.
|
|
36
|
+
// status is one of "completed" | "failed".
|
|
37
|
+
parseResult(_events, _exitCode, _signal, _stderr) {
|
|
38
|
+
return { payload: '', status: 'completed' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { AgentAdapter };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { AgentAdapter } = require('./base');
|
|
5
|
+
const { toOpenCodeMcp } = require('../mcp/normalize');
|
|
6
|
+
|
|
7
|
+
// Maps opencode's real `--format json` event stream types (verified against
|
|
8
|
+
// packages/opencode/src/cli/cmd/run.ts) onto the Agent node's generic event
|
|
9
|
+
// vocabulary (spec section "Event output").
|
|
10
|
+
const TYPE_MAP = {
|
|
11
|
+
step_start: 'started',
|
|
12
|
+
step_finish: 'progress',
|
|
13
|
+
tool_use: 'tool',
|
|
14
|
+
text: 'agent',
|
|
15
|
+
reasoning: 'agent',
|
|
16
|
+
error: 'failed'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class OpenCodeAdapter extends AgentAdapter {
|
|
20
|
+
validate(resolved) {
|
|
21
|
+
if (resolved.cwd) {
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
stat = fs.statSync(resolved.cwd);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
throw new Error(`cwd does not exist: ${resolved.cwd}`);
|
|
27
|
+
}
|
|
28
|
+
if (!stat.isDirectory()) {
|
|
29
|
+
throw new Error(`cwd is not a directory: ${resolved.cwd}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (resolved.invocation === 'prompt') {
|
|
34
|
+
if (!resolved.prompt || !String(resolved.prompt).trim()) {
|
|
35
|
+
throw new Error('prompt invocation requires a non-empty prompt (msg.payload or the Prompt field)');
|
|
36
|
+
}
|
|
37
|
+
} else if (resolved.invocation === 'skill' || resolved.invocation === 'command') {
|
|
38
|
+
if (!resolved.invocationName || !String(resolved.invocationName).trim()) {
|
|
39
|
+
throw new Error(`${resolved.invocation} invocation requires a non-empty name`);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
throw new Error(`unknown invocation mode: ${resolved.invocation}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const server of resolved.mcpServers || []) {
|
|
46
|
+
if (!server || !server.name) {
|
|
47
|
+
throw new Error('mcpServers entries require a name');
|
|
48
|
+
}
|
|
49
|
+
if (server.type === 'remote' && !server.url) {
|
|
50
|
+
throw new Error(`mcp server "${server.name}" (remote) requires a url`);
|
|
51
|
+
}
|
|
52
|
+
if (server.type === 'local' && !server.command) {
|
|
53
|
+
throw new Error(`mcp server "${server.name}" (local) requires a command`);
|
|
54
|
+
}
|
|
55
|
+
if (server.type !== 'remote' && server.type !== 'local') {
|
|
56
|
+
throw new Error(`mcp server "${server.name}" has unknown type: ${server.type}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
buildExecution(resolved) {
|
|
62
|
+
const args = ['run', '--format', 'json'];
|
|
63
|
+
|
|
64
|
+
// Resuming an existing session (opencode run -s <id> ...) rather
|
|
65
|
+
// than always starting a new one -- verified against `opencode run
|
|
66
|
+
// --help`: -s/--session takes the id to continue.
|
|
67
|
+
if (resolved.sessionID) args.push('--session', String(resolved.sessionID));
|
|
68
|
+
|
|
69
|
+
if (resolved.cwd) args.push('--dir', resolved.cwd);
|
|
70
|
+
if (resolved.model) args.push('--model', resolved.model);
|
|
71
|
+
if (resolved.auto) args.push('--auto');
|
|
72
|
+
|
|
73
|
+
// Skill and Command/Template invocation share the same underlying
|
|
74
|
+
// opencode mechanism: skills are registered internally as commands
|
|
75
|
+
// (source:"skill"), so `--command <name>` handles both -- verified
|
|
76
|
+
// against packages/opencode/src/command/index.ts.
|
|
77
|
+
if (resolved.invocation === 'skill' || resolved.invocation === 'command') {
|
|
78
|
+
args.push('--command', String(resolved.invocationName));
|
|
79
|
+
args.push(resolved.args !== undefined && resolved.args !== null ? String(resolved.args) : '');
|
|
80
|
+
} else {
|
|
81
|
+
args.push(String(resolved.prompt));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const env = {};
|
|
85
|
+
if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
|
|
86
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ mcp: toOpenCodeMcp(resolved.mcpServers) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { command: 'opencode', args, env };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parseEvent(line) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed) return null;
|
|
95
|
+
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = JSON.parse(trimmed);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Malformed/non-JSON diagnostic output must never crash Node-RED.
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const type = TYPE_MAP[raw.type] || raw.type || 'progress';
|
|
105
|
+
return { type, sessionID: raw.sessionID, data: raw };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
parseResult(events, exitCode, signal, stderr) {
|
|
109
|
+
const raw = events.map((e) => e.data);
|
|
110
|
+
const errorEvent = raw.find((e) => e.type === 'error');
|
|
111
|
+
const sessionID = raw.length ? raw[raw.length - 1].sessionID : undefined;
|
|
112
|
+
|
|
113
|
+
const payload = raw
|
|
114
|
+
.filter((e) => e.type === 'text' && e.part && typeof e.part.text === 'string')
|
|
115
|
+
.map((e) => e.part.text)
|
|
116
|
+
.join('\n')
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
if (errorEvent) {
|
|
120
|
+
const message =
|
|
121
|
+
(errorEvent.error &&
|
|
122
|
+
((errorEvent.error.data && errorEvent.error.data.message) || errorEvent.error.name)) ||
|
|
123
|
+
'opencode reported an error';
|
|
124
|
+
return { payload, sessionID, status: 'failed', errorMessage: message };
|
|
125
|
+
}
|
|
126
|
+
if (signal) {
|
|
127
|
+
return { payload, sessionID, status: 'failed', errorMessage: `process killed by signal ${signal}` };
|
|
128
|
+
}
|
|
129
|
+
if (exitCode !== 0) {
|
|
130
|
+
return {
|
|
131
|
+
payload,
|
|
132
|
+
sessionID,
|
|
133
|
+
status: 'failed',
|
|
134
|
+
errorMessage: `exited with code ${exitCode}${stderr ? ': ' + String(stderr).trim() : ''}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { payload, sessionID, status: 'completed' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { OpenCodeAdapter };
|