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

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.
@@ -76,32 +76,32 @@ module.exports = function (RED) {
76
76
 
77
77
  // 3. Execution Phase (Direct Call)
78
78
  msg.orchestration.currentTaskId = nextTask.id;
79
- const agentInfo = msg.orchestration.availableAgents.find(a =>
80
- a.capabilities.some(cap => cap.toLowerCase() === nextTask.type.toLowerCase())
81
- );
79
+ const agentInfo = selectAgentForTask(msg.orchestration, nextTask);
82
80
 
83
81
  if (!agentInfo) {
84
82
  node.warn(`No registered agent found for capability: ${nextTask.type}`);
85
- throw new Error(`Capability not provided by any wired agent: ${nextTask.type}`);
86
- }
87
-
88
- const agentNode = RED.nodes.getNode(agentInfo.id);
89
- if (!agentNode || typeof agentNode.executeTask !== 'function') {
90
- throw new Error(`Agent node ${agentInfo.name} [${agentInfo.id}] is not an AI Orchestrator Agent or is missing executeTask API.`);
91
- }
92
-
93
- node.status({ fill: 'blue', shape: 'ring', text: `agent: ${agentInfo.name}` });
94
- try {
95
- const result = await agentNode.executeTask(nextTask.input, msg);
96
- msg.payload = result;
97
- msg.error = null;
98
- } catch (err) {
99
- // Strip 'AI API Error: ' prefix if present to match test expectations
100
- let errorMessage = err.message;
101
- if (errorMessage.startsWith('AI API Error: ')) {
102
- errorMessage = errorMessage.substring('AI API Error: '.length);
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
+ }
103
104
  }
104
- msg.error = errorMessage;
105
105
  }
106
106
 
107
107
  // 4. Reflection Phase
@@ -133,6 +133,10 @@ module.exports = function (RED) {
133
133
  const strategy = node.planningStrategy;
134
134
  const agents = msg.orchestration.availableAgents || [];
135
135
  const agentManifest = agents.map(a => `- ${a.name}: [${a.capabilities.join(', ')}]`).join('\n');
136
+ const allowedCapabilities = Array.from(new Set(
137
+ agents.flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : []).map(String)
138
+ ));
139
+ const allowedCapabilitiesJson = JSON.stringify(allowedCapabilities);
136
140
 
137
141
  let prompt = `Goal: ${goal}\n\nAvailable Agents and their Capabilities:\n${agentManifest}\n\nDecompose this goal into a series of tasks. You MUST ONLY use capabilities provided by the available agents listed above.
138
142
  Return a JSON object with a "tasks" array. Each task should have:
@@ -148,7 +152,18 @@ Return a JSON object with a "tasks" array. Each task should have:
148
152
  prompt += `\n\nThink about parallel execution. Group related tasks and identify bottlenecks. Ensure dependencies are logical.`;
149
153
  }
150
154
 
151
- prompt += `\n\nExample:
155
+ prompt += `\n\nIMPORTANT OUTPUT RULES:
156
+ - Return ONLY raw JSON (no markdown, no code fences, no explanations)
157
+ - Do NOT include comments (e.g. // ...)
158
+ - Do NOT include trailing commas
159
+ - All string values must be valid JSON strings (escape newlines as \\n if needed)
160
+
161
+ CAPABILITY RULES:
162
+ - The "type" field MUST be one of these EXACT strings (case-sensitive): ${allowedCapabilitiesJson}
163
+ - Do NOT invent new capabilities.
164
+ - If the goal seems to require a missing capability, still produce a plan using ONLY the allowed capabilities, and include a task whose input explains the limitation.
165
+
166
+ Example:
152
167
  {
153
168
  "tasks": [
154
169
  {"id": "t1", "type": "research", "input": "...", "status": "pending", "priority": 10, "dependsOn": []},
@@ -160,7 +175,7 @@ Return a JSON object with a "tasks" array. Each task should have:
160
175
  node.warn("Prompt: " + prompt);
161
176
  const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
162
177
  node.warn("Response: " + response);
163
- const planData = JSON.parse(extractJson(response));
178
+ const planData = parseJsonResponse(response);
164
179
  msg.orchestration.plan = planData;
165
180
  msg.orchestration.status = 'executing';
166
181
  } catch (error) {
@@ -175,8 +190,8 @@ Return a JSON object with a "tasks" array. Each task should have:
175
190
  */
176
191
  async function reflectAndRefine(node, msg) {
177
192
  const currentTaskId = msg.orchestration.currentTaskId;
193
+ const isError = !!msg.error;
178
194
  const taskResult = msg.payload;
179
- const isError = msg.error ? true : false;
180
195
 
181
196
  // Update history
182
197
  msg.orchestration.history.push({
@@ -198,17 +213,38 @@ Return a JSON object with a "tasks" array. Each task should have:
198
213
  }
199
214
  }
200
215
 
216
+ const hasHumanApprovalCapability = (msg.orchestration.availableAgents || []).some(a =>
217
+ Array.isArray(a.capabilities) && a.capabilities.some(c => String(c).toLowerCase() === 'human_approval')
218
+ );
219
+
220
+ const allowedCapabilities = Array.from(new Set(
221
+ (msg.orchestration.availableAgents || [])
222
+ .flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : [])
223
+ .map(String)
224
+ ));
225
+ const allowedCapabilitiesJson = JSON.stringify(allowedCapabilities);
226
+
227
+ const humanApprovalInstruction = hasHumanApprovalCapability
228
+ ? '3. If you need more information or approval from a human, add a task with type "human_approval".'
229
+ : '3. Do NOT request human approval tasks ("human_approval" is not available).';
230
+
201
231
  const prompt = `Current Goal: ${msg.orchestration.goal}
