node-red-contrib-ai-agent 0.5.12 → 0.5.13

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.
@@ -6,7 +6,11 @@
6
6
  name: { value: "" },
7
7
  maxIterations: { value: 5, validate: RED.validators.number() },
8
8
  planningStrategy: { value: "simple" },
9
- defaultGoal: { value: "" }
9
+ defaultGoal: { value: "" },
10
+ maxHistory: { value: 50, validate: RED.validators.number() },
11
+ providerBaseUrl: { value: "https://openrouter.ai/api/v1" },
12
+ timeoutMs: { value: 30000, validate: RED.validators.number() },
13
+ debug: { value: false }
10
14
  },
11
15
  inputs: 1,
12
16
  outputs: 1,
@@ -39,6 +43,22 @@
39
43
  <label for="node-input-defaultGoal"><i class="fa fa-bullseye"></i> Default Goal</label>
40
44
  <textarea id="node-input-defaultGoal" style="width: 100%" rows="3" placeholder="Optional default goal for the orchestrator"></textarea>
41
45
  </div>
46
+ <div class="form-row">
47
+ <label for="node-input-maxHistory"><i class="fa fa-history"></i> Max History</label>
48
+ <input type="number" id="node-input-maxHistory" placeholder="50">
49
+ </div>
50
+ <div class="form-row">
51
+ <label for="node-input-providerBaseUrl"><i class="fa fa-globe"></i> Provider Base URL</label>
52
+ <input type="text" id="node-input-providerBaseUrl" placeholder="https://openrouter.ai/api/v1">
53
+ </div>
54
+ <div class="form-row">
55
+ <label for="node-input-timeoutMs"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
56
+ <input type="number" id="node-input-timeoutMs" placeholder="30000">
57
+ </div>
58
+ <div class="form-row">
59
+ <label for="node-input-debug"><i class="fa fa-bug"></i> Debug</label>
60
+ <input type="checkbox" id="node-input-debug" style="width:auto">
61
+ </div>
42
62
  </script>
43
63
 
44
64
  <script type="text/html" data-content-help-name="ai-orchestrator">
