@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,8 +1,8 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { AgentAdapter } = require('./base');
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
- 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'
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
- skill: '.github/skills',
32
- command: '.github/prompts'
31
+ skill: ".github/skills",
32
+ command: ".github/prompts",
33
33
  };
34
34
 
35
35
  function resolveResourcePath(name, kind, cwd) {
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(`could not find a ${kind} named "${name}" (looked for: ${candidates.join(', ')})`);
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
- validate(resolved) {
57
- if (resolved.cwd) {
58
- let stat;
59
- try {
60
- stat = fs.statSync(resolved.cwd);
61
- } catch (err) {
62
- throw new Error(`cwd does not exist: ${resolved.cwd}`);
63
- }
64
- if (!stat.isDirectory()) {
65
- throw new Error(`cwd is not a directory: ${resolved.cwd}`);
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
- if (resolved.sessionID) {
92
- // Every pi invocation is built with --no-session (see
93
- // buildExecution) -- deliberately ephemeral, matching the
94
- // "one CLI process = one execution" model this adapter was
95
- // verified against. Silently accepting a sessionID here would
96
- // imply continuation that never actually happens.
97
- throw new Error(
98
- 'the pi adapter does not support session continuation through this node (every pi run uses ' +
99
- '--no-session); leave Session ID blank or switch Agent to OpenCode'
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
- buildExecution(resolved) {
105
- const args = ['--no-session', '--mode', 'json'];
106
-
107
- if (resolved.model) args.push('--model', String(resolved.model));
108
-
109
- // pi has no permission-prompt system to bypass in non-interactive
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
- let message;
122
- if (resolved.invocation === 'prompt') {
123
- message = String(resolved.prompt);
124
- } else {
125
- const resourcePath = resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
126
- const flag = resolved.invocation === 'skill' ? '--skill' : '--prompt-template';
127
- args.push(flag, resourcePath);
128
-
129
- // pi has no deterministic slash-command dispatch like
130
- // opencode's --command (verified: without this explicit
131
- // instruction the model never used the loaded skill/template
132
- // on its own). Spelling out the path and arguments in plain
133
- // language is what actually worked in testing.
134
- const kind = resolved.invocation === 'skill' ? 'skill' : 'prompt template';
135
- const argsText = resolved.args !== undefined && resolved.args !== null ? String(resolved.args) : '';
136
- message = `Use the "${resolved.invocationName}" ${kind} at ${resourcePath}. ${argsText}`.trim();
137
- }
138
- args.push(message);
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
- return { command: 'pi', args, env: {} };
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
- parseEvent(line) {
144
- const trimmed = line.trim();
145
- if (!trimmed) return null;
150
+ return { command: "pi", args, env: {} };
151
+ }
146
152
 
147
- let raw;
148
- try {
149
- raw = JSON.parse(trimmed);
150
- } catch (err) {
151
- return null;
152
- }
153
+ parseEvent(line) {
154
+ const trimmed = line.trim();
155
+ if (!trimmed) return null;
153
156
 
154
- if (raw.type === 'session') {
155
- return { type: 'started', sessionID: raw.id, data: raw };
156
- }
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(trimmed);
160
+ } catch (err) {
161
+ return null;
162
+ }
157
163
 
158
- if (raw.type === 'message_update' && raw.assistantMessageEvent) {
159
- const inner = raw.assistantMessageEvent;
160
- if (inner.type === 'text_end' || inner.type === 'thinking_end') {
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
- const type = TYPE_MAP[raw.type];
167
- if (!type) return null;
168
- return { type, data: raw };
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
- parseResult(events, exitCode, signal, stderr) {
172
- const raw = events.map((e) => e.data);
173
- const sessionEvent = raw.find((e) => e.type === 'session');
174
- const sessionID = sessionEvent ? sessionEvent.id : undefined;
175
- const agentEnd = [...raw].reverse().find((e) => e.type === 'agent_end');
176
-
177
- let payload = '';
178
- let errorMessage;
179
-
180
- if (agentEnd && Array.isArray(agentEnd.messages)) {
181
- const lastAssistant = [...agentEnd.messages].reverse().find((m) => m.role === 'assistant');
182
- if (lastAssistant) {
183
- if (lastAssistant.stopReason === 'error') {
184
- // pi does NOT set a non-zero exit code for a model/API
185
- // error (verified: exit 0 with stopReason:"error" deep
186
- // in the event stream) -- this is the only reliable
187
- // signal, unlike opencode's {"type":"error"} + exit code.
188
- errorMessage = lastAssistant.errorMessage || 'pi reported an error';
189
- } else if (Array.isArray(lastAssistant.content)) {
190
- payload = lastAssistant.content
191
- .filter((c) => c.type === 'text' && typeof c.text === 'string')
192
- .map((c) => c.text)
193
- .join('\n')
194
- .trim();
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
- if (errorMessage) {
200
- return { payload, sessionID, status: 'failed', errorMessage };
201
- }
202
- if (signal) {
203
- return { payload, sessionID, status: 'failed', errorMessage: `process killed by signal ${signal}` };
204
- }
205
- if (exitCode !== 0) {
206
- return {
207
- payload,
208
- sessionID,
209
- status: 'failed',
210
- errorMessage: `exited with code ${exitCode}${stderr ? ': ' + String(stderr).trim() : ''}`
211
- };
212
- }
213
- if (!agentEnd) {
214
- return { payload, sessionID, status: 'failed', errorMessage: 'pi produced no agent_end event' };
215
- }
216
- return { payload, sessionID, status: 'completed' };
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
- 'use strict';
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
- adapter.validate(resolved); // throws synchronously on bad config
21
+ adapter.validate(resolved); // throws synchronously on bad config
22
22
 
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
- };
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
- const events = [];
34
- const startedAt = Date.now();
35
- if (onStatus) onStatus('running');
33
+ const events = [];
34
+ const startedAt = Date.now();
35
+ if (onStatus) onStatus("running");
36
36
 
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
- });
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
- 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;
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
- if (onStatus) onStatus(status);
57
+ if (onStatus) onStatus(status);
58
58
 
59
- return Object.assign({}, result, {
60
- status,
61
- exitCode: outcome.exitCode,
62
- signal: outcome.signal,
63
- timedOut: outcome.timedOut,
64
- durationMs,
65
- events
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 };