@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.
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
|
-
const fs = require(
|
|
4
|
-
const path = require(
|
|
5
|
-
const { AgentAdapter } = require(
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { AgentAdapter } = require("./base");
|
|
6
6
|
|
|
7
7
|
// Maps pi's real `--mode json` event stream (verified empirically against
|
|
8
8
|
// pi 0.84.1 -- its own docs don't spell this out) onto the Agent node's
|
|
@@ -11,12 +11,12 @@ const { AgentAdapter } = require('./base');
|
|
|
11
11
|
// output 2 with one message per streamed token; only the completed chunk
|
|
12
12
|
// (*_end) is surfaced.
|
|
13
13
|
const TYPE_MAP = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
agent_start: "started",
|
|
15
|
+
tool_execution_start: "tool",
|
|
16
|
+
tool_execution_update: "tool",
|
|
17
|
+
tool_execution_end: "tool",
|
|
18
|
+
turn_end: "progress",
|
|
19
|
+
agent_end: "completed",
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
// pi's project-level skill/prompt-template directories, per this
|
|
@@ -28,193 +28,213 @@ const TYPE_MAP = {
|
|
|
28
28
|
// has no idea the file exists and will try to go hunting for it with its
|
|
29
29
|
// own tools instead).
|
|
30
30
|
const RESOURCE_DIRS = {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
skill: ".github/skills",
|
|
32
|
+
command: ".github/prompts",
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
function resolveResourcePath(name, kind, cwd) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
36
|
+
const base = cwd || process.cwd();
|
|
37
|
+
const candidates = [];
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(name)) {
|
|
40
|
+
candidates.push(name);
|
|
41
|
+
} else if (name.includes("/") || name.endsWith(".md")) {
|
|
42
|
+
candidates.push(path.join(base, name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dir = RESOURCE_DIRS[kind];
|
|
46
|
+
candidates.push(path.join(base, dir, name, "SKILL.md"));
|
|
47
|
+
candidates.push(path.join(base, dir, `${name}.md`));
|
|
48
|
+
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(
|
|
53
|
+
`could not find a ${kind} named "${name}" (looked for: ${candidates.join(", ")})`,
|
|
54
|
+
);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
class PiAdapter extends AgentAdapter {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (resolved.invocation === 'prompt') {
|
|
70
|
-
if (!resolved.prompt || !String(resolved.prompt).trim()) {
|
|
71
|
-
throw new Error('prompt invocation requires a non-empty prompt (msg.payload or the Prompt field)');
|
|
72
|
-
}
|
|
73
|
-
} else if (resolved.invocation === 'skill' || resolved.invocation === 'command') {
|
|
74
|
-
if (!resolved.invocationName || !String(resolved.invocationName).trim()) {
|
|
75
|
-
throw new Error(`${resolved.invocation} invocation requires a non-empty name`);
|
|
76
|
-
}
|
|
77
|
-
// Throws its own clear error if nothing matches -- fail before
|
|
78
|
-
// spawning anything, per the adapter contract.
|
|
79
|
-
resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
|
|
80
|
-
} else {
|
|
81
|
-
throw new Error(`unknown invocation mode: ${resolved.invocation}`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
'the pi adapter does not support MCP servers (no MCP CLI/config surface was found in `pi --help`); ' +
|
|
87
|
-
'remove the configured MCP servers or switch Agent to OpenCode'
|
|
88
|
-
);
|
|
89
|
-
}
|
|
58
|
+
validate(resolved) {
|
|
59
|
+
if (resolved.cwd) {
|
|
60
|
+
let stat;
|
|
61
|
+
try {
|
|
62
|
+
stat = fs.statSync(resolved.cwd);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`cwd does not exist: ${resolved.cwd}`);
|
|
65
|
+
}
|
|
66
|
+
if (!stat.isDirectory()) {
|
|
67
|
+
throw new Error(`cwd is not a directory: ${resolved.cwd}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
90
70
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
71
|
+
if (resolved.invocation === "prompt") {
|
|
72
|
+
if (!resolved.prompt || !String(resolved.prompt).trim()) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"prompt invocation requires a non-empty prompt (msg.payload or the Prompt field)",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
} else if (resolved.invocation === "skill" || resolved.invocation === "command") {
|
|
78
|
+
if (!resolved.invocationName || !String(resolved.invocationName).trim()) {
|
|
79
|
+
throw new Error(`${resolved.invocation} invocation requires a non-empty name`);
|
|
80
|
+
}
|
|
81
|
+
// Throws its own clear error if nothing matches -- fail before
|
|
82
|
+
// spawning anything, per the adapter contract.
|
|
83
|
+
resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error(`unknown invocation mode: ${resolved.invocation}`);
|
|
102
86
|
}
|
|
103
87
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// mode -- read/bash/edit/write tools just run immediately with no
|
|
111
|
-
// confirmation of any kind (verified: an unrestricted run had the
|
|
112
|
-
// model shell out to `find /` on its own with zero gating). The
|
|
113
|
-
// closest analogue to opencode's --auto is restricting which
|
|
114
|
-
// tools are even available: "not auto" -> read-only tool set,
|
|
115
|
-
// "auto" -> everything. This is an approximation, not a true
|
|
116
|
-
// permission bypass -- documented in the node's help text.
|
|
117
|
-
if (!resolved.auto) {
|
|
118
|
-
args.push('--tools', 'read,grep,find,ls');
|
|
119
|
-
}
|
|
88
|
+
if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"the pi adapter does not support MCP servers (no MCP CLI/config surface was found in `pi --help`); " +
|
|
91
|
+
"remove the configured MCP servers or switch Agent to OpenCode",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
120
94
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
95
|
+
if (resolved.sessionID) {
|
|
96
|
+
// Every pi invocation is built with --no-session (see
|
|
97
|
+
// buildExecution) -- deliberately ephemeral, matching the
|
|
98
|
+
// "one CLI process = one execution" model this adapter was
|
|
99
|
+
// verified against. Silently accepting a sessionID here would
|
|
100
|
+
// imply continuation that never actually happens.
|
|
101
|
+
throw new Error(
|
|
102
|
+
"the pi adapter does not support session continuation through this node (every pi run uses " +
|
|
103
|
+
"--no-session); leave Session ID blank or switch Agent to OpenCode",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
buildExecution(resolved) {
|
|
109
|
+
const args = ["--no-session", "--mode", "json"];
|
|
110
|
+
|
|
111
|
+
if (resolved.model) args.push("--model", String(resolved.model));
|
|
112
|
+
|
|
113
|
+
// pi has no permission-prompt system to bypass in non-interactive
|
|
114
|
+
// mode -- read/bash/edit/write tools just run immediately with no
|
|
115
|
+
// confirmation of any kind (verified: an unrestricted run had the
|
|
116
|
+
// model shell out to `find /` on its own with zero gating). The
|
|
117
|
+
// closest analogue to opencode's --auto is restricting which
|
|
118
|
+
// tools are even available: "not auto" -> read-only tool set,
|
|
119
|
+
// "auto" -> everything. This is an approximation, not a true
|
|
120
|
+
// permission bypass -- documented in the node's help text.
|
|
121
|
+
if (!resolved.auto) {
|
|
122
|
+
args.push("--tools", "read,grep,find,ls");
|
|
123
|
+
}
|
|
139
124
|
|
|
140
|
-
|
|
125
|
+
let message;
|
|
126
|
+
if (resolved.invocation === "prompt") {
|
|
127
|
+
message = String(resolved.prompt);
|
|
128
|
+
} else {
|
|
129
|
+
const resourcePath = resolveResourcePath(
|
|
130
|
+
resolved.invocationName,
|
|
131
|
+
resolved.invocation,
|
|
132
|
+
resolved.cwd,
|
|
133
|
+
);
|
|
134
|
+
const flag = resolved.invocation === "skill" ? "--skill" : "--prompt-template";
|
|
135
|
+
args.push(flag, resourcePath);
|
|
136
|
+
|
|
137
|
+
// pi has no deterministic slash-command dispatch like
|
|
138
|
+
// opencode's --command (verified: without this explicit
|
|
139
|
+
// instruction the model never used the loaded skill/template
|
|
140
|
+
// on its own). Spelling out the path and arguments in plain
|
|
141
|
+
// language is what actually worked in testing.
|
|
142
|
+
const kind = resolved.invocation === "skill" ? "skill" : "prompt template";
|
|
143
|
+
const argsText =
|
|
144
|
+
resolved.args !== undefined && resolved.args !== null ? String(resolved.args) : "";
|
|
145
|
+
message =
|
|
146
|
+
`Use the "${resolved.invocationName}" ${kind} at ${resourcePath}. ${argsText}`.trim();
|
|
141
147
|
}
|
|
148
|
+
args.push(message);
|
|
142
149
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!trimmed) return null;
|
|
150
|
+
return { command: "pi", args, env: {} };
|
|
151
|
+
}
|
|
146
152
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} catch (err) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
+
parseEvent(line) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
if (!trimmed) return null;
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(trimmed);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
157
163
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return { type: 'agent', data: raw };
|
|
162
|
-
}
|
|
163
|
-
return null; // skip granular *_start/*_delta streaming noise
|
|
164
|
-
}
|
|
164
|
+
if (raw.type === "session") {
|
|
165
|
+
return { type: "started", sessionID: raw.id, data: raw };
|
|
166
|
+
}
|
|
165
167
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
if (raw.type === "message_update" && raw.assistantMessageEvent) {
|
|
169
|
+
const inner = raw.assistantMessageEvent;
|
|
170
|
+
if (inner.type === "text_end" || inner.type === "thinking_end") {
|
|
171
|
+
return { type: "agent", data: raw };
|
|
172
|
+
}
|
|
173
|
+
return null; // skip granular *_start/*_delta streaming noise
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
176
|
+
const type = TYPE_MAP[raw.type];
|
|
177
|
+
if (!type) return null;
|
|
178
|
+
return { type, data: raw };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parseResult(events, exitCode, signal, stderr) {
|
|
182
|
+
const raw = events.map((e) => e.data);
|
|
183
|
+
const sessionEvent = raw.find((e) => e.type === "session");
|
|
184
|
+
const sessionID = sessionEvent ? sessionEvent.id : undefined;
|
|
185
|
+
const agentEnd = [...raw].reverse().find((e) => e.type === "agent_end");
|
|
186
|
+
|
|
187
|
+
let payload = "";
|
|
188
|
+
let errorMessage;
|
|
189
|
+
|
|
190
|
+
if (agentEnd && Array.isArray(agentEnd.messages)) {
|
|
191
|
+
const lastAssistant = [...agentEnd.messages].reverse().find((m) => m.role === "assistant");
|
|
192
|
+
if (lastAssistant) {
|
|
193
|
+
if (lastAssistant.stopReason === "error") {
|
|
194
|
+
// pi does NOT set a non-zero exit code for a model/API
|
|
195
|
+
// error (verified: exit 0 with stopReason:"error" deep
|
|
196
|
+
// in the event stream) -- this is the only reliable
|
|
197
|
+
// signal, unlike opencode's {"type":"error"} + exit code.
|
|
198
|
+
errorMessage = lastAssistant.errorMessage || "pi reported an error";
|
|
199
|
+
} else if (Array.isArray(lastAssistant.content)) {
|
|
200
|
+
payload = lastAssistant.content
|
|
201
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
202
|
+
.map((c) => c.text)
|
|
203
|
+
.join("\n")
|
|
204
|
+
.trim();
|
|
197
205
|
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
198
208
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
209
|
+
if (errorMessage) {
|
|
210
|
+
return { payload, sessionID, status: "failed", errorMessage };
|
|
211
|
+
}
|
|
212
|
+
if (signal) {
|
|
213
|
+
return {
|
|
214
|
+
payload,
|
|
215
|
+
sessionID,
|
|
216
|
+
status: "failed",
|
|
217
|
+
errorMessage: `process killed by signal ${signal}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (exitCode !== 0) {
|
|
221
|
+
return {
|
|
222
|
+
payload,
|
|
223
|
+
sessionID,
|
|
224
|
+
status: "failed",
|
|
225
|
+
errorMessage: `exited with code ${exitCode}${stderr ? ": " + String(stderr).trim() : ""}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (!agentEnd) {
|
|
229
|
+
return {
|
|
230
|
+
payload,
|
|
231
|
+
sessionID,
|
|
232
|
+
status: "failed",
|
|
233
|
+
errorMessage: "pi produced no agent_end event",
|
|
234
|
+
};
|
|
217
235
|
}
|
|
236
|
+
return { payload, sessionID, status: "completed" };
|
|
237
|
+
}
|
|
218
238
|
}
|
|
219
239
|
|
|
220
240
|
module.exports = { PiAdapter, resolveResourcePath };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
3
|
// Framework-agnostic orchestrator: given an adapter, a runtime, and an
|
|
4
4
|
// already-resolved (typed-input-evaluated) input object, runs one agent
|
|
@@ -18,52 +18,52 @@
|
|
|
18
18
|
// onEvent(event) -- called for every parsed stdout event (output 2)
|
|
19
19
|
// onStatus(status) -- 'running' | 'completed' | 'failed' | 'timeout'
|
|
20
20
|
async function runAgent({ adapter, runtime, resolved, executionId, onEvent, onStatus }) {
|
|
21
|
-
|
|
21
|
+
adapter.validate(resolved); // throws synchronously on bad config
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
const built = adapter.buildExecution(resolved);
|
|
24
|
+
const executionRequest = {
|
|
25
|
+
id: executionId,
|
|
26
|
+
command: built.command,
|
|
27
|
+
args: built.args,
|
|
28
|
+
cwd: resolved.cwd || undefined,
|
|
29
|
+
env: Object.assign({}, process.env, built.env || {}),
|
|
30
|
+
timeoutMs: resolved.timeoutMs || undefined,
|
|
31
|
+
};
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const events = [];
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
if (onStatus) onStatus("running");
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
const outcome = await runtime.execute(executionRequest, {
|
|
38
|
+
onLine: (line) => {
|
|
39
|
+
let event;
|
|
40
|
+
try {
|
|
41
|
+
event = adapter.parseEvent(line);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// parseEvent must never crash the node; treat as ignorable.
|
|
44
|
+
event = null;
|
|
45
|
+
}
|
|
46
|
+
if (event) {
|
|
47
|
+
events.push(event);
|
|
48
|
+
if (onEvent) onEvent(event);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
const durationMs = Date.now() - startedAt;
|
|
54
|
+
const result = adapter.parseResult(events, outcome.exitCode, outcome.signal, outcome.stderr);
|
|
55
|
+
const status = outcome.timedOut ? "timeout" : result.status;
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
if (onStatus) onStatus(status);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
return Object.assign({}, result, {
|
|
60
|
+
status,
|
|
61
|
+
exitCode: outcome.exitCode,
|
|
62
|
+
signal: outcome.signal,
|
|
63
|
+
timedOut: outcome.timedOut,
|
|
64
|
+
durationMs,
|
|
65
|
+
events,
|
|
66
|
+
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
module.exports = { runAgent };
|