@tbrandenburg/node-red-agents 0.3.8 → 0.4.0

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.
@@ -75,6 +75,15 @@ class OpenCodeAdapter extends AgentAdapter {
75
75
  if (resolved.model) args.push("--model", resolved.model);
76
76
  if (resolved.auto) args.push("--auto");
77
77
 
78
+ // --variant <effort> -- verified working against a real `opencode run`
79
+ // invocation (see CAPABILITIES.effortControl below). systemPrompt has
80
+ // no verified CLI flag for this adapter (CAPABILITIES.systemPromptControl
81
+ // is false), so it's never forwarded here -- agent.js already warns and
82
+ // drops it before this is even called.
83
+ if (OpenCodeAdapter.CAPABILITIES.effortControl && resolved.effort) {
84
+ args.push("--variant", resolved.effort);
85
+ }
86
+
78
87
  // Skill and Command/Template invocation share the same underlying
79
88
  // opencode mechanism: skills are registered internally as commands
80
89
  // (source:"skill"), so `--command <name>` handles both -- verified
@@ -87,8 +96,36 @@ class OpenCodeAdapter extends AgentAdapter {
87
96
  }
88
97
 
89
98
  const env = {};
99
+ const opencodeConfig = {};
90
100
  if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
91
- env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ mcp: toOpenCodeMcp(resolved.mcpServers) });
101
+ opencodeConfig.mcp = toOpenCodeMcp(resolved.mcpServers);
102
+ }
103
+
104
+ // allowed_tools/denied_tools (issue #25): opencode has no direct
105
+ // `--tools` flag (unlike pi.js), but its config schema supports a
106
+ // per-agent `tools: { <name>: true|false }` map (verified against
107
+ // opencode's own agent docs). Rather than inventing a new delivery
108
+ // mechanism, this reuses the exact same OPENCODE_CONFIG_CONTENT env
109
+ // var already used for mcpServers above -- an ephemeral, per-process
110
+ // config the child process reads and that vanishes with it, with
111
+ // nothing left on disk to clean up (unlike the srt inline-settings
112
+ // temp file, which outlives the process and does need explicit
113
+ // unlinking). A fixed, unique-per-node agent name is defined as
114
+ // "primary" (required for `opencode run --agent <name>` to accept it)
115
+ // and selected via --agent.
116
+ const hasAllow = Array.isArray(resolved.allowedTools) && resolved.allowedTools.length > 0;
117
+ const hasDeny = Array.isArray(resolved.deniedTools) && resolved.deniedTools.length > 0;
118
+ if (OpenCodeAdapter.CAPABILITIES.toolRestrictions && (hasAllow || hasDeny)) {
119
+ const tools = {};
120
+ for (const name of resolved.deniedTools || []) tools[name] = false;
121
+ for (const name of resolved.allowedTools || []) tools[name] = true;
122
+ const agentName = "node-red-agent-tools";
123
+ opencodeConfig.agent = { [agentName]: { mode: "primary", tools } };
124
+ args.push("--agent", agentName);
125
+ }
126
+
127
+ if (Object.keys(opencodeConfig).length > 0) {
128
+ env.OPENCODE_CONFIG_CONTENT = JSON.stringify(opencodeConfig);
92
129
  }
93
130
 
94
131
  return { command: "opencode", args, env };