@@ -14,9 +14,18 @@ module.exports = function (RED) {
14
14
  const node = this;
15
15
 
16
16
  this.name = config.name || 'AI Orchestrator';
17
- this.maxIterations = parseInt(config.maxIterations) || 5;
18
- this.planningStrategy = config.planningStrategy || 'simple';
17
+ this.maxIterations = normalizePositiveInt(config.maxIterations, 5, 1, 100);
18
+ this.planningStrategy = (config.planningStrategy || 'simple');
19
19
  this.defaultGoal = config.defaultGoal || '';
20
+ this.maxHistory = normalizePositiveInt(config.maxHistory, 50, 1, 1000);
21
+ this.providerBaseUrl = String(config.providerBaseUrl || 'https://openrouter.ai/api/v1');
22
+ this.timeoutMs = normalizePositiveInt(config.timeoutMs, 30000, 1000, 300000);
23
+ this.debug = !!config.debug;
24
+
25
+ const configErrors = validateNodeConfig(node);
26
+ if (configErrors.length > 0) {
27
+ node.error(configErrors.join('; '));
28
+ }
20
29
 
21
30
  node.on('input', async function (msg, send, done) {
22
31
  send = send || function () { node.send.apply(node, arguments) };
@@ -38,88 +47,116 @@ module.exports = function (RED) {
38
47
  history: [],
39
48
  plan: null
40
49
  };
41
- } else {
42
- msg.orchestration.iterations++;
43
50
  }
44
51
 
45
- // Check for max iterations
46
- if (msg.orchestration.iterations >= node.maxIterations) {
47
- node.warn('Max iterations reached');
48
- msg.orchestration.status = 'failed';
49
- msg.orchestration.error = 'Max iterations reached';
50
- node.status({ fill: 'red', shape: 'dot', text: 'max iterations' });
51
- send(msg);
52
+ const messageErrors = validateMessage(msg, node);
53
+ if (messageErrors.length > 0) {
54
+ throw new Error(messageErrors.join('; '));
55
+ }
56
+
57
+ if (msg.orchestration._running) {
52
58
  if (done) done();
53
59
  return;
54
60
  }
55
61
 
56
- // AI Configuration check
57
- if (!msg.aiagent || !msg.aiagent.apiKey) {
58
- throw new Error('AI Model configuration missing or API key not found.');
62
+ msg.orchestration._running = true;
63
+ setImmediate(() => processNextStep(RED, node, msg, send, done));
64
+ } catch (error) {
65
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
66
+ node.error(error.message, msg);
67
+ if (done) done(error);
68
+ }
69
+ });
70
+ }
71
+
72
+ async function processNextStep(RED, node, msg, send, done) {
73
+ try {
74
+ if (!msg.orchestration) {
75
+ msg.orchestration = { status: 'failed', error: 'Orchestration state missing' };
76
+ }
77
+
78
+ if (msg.orchestration.status === 'completed' || msg.orchestration.status === 'failed') {
79
+ finalizeOrchestration(node, msg, send, done);
80
+ return;
81
+ }
82
+
83
+ if (msg.orchestration.iterations >= node.maxIterations) {
84
+ msg.orchestration.status = 'failed';
85
+ msg.orchestration.error = 'Max iterations reached';
86
+ node.status({ fill: 'red', shape: 'dot', text: 'max iterations' });
87
+ finalizeOrchestration(node, msg, send, done);
88
+ return;
89
+ }
90
+
91
+ if (msg.orchestration.status === 'planning' || !msg.orchestration.plan) {
92
+ node.status({ fill: 'blue', shape: 'dot', text: 'planning...' });
93
+ await createInitialPlan(node, msg);
94
+
95
+ const planErrors = validatePlan(msg.orchestration.plan, msg.orchestration.availableAgents || []);
96
+ if (planErrors.length > 0) {
97
+ msg.orchestration.status = 'failed';
98
+ msg.orchestration.error = planErrors.join('; ');
99
+ node.status({ fill: 'red', shape: 'ring', text: 'plan invalid' });
100
+ finalizeOrchestration(node, msg, send, done);
101
+ return;
59
102
  }
60
103
 
61
- // Inner loop for Zero-Wire execution
62
- while (msg.orchestration.status !== 'completed' && msg.orchestration.status !== 'failed') {
104
+ setImmediate(() => processNextStep(RED, node, msg, send, done));
105
+ return;
106
+ }
63
107
 
64
- // 1. Planning Phase
65
- if (msg.orchestration.status === 'planning' || !msg.orchestration.plan) {
66
- node.status({ fill: 'blue', shape: 'dot', text: 'planning...' });
67
- await createInitialPlan(node, msg);
68
- }
108
+ const nextTask = getNextTask(msg.orchestration.plan);
109
+ if (!nextTask) {
110
+ msg.orchestration.status = 'completed';
111
+ finalizeOrchestration(node, msg, send, done);
112
+ return;
113
+ }
69
114
 
70
- // 2. Find Next Task
71
- const nextTask = getNextTask(msg.orchestration.plan);
72
- if (!nextTask) {
73
- msg.orchestration.status = 'completed';
74
- break;
75
- }
115
+ msg.orchestration.currentTaskId = nextTask.id;
116
+ const agentInfo = selectAgentForTask(msg.orchestration, nextTask);
76
117
 
77
- // 3. Execution Phase (Direct Call)
78
- msg.orchestration.currentTaskId = nextTask.id;
79
- const agentInfo = selectAgentForTask(msg.orchestration, nextTask);
80
-
81
- if (!agentInfo) {
82
- node.warn(`No registered agent found for capability: ${nextTask.type}`);
83
- msg.error = `Capability not provided by any wired agent: ${nextTask.type}`;
84
- msg.payload = null;
85
- } else {
86
- const agentNode = RED.nodes.getNode(agentInfo.id);
87
- if (!agentNode || typeof agentNode.executeTask !== 'function') {
88
- msg.error = `Agent node ${agentInfo.name} [${agentInfo.id}] is not an AI Orchestrator Agent or is missing executeTask API.`;
89
- msg.payload = null;
90
- } else {
91
- node.status({ fill: 'blue', shape: 'ring', text: `agent: ${agentInfo.name}` });
92
- try {
93
- const result = await agentNode.executeTask(nextTask.input, msg);
94
- msg.payload = result;
95
- msg.error = null;
96
- } catch (err) {
97
- // Strip 'AI API Error: ' prefix if present to match test expectations
98
- let errorMessage = err.message;
99
- if (errorMessage.startsWith('AI API Error: ')) {
100
- errorMessage = errorMessage.substring('AI API Error: '.length);
101
- }
102
- msg.error = errorMessage;
103
- }
118
+ if (!agentInfo) {
119
+ msg.error = `Capability not provided by any wired agent: ${nextTask.type}`;
120
+ msg.payload = null;
121
+ } else {
122
+ const agentNode = RED.nodes.getNode(agentInfo.id);
123
+ if (!agentNode || typeof agentNode.executeTask !== 'function') {
124
+ msg.error = `Agent node ${agentInfo.name} [${agentInfo.id}] is not an AI Orchestrator Agent or is missing executeTask API.`;
125
+ msg.payload = null;
126
+ } else {
127
+ node.status({ fill: 'blue', shape: 'ring', text: `agent: ${agentInfo.name}` });
128
+ try {
129
+ const result = await agentNode.executeTask(nextTask.input, msg);
130
+ msg.payload = result;
131
+ msg.error = null;
132
+ } catch (err) {
133
+ let errorMessage = err && err.message ? err.message : String(err);
134
+ if (errorMessage.startsWith('AI API Error: ')) {
135
+ errorMessage = errorMessage.substring('AI API Error: '.length);
104
136
  }
137
+ msg.error = errorMessage;
105
138
  }
106
-
107
- // 4. Reflection Phase
108
- node.status({ fill: 'blue', shape: 'dot', text: 'reflecting...' });
109
- await reflectAndRefine(node, msg);
110
139
  }
140
+ }
111
141
 
112
- // Final Output
113
- node.status({ fill: 'green', shape: 'dot', text: msg.orchestration.status });
114
- send(msg);
142
+ node.status({ fill: 'blue', shape: 'dot', text: 'reflecting...' });
143
+ await reflectAndRefine(node, msg);
144
+ msg.orchestration.iterations++;
115
145
 
116
- if (done) done();
117
- } catch (error) {
118
- node.status({ fill: 'red', shape: 'ring', text: 'error' });
119
- node.error(error.message, msg);
120
- if (done) done(error);
121
- }
122
- });
146
+ setImmediate(() => processNextStep(RED, node, msg, send, done));
147
+ } catch (error) {
148
+ msg.orchestration.status = 'failed';
149
+ msg.orchestration.error = error && error.message ? error.message : String(error);
150
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
151
+ finalizeOrchestration(node, msg, send, done, error);
152
+ }
153
+ }
154
+
155
+ function finalizeOrchestration(node, msg, send, done, error) {
156
+ msg.orchestration._running = false;
157
+ node.status({ fill: msg.orchestration.status === 'completed' ? 'green' : 'red', shape: 'dot', text: msg.orchestration.status });
158
+ send(msg);
159
+ if (done) done(error);
123
160
  }