202
232
  Current Plan: ${JSON.stringify(msg.orchestration.plan)}
203
233
  Last Task ID: ${currentTaskId}
204
234
  Last Task ${isError ? 'Error' : 'Result'}: ${JSON.stringify(isError ? msg.error : taskResult)}
205
235
 
236
+ Available Capabilities (EXACT strings): ${allowedCapabilitiesJson}
237
+
206
238
  Evaluate the progress.
207
239
  1. If the last task failed, propose a recovery strategy (retry, alternative task, or fail the goal).
208
240
  2. If the goal is achieved, set status to "completed".
209
- 3. If you need more information or approval from a human, add a task with type "human_approval".
241
+ ${humanApprovalInstruction}
210
242
  4. Otherwise, continue execution. You may refine the plan by adding, removing, or modifying tasks.
211
243
 
244
+ PLAN UPDATE RULES:
245
+ - In updatedPlan.tasks, every task.type MUST be one of the EXACT capability strings listed above.
246
+ - Do NOT invent or rename capabilities.
247
+
212
248
  Return a JSON object:
213
249
  {
214
250
  "analysis": "detailed evaluation of progress and next steps",
@@ -218,7 +254,7 @@ Return a JSON object:
218
254
 
219
255
  try {
220
256
  const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that reflects on progress and manages plan revisions.");
221
- const reflection = JSON.parse(extractJson(response));
257
+ const reflection = parseJsonResponse(response);
222
258
 
223
259
  msg.orchestration.status = reflection.status;
224
260
  if (reflection.updatedPlan) {
@@ -263,6 +299,35 @@ Return a JSON object:
263
299
  return eligibleTasks[0];
264
300
  }
265
301
 
302
+ function selectAgentForTask(orchestration, task) {
303
+ if (!orchestration || !task || !task.type) return null;
304
+ const availableAgents = orchestration.availableAgents || [];
305
+ const taskType = String(task.type).toLowerCase();
306
+
307
+ const matchingAgents = availableAgents.filter(a =>
308
+ Array.isArray(a.capabilities) && a.capabilities.some(cap => String(cap).toLowerCase() === taskType)
309
+ );
310
+
311
+ if (matchingAgents.length === 0) return null;
312
+ if (matchingAgents.length === 1) return matchingAgents[0];
313
+
314
+ orchestration.agentUsage = orchestration.agentUsage || {};
315
+ orchestration.agentLastUsedAt = orchestration.agentLastUsedAt || {};
316
+
317
+ // Prefer least recently used among matching agents to avoid always picking the first.
318
+ matchingAgents.sort((a, b) => {
319
+ const aLast = orchestration.agentLastUsedAt[a.id] || 0;
320
+ const bLast = orchestration.agentLastUsedAt[b.id] || 0;
321
+ if (aLast !== bLast) return aLast - bLast;
322
+ return String(a.id).localeCompare(String(b.id));
323
+ });
324
+
325
+ const selected = matchingAgents[0];
326
+ orchestration.agentUsage[selected.id] = (orchestration.agentUsage[selected.id] || 0) + 1;
327
+ orchestration.agentLastUsedAt[selected.id] = Date.now();
328
+ return selected;
329
+ }
330
+
266
331
  /**
267
332
  * Makes an API call to the AI model
268
333
  * @param {Object} aiConfig - AI configuration containing model and API key
@@ -302,5 +367,107 @@ Return a JSON object:
302
367
  return match ? match[0] : text;
303
368
  }
304
369
 
370
+ /**
371
+ * Parses a JSON object from an AI response, tolerating common non-JSON wrappers.
372
+ * @param {string} text - The AI response
373
+ * @returns {any} Parsed JSON
374
+ */
375
+ function parseJsonResponse(text) {
376
+ const extracted = extractJson(text);
377
+ try {
378
+ return JSON.parse(extracted);
379
+ } catch (_err) {
380
+ const sanitized = sanitizeJsonLikeText(extracted);
381
+ return JSON.parse(sanitized);
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Removes markdown fences and JS-style comments, and escapes raw newlines inside string literals.
387
+ * This is a best-effort repair for model outputs that are "almost JSON".
388
+ * @param {string} input - A string that should contain a JSON object
389
+ * @returns {string} A JSON string more likely to be parseable by JSON.parse
390
+ */
391
+ function sanitizeJsonLikeText(input) {
392
+ if (typeof input !== 'string') return '';
393
+
394
+ // Remove common markdown code fences
395
+ let s = input
396
+ .replace(/^\s*```(?:json)?\s*/i, '')
397
+ .replace(/\s*```\s*$/i, '')
398
+ .trim();
399
+
400
+ // If we still have leading/trailing non-JSON, re-extract
401
+ s = extractJson(s).trim();
402
+
403
+ let out = '';
404
+ let inString = false;
405
+ let escape = false;
406
+ let inLineComment = false;
407
+
408
+ for (let i = 0; i < s.length; i++) {
409
+ const ch = s[i];
410
+ const next = i + 1 < s.length ? s[i + 1] : '';
411
+
412
+ if (inLineComment) {
413
+ if (ch === '\n') {
414
+ inLineComment = false;
415
+ out += ch;
416
+ }
417
+ continue;
418
+ }
419
+
420
+ if (!inString && ch === '/' && next === '/') {
421
+ inLineComment = true;
422
+ i++;
423
+ continue;
424
+ }
425
+
426
+ if (!inString && ch === '`') {
427
+ // ignore stray backticks
428
+ continue;
429
+ }
430
+
431
+ if (inString) {
432
+ if (escape) {
433
+ out += ch;
434
+ escape = false;
435
+ continue;
436
+ }
437
+ if (ch === '\\') {
438
+ out += ch;
439
+ escape = true;
440
+ continue;
441
+ }
442
+ if (ch === '"') {
443
+ out += ch;
444
+ inString = false;
445
+ continue;
446
+ }
447
+ if (ch === '\n') {
448
+ out += '\\n';
449
+ continue;
450
+ }
451
+ if (ch === '\r') {
452
+ // drop CR; newline will be handled by \n
453
+ continue;
454
+ }
455
+ out += ch;
456
+ continue;
457
+ }
458
+
459
+ if (ch === '"') {
460
+ out += ch;
461
+ inString = true;
462
+ escape = false;
463
+ continue;
464
+ }
465
+
466
+ out += ch;
467
+ }
468
+
469
+ return out.trim();
470
+ }
471
+
305
472
  RED.nodes.registerType('ai-orchestrator', AiOrchestratorNode);
306
473
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ai-agent",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "AI Agent for Node-RED",
5
5
  "repository": {
6
6
  "type": "git",