@@ -121,6 +158,14 @@ class OpenCodeAdapter extends AgentAdapter {
121
158
  .join("\n")
122
159
  .trim();
123
160
 
161
+ // Sums cost/tokens across every step_finish event seen during this run
162
+ // -- verified against a real `opencode run --format json` invocation,
163
+ // whose step_finish `part` carries { tokens: {input,output,reasoning,
164
+ // cache:{read,write},total}, cost }. `costUsd`/`tokens` stay undefined
165
+ // (rather than 0) when no step_finish event was observed at all, so
166
+ // agent.js can omit the fields entirely instead of reporting a false 0.
167
+ const usage = summarizeUsage(raw);
168
+
124
169
  if (errorEvent) {
125
170
  const errDetail = errorEvent.error || {};
126
171
  const message =
@@ -150,32 +195,100 @@ class OpenCodeAdapter extends AgentAdapter {
150
195
  }
151
196
  if (stderr && String(stderr).trim()) extras.push(String(stderr).trim());
152
197
  const errorMessage = extras.length ? `${message} (${extras.join("; ")})` : message;
153
- return {
154
- payload,
155
- sessionID,
156
- status: "failed",
157
- errorMessage,
158
- errorDetail: errDetail,
159
- };
198
+ return Object.assign(
199
+ {
200
+ payload,
201
+ sessionID,
202
+ status: "failed",
203
+ errorMessage,
204
+ errorDetail: errDetail,
205
+ },
206
+ usage,
207
+ );
160
208
  }
161
209
  if (signal) {
162
- return {
163
- payload,
164
- sessionID,
165
- status: "failed",
166
- errorMessage: `process killed by signal ${signal}`,
167
- };
210
+ return Object.assign(
211
+ {
212
+ payload,
213
+ sessionID,
214
+ status: "failed",
215
+ errorMessage: `process killed by signal ${signal}`,
216
+ },
217
+ usage,
218
+ );
168
219
  }
169
220
  if (exitCode !== 0) {
170
- return {
171
- payload,
172
- sessionID,
173
- status: "failed",
174
- errorMessage: `exited with code ${exitCode}${stderr ? ": " + String(stderr).trim() : ""}`,
221
+ return Object.assign(
222
+ {
223
+ payload,
224
+ sessionID,
225
+ status: "failed",
226
+ errorMessage: `exited with code ${exitCode}${stderr ? ": " + String(stderr).trim() : ""}`,
227
+ },
228
+ usage,
229
+ );
230
+ }
231
+ if (!payload) {
232
+ return Object.assign(
233
+ {
234
+ payload,
235
+ sessionID,
236
+ status: "failed",
237
+ errorMessage:
238
+ "opencode produced no assistant output (silent rejection or empty response)",
239
+ },
240
+ usage,
241
+ );
242
+ }
243
+ return Object.assign({ payload, sessionID, status: "completed" }, usage);
244
+ }
245
+ }
246
+
247
+ // Sums cost (USD) and token counts across every step_finish event in a run.
248
+ // Returns {} (no keys at all) when no step_finish event carried usable
249
+ // data, so Object.assign(...) callers above never introduce costUsd/tokens
250
+ // keys with `undefined` values -- agent.js relies on the key's mere
251
+ // presence (not just its value) to decide whether to surface it.
252
+ function summarizeUsage(raw) {
253
+ let costUsd;
254
+ let tokens;
255
+ for (const e of raw) {
256
+ if (e.type !== "step_finish" || !e.part || typeof e.part !== "object") continue;
257
+ const part = e.part;
258
+ if (typeof part.cost === "number") {
259
+ costUsd = (costUsd || 0) + part.cost;
260
+ }
261
+ if (part.tokens && typeof part.tokens === "object") {
262
+ tokens = tokens || {
263
+ total: 0,
264
+ input: 0,
265
+ output: 0,
266
+ reasoning: 0,
267
+ cache: { read: 0, write: 0 },
175
268
  };
269
+ tokens.total += Number(part.tokens.total) || 0;
270
+ tokens.input += Number(part.tokens.input) || 0;
271
+ tokens.output += Number(part.tokens.output) || 0;
272
+ tokens.reasoning += Number(part.tokens.reasoning) || 0;
273
+ if (part.tokens.cache) {
274
+ tokens.cache.read += Number(part.tokens.cache.read) || 0;
275
+ tokens.cache.write += Number(part.tokens.cache.write) || 0;
276
+ }
176
277
  }
177
- return { payload, sessionID, status: "completed" };
178
278
  }
279
+ const usage = {};
280
+ if (costUsd !== undefined) usage.costUsd = costUsd;
281
+ if (tokens !== undefined) usage.tokens = tokens;
282
+ return usage;
179
283
  }