124
161
 
125
162
  /**
@@ -172,9 +209,9 @@ Example:
172
209
  }`;
173
210
 
174
211
  try {
175
- node.warn("Prompt: " + prompt);
176
- const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
177
- node.warn("Response: " + response);
212
+ debugLog(node, 'Planning Prompt', prompt);
213
+ const response = await callAI(node, msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
214
+ debugLog(node, 'Planning Response', response);
178
215
  const planData = parseJsonResponse(response);
179
216
  msg.orchestration.plan = planData;
180
217
  msg.orchestration.status = 'executing';
@@ -201,6 +238,10 @@ Example:
201
238
  timestamp: new Date().toISOString()
202
239
  });
203
240
 
241
+ if (Array.isArray(msg.orchestration.history) && msg.orchestration.history.length > node.maxHistory) {
242
+ msg.orchestration.history = msg.orchestration.history.slice(-node.maxHistory);
243
+ }
244
+
204
245
  // Update task status in plan
205
246
  const task = msg.orchestration.plan.tasks.find(t => t.id === currentTaskId);
206
247
  if (task) {
@@ -253,7 +294,7 @@ Return a JSON object:
253
294
  }`;
254
295
 
255
296
  try {
256
- const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that reflects on progress and manages plan revisions.");
297
+ const response = await callAI(node, msg.aiagent, prompt, "You are an AI Orchestrator that reflects on progress and manages plan revisions.");
257
298
  const reflection = parseJsonResponse(response);
258
299
 
259
300
  msg.orchestration.status = reflection.status;
@@ -336,9 +377,9 @@ Return a JSON object:
336
377
  * @returns {Promise<string>} The AI response content
337
378
  * @throws {Error} If API call fails
338
379
  */
