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.
- package/orchestrator/orchestrator.js +194 -27
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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\
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
};
|