180
284
 
285
+ OpenCodeAdapter.CAPABILITIES = {
286
+ sessionResume: true, // opencode.js -s/--session verified working
287
+ structuredOutput: "best-effort", // no --schema/--json-schema CLI flag
288
+ toolRestrictions: true, // via materialized temp agent config + --agent
289
+ effortControl: true, // --variant, verified working
290
+ systemPromptControl: false, // no CLI flag found
291
+ costReporting: true, // step_finish tokens/cost already in --format json stream
292
+ };
293
+
181
294
  module.exports = { OpenCodeAdapter };
@@ -118,7 +118,21 @@ class PiAdapter extends AgentAdapter {
118
118
  // tools are even available: "not auto" -> read-only tool set,
119
119
  // "auto" -> everything. This is an approximation, not a true
120
120
  // permission bypass -- documented in the node's help text.
121
- if (!resolved.auto) {
121
+ //
122
+ // allowed_tools (issue #25): a configured allow-list always wins over
123
+ // the auto-derived default above, for both auto and non-auto runs --
124
+ // an explicit list is a stronger signal than the auto/read-only
125
+ // heuristic. denied_tools has no equivalent here: pi's --tools flag is
126
+ // allow-list-only (verified against `pi --help`), so there's no
127
+ // mechanism to subtract individual tools from an otherwise-unbounded
128
+ // set; only allowedTools is wired up for this adapter.
129
+ const hasAllow =
130
+ PiAdapter.CAPABILITIES.toolRestrictions &&
131
+ Array.isArray(resolved.allowedTools) &&
132
+ resolved.allowedTools.length > 0;
133
+ if (hasAllow) {
134
+ args.push("--tools", resolved.allowedTools.join(","));
135
+ } else if (!resolved.auto) {
122
136
  args.push("--tools", "read,grep,find,ls");
123
137
  }
124
138
 
@@ -244,8 +258,25 @@ class PiAdapter extends AgentAdapter {
244
258
  errorMessage: "pi produced no agent_end event",
245
259
  };
246
260
  }
261
+ if (!payload) {
262
+ return {
263
+ payload,
264
+ sessionID,
265
+ status: "failed",
266
+ errorMessage: "pi produced no assistant output (silent rejection or empty response)",
267
+ };
268
+ }
247
269
  return { payload, sessionID, status: "completed" };
248
270
  }
249
271
  }
250
272
 