339
- async function callAI(aiConfig, prompt, systemPrompt) {
380
+ async function callAI(node, aiConfig, prompt, systemPrompt) {
340
381
  const response = await axios.post(
341
- 'https://openrouter.ai/api/v1/chat/completions',
382
+ buildChatCompletionsUrl(node, aiConfig),
342
383
  {
343
384
  model: aiConfig.model,
344
385
  messages: [
@@ -350,13 +391,28 @@ Return a JSON object:
350
391
  {
351
392
  headers: {
352
393
  'Authorization': `Bearer ${aiConfig.apiKey}`,
353
- 'Content-Type': 'application/json'
354
- }
394
+ 'Content-Type': 'application/json',
395
+ 'HTTP-Referer': 'https://nodered.org/',
396
+ 'X-Title': 'Node-RED AI Orchestrator'
397
+ },
398
+ timeout: getTimeoutMs(node, aiConfig)
355
399
  }
356
400
  );
357
401
  return response.data.choices[0]?.message?.content || '';
358
402
  }
359
403
 
404
+ function buildChatCompletionsUrl(node, aiConfig) {
405
+ const baseUrl = (aiConfig && aiConfig.baseUrl) ? String(aiConfig.baseUrl) : String(node.providerBaseUrl);
406
+ return baseUrl.replace(/\/+$/, '') + '/chat/completions';
407
+ }
408
+
409
+ function getTimeoutMs(node, aiConfig) {
410
+ if (aiConfig && aiConfig.timeoutMs !== undefined) {
411
+ return normalizePositiveInt(aiConfig.timeoutMs, node.timeoutMs, 1000, 300000);
412
+ }
413
+ return node.timeoutMs;
414
+ }
415
+
360
416
  /**
361
417
  * Extracts JSON from a text response
362
418
  * @param {string} text - The text containing JSON
@@ -469,5 +525,154 @@ Return a JSON object:
469
525
  return out.trim();
470
526
  }
471
527
 
528
+ function debugLog(node, label, payload) {
529
+ if (!node || !node.debug || typeof node.warn !== 'function') return;
530
+ try {
531
+ const serialized = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
532
+ node.warn(`[AI Orchestrator] ${label}: ${serialized}`);
533
+ } catch (_err) {
534
+ node.warn(`[AI Orchestrator] ${label}: [unserializable payload]`);
535
+ }
536
+ }
537
+
538
+ function normalizePositiveInt(value, fallback, min, max) {
539
+ const n = parseInt(value);
540
+ if (!Number.isFinite(n)) return fallback;
541
+ if (n < min) return min;
542
+ if (n > max) return max;
543
+ return n;
544
+ }
545
+
546
+ function validateNodeConfig(node) {
547
+ const errors = [];
548
+ if (!['simple', 'advanced'].includes(String(node.planningStrategy))) {
549
+ errors.push('planningStrategy must be "simple" or "advanced"');
550
+ }
551
+ if (!String(node.providerBaseUrl || '').trim()) {
552
+ errors.push('providerBaseUrl must be a non-empty string');
553
+ }
554
+ return errors;
555
+ }
556
+
557
+ function validateMessage(msg, node) {
558
+ const errors = [];
559
+ if (!msg || typeof msg !== 'object') {
560
+ errors.push('msg must be an object');
561
+ return errors;
562
+ }
563
+ if (!msg.aiagent) {
564
+ errors.push('AI Model configuration missing (msg.aiagent)');
565
+ return errors;
566
+ }
567
+ if (!msg.aiagent.apiKey || !String(msg.aiagent.apiKey).trim()) {
568
+ errors.push('AI Model API key not found (msg.aiagent.apiKey)');
569
+ }
570
+ if (!msg.aiagent.model || !String(msg.aiagent.model).trim()) {
571
+ errors.push('AI Model not found (msg.aiagent.model)');
572
+ }
573
+ if (msg.orchestration && (!msg.orchestration.goal || !String(msg.orchestration.goal).trim())) {
574
+ errors.push('Orchestration goal is empty');
575
+ }
576
+ return errors;
577
+ }
578
+
579
+ function validatePlan(plan, availableAgents) {
580
+ const errors = [];
581
+ if (!plan || typeof plan !== 'object') {
582
+ errors.push('Plan is missing');
583
+ return errors;
584
+ }
585
+ if (!Array.isArray(plan.tasks)) {
586
+ errors.push('Plan.tasks must be an array');
587
+ return errors;
588
+ }
589
+
590
+ const allowedCapabilities = new Set(
591
+ (availableAgents || [])
592
+ .flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : [])
593
+ .map(c => String(c))
594
+ );
595
+
596
+ const idSet = new Set();
597
+ for (const task of plan.tasks) {
598
+ if (!task || typeof task !== 'object') {
599
+ errors.push('Task must be an object');
600
+ continue;
601
+ }
602
+ if (!task.id || !String(task.id).trim()) {
603
+ errors.push('Task.id is required');
604
+ } else {
605
+ const id = String(task.id);
606
+ if (idSet.has(id)) errors.push(`Duplicate task id: ${id}`);
607
+ idSet.add(id);
608
+ }
609
+ if (!task.type || !String(task.type).trim()) {
610
+ errors.push(`Task ${String(task.id || '?')} is missing type`);
611
+ } else if (allowedCapabilities.size > 0 && !allowedCapabilities.has(String(task.type))) {
612
+ errors.push(`Task ${String(task.id || '?')} has unsupported type: ${String(task.type)}`);
613
+ }
614
+ if (!task.status) {
615
+ task.status = 'pending';
616
+ }
617
+ if (!['pending', 'completed', 'failed'].includes(String(task.status))) {
618
+ errors.push(`Task ${String(task.id || '?')} has invalid status: ${String(task.status)}`);
619
+ }
620
+ if (!Array.isArray(task.dependsOn)) {
621
+ task.dependsOn = task.dependsOn ? [task.dependsOn] : [];
622
+ }
623
+ }
624
+
625
+ for (const task of plan.tasks) {
626
+ for (const depId of (task.dependsOn || [])) {
627
+ const dep = String(depId);
628
+ if (!idSet.has(dep)) {
629
+ errors.push(`Task ${String(task.id || '?')} depends on non-existent task ${dep}`);
630
+ }
631
+ }
632
+ }
633
+
634
+ const cycle = findDependencyCycle(plan.tasks);
635
+ if (cycle) {
636
+ errors.push(`Circular dependencies detected: ${cycle.join(' -> ')}`);
637
+ }
638
+
639
+ return errors;
640
+ }
641
+
642
+ function findDependencyCycle(tasks) {
643
+ const graph = new Map();
644
+ for (const t of tasks || []) {
645
+ if (!t || !t.id) continue;
646
+ graph.set(String(t.id), (t.dependsOn || []).map(String));
647
+ }
648
+
649
+ const visiting = new Set();
650
+ const visited = new Set();
651
+
652
+ function dfs(nodeId, path) {
653
+ if (visiting.has(nodeId)) {
654
+ const idx = path.indexOf(nodeId);
655
+ return idx >= 0 ? path.slice(idx).concat([nodeId]) : path.concat([nodeId]);
656
+ }
657
+ if (visited.has(nodeId)) return null;
658
+
659
+ visiting.add(nodeId);
660
+ visited.add(nodeId);
661
+ const deps = graph.get(nodeId) || [];
662
+ for (const dep of deps) {
663
+ const result = dfs(dep, path.concat([nodeId]));
664
+ if (result) return result;
665
+ }
666
+ visiting.delete(nodeId);
667
+ return null;
668
+ }
669
+
670
+ for (const nodeId of graph.keys()) {
671
+ const result = dfs(nodeId, []);
672
+ if (result) return result;
673
+ }
674
+ return null;
675
+ }
676
+
472
677
  RED.nodes.registerType('ai-orchestrator', AiOrchestratorNode);
473
678
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ai-agent",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "AI Agent for Node-RED",
5
5
  "repository": {
6
6
  "type": "git",