node-red-contrib-ai-agent 0.5.11 → 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.
- package/orchestrator/orchestrator.html +21 -1
- package/orchestrator/orchestrator.js +331 -67
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
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
|
+
}
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
if (msg.orchestration.status === 'completed' || msg.orchestration.status === 'failed') {
|
|
79
|
+
finalizeOrchestration(node, msg, send, done);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
msg.orchestration.status = 'completed';
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
91
|
+
if (msg.orchestration.status === 'planning' || !msg.orchestration.plan) {
|
|
92
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'planning...' });
|
|
93
|
+
await createInitialPlan(node, msg);
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
);
|
|
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;
|
|
102
|
+
}
|
|
82
103
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
104
|
+
setImmediate(() => processNextStep(RED, node, msg, send, done));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
const nextTask = getNextTask(msg.orchestration.plan);
|
|
109
|
+
if (!nextTask) {
|
|
110
|
+
msg.orchestration.status = 'completed';
|
|
111
|
+
finalizeOrchestration(node, msg, send, done);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
92
114
|
|
|
115
|
+
msg.orchestration.currentTaskId = nextTask.id;
|
|
116
|
+
const agentInfo = selectAgentForTask(msg.orchestration, nextTask);
|
|
117
|
+
|
|
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 {
|
|
93
127
|
node.status({ fill: 'blue', shape: 'ring', text: `agent: ${agentInfo.name}` });
|
|
94
128
|
try {
|
|
95
129
|
const result = await agentNode.executeTask(nextTask.input, msg);
|
|
96
130
|
msg.payload = result;
|
|
97
131
|
msg.error = null;
|
|
98
132
|
} catch (err) {
|
|
99
|
-
|
|
100
|
-
let errorMessage = err.message;
|
|
133
|
+
let errorMessage = err && err.message ? err.message : String(err);
|
|
101
134
|
if (errorMessage.startsWith('AI API Error: ')) {
|
|
102
135
|
errorMessage = errorMessage.substring('AI API Error: '.length);
|
|
103
136
|
}
|
|
104
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
142
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'reflecting...' });
|
|
143
|
+
await reflectAndRefine(node, msg);
|
|
144
|
+
msg.orchestration.iterations++;
|
|
115
145
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
/**
|
|
@@ -133,6 +170,10 @@ module.exports = function (RED) {
|
|
|
133
170
|
const strategy = node.planningStrategy;
|
|
134
171
|
const agents = msg.orchestration.availableAgents || [];
|
|
135
172
|
const agentManifest = agents.map(a => `- ${a.name}: [${a.capabilities.join(', ')}]`).join('\n');
|
|
173
|
+
const allowedCapabilities = Array.from(new Set(
|
|
174
|
+
agents.flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : []).map(String)
|
|
175
|
+
));
|
|
176
|
+
const allowedCapabilitiesJson = JSON.stringify(allowedCapabilities);
|
|
136
177
|
|
|
137
178
|
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
179
|
Return a JSON object with a "tasks" array. Each task should have:
|
|
@@ -154,6 +195,11 @@ Return a JSON object with a "tasks" array. Each task should have:
|
|
|
154
195
|
- Do NOT include trailing commas
|
|
155
196
|
- All string values must be valid JSON strings (escape newlines as \\n if needed)
|
|
156
197
|
|
|
198
|
+
CAPABILITY RULES:
|
|
199
|
+
- The "type" field MUST be one of these EXACT strings (case-sensitive): ${allowedCapabilitiesJson}
|
|
200
|
+
- Do NOT invent new capabilities.
|
|
201
|
+
- 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.
|
|
202
|
+
|
|
157
203
|
Example:
|
|
158
204
|
{
|
|
159
205
|
"tasks": [
|
|
@@ -163,9 +209,9 @@ Example:
|
|
|
163
209
|
}`;
|
|
164
210
|
|
|
165
211
|
try {
|
|
166
|
-
node
|
|
167
|
-
const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
|
|
168
|
-
node
|
|
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);
|
|
169
215
|
const planData = parseJsonResponse(response);
|
|
170
216
|
msg.orchestration.plan = planData;
|
|
171
217
|
msg.orchestration.status = 'executing';
|
|
@@ -181,8 +227,8 @@ Example:
|
|
|
181
227
|
*/
|
|
182
228
|
async function reflectAndRefine(node, msg) {
|
|
183
229
|
const currentTaskId = msg.orchestration.currentTaskId;
|
|
230
|
+
const isError = !!msg.error;
|
|
184
231
|
const taskResult = msg.payload;
|
|
185
|
-
const isError = msg.error ? true : false;
|
|
186
232
|
|
|
187
233
|
// Update history
|
|
188
234
|
msg.orchestration.history.push({
|
|
@@ -192,6 +238,10 @@ Example:
|
|
|
192
238
|
timestamp: new Date().toISOString()
|
|
193
239
|
});
|
|
194
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
|
+
|
|
195
245
|
// Update task status in plan
|
|
196
246
|
const task = msg.orchestration.plan.tasks.find(t => t.id === currentTaskId);
|
|
197
247
|
if (task) {
|
|
@@ -204,17 +254,38 @@ Example:
|
|
|
204
254
|
}
|
|
205
255
|
}
|
|
206
256
|
|
|
257
|
+
const hasHumanApprovalCapability = (msg.orchestration.availableAgents || []).some(a =>
|
|
258
|
+
Array.isArray(a.capabilities) && a.capabilities.some(c => String(c).toLowerCase() === 'human_approval')
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const allowedCapabilities = Array.from(new Set(
|
|
262
|
+
(msg.orchestration.availableAgents || [])
|
|
263
|
+
.flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : [])
|
|
264
|
+
.map(String)
|
|
265
|
+
));
|
|
266
|
+
const allowedCapabilitiesJson = JSON.stringify(allowedCapabilities);
|
|
267
|
+
|
|
268
|
+
const humanApprovalInstruction = hasHumanApprovalCapability
|
|
269
|
+
? '3. If you need more information or approval from a human, add a task with type "human_approval".'
|
|
270
|
+
: '3. Do NOT request human approval tasks ("human_approval" is not available).';
|
|
271
|
+
|
|
207
272
|
const prompt = `Current Goal: ${msg.orchestration.goal}
|
|
208
273
|
Current Plan: ${JSON.stringify(msg.orchestration.plan)}
|
|
209
274
|
Last Task ID: ${currentTaskId}
|
|
210
275
|
Last Task ${isError ? 'Error' : 'Result'}: ${JSON.stringify(isError ? msg.error : taskResult)}
|
|
211
276
|
|
|
277
|
+
Available Capabilities (EXACT strings): ${allowedCapabilitiesJson}
|
|
278
|
+
|
|
212
279
|
Evaluate the progress.
|
|
213
280
|
1. If the last task failed, propose a recovery strategy (retry, alternative task, or fail the goal).
|
|
214
281
|
2. If the goal is achieved, set status to "completed".
|
|
215
|
-
|
|
282
|
+
${humanApprovalInstruction}
|
|
216
283
|
4. Otherwise, continue execution. You may refine the plan by adding, removing, or modifying tasks.
|
|
217
284
|
|
|
285
|
+
PLAN UPDATE RULES:
|
|
286
|
+
- In updatedPlan.tasks, every task.type MUST be one of the EXACT capability strings listed above.
|
|
287
|
+
- Do NOT invent or rename capabilities.
|
|
288
|
+
|
|
218
289
|
Return a JSON object:
|
|
219
290
|
{
|
|
220
291
|
"analysis": "detailed evaluation of progress and next steps",
|
|
@@ -223,7 +294,7 @@ Return a JSON object:
|
|
|
223
294
|
}`;
|
|
224
295
|
|
|
225
296
|
try {
|
|
226
|
-
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.");
|
|
227
298
|
const reflection = parseJsonResponse(response);
|
|
228
299
|
|
|
229
300
|
msg.orchestration.status = reflection.status;
|
|
@@ -269,6 +340,35 @@ Return a JSON object:
|
|
|
269
340
|
return eligibleTasks[0];
|
|
270
341
|
}
|
|
271
342
|
|
|
343
|
+
function selectAgentForTask(orchestration, task) {
|
|
344
|
+
if (!orchestration || !task || !task.type) return null;
|
|
345
|
+
const availableAgents = orchestration.availableAgents || [];
|
|
346
|
+
const taskType = String(task.type).toLowerCase();
|
|
347
|
+
|
|
348
|
+
const matchingAgents = availableAgents.filter(a =>
|
|
349
|
+
Array.isArray(a.capabilities) && a.capabilities.some(cap => String(cap).toLowerCase() === taskType)
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
if (matchingAgents.length === 0) return null;
|
|
353
|
+
if (matchingAgents.length === 1) return matchingAgents[0];
|
|
354
|
+
|
|
355
|
+
orchestration.agentUsage = orchestration.agentUsage || {};
|
|
356
|
+
orchestration.agentLastUsedAt = orchestration.agentLastUsedAt || {};
|
|
357
|
+
|
|
358
|
+
// Prefer least recently used among matching agents to avoid always picking the first.
|
|
359
|
+
matchingAgents.sort((a, b) => {
|
|
360
|
+
const aLast = orchestration.agentLastUsedAt[a.id] || 0;
|
|
361
|
+
const bLast = orchestration.agentLastUsedAt[b.id] || 0;
|
|
362
|
+
if (aLast !== bLast) return aLast - bLast;
|
|
363
|
+
return String(a.id).localeCompare(String(b.id));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const selected = matchingAgents[0];
|
|
367
|
+
orchestration.agentUsage[selected.id] = (orchestration.agentUsage[selected.id] || 0) + 1;
|
|
368
|
+
orchestration.agentLastUsedAt[selected.id] = Date.now();
|
|
369
|
+
return selected;
|
|
370
|
+
}
|
|
371
|
+
|
|
272
372
|
/**
|
|
273
373
|
* Makes an API call to the AI model
|
|
274
374
|
* @param {Object} aiConfig - AI configuration containing model and API key
|
|
@@ -277,9 +377,9 @@ Return a JSON object:
|
|
|
277
377
|
* @returns {Promise<string>} The AI response content
|
|
278
378
|
* @throws {Error} If API call fails
|
|
279
379
|
*/
|
|
280
|
-
async function callAI(aiConfig, prompt, systemPrompt) {
|
|
380
|
+
async function callAI(node, aiConfig, prompt, systemPrompt) {
|
|
281
381
|
const response = await axios.post(
|
|
282
|
-
|
|
382
|
+
buildChatCompletionsUrl(node, aiConfig),
|
|
283
383
|
{
|
|
284
384
|
model: aiConfig.model,
|
|
285
385
|
messages: [
|
|
@@ -291,13 +391,28 @@ Return a JSON object:
|
|
|
291
391
|
{
|
|
292
392
|
headers: {
|
|
293
393
|
'Authorization': `Bearer ${aiConfig.apiKey}`,
|
|
294
|
-
'Content-Type': 'application/json'
|
|
295
|
-
|
|
394
|
+
'Content-Type': 'application/json',
|
|
395
|
+
'HTTP-Referer': 'https://nodered.org/',
|
|
396
|
+
'X-Title': 'Node-RED AI Orchestrator'
|
|
397
|
+
},
|
|
398
|
+
timeout: getTimeoutMs(node, aiConfig)
|
|
296
399
|
}
|
|
297
400
|
);
|
|
298
401
|
return response.data.choices[0]?.message?.content || '';
|
|
299
402
|
}
|
|
300
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
|
+
|
|
301
416
|
/**
|
|
302
417
|
* Extracts JSON from a text response
|
|
303
418
|
* @param {string} text - The text containing JSON
|
|
@@ -410,5 +525,154 @@ Return a JSON object:
|
|
|
410
525
|
return out.trim();
|
|
411
526
|
}
|
|
412
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
|
+
|
|
413
677
|
RED.nodes.registerType('ai-orchestrator', AiOrchestratorNode);
|
|
414
678
|
};
|