@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.
@@ -7,6 +7,16 @@ const { writeInlineSettingsFile } = require("../../shared/srt-settings");
7
7
  const { runAgent } = require("./lib/execution/lifecycle");
8
8
  const { ExecutionScheduler } = require("./lib/execution/scheduler");
9
9
  const { computeNodeStatus } = require("./lib/execution/status");
10
+ const { getCapabilities } = require("./lib/agents/capabilities");
11
+ const { shouldRetry } = require("./lib/execution/retry");
12
+ const { substituteInputs } = require("./lib/execution/inputs");
13
+ const {
14
+ STRUCTURED_OUTPUT_MAX_REASKS,
15
+ compileOutputFormat,
16
+ tryParseStructuredOutput,
17
+ augmentPromptForSchema,
18
+ buildReaskPrompt,
19
+ } = require("./lib/execution/structured-output");
10
20
 
11
21
  // Registries. Adding a future adapter/runtime is just one more entry here --
12
22
  // nothing else in this file (or in lib/execution/lifecycle.js) needs to
@@ -73,6 +83,14 @@ module.exports = function (RED) {
73
83
  node.arguments_ = config.arguments !== undefined ? config.arguments : "payload";
74
84
  node.argumentsType = config.argumentsType || "msg";
75
85
 
86
+ // Named, multi-value $INPUTS.<name> templating (issue #20): a list of
87
+ // { name, value, valueType } typed-input entries, each resolved
88
+ // per-message via resolveTyped below and substituted into the
89
+ // resolved arguments string before it's handed to either adapter.
90
+ // Pure text templating, zero adapter-specific code -- see
91
+ // lib/execution/inputs.js. Default empty list = zero behavior change.
92
+ node.inputs = Array.isArray(config.inputs) ? config.inputs : [];
93
+
76
94
  node.cwd = config.cwd !== undefined ? config.cwd : "cwd";
77
95
  node.cwdType = config.cwdType || "msg";
78
96
 
@@ -83,6 +101,75 @@ module.exports = function (RED) {
83
101
 
84
102
  node.mcpServers = Array.isArray(config.mcpServers) ? config.mcpServers : [];
85
103
 
104
+ // Optional per-adapter capability-gated fields (issue #25), modeled on
105
+ // Archon's DagNodeBase: systemPrompt override, effort (reasoning
106
+ // depth), and allowed/denied tool lists. Each is only ever honored by
107
+ // an adapter whose CAPABILITIES flag says it's actually wired up (see
108
+ // lib/agents/capabilities.js) -- otherwise startExecution below warns
109
+ // once and drops it, never a hard error.
110
+ node.systemPrompt = config.systemPrompt !== undefined ? config.systemPrompt : "";
111
+ node.systemPromptType = config.systemPromptType || "str";
112
+
113
+ node.effort = config.effort !== undefined ? config.effort : "";
114
+ node.effortType = config.effortType || "str";
115
+
116
+ node.allowedTools = Array.isArray(config.allowedTools) ? config.allowedTools : [];
117
+ node.deniedTools = Array.isArray(config.deniedTools) ? config.deniedTools : [];
118
+
119
+ // Node-level retry (issue #24): fully opt-in-by-default at a
120
+ // conservative setting (2 total attempts = 1 original + 1 retry, per
121
+ // the issue's "default 2 attempts" wording), gated by a small
122
+ // transient-vs-fatal string classifier (lib/execution/retry.js) so a
123
+ // fatal error (bad auth, quota exhaustion) never burns a retry. See
124
+ // startExecution below for the actual retry loop.
125
+ node.retryMaxAttempts = Number.isFinite(Number(config.retryMaxAttempts))
126
+ ? Math.max(1, Number(config.retryMaxAttempts))
127
+ : 2;
128
+ node.retryDelayMs = Number.isFinite(Number(config.retryDelayMs))
129
+ ? Math.max(0, Number(config.retryDelayMs))
130
+ : 3000;
131
+ node.retryOnError = config.retryOnError === "all" ? "all" : "transient";
132
+
133
+ // output_format (issue #23): fully opt-in, JSON-Schema-as-text config
134
+ // field. Compiled once here (deploy time), not per-execution -- a bad
135
+ // schema or an adapter that doesn't support structured output at all
136
+ // (capabilities.structuredOutput === false) sets node.outputFormatError,
137
+ // which the input handler below checks before ever building an
138
+ // execution, mirroring the existing srtSettingsError deploy-time-
139
+ // validation pattern above.
140
+ node.outputFormat = config.outputFormat !== undefined ? config.outputFormat : "";
141
+ node.compiledOutputFormat = undefined; // AJV validate fn, if configured+valid
142
+ node.outputFormatSchema = undefined; // parsed schema object, if configured+valid
143
+ node.outputFormatError = undefined;
144
+
145
+ if (node.outputFormat && String(node.outputFormat).trim()) {
146
+ let schema;
147
+ try {
148
+ schema = JSON.parse(node.outputFormat);
149
+ } catch (err) {
150
+ node.outputFormatError = `invalid output_format schema: ${err.message}`;
151
+ }
152
+ if (!node.outputFormatError) {
153
+ const compiled = compileOutputFormat(schema);
154
+ if (compiled.error) {
155
+ node.outputFormatError = `invalid output_format schema: ${compiled.error}`;
156
+ } else {
157
+ node.compiledOutputFormat = compiled.validate;
158
+ node.outputFormatSchema = schema;
159
+ }
160
+ }
161
+ if (!node.outputFormatError && AGENTS[node.agent]) {
162
+ const capabilities = getCapabilities(AGENTS[node.agent]());
163
+ if (capabilities.structuredOutput === false) {
164
+ node.outputFormatError = `output_format not supported by ${node.agent}`;
165
+ }
166
+ }
167
+ if (node.outputFormatError) {
168
+ node.error(`agent: ${node.outputFormatError}`);
169
+ node.status({ fill: "red", shape: "ring", text: node.outputFormatError });
170
+ }
171
+ }
172
+
86
173
  node.srtBinary = config.srtBinary || "";
87
174
  node.srtSettingsMode = config.srtSettingsMode || "file";
88
175
  node.srtSettingsPath = config.srtSettingsPath || "";
@@ -190,8 +277,15 @@ module.exports = function (RED) {
190
277
  };
191
278
  }
192
279
 
193
- function emitEvent(send, msg, executionId, type, agentName, cwd) {
194
- send([null, lifecycleEnvelope(msg, executionId, { type }, agentName, cwd)]);
280
+ // `extra` (e.g. { costUsd, tokens } -- see startExecution/onSettled
281
+ // below) is merged into the { type } payload only when provided, so
282
+ // every other emitEvent call site (queued/cancelled/running/etc.)
283
+ // keeps its existing { type }-only payload shape unchanged.
284
+ function emitEvent(send, msg, executionId, type, agentName, cwd, extra) {
285
+ send([
286
+ null,
287
+ lifecycleEnvelope(msg, executionId, Object.assign({ type }, extra), agentName, cwd),
288
+ ]);
195
289
  }
196
290
 
197
291
  // The actual work for one execution. Only ever invoked by the
@@ -201,37 +295,236 @@ module.exports = function (RED) {
201
295
  const { executionId, msg, send, done, resolved } = item;
202
296
  const adapter = AGENTS[node.agent]();
203
297
  const runtime = buildRuntime(node);
298
+ const capabilities = getCapabilities(adapter);
299
+
300
+ // Capability-gated warn-and-drop (issue #25): a field the user
301
+ // configured but this adapter doesn't actually wire up gets exactly
302
+ // one node.warn per run here -- never a hard error, and never a
303
+ // silent no-op either. Adapters themselves only ever act on these
304
+ // fields when their own CAPABILITIES flag agrees (see opencode.js/
305
+ // pi.js), so this is the single place responsible for surfacing
306
+ // the "ignored" case to the flow author.
307
+ function warnUnsupported(field, isSet, supported) {
308
+ if (isSet && !supported) {
309
+ node.warn(`${field} is not supported by the ${node.agent} adapter and will be ignored`);
310
+ }
311
+ }
312
+ warnUnsupported("systemPrompt", !!resolved.systemPrompt, capabilities.systemPromptControl);
313
+ warnUnsupported("effort", !!resolved.effort, capabilities.effortControl);
314
+ warnUnsupported(
315
+ "allowed_tools",
316
+ Array.isArray(resolved.allowedTools) && resolved.allowedTools.length > 0,
317
+ capabilities.toolRestrictions,
318
+ );
319
+ warnUnsupported(
320
+ "denied_tools",
321
+ Array.isArray(resolved.deniedTools) && resolved.deniedTools.length > 0,
322
+ capabilities.toolRestrictions,
323
+ );
204
324
 
205
- return runAgent({
206
- adapter,
207
- runtime,
208
- resolved,
209
- executionId,
210
- onEvent: (event) => {
211
- send([null, lifecycleEnvelope(msg, executionId, event, resolved.agentName, resolved.cwd)]);
212
- },
213
- onStatus: (status) => {
214
- if (status === "running") {
215
- emitEvent(send, msg, executionId, "running", resolved.agentName, resolved.cwd);
216
- } else {
217
- // Terminal (completed/failed/timeout): stash rather
218
- // than emit immediately -- the scheduler hasn't
219
- // removed this execution from `active` yet at this
220
- // point, so the active/queued counts on the
221
- // envelope would be stale by one. onSettled (below)
222
- // emits it once the scheduler's own bookkeeping,
223
- // including any newly-started queued item, is
224
- // fully settled.
225
- item.finalStatus = status;
325
+ function invoke(currentResolved) {
326
+ return runAgent({
327
+ adapter,
328
+ runtime,
329
+ resolved: currentResolved,
330
+ executionId,
331
+ onEvent: (event) => {
332
+ send([
333
+ null,
334
+ lifecycleEnvelope(msg, executionId, event, resolved.agentName, resolved.cwd),
335
+ ]);
336
+ },
337
+ onStatus: (status) => {
338
+ if (status === "running") {
339
+ emitEvent(send, msg, executionId, "running", resolved.agentName, resolved.cwd);
340
+ } else {
341
+ // Terminal (completed/failed/timeout): stash rather
342
+ // than emit immediately -- the scheduler hasn't
343
+ // removed this execution from `active` yet at this
344
+ // point, so the active/queued counts on the
345
+ // envelope would be stale by one. onSettled (below)
346
+ // emits it once the scheduler's own bookkeeping,
347
+ // including any newly-started queued item, is
348
+ // fully settled.
349
+ item.finalStatus = status;
350
+ }
351
+ },
352
+ });
353
+ }
354
+
355
+ // output_format (issue #23): only ever engaged for prompt invocation
356
+ // with a compiled schema (deploy-time-validated -- see the
357
+ // constructor above). Runs the adapter once with the prompt
358
+ // augmented to ask for schema-matching JSON, then parses+validates
359
+ // the result; on failure, best-effort adapters (capabilities.
360
+ // structuredOutput === "best-effort") get up to
361
+ // STRUCTURED_OUTPUT_MAX_REASKS additional turns (reusing the prior
362
+ // sessionID when capabilities.sessionResume is true, so context
363
+ // isn't lost) before the whole execution is reported as failed --
364
+ // it must never silently fall back to raw, unvalidated text.
365
+ const outputFormat = node.compiledOutputFormat
366
+ ? { validate: node.compiledOutputFormat, schema: node.outputFormatSchema }
367
+ : undefined;
368
+ const structuredEnabled = !!outputFormat && resolved.invocation === "prompt";
369
+
370
+ async function executeWithStructuredOutput(execResolved) {
371
+ const firstResolved = structuredEnabled
372
+ ? Object.assign({}, execResolved, {
373
+ prompt: augmentPromptForSchema(execResolved.prompt, outputFormat.schema),
374
+ })
375
+ : execResolved;
376
+
377
+ let result = await invoke(firstResolved);
378
+ if (!structuredEnabled || result.status !== "completed") return result;
379
+
380
+ let parsed = tryParseStructuredOutput(result.payload);
381
+ let valid = parsed !== undefined && outputFormat.validate(parsed);
382
+ let lastErrors = outputFormat.validate.errors;
383
+ let attempts = 0;
384
+ const maxReasks =
385
+ capabilities.structuredOutput === "best-effort" ? STRUCTURED_OUTPUT_MAX_REASKS : 0;
386
+
387
+ while (!valid && attempts < maxReasks) {
388
+ attempts += 1;
389
+ const reaskResolved = Object.assign({}, execResolved, {
390
+ prompt: buildReaskPrompt(execResolved.prompt, outputFormat.schema, lastErrors),
391
+ sessionID:
392
+ capabilities.sessionResume && result.sessionID
393
+ ? result.sessionID
394
+ : execResolved.sessionID,
395
+ });
396
+ result = await invoke(reaskResolved);
397
+ if (result.status !== "completed") break;
398
+ parsed = tryParseStructuredOutput(result.payload);
399
+ valid = parsed !== undefined && outputFormat.validate(parsed);
400
+ lastErrors = outputFormat.validate.errors;
401
+ }
402
+
403
+ if (!valid) {
404
+ const errText =
405
+ lastErrors && lastErrors.length
406
+ ? lastErrors.map((e) => `${e.instancePath || "(root)"} ${e.message}`).join("; ")
407
+ : "response was not valid JSON matching output_format";
408
+ return Object.assign({}, result, {
409
+ status: "failed",
410
+ errorMessage: `output_format validation failed after ${attempts} reask(s): ${errText}`,
411
+ });
412
+ }
413
+
414
+ return Object.assign({}, result, { structuredOutput: parsed });
415
+ }
416
+
417
+ function delay(ms) {
418
+ return new Promise((resolve) => setTimeout(resolve, ms));
419
+ }
420
+
421
+ // Node-level retry (issue #24): a whole executeWithStructuredOutput()
422
+ // call (first attempt + any reasks) counts as one "attempt" here --
423
+ // retries only kick in once that entire pipeline has settled on a
424
+ // final failed/timeout result. Runs inside this one scheduler slot
425
+ // (see the module header comment on ExecutionScheduler's contract)
426
+ // -- never re-`submit()`s to the scheduler, so no duplicate
427
+ // queued/running events and no extra slot churn.
428
+ async function executeWithRetry() {
429
+ let currentResolved = resolved;
430
+ let attempt = 1;
431
+ for (;;) {
432
+ const result = await executeWithStructuredOutput(currentResolved);
433
+ const isTerminalFailure = result.status === "failed" || result.status === "timeout";
434
+ if (
435
+ !isTerminalFailure ||
436
+ attempt >= node.retryMaxAttempts ||
437
+ !shouldRetry(result, node.retryOnError)
438
+ ) {
439
+ return result;
226
440
  }
227
- },
228
- })
441
+
442
+ attempt += 1;
443
+ emitEvent(send, msg, executionId, "retrying", resolved.agentName, resolved.cwd, {
444
+ attempt,
445
+ maxAttempts: node.retryMaxAttempts,
446
+ });
447
+
448
+ if (node.retryDelayMs > 0) await delay(node.retryDelayMs);
449
+
450
+ // Session-reuse-on-retry: only when the adapter can actually
451
+ // resume a session (capabilities.sessionResume, e.g. opencode)
452
+ // and the failed attempt got far enough to mint one -- pi
453
+ // (sessionResume: false) always retries sessionless, unchanged.
454
+ currentResolved =
455
+ capabilities.sessionResume && result.sessionID
456
+ ? Object.assign({}, currentResolved, { sessionID: result.sessionID })
457
+ : currentResolved;
458
+ }
459
+ }
460
+
461
+ return executeWithRetry()
229
462
  .then((result) => {
230
463
  node.lastTerminal = result.status;
231
464
  node.lastText = undefined;
232
465
 
466
+ // output_format success (issue #23): canonicalize msg.payload to
467
+ // the parsed-then-restringified JSON text (not the adapter's
468
+ // possibly fence-wrapped/padded raw text), and stash the parsed
469
+ // object separately on agentExecution below.
470
+ if (
471
+ structuredEnabled &&
472
+ result.status === "completed" &&
473
+ result.structuredOutput !== undefined
474
+ ) {
475
+ result = Object.assign({}, result, {
476
+ payload: JSON.stringify(result.structuredOutput),
477
+ });
478
+ }
479
+
480
+ const agentExecution = {
481
+ id: executionId,
482
+ status: result.status,
483
+ exitCode: result.exitCode,
484
+ signal: result.signal,
485
+ timedOut: result.timedOut,
486
+ durationMs: result.durationMs,
487
+ sessionID: result.sessionID,
488
+ // Raw error object from the adapter (e.g. opencode's full
489
+ // {"type":"error"} payload, or pi's failing assistant
490
+ // message) when the run failed -- the `done(err)` string
491
+ // below only carries a single summarized message/name, so
492
+ // anything needing the fuller detail (extra fields the
493
+ // adapter didn't fold into errorMessage) should wire a
494
+ // Debug node to output 1 and inspect this field.
495
+ errorDetail: result.errorDetail,
496
+ };
497
+ if (structuredEnabled && result.structuredOutput !== undefined) {
498
+ agentExecution.structuredOutput = result.structuredOutput;
499
+ agentExecution.declaredFields = Object.keys(
500
+ (outputFormat.schema && outputFormat.schema.properties) || {},
501
+ );
502
+ }
503
+
504
+ // Only adapters declaring costReporting (see
505
+ // lib/agents/capabilities.js) ever populate result.costUsd/
506
+ // .tokens (e.g. opencode.js's parseResult) -- checking the
507
+ // capability first, rather than just `!== undefined`, means an
508
+ // adapter that isn't wired for this can never leak a stray
509
+ // key even if its result object happens to carry one.
510
+ let usage;
511
+ if (capabilities.costReporting) {
512
+ if (result.costUsd !== undefined) agentExecution.costUsd = result.costUsd;
513
+ if (result.tokens !== undefined) agentExecution.tokens = result.tokens;
514
+ if (agentExecution.costUsd !== undefined || agentExecution.tokens !== undefined) {
515
+ usage = {};
516
+ if (agentExecution.costUsd !== undefined) usage.costUsd = agentExecution.costUsd;
517
+ if (agentExecution.tokens !== undefined) usage.tokens = agentExecution.tokens;
518
+ }
519
+ }
520
+ // Stashed for onSettled below (the deferred terminal lifecycle
521
+ // event on output 2, see the onStatus comment above) -- by the
522
+ // time onSettled fires this Promise has already resolved, so
523
+ // item.finalUsage is guaranteed to be set.
524
+ item.finalUsage = usage;
525
+
233
526
  const resultMsg = Object.assign({}, msg, {
234
- payload: result.payload,
527
+ payload: result.status === "completed" ? result.payload : null,
235
528
  agent: node.agent,
236
529
  runtime: node.runtime,
237
530
  agentId: node.id,
@@ -242,23 +535,7 @@ module.exports = function (RED) {
242
535
  // (default) Session ID field -- msg.sessionID -- of
243
536
  // this or another agent node with no extra wiring.
244
537
  sessionID: result.sessionID,
245
- agentExecution: {
246
- id: executionId,
247
- status: result.status,
248
- exitCode: result.exitCode,
249
- signal: result.signal,
250
- timedOut: result.timedOut,
251
- durationMs: result.durationMs,
252
- sessionID: result.sessionID,
253
- // Raw error object from the adapter (e.g. opencode's full
254
- // {"type":"error"} payload, or pi's failing assistant
255
- // message) when the run failed -- the `done(err)` string
256
- // below only carries a single summarized message/name, so
257
- // anything needing the fuller detail (extra fields the
258
- // adapter didn't fold into errorMessage) should wire a
259
- // Debug node to output 1 and inspect this field.
260
- errorDetail: result.errorDetail,
261
- },
538
+ agentExecution,
262
539
  });
263
540
  send([resultMsg, null]);
264
541
 
@@ -306,6 +583,7 @@ module.exports = function (RED) {
306
583
  item.finalStatus,
307
584
  item.resolved.agentName,
308
585
  item.resolved.cwd,
586
+ item.finalUsage,
309
587
  );
310
588
  }
311
589
  updateStatus();
@@ -388,6 +666,14 @@ module.exports = function (RED) {
388
666
  return;
389
667
  }
390
668
 
669
+ if (node.outputFormatError) {
670
+ node.lastTerminal = "failed";
671
+ node.lastText = "bad output_format";
672
+ updateStatus();
673
+ done(new Error(`agent: ${node.outputFormatError}`));
674
+ return;
675
+ }
676
+
391
677
  if (msg.operation === "terminate") {
392
678
  handleTerminateOperation(msg, send, done);
393
679
  return;
@@ -417,7 +703,20 @@ module.exports = function (RED) {
417
703
  : undefined,
418
704
  args:
419
705
  node.invocation !== "prompt"
420
- ? resolveTyped(node.arguments_, node.argumentsType, msg, msg.payload)
706
+ ? (() => {
707
+ const raw = resolveTyped(node.arguments_, node.argumentsType, msg, msg.payload);
708
+ if (typeof raw !== "string" || node.inputs.length === 0) return raw;
709
+ const inputsMap = {};
710
+ node.inputs.forEach((entry) => {
711
+ inputsMap[entry.name] = resolveTyped(
712
+ entry.value,
713
+ entry.valueType || "msg",
714
+ msg,
715
+ "",
716
+ );
717
+ });
718
+ return substituteInputs(raw, inputsMap);
719
+ })()
421
720
  : undefined,
422
721
  cwd: (() => {
423
722
  const v = resolveTyped(node.cwd, node.cwdType, msg, "");
@@ -440,6 +739,16 @@ module.exports = function (RED) {
440
739
  : num * 1000;
441
740
  })(),
442
741
  mcpServers: node.mcpServers,
742
+ systemPrompt: (() => {
743
+ const v = resolveTyped(node.systemPrompt, node.systemPromptType, msg, "");
744
+ return v === undefined || v === null ? "" : String(v).trim();
745
+ })(),
746
+ effort: (() => {
747
+ const v = resolveTyped(node.effort, node.effortType, msg, "");
748
+ return v === undefined || v === null ? "" : String(v).trim();
749
+ })(),
750
+ allowedTools: node.allowedTools,
751
+ deniedTools: node.deniedTools,
443
752
  };
444
753
  } catch (err) {
445
754
  node.lastTerminal = "failed";
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ // Fixed set of capability flags an AgentAdapter subclass may declare via
4
+ // a static `CAPABILITIES` object exported alongside the class (see
5
+ // opencode.js / pi.js). A flag must reflect wired-up behavior in this
6
+ // adapter's buildExecution()/parseResult(), never what the underlying
7
+ // CLI could theoretically support if we wired more of it up -- see
8
+ // Archon's ProviderCapabilities convention. Missing flags default to
9
+ // their listed default (all "unsupported").
10
+ const DEFAULT_CAPABILITIES = {
11
+ sessionResume: false,
12
+ structuredOutput: false, // false | "best-effort" | "enforced"
13
+ toolRestrictions: false,
14
+ effortControl: false,
15
+ systemPromptControl: false,
16
+ costReporting: false,
17
+ };
18
+
19
+ function getCapabilities(adapter) {
20
+ return Object.assign({}, DEFAULT_CAPABILITIES, adapter.constructor.CAPABILITIES || {});
21
+ }
22
+
23
+ module.exports = { DEFAULT_CAPABILITIES, getCapabilities };