@tbrandenburg/node-red-agents 0.3.7 → 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.
- package/README.md +3 -1
- package/nodes/agent/agent.html +1017 -575
- package/nodes/agent/agent.js +353 -44
- package/nodes/agent/lib/agents/capabilities.js +23 -0
- package/nodes/agent/lib/agents/opencode.js +158 -26
- package/nodes/agent/lib/agents/pi.js +32 -1
- package/nodes/agent/lib/execution/inputs.js +22 -0
- package/nodes/agent/lib/execution/lifecycle.js +7 -1
- package/nodes/agent/lib/execution/retry.js +61 -0
- package/nodes/agent/lib/execution/structured-output.js +111 -0
- package/nodes/agent-server/agent-server.html +52 -3
- package/nodes/agent-server/lib/model.js +3 -5
- package/nodes/gh/gh.js +4 -1
- package/package.json +5 -2
- package/shared/model-format.js +34 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const { AgentAdapter } = require("./base");
|
|
5
5
|
const { toOpenCodeMcp } = require("../mcp/normalize");
|
|
6
|
+
const { assertModelFormat } = require("../../../../shared/model-format");
|
|
6
7
|
|
|
7
8
|
// Maps opencode's real `--format json` event stream types (verified against
|
|
8
9
|
// packages/opencode/src/cli/cmd/run.ts) onto the Agent node's generic event
|
|
@@ -18,6 +19,8 @@ const TYPE_MAP = {
|
|
|
18
19
|
|
|
19
20
|
class OpenCodeAdapter extends AgentAdapter {
|
|
20
21
|
validate(resolved) {
|
|
22
|
+
assertModelFormat(resolved.model);
|
|
23
|
+
|
|
21
24
|
if (resolved.cwd) {
|
|
22
25
|
let stat;
|
|
23
26
|
try {
|
|
@@ -72,6 +75,15 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
72
75
|
if (resolved.model) args.push("--model", resolved.model);
|
|
73
76
|
if (resolved.auto) args.push("--auto");
|
|
74
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
|
+
|
|
75
87
|
// Skill and Command/Template invocation share the same underlying
|
|
76
88
|
// opencode mechanism: skills are registered internally as commands
|
|
77
89
|
// (source:"skill"), so `--command <name>` handles both -- verified
|
|
@@ -84,8 +96,36 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
const env = {};
|
|
99
|
+
const opencodeConfig = {};
|
|
87
100
|
if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
|
|
88
|
-
|
|
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);
|
|
89
129
|
}
|
|
90
130
|
|
|
91
131
|
return { command: "opencode", args, env };
|
|
@@ -107,7 +147,7 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
107
147
|
return { type, sessionID: raw.sessionID, data: raw };
|
|
108
148
|
}
|
|
109
149
|
|
|
110
|
-
parseResult(events, exitCode, signal, stderr) {
|
|
150
|
+
parseResult(events, exitCode, signal, stderr, resolved) {
|
|
111
151
|
const raw = events.map((e) => e.data);
|
|
112
152
|
const errorEvent = raw.find((e) => e.type === "error");
|
|
113
153
|
const sessionID = raw.length ? raw[raw.length - 1].sessionID : undefined;
|
|
@@ -118,45 +158,137 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
118
158
|
.join("\n")
|
|
119
159
|
.trim();
|
|
120
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
|
+
|
|
121
169
|
if (errorEvent) {
|
|
122
170
|
const errDetail = errorEvent.error || {};
|
|
123
171
|
const message =
|
|
124
|
-
(errDetail.data && errDetail.data.message) ||
|
|
172
|
+
(errDetail.data && errDetail.data.message) ||
|
|
173
|
+
errDetail.name ||
|
|
174
|
+
"opencode reported an error";
|
|
125
175
|
// The JSON error event only carries opencode's own top-level
|
|
126
|
-
// message/name -- append name (if distinct)
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
176
|
+
// message/name -- append name (if distinct), its diagnostic ref (if
|
|
177
|
+
// any -- note this does NOT reliably show up in opencode's own log
|
|
178
|
+
// file, verified empirically, so it's a weak clue at best), and any
|
|
179
|
+
// stderr output opencode wrote alongside it, since all three would
|
|
180
|
+
// otherwise be silently dropped here (unlike the exitCode!==0
|
|
181
|
+
// branch below, which already surfaces stderr).
|
|
130
182
|
const extras = [];
|
|
131
183
|
if (errDetail.name && errDetail.name !== message) extras.push(errDetail.name);
|
|
184
|
+
if (errDetail.data && errDetail.data.ref) extras.push(`ref=${errDetail.data.ref}`);
|
|
185
|
+
// "UnknownError" is opencode's catch-all for a request the provider/
|
|
186
|
+
// server rejected before generating any content -- in practice the
|
|
187
|
+
// single most common trigger we've seen is a `--model` value that
|
|
188
|
+
// doesn't exist (wrong provider, typo, or a model that isn't
|
|
189
|
+
// actually available to this account). It's not the only possible
|
|
190
|
+
// cause, so this is phrased as a hint, not a diagnosis.
|
|
191
|
+
if (errDetail.name === "UnknownError" && resolved && resolved.model) {
|
|
192
|
+
extras.push(
|
|
193
|
+
`possible cause: model "${resolved.model}" may not exist or isn't available -- run "opencode models" to check`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
132
196
|
if (stderr && String(stderr).trim()) extras.push(String(stderr).trim());
|
|
133
197
|
const errorMessage = extras.length ? `${message} (${extras.join("; ")})` : message;
|
|
134
|
-
return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
198
|
+
return Object.assign(
|
|
199
|
+
{
|
|
200
|
+
payload,
|
|
201
|
+
sessionID,
|
|
202
|
+
status: "failed",
|
|
203
|
+
errorMessage,
|
|
204
|
+
errorDetail: errDetail,
|
|
205
|
+
},
|
|
206
|
+
usage,
|
|
207
|
+
);
|
|
141
208
|
}
|
|
142
209
|
if (signal) {
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
210
|
+
return Object.assign(
|
|
211
|
+
{
|
|
212
|
+
payload,
|
|
213
|
+
sessionID,
|
|
214
|
+
status: "failed",
|
|
215
|
+
errorMessage: `process killed by signal ${signal}`,
|
|
216
|
+
},
|
|
217
|
+
usage,
|
|
218
|
+
);
|
|
149
219
|
}
|
|
150
220
|
if (exitCode !== 0) {
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 },
|
|
156
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
|
+
}
|
|
157
277
|
}
|
|
158
|
-
return { payload, sessionID, status: "completed" };
|
|
159
278
|
}
|
|
279
|
+
const usage = {};
|
|
280
|
+
if (costUsd !== undefined) usage.costUsd = costUsd;
|
|
281
|
+
if (tokens !== undefined) usage.tokens = tokens;
|
|
282
|
+
return usage;
|
|
160
283
|
}
|
|
161
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
|
+
|
|
162
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
|
-
|
|
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 };
|
|
@@ -51,7 +51,13 @@ async function runAgent({ adapter, runtime, resolved, executionId, onEvent, onSt
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const durationMs = Date.now() - startedAt;
|
|
54
|
-
const result = adapter.parseResult(
|
|
54
|
+
const result = adapter.parseResult(
|
|
55
|
+
events,
|
|
56
|
+
outcome.exitCode,
|
|
57
|
+
outcome.signal,
|
|
58
|
+
outcome.stderr,
|
|
59
|
+
resolved,
|
|
60
|
+
);
|
|
55
61
|
const status = outcome.timedOut ? "timeout" : result.status;
|
|
56
62
|
|
|
57
63
|
if (onStatus) onStatus(status);
|
|
@@ -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
|
+
};
|
|
@@ -49,7 +49,18 @@
|
|
|
49
49
|
srtAllowedDomains: { value: [] },
|
|
50
50
|
srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
|
|
51
51
|
srtStrictAllowlist: { value: true },
|
|
52
|
-
srtAdvancedJson: {
|
|
52
|
+
srtAdvancedJson: {
|
|
53
|
+
value: '',
|
|
54
|
+
validate: function (v) {
|
|
55
|
+
if (!v || !v.trim()) return true;
|
|
56
|
+
try {
|
|
57
|
+
JSON.parse(v);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
53
64
|
},
|
|
54
65
|
inputs: 1,
|
|
55
66
|
outputs: 2,
|
|
@@ -135,8 +146,46 @@
|
|
|
135
146
|
});
|
|
136
147
|
return values;
|
|
137
148
|
}
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
|
|
150
|
+
// Same rationale as the agent node's oneditsave: only
|
|
151
|
+
// persist the SRT inline-settings lists/fields while
|
|
152
|
+
// they're actually the active runtime+mode, otherwise
|
|
153
|
+
// reset them to their defaults so switching back to
|
|
154
|
+
// Direct (or File path mode) doesn't silently leave a
|
|
155
|
+
// stale SRT config sitting in the deployed flow.
|
|
156
|
+
//
|
|
157
|
+
// Note: Node-RED's own core copies each defaults-bound
|
|
158
|
+
// plain input's *current DOM value* into `this[key]`
|
|
159
|
+
// right after oneditsave returns, so the reset has to
|
|
160
|
+
// touch the DOM element itself for any field with a
|
|
161
|
+
// plain `#node-input-<name>` binding (srtSettingsMode,
|
|
162
|
+
// srtSettingsPath, srtBinary, srtStrictAllowlist,
|
|
163
|
+
// srtAdvancedJson) -- setting `this.<name>` alone would
|
|
164
|
+
// get silently overwritten straight back by that hidden
|
|
165
|
+
// element's stale value. srtAllowedDomains/
|
|
166
|
+
// srtAllowedWriteDirs are the exception (no such element
|
|
167
|
+
// exists -- only the "-list" editableList container), so
|
|
168
|
+
// those are fine set directly on `this`.
|
|
169
|
+
const runtime = $('#node-input-runtime').val();
|
|
170
|
+
const srtSettingsMode = $('#node-input-srtSettingsMode').val();
|
|
171
|
+
|
|
172
|
+
if (runtime !== 'srt') {
|
|
173
|
+
$('#node-input-srtSettingsMode').val('file');
|
|
174
|
+
$('#node-input-srtSettingsPath').val('');
|
|
175
|
+
$('#node-input-srtBinary').val('');
|
|
176
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
177
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
178
|
+
this.srtAllowedDomains = [];
|
|
179
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
180
|
+
} else if (srtSettingsMode !== 'inline') {
|
|
181
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
182
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
183
|
+
this.srtAllowedDomains = [];
|
|
184
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
185
|
+
} else {
|
|
186
|
+
this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
|
|
187
|
+
this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
|
|
188
|
+
}
|
|
140
189
|
}
|
|
141
190
|
});
|
|
142
191
|
}());
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const { assertModelFormat } = require("../../../shared/model-format");
|
|
4
|
+
|
|
3
5
|
// The `agent` node's --model flag accepts a plain "provider/model" string
|
|
4
6
|
// (opencode's CLI parses that itself). The HTTP API has no such shorthand --
|
|
5
7
|
// POST /session/:id/message's `model` field must be a
|
|
@@ -10,12 +12,8 @@
|
|
|
10
12
|
function parseModel(value) {
|
|
11
13
|
if (value === undefined || value === null || value === "") return undefined;
|
|
12
14
|
const str = String(value);
|
|
15
|
+
assertModelFormat(str); // throws with a shared, consistent message (see shared/model-format.js)
|
|
13
16
|
const slash = str.indexOf("/");
|
|
14
|
-
if (slash <= 0 || slash === str.length - 1) {
|
|
15
|
-
throw new Error(
|
|
16
|
-
`invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-4.6")`,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
17
|
return { providerID: str.slice(0, slash), modelID: str.slice(slash + 1) };
|
|
20
18
|
}
|
|
21
19
|
|
package/nodes/gh/gh.js
CHANGED
|
@@ -43,7 +43,10 @@ function validateCommand(command) {
|
|
|
43
43
|
// the (locale/version dependent) human-readable message themselves. Order
|
|
44
44
|
// matters: more specific patterns are checked before generic ones.
|
|
45
45
|
const ERROR_TYPE_PATTERNS = [
|
|
46
|
-
[
|
|
46
|
+
[
|
|
47
|
+
/not logged into|to authenticate|gh auth login|authentication required|bad credentials/i,
|
|
48
|
+
"auth",
|
|
49
|
+
],
|
|
47
50
|
[/api rate limit exceeded|secondary rate limit/i, "rate-limit"],
|
|
48
51
|
[/has disabled issues|has disabled pull requests|has disabled projects/i, "feature-disabled"],
|
|
49
52
|
[/could not resolve to a repository|repository not found|404/i, "not-found"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tbrandenburg/node-red-agents",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"!**/fixtures/**"
|
|
42
42
|
],
|
|
43
43
|
"engines": {
|
|
44
|
-
"node": ">=
|
|
44
|
+
"node": ">=20"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "node --test --experimental-test-coverage=false 'nodes/**/test/**/*.spec.js' 'shared/**/test/**/*.spec.js'"
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// opencode's model identifiers are always "provider/model" (e.g.
|
|
4
|
+
// "github-copilot/claude-sonnet-5"). Both the `agent` node (which passes
|
|
5
|
+
// the string straight through to `opencode run --model`) and the
|
|
6
|
+
// `agent-server` node (which splits it into { providerID, modelID } for the
|
|
7
|
+
// HTTP API, see agent-server/lib/model.js) need the same shape check.
|
|
8
|
+
//
|
|
9
|
+
// This exists because a malformed or non-existent model string currently
|
|
10
|
+
// only surfaces as opencode's own generic, unhelpful failure -- e.g.
|
|
11
|
+
// `opencode run --model bogus "hi"` prints
|
|
12
|
+
// {"type":"error","error":{"name":"UnknownError","data":{"message":
|
|
13
|
+
// "Unexpected server error. Check server logs for details.","ref":"err_..."}}}
|
|
14
|
+
// with exit code 0, and that "err_..." ref does *not* actually appear in
|
|
15
|
+
// opencode's own log file (verified empirically) -- so "check server logs"
|
|
16
|
+
// is a dead end. Catching an obviously-malformed string (no slash, or an
|
|
17
|
+
// empty provider/model half) before ever spawning opencode turns that into
|
|
18
|
+
// an immediate, actionable Node-RED error instead. It cannot catch a
|
|
19
|
+
// syntactically valid but non-existent "provider/model" pair -- that still
|
|
20
|
+
// requires either `opencode models` (see opencode's own --help) or opencode
|
|
21
|
+
// fixing its own error reporting.
|
|
22
|
+
function assertModelFormat(value) {
|
|
23
|
+
if (value === undefined || value === null || value === "") return;
|
|
24
|
+
const str = String(value);
|
|
25
|
+
const slash = str.indexOf("/");
|
|
26
|
+
if (slash <= 0 || slash === str.length - 1) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-5"); ` +
|
|
29
|
+
`run "opencode models" to list valid values`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { assertModelFormat };
|