273
+ PiAdapter.CAPABILITIES = {
274
+ sessionResume: false, // every run uses --no-session
275
+ structuredOutput: "best-effort", // no schema CLI flag; prompt+parse only
276
+ toolRestrictions: true, // --tools flag
277
+ effortControl: false, // no verified CLI flag yet (pi not installed here)
278
+ systemPromptControl: false, // no verified CLI flag yet
279
+ costReporting: false, // pi CLI not installed/verified
280
+ };
281
+
251
282
  module.exports = { PiAdapter, resolveResourcePath };
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ // Pure string-templating helper for issue #20's $INPUTS.<name> substitution.
4
+ // Deliberately dumb: no BindingDirective/DAG concept (see issue text) --
5
+ // just a name -> value map applied to a single free-text string before it
6
+ // becomes the invocation's resolved.args, adapter-agnostic. Unmatched
7
+ // tokens are left as literal text rather than throwing, so a typo in a
8
+ // flow's arguments string degrades to visible-but-harmless output instead
9
+ // of a hard failure.
10
+ const TOKEN_RE = /\$INPUTS\.([A-Za-z0-9_]+)/g;
11
+
12
+ function substituteInputs(text, inputsMap) {
13
+ if (typeof text !== "string" || !text) return text;
14
+ const map = inputsMap || {};
15
+ return text.replace(TOKEN_RE, (match, name) => {
16
+ if (!Object.prototype.hasOwnProperty.call(map, name)) return match;
17
+ const value = map[name];
18
+ return value === undefined || value === null ? match : String(value);
19
+ });
20
+ }
21
+
22
+ module.exports = { substituteInputs };
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+
3
+ // Node-level retry classification (issue #24). Deliberately tiny and
4
+ // framework-agnostic (no Node-RED dependency) so it can be unit-tested in
5
+ // isolation -- see test/execution/retry.spec.js. Consumed by agent.js's
6
+ // startExecution retry loop, which owns the actual delay/re-invoke wiring
7
+ // (this module only ever classifies/decides, never sleeps or retries
8
+ // anything itself).
9
+
10
+ // FATAL patterns are checked first and win over TRANSIENT when both match
11
+ // (e.g. a 401 body that also happens to mention a network-ish word) --
12
+ // per the issue spec, an auth/permission failure should never be retried
13
+ // just because its text also contains a transient-looking substring.
14
+ const FATAL_PATTERNS = [
15
+ "unauthorized",
16
+ "forbidden",
17
+ "invalid token",
18
+ "authentication failed",
19
+ "permission denied",
20
+ "401",
21
+ "403",
22
+ "credit exhaustion",
23
+ "credit balance",
24
+ ];
25
+
26
+ const TRANSIENT_PATTERNS = [
27
+ "timeout",
28
+ "econnrefused",
29
+ "econnreset",
30
+ "etimedout",
31
+ "503",
32
+ "502",
33
+ "429",
34
+ "rate limit",
35
+ "too many requests",
36
+ "overloaded",
37
+ "network error",
38
+ "socket hang up",
39
+ "exited with code",
40
+ ];
41
+
42
+ function classifyError(message) {
43
+ const text = String(message || "").toLowerCase();
44
+ if (FATAL_PATTERNS.some((p) => text.includes(p))) return "FATAL";
45
+ if (TRANSIENT_PATTERNS.some((p) => text.includes(p))) return "TRANSIENT";
46
+ return "UNKNOWN";
47
+ }
48
+
49
+ // `result.retryable === false` is an explicit escape hatch (e.g. issue
50
+ // #23's structured-output validation failure, once wired) that always
51
+ // wins regardless of `onError` -- checked before any error-text
52
+ // classification.
53
+ function shouldRetry(result, onError) {
54
+ if (result && result.retryable === false) return false;
55
+ const classification = classifyError(result && result.errorMessage);
56
+ if (classification === "FATAL") return false;
57
+ if (onError === "all") return true;
58
+ return classification === "TRANSIENT";
59
+ }
60
+
61
+ module.exports = { classifyError, shouldRetry };
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+
3
+ const Ajv = require("ajv");
4
+
5
+ // Best-effort reask budget (issue #23): neither the opencode nor pi CLI has
6
+ // a --schema/--json-schema flag (verified against both CLIs' --help), so
7
+ // output_format is enforced entirely via prompt augmentation + post-hoc
8
+ // parsing/validation + a bounded number of "please retry" reask turns. This
9
+ // is a fixed constant, not per-node configurable, to keep the failure mode
10
+ // predictable (a run either produces valid structured output within this
11
+ // budget, or fails outright -- never silently degrades to raw text).
12
+ const STRUCTURED_OUTPUT_MAX_REASKS = 3;
13
+
14
+ // Compiles a JSON Schema (already-parsed object) into an AJV validate
15
+ // function. Never throws -- returns { error: <message> } instead, so a
16
+ // caller (agent.js, at deploy time) can surface a clean red node status
17
+ // ("invalid output_format schema: <msg>") rather than crashing Node-RED.
18
+ function compileOutputFormat(schema) {
19
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
20
+ return { error: "output_format must be a JSON object (a JSON Schema)" };
21
+ }
22
+ try {
23
+ const ajv = new Ajv({ strict: false });
24
+ const validate = ajv.compile(schema);
25
+ return { validate };
26
+ } catch (err) {
27
+ return { error: err.message };
28
+ }
29
+ }
30
+
31
+ // Strips a single ```json ... ``` (or plain ``` ... ```) fence if present,
32
+ // otherwise returns the text unchanged -- models asked for "only JSON"
33
+ // still commonly wrap it in a markdown code fence.
34
+ function stripCodeFence(text) {
35
+ const match = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
36
+ return match ? match[1] : text;
37
+ }
38
+
39
+ // Parses `text` to a plain object only (arrays/primitives at the top level
40
+ // are rejected -- output_format is an object-only contract). Returns
41
+ // undefined instead of throwing on any failure.
42
+ function parseObjectOnly(text) {
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(text);
46
+ } catch (err) {
47
+ return undefined;
48
+ }
49
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
50
+ ? parsed
51
+ : undefined;
52
+ }
53
+
54
+ // Tiered best-effort parse of an agent's raw text response into a JSON
55
+ // object matching output_format, per issue #23:
56
+ // 1. Strip a ```json fence if present.
57
+ // 2. Try a clean JSON.parse of the (fence-stripped) whole text.
58
+ // 3. If that fails, scan for the FIRST '{' (not the last -- avoids
59
+ // grabbing a trailing example instead of the real payload) through
60
+ // the last '}' and retry JSON.parse on that slice.
61
+ // 4. If that also fails, return undefined (no jsonrepair dependency --
62
+ // shipped without it per the issue's own v1 allowance).
63
+ function tryParseStructuredOutput(text) {
64
+ if (typeof text !== "string" || !text.trim()) return undefined;
65
+
66
+ const candidate = stripCodeFence(text).trim();
67
+
68
+ const clean = parseObjectOnly(candidate);
69
+ if (clean !== undefined) return clean;
70
+
71
+ const firstBrace = candidate.indexOf("{");
72
+ const lastBrace = candidate.lastIndexOf("}");
73
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return undefined;
74
+
75
+ return parseObjectOnly(candidate.slice(firstBrace, lastBrace + 1));
76
+ }
77
+
78
+ // Appends a fixed instruction block asking the model for ONLY a JSON
79
+ // object matching `schema` (pretty-printed). Same functional intent as
80
+ // Archon's structured-output prompting, written independently.
81
+ function augmentPromptForSchema(prompt, schema) {
82
+ return (
83
+ `${prompt}\n\n` +
84
+ "Respond with ONLY a single JSON object (no surrounding prose, no " +
85
+ "markdown code fences) that validates against this JSON Schema:\n" +
86
+ `${JSON.stringify(schema, null, 2)}`
87
+ );
88
+ }
89
+
90
+ // Builds a reask prompt: the original prompt, the AJV validation errors
91
+ // from the previous (invalid) attempt, and the schema again.
92
+ function buildReaskPrompt(originalPrompt, schema, errors) {
93
+ const errorLines = (errors || [])
94
+ .map((e) => `- ${e.instancePath || "(root)"} ${e.message}`)
95
+ .join("\n");
96
+ return (
97
+ `${originalPrompt}\n\n` +
98
+ "Your previous response did not validate against the required JSON " +
99
+ `Schema. Validation errors:\n${errorLines || "(response was not valid JSON)"}\n\n` +
100
+ "Respond again with ONLY a single JSON object (no prose, no markdown " +
101
+ `code fences) that validates against this schema:\n${JSON.stringify(schema, null, 2)}`
102
+ );
103
+ }
104
+
105
+ module.exports = {
106
+ STRUCTURED_OUTPUT_MAX_REASKS,
107
+ compileOutputFormat,
108
+ tryParseStructuredOutput,
109
+ augmentPromptForSchema,
110
+ buildReaskPrompt,
111
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-agents",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Node-RED nodes for running coding agents (opencode, pi) and GitHub CLI operations from flows.",
5
5
  "keywords": [
6
6
  "node-red",
@@ -56,5 +56,8 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "node-red-node-test-helper": "^0.3.6"
59
+ },
60
+ "dependencies": {
61
+ "ajv": "^8.20.0"
59
62
  }
60
63
  }