@tbrandenburg/node-red-agents 0.1.5 → 0.3.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.
@@ -84,6 +84,9 @@
84
84
  runtime: { value: 'direct' },
85
85
  invocation: { value: 'prompt' },
86
86
 
87
+ agentName: { value: '' },
88
+ agentNameType: { value: 'str' },
89
+
87
90
  model: { value: '' },
88
91
  modelType: { value: 'str' },
89
92
 
@@ -140,6 +143,11 @@
140
143
  MODEL_SUGGESTIONS.forEach((m) => datalist.append('<option value="' + m + '">'));
141
144
  }
142
145
 
146
+ $('#node-input-agentName').typedInput({
147
+ typeField: '#node-input-agentNameType',
148
+ types: ['str', 'msg', 'flow', 'global', 'env']
149
+ });
150
+
143
151
  $('#node-input-prompt').typedInput({
144
152
  typeField: '#node-input-promptType',
145
153
  types: ['msg', 'str', 'flow', 'global', 'env']
@@ -317,6 +325,15 @@
317
325
  <input type="text" id="node-input-name" placeholder="Agent">
318
326
  </div>
319
327
 
328
+ <div class="form-row">
329
+ <label for="node-input-agentName"><i class="fa fa-id-badge"></i> Agent name
330
+ <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
331
+ title="Reported as msg.agentName on the result/event outputs. Leave blank (str) to use the Name field above; set to msg/flow/global to make it dynamic per message."></i>
332
+ </label>
333
+ <input type="text" id="node-input-agentName" placeholder="leave blank to use the Name field above" style="width:60%">
334
+ <input type="hidden" id="node-input-agentNameType">
335
+ </div>
336
+
320
337
  <div class="form-row">
321
338
  <label for="node-input-agent"><i class="fa fa-cube"></i> Agent</label>
322
339
  <select id="node-input-agent" style="width:70%">
@@ -475,6 +492,12 @@
475
492
  the id of the execution to kill, as returned by an earlier run in
476
493
  <code>msg.agentExecution.id</code> or an Events message's
477
494
  <code>executionId</code>.</dd>
495
+ <dt class="optional">concurrency <span class="property-type">number</span></dt>
496
+ <dd>Overrides the Concurrency field at runtime, applied before this
497
+ message is submitted -- no redeploy needed. Raising it starts any
498
+ already-queued messages immediately; lowering it only affects
499
+ future scheduling (already-running executions are never
500
+ cancelled). Non-numeric/non-positive values are ignored.</dd>
478
501
  </dl>
479
502
  <h3>Outputs</h3>
480
503
  <ol class="node-ports">
@@ -488,6 +511,16 @@
488
511
  conversation on the next message.</dd>
489
512
  <dt>agentExecution <span class="property-type">object</span></dt>
490
513
  <dd>id, status, exitCode, signal, timedOut, durationMs, sessionID.</dd>
514
+ <dt>agentId <span class="property-type">string</span></dt>
515
+ <dd>This node instance's own Node-RED id, so a flow with
516
+ several agent nodes can tell which one produced a
517
+ given message.</dd>
518
+ <dt>agentName <span class="property-type">string</span></dt>
519
+ <dd>The resolved Agent name field, falling back to this node
520
+ instance's configured Name if the Agent name field is
521
+ blank/unresolved for this message (e.g. via
522
+ <code>msg.agentName</code> when configured as a msg
523
+ property).</dd>
491
524
  </dl>
492
525
  </li>
493
526
  <li>Events
@@ -498,6 +531,10 @@
498
531
  <dt>executionId <span class="property-type">string</span></dt>
499
532
  <dd>Correlates events (and the eventual result) belonging to
500
533
  the same execution when several run concurrently.</dd>
534
+ <dt>agentId <span class="property-type">string</span></dt>
535
+ <dd>This node instance's own Node-RED id (see Result output).</dd>
536
+ <dt>agentName <span class="property-type">string</span></dt>
537
+ <dd>The resolved Agent name for this execution (see Result output).</dd>
501
538
  </dl>
502
539
  </li>
503
540
  </ol>
@@ -509,7 +546,9 @@
509
546
  complete in a different order than the messages arrived; ordering is
510
547
  not guaranteed above concurrency 1. On redeploy/removal, queued
511
548
  messages are cancelled (a <code>cancelled</code> event plus a Node-RED
512
- error) and any still-running agent processes are terminated.</p>
549
+ error) and any still-running agent processes are terminated. Send
550
+ <code>msg.concurrency</code> to change the bound at runtime without a
551
+ redeploy -- see Inputs below.</p>
513
552
  <p><b>Terminating a specific execution on demand</b>: send a message with
514
553
  <code>msg.operation = "terminate"</code> and <code>msg.executionId</code>
515
554
  set to the id to kill (from a prior run's <code>msg.agentExecution.id</code>
@@ -45,6 +45,14 @@ module.exports = function (RED) {
45
45
  node.runtime = config.runtime || "direct";
46
46
  node.invocation = config.invocation || "prompt";
47
47
 
48
+ // Typed-input override of the agentName reported on outgoing/event
49
+ // messages (see lifecycleEnvelope below). Same typed-input pattern as
50
+ // every other overridable field: blank/unresolved falls back to
51
+ // node.name, so existing flows relying on the (previously static)
52
+ // node.name value keep working unchanged.
53
+ node.agentName = config.agentName !== undefined ? config.agentName : "";
54
+ node.agentNameType = config.agentNameType || "str";
55
+
48
56
  node.model = config.model || "";
49
57
  node.modelType = config.modelType || "str";
50
58
 
@@ -150,19 +158,30 @@ module.exports = function (RED) {
150
158
  }
151
159
  }
152
160
 
161
+ // Per-message resolution of the reported agentName: the typed-input
162
+ // agentName/agentNameType field (e.g. msg.agentName) if configured and
163
+ // present, else node.name -- same fallback behavior a blank/unset
164
+ // typed-input field has always had for every other field.
165
+ function resolveAgentName(msg) {
166
+ const v = resolveTyped(node.agentName, node.agentNameType, msg, node.name);
167
+ return v === undefined || v === null || v === "" ? node.name : String(v).trim();
168
+ }
169
+
153
170
  // Common envelope for every message on output 2 (the lifecycle/event
154
171
  // stream): correlation (topic, executionId), live scheduler counts
155
172
  // (so a widget bound to this stream always has the current
156
173
  // active/queued numbers, no separate polling needed) and a
157
174
  // timestamp (so external aggregation/history doesn't have to rely
158
175
  // on message-arrival time).
159
- function lifecycleEnvelope(msg, executionId, payload) {
176
+ function lifecycleEnvelope(msg, executionId, payload, agentName) {
160
177
  return {
161
178
  _msgid: msg._msgid,
162
179
  topic: msg.topic,
163
180
  payload,
164
181
  agent: node.agent,
165
182
  runtime: node.runtime,
183
+ agentId: node.id,
184
+ agentName,
166
185
  executionId,
167
186
  active: node.scheduler.activeCount,
168
187
  queued: node.scheduler.queuedCount,
@@ -170,8 +189,8 @@ module.exports = function (RED) {
170
189
  };
171
190
  }
172
191
 
173
- function emitEvent(send, msg, executionId, type) {
174
- send([null, lifecycleEnvelope(msg, executionId, { type })]);
192
+ function emitEvent(send, msg, executionId, type, agentName) {
193
+ send([null, lifecycleEnvelope(msg, executionId, { type }, agentName)]);
175
194
  }
176
195
 
177
196
  // The actual work for one execution. Only ever invoked by the
@@ -188,11 +207,11 @@ module.exports = function (RED) {
188
207
  resolved,
189
208
  executionId,
190
209
  onEvent: (event) => {
191
- send([null, lifecycleEnvelope(msg, executionId, event)]);
210
+ send([null, lifecycleEnvelope(msg, executionId, event, resolved.agentName)]);
192
211
  },
193
212
  onStatus: (status) => {
194
213
  if (status === "running") {
195
- emitEvent(send, msg, executionId, "running");
214
+ emitEvent(send, msg, executionId, "running", resolved.agentName);
196
215
  } else {
197
216
  // Terminal (completed/failed/timeout): stash rather
198
217
  // than emit immediately -- the scheduler hasn't
@@ -214,6 +233,8 @@ module.exports = function (RED) {
214
233
  payload: result.payload,
215
234
  agent: node.agent,
216
235
  runtime: node.runtime,
236
+ agentId: node.id,
237
+ agentName: resolved.agentName,
217
238
  // Top-level, in addition to agentExecution.sessionID
218
239
  // below: matches the agent-server node's convention
219
240
  // so this output can be fed straight back into the
@@ -261,13 +282,22 @@ module.exports = function (RED) {
261
282
  node.scheduler = new ExecutionScheduler({
262
283
  concurrency: node.concurrency,
263
284
  onStart: startExecution,
264
- onQueued: (item) => emitEvent(item.send, item.msg, item.executionId, "queued"),
285
+ onQueued: (item) =>
286
+ emitEvent(item.send, item.msg, item.executionId, "queued", item.resolved.agentName),
265
287
  // Runs after this item is removed from `active` and any newly-
266
288
  // eligible queued item has already been started, so the terminal
267
289
  // event's active/queued counts are accurate (see the onStatus
268
290
  // comment in startExecution for why it's deferred to here).
269
291
  onSettled: (item) => {
270
- if (item.finalStatus) emitEvent(item.send, item.msg, item.executionId, item.finalStatus);
292
+ if (item.finalStatus) {
293
+ emitEvent(
294
+ item.send,
295
+ item.msg,
296
+ item.executionId,
297
+ item.finalStatus,
298
+ item.resolved.agentName,
299
+ );
300
+ }
271
301
  updateStatus();
272
302
  },
273
303
  });
@@ -287,6 +317,7 @@ module.exports = function (RED) {
287
317
  return;
288
318
  }
289
319
 
320
+ const agentName = resolveAgentName(msg);
290
321
  const result = node.scheduler.cancel(executionId);
291
322
  if (!result) {
292
323
  done(new Error(`agent: unknown or already-finished executionId "${executionId}"`));
@@ -295,7 +326,13 @@ module.exports = function (RED) {
295
326
 
296
327
  if (result.status === "queued") {
297
328
  const queuedItem = result.item;
298
- emitEvent(queuedItem.send, queuedItem.msg, executionId, "cancelled");
329
+ emitEvent(
330
+ queuedItem.send,
331
+ queuedItem.msg,
332
+ executionId,
333
+ "cancelled",
334
+ queuedItem.resolved ? queuedItem.resolved.agentName : agentName,
335
+ );
299
336
  queuedItem.done(
300
337
  new Error("agent: execution cancelled before it started (terminate requested)"),
301
338
  );
@@ -303,6 +340,8 @@ module.exports = function (RED) {
303
340
  send([
304
341
  Object.assign({}, msg, {
305
342
  payload: { executionId, terminated: true, status: "cancelled" },
343
+ agentId: node.id,
344
+ agentName,
306
345
  }),
307
346
  null,
308
347
  ]);
@@ -320,6 +359,8 @@ module.exports = function (RED) {
320
359
  send([
321
360
  Object.assign({}, msg, {
322
361
  payload: { executionId, terminated: true, status: "terminating" },
362
+ agentId: node.id,
363
+ agentName,
323
364
  }),
324
365
  null,
325
366
  ]);
@@ -342,9 +383,19 @@ module.exports = function (RED) {
342
383
  return;
343
384
  }
344
385
 
386
+ // Optional per-message override of the deploy-time Concurrency
387
+ // field, applied before this message is submitted so a raised
388
+ // bound can immediately start any items already queued from
389
+ // earlier messages. Invalid values (non-numeric/non-positive)
390
+ // are ignored -- see ExecutionScheduler.setConcurrency.
391
+ if (msg.concurrency !== undefined) {
392
+ node.scheduler.setConcurrency(Number(msg.concurrency));
393
+ }
394
+
345
395
  let resolved;
346
396
  try {
347
397
  resolved = {
398
+ agentName: resolveAgentName(msg),
348
399
  invocation: node.invocation,
349
400
  prompt:
350
401
  node.invocation === "prompt"
@@ -27,6 +27,20 @@ class ExecutionScheduler {
27
27
  return this.active.size;
28
28
  }
29
29
 
30
+ // Runtime override of the concurrency bound (e.g. from a per-message
31
+ // msg.concurrency), as opposed to the fixed value passed into the
32
+ // constructor. Invalid input (non-finite/non-positive) is ignored
33
+ // rather than falling back to 1 -- an in-flight bound should never be
34
+ // silently reset by a bad message. Raising the bound immediately
35
+ // drains eligible queued items via _advance(); lowering it only
36
+ // affects future scheduling -- already-active items are never
37
+ // cancelled by this call.
38
+ setConcurrency(concurrency) {
39
+ if (!Number.isFinite(concurrency) || concurrency <= 0) return;
40
+ this.concurrency = Math.floor(concurrency);
41
+ this._advance();
42
+ }
43
+
30
44
  get queuedCount() {
31
45
  return this.queue.length;
32
46
  }
@@ -18,6 +18,9 @@
18
18
  defaults: {
19
19
  name: { value: '' },
20
20
 
21
+ serverName: { value: '' },
22
+ serverNameType: { value: 'str' },
23
+
21
24
  operation: { value: 'message' },
22
25
 
23
26
  hostname: { value: '127.0.0.1' },
@@ -71,6 +74,10 @@
71
74
  typeField: '#node-input-modelType',
72
75
  types: ['str', 'msg', 'flow', 'global', 'env']
73
76
  });
77
+ $('#node-input-serverName').typedInput({
78
+ typeField: '#node-input-serverNameType',
79
+ types: ['str', 'msg', 'flow', 'global', 'env']
80
+ });
74
81
 
75
82
  function updateOperationRows() {
76
83
  const op = $('#node-input-operation').val();
@@ -141,6 +148,15 @@
141
148
  <input type="text" id="node-input-name" placeholder="Agent Server">
142
149
  </div>
143
150
 
151
+ <div class="form-row">
152
+ <label for="node-input-serverName"><i class="fa fa-id-badge"></i> Server name
153
+ <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
154
+ title="Reported as msg.serverName on the result/event outputs. Leave blank (str) to use the Name field above; set to msg/flow/global to make it dynamic per message."></i>
155
+ </label>
156
+ <input type="text" id="node-input-serverName" placeholder="leave blank to use the Name field above" style="width:60%">
157
+ <input type="hidden" id="node-input-serverNameType">
158
+ </div>
159
+
144
160
  <div class="form-row">
145
161
  <label for="node-input-operation"><i class="fa fa-play"></i> Operation</label>
146
162
  <select id="node-input-operation" style="width:70%">
@@ -194,7 +210,7 @@
194
210
  <label for="node-input-maxInstances"><i class="fa fa-tasks"></i> Max instances</label>
195
211
  <input type="text" id="node-input-maxInstances" placeholder="5" style="width:80px">
196
212
  <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
197
- title="Maximum number of daemons this node may have spawned at once. A new no-session-id trigger errors immediately once reached (no queueing). 0 = unlimited."></i>
213
+ title="Maximum number of daemons this node may have spawned at once. A new no-session-id trigger errors immediately once reached (no queueing). 0 = unlimited. Overridable at runtime via msg.maxInstances."></i>
198
214
  </div>
199
215
  <div class="form-row agent-server-row-spawn">
200
216
  <label for="node-input-startupTimeoutMs"><i class="fa fa-clock-o"></i> Startup timeout</label>
@@ -340,6 +356,12 @@
340
356
  configured Operation for a single trigger -- e.g. to ask a message-sending node
341
357
  instance for its own current status, without needing a second node pointed at
342
358
  nothing.</p>
359
+ <p>Set <code>msg.maxInstances</code> to override the Max instances field at
360
+ runtime, without a redeploy -- 0 keeps meaning "unlimited", and
361
+ negative/non-numeric values are ignored. Lowering it below the current
362
+ number of tracked daemons never kills any of them; it only blocks
363
+ <em>future</em> spawns until the count naturally drops back under the
364
+ new cap.</p>
343
365
  <h3>Outputs</h3>
344
366
  <ol class="node-ports">
345
367
  <li>Result
@@ -348,6 +370,16 @@
348
370
  <dd>Operation-dependent: assistant reply text (message), a status object, etc.</dd>
349
371
  <dt>sessionID <span class="property-type">string</span></dt>
350
372
  <dd>Echoed back so a later trigger can reuse the same daemon/session.</dd>
373
+ <dt>serverId <span class="property-type">string</span></dt>
374
+ <dd>This node instance's own Node-RED id, so a flow with
375
+ several agent-server nodes can tell which one produced a
376
+ given message.</dd>
377
+ <dt>serverName <span class="property-type">string</span></dt>
378
+ <dd>The resolved Server name field, falling back to this node
379
+ instance's configured Name if the Server name field is
380
+ blank/unresolved for this message (e.g. via
381
+ <code>msg.serverName</code> when configured as a msg
382
+ property).</dd>
351
383
  </dl>
352
384
  </li>
353
385
  <li>Lifecycle events
@@ -357,6 +389,10 @@
357
389
  <code>failed</code> / <code>closed</code>, each with <code>sessionID</code>,
358
390
  <code>timestamp</code>, and this node instance's live <code>active</code>
359
391
  (busy) / <code>tracked</code> (total) daemon counts.</dd>
392
+ <dt>serverId <span class="property-type">string</span></dt>
393
+ <dd>This node instance's own Node-RED id (see Result output).</dd>
394
+ <dt>serverName <span class="property-type">string</span></dt>
395
+ <dd>The resolved Server name for this event (see Result output).</dd>
360
396
  </dl>
361
397
  </li>
362
398
  </ol>
@@ -21,6 +21,12 @@ module.exports = function (RED) {
21
21
 
22
22
  node.operation = config.operation || "message";
23
23
 
24
+ // Typed-input override of the serverName reported on outgoing/event
25
+ // messages (see emitEvent below). Same typed-input pattern as every
26
+ // other overridable field: blank/unresolved falls back to node.name.
27
+ node.serverName = config.serverName !== undefined ? config.serverName : "";
28
+ node.serverNameType = config.serverNameType || "str";
29
+
24
30
  node.sessionIdProp = config.sessionIdProp !== undefined ? config.sessionIdProp : "sessionID";
25
31
  node.sessionIdPropType = config.sessionIdPropType || "msg";
26
32
 
@@ -105,6 +111,8 @@ module.exports = function (RED) {
105
111
  topic: msg && msg.topic,
106
112
  payload: { type },
107
113
  sessionID,
114
+ serverId: node.id,
115
+ serverName: resolveServerName(msg),
108
116
  active: summary.busy,
109
117
  tracked: summary.total,
110
118
  timestamp: Date.now(),
@@ -122,6 +130,16 @@ module.exports = function (RED) {
122
130
  }
123
131
  }
124
132
 
133
+ // Per-message resolution of the reported serverName: the typed-input
134
+ // serverName/serverNameType field (e.g. msg.serverName) if configured
135
+ // and present, else node.name -- same fallback every other typed-input
136
+ // field has when blank/unresolved. Tolerates a missing msg (emitEvent
137
+ // is sometimes called without one, e.g. from spawnNewInstance's own
138
+ // internal bookkeeping) by resolving against an empty object.
139
+ function resolveServerName(msg) {
140
+ return resolveTyped(node.serverName, node.serverNameType, msg || {}, node.name);
141
+ }
142
+
125
143
  function authOptions() {
126
144
  return node.authUsername || node.authPassword
127
145
  ? { username: node.authUsername, password: node.authPassword }
@@ -236,6 +254,8 @@ module.exports = function (RED) {
236
254
  const resultMsg = Object.assign({}, msg, {
237
255
  payload: extractText(response),
238
256
  sessionID,
257
+ serverId: node.id,
258
+ serverName: resolveServerName(msg),
239
259
  agentServer: {
240
260
  sessionID,
241
261
  host: record && record.host,
@@ -330,7 +350,14 @@ module.exports = function (RED) {
330
350
  if (!sessionID) {
331
351
  // Aggregate across every daemon this node instance is
332
352
  // tracking -- purely local, no network calls.
333
- send([Object.assign({}, msg, { payload: node.registry.summary() }), null]);
353
+ send([
354
+ Object.assign({}, msg, {
355
+ payload: node.registry.summary(),
356
+ serverId: node.id,
357
+ serverName: resolveServerName(msg),
358
+ }),
359
+ null,
360
+ ]);
334
361
  done();
335
362
  return;
336
363
  }
@@ -355,6 +382,8 @@ module.exports = function (RED) {
355
382
  startedAt: record.startedAt,
356
383
  lastUsed: record.lastUsed,
357
384
  },
385
+ serverId: node.id,
386
+ serverName: resolveServerName(msg),
358
387
  }),
359
388
  null,
360
389
  ]);
@@ -387,7 +416,15 @@ module.exports = function (RED) {
387
416
  .then(() => {
388
417
  node.registry.setBusy(sessionID, false);
389
418
  updateStatus();
390
- send([Object.assign({}, msg, { payload: true, sessionID }), null]);
419
+ send([
420
+ Object.assign({}, msg, {
421
+ payload: true,
422
+ sessionID,
423
+ serverId: node.id,
424
+ serverName: resolveServerName(msg),
425
+ }),
426
+ null,
427
+ ]);
391
428
  done();
392
429
  })
393
430
  .catch((err) => done(new Error(`agent-server: abort failed: ${err.message}`)));
@@ -416,7 +453,15 @@ module.exports = function (RED) {
416
453
  ...authOptions(),
417
454
  })
418
455
  .then((history) => {
419
- send([Object.assign({}, msg, { payload: history, sessionID }), null]);
456
+ send([
457
+ Object.assign({}, msg, {
458
+ payload: history,
459
+ sessionID,
460
+ serverId: node.id,
461
+ serverName: resolveServerName(msg),
462
+ }),
463
+ null,
464
+ ]);
420
465
  done();
421
466
  })
422
467
  .catch((err) => done(new Error(`agent-server: history fetch failed: ${err.message}`)));
@@ -450,7 +495,15 @@ module.exports = function (RED) {
450
495
  .then(() => {
451
496
  emitEvent(sessionID, "terminated", msg);
452
497
  updateStatus();
453
- send([Object.assign({}, msg, { payload: true, sessionID }), null]);
498
+ send([
499
+ Object.assign({}, msg, {
500
+ payload: true,
501
+ sessionID,
502
+ serverId: node.id,
503
+ serverName: resolveServerName(msg),
504
+ }),
505
+ null,
506
+ ]);
454
507
  done();
455
508
  })
456
509
  .catch((err) => done(new Error(`agent-server: terminate failed: ${err.message}`)));
@@ -464,6 +517,18 @@ module.exports = function (RED) {
464
517
  return;
465
518
  }
466
519
 
520
+ // Optional per-message override of the deploy-time Max instances
521
+ // field, applied before spawnNewInstance()'s guard clause reads it
522
+ // -- no redeploy needed. 0 stays "unlimited" (same as the
523
+ // constructor's semantics above); negative/non-numeric values are
524
+ // ignored. Lowering the limit below the current registry size
525
+ // never evicts already-spawned daemons -- it only blocks *future*
526
+ // spawns until the count naturally drops back under the new cap.
527
+ if (msg.maxInstances !== undefined) {
528
+ const n = Number(msg.maxInstances);
529
+ if (Number.isFinite(n) && n >= 0) node.maxInstances = Math.floor(n);
530
+ }
531
+
467
532
  // msg.operation can override the configured default for this one
468
533
  // trigger. This matters because each node instance's registry is
469
534
  // private (per-node-instance scoping, same as the `agent` node's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-agents",
3
- "version": "0.1.5",
3
+ "version": "0.3.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",