node-red-contrib-ai-agent 0.5.12 → 0.5.15
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 +290 -79
- 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) };
|
|
@@ -29,97 +38,127 @@ module.exports = function (RED) {
|
|
|
29
38
|
// Pipeline Discovery: Extract agents from upstream chain
|
|
30
39
|
const availableAgents = msg.agents || [];
|
|
31
40
|
|
|
41
|
+
const payloadGoal = (typeof msg.payload === 'string' && msg.payload.trim()) ? msg.payload : '';
|
|
42
|
+
|
|
32
43
|
msg.orchestration = {
|
|
33
44
|
planId: 'plan-' + Date.now(),
|
|
34
45
|
iterations: 0,
|
|
35
|
-
goal:
|
|
46
|
+
goal: payloadGoal || node.defaultGoal,
|
|
36
47
|
status: 'planning',
|
|
37
48
|
availableAgents: availableAgents,
|
|
38
49
|
history: [],
|
|
39
50
|
plan: null
|
|
40
51
|
};
|
|
41
|
-
} else {
|
|
42
|
-
msg.orchestration.iterations++;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
send(msg);
|
|
54
|
+
const messageErrors = validateMessage(msg, node);
|
|
55
|
+
if (messageErrors.length > 0) {
|
|
56
|
+
throw new Error(messageErrors.join('; '));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (msg.orchestration._running) {
|
|
52
60
|
if (done) done();
|
|
53
61
|
return;
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
msg.orchestration._running = true;
|
|
65
|
+
setImmediate(() => processNextStep(RED, node, msg, send, done));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
68
|
+
node.error(error.message, msg);
|
|
69
|
+
if (done) done(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function processNextStep(RED, node, msg, send, done) {
|
|
75
|
+
try {
|
|
76
|
+
if (!msg.orchestration) {
|
|
77
|
+
msg.orchestration = { status: 'failed', error: 'Orchestration state missing' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (msg.orchestration.status === 'completed' || msg.orchestration.status === 'failed') {
|
|
81
|
+
finalizeOrchestration(node, msg, send, done);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg.orchestration.iterations >= node.maxIterations) {
|
|
86
|
+
msg.orchestration.status = 'failed';
|
|
87
|
+
msg.orchestration.error = 'Max iterations reached';
|
|
88
|
+
node.status({ fill: 'red', shape: 'dot', text: 'max iterations' });
|
|
89
|
+
finalizeOrchestration(node, msg, send, done);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (msg.orchestration.status === 'planning' || !msg.orchestration.plan) {
|
|
94
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'planning...' });
|
|
95
|
+
await createInitialPlan(node, msg);
|
|
96
|
+
|
|
97
|
+
const planErrors = validatePlan(msg.orchestration.plan, msg.orchestration.availableAgents || []);
|
|
98
|
+
if (planErrors.length > 0) {
|
|
99
|
+
msg.orchestration.status = 'failed';
|
|
100
|
+
msg.orchestration.error = planErrors.join('; ');
|
|
101
|
+
node.status({ fill: 'red', shape: 'ring', text: 'plan invalid' });
|
|
102
|
+
finalizeOrchestration(node, msg, send, done);
|
|
103
|
+
return;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
|
-
|
|
62
|
-
|
|
106
|
+
setImmediate(() => processNextStep(RED, node, msg, send, done));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
63
109
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
110
|
+
const nextTask = getNextTask(msg.orchestration.plan);
|
|
111
|
+
if (!nextTask) {
|
|
112
|
+
msg.orchestration.status = 'completed';
|
|
113
|
+
finalizeOrchestration(node, msg, send, done);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
69
116
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!nextTask) {
|
|
73
|
-
msg.orchestration.status = 'completed';
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
117
|
+
msg.orchestration.currentTaskId = nextTask.id;
|
|
118
|
+
const agentInfo = selectAgentForTask(msg.orchestration, nextTask);
|
|
76
119
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
}
|
|
120
|
+
if (!agentInfo) {
|
|
121
|
+
msg.error = `Capability not provided by any wired agent: ${nextTask.type}`;
|
|
122
|
+
msg.payload = null;
|
|
123
|
+
} else {
|
|
124
|
+
const agentNode = RED.nodes.getNode(agentInfo.id);
|
|
125
|
+
if (!agentNode || typeof agentNode.executeTask !== 'function') {
|
|
126
|
+
msg.error = `Agent node ${agentInfo.name} [${agentInfo.id}] is not an AI Orchestrator Agent or is missing executeTask API.`;
|
|
127
|
+
msg.payload = null;
|
|
128
|
+
} else {
|
|
129
|
+
node.status({ fill: 'blue', shape: 'ring', text: `agent: ${agentInfo.name}` });
|
|
130
|
+
try {
|
|
131
|
+
const result = await agentNode.executeTask(nextTask.input, msg);
|
|
132
|
+
msg.payload = result;
|
|
133
|
+
msg.error = null;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
let errorMessage = err && err.message ? err.message : String(err);
|
|
136
|
+
if (errorMessage.startsWith('AI API Error: ')) {
|
|
137
|
+
errorMessage = errorMessage.substring('AI API Error: '.length);
|
|
104
138
|
}
|
|
139
|
+
msg.error = errorMessage;
|
|
105
140
|
}
|
|
106
|
-
|
|
107
|
-
// 4. Reflection Phase
|
|
108
|
-
node.status({ fill: 'blue', shape: 'dot', text: 'reflecting...' });
|
|
109
|
-
await reflectAndRefine(node, msg);
|
|
110
141
|
}
|
|
142
|
+
}
|
|
111
143
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
node.status({ fill: 'blue', shape: 'dot', text: 'reflecting...' });
|
|
145
|
+
await reflectAndRefine(node, msg);
|
|
146
|
+
msg.orchestration.iterations++;
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
148
|
+
setImmediate(() => processNextStep(RED, node, msg, send, done));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
msg.orchestration.status = 'failed';
|
|
151
|
+
msg.orchestration.error = error && error.message ? error.message : String(error);
|
|
152
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
153
|
+
finalizeOrchestration(node, msg, send, done, error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function finalizeOrchestration(node, msg, send, done, error) {
|
|
158
|
+
msg.orchestration._running = false;
|
|
159
|
+
node.status({ fill: msg.orchestration.status === 'completed' ? 'green' : 'red', shape: 'dot', text: msg.orchestration.status });
|
|
160
|
+
send(msg);
|
|
161
|
+
if (done) done(error);
|
|
123
162
|
}
|
|
124
163
|
|
|
125
164
|
/**
|
|
@@ -130,6 +169,7 @@ module.exports = function (RED) {
|
|
|
130
169
|
*/
|
|
131
170
|
async function createInitialPlan(node, msg) {
|
|
132
171
|
const goal = msg.orchestration.goal;
|
|
172
|
+
const goalText = typeof goal === 'string' ? goal : JSON.stringify(goal, null, 2);
|
|
133
173
|
const strategy = node.planningStrategy;
|
|
134
174
|
const agents = msg.orchestration.availableAgents || [];
|
|
135
175
|
const agentManifest = agents.map(a => `- ${a.name}: [${a.capabilities.join(', ')}]`).join('\n');
|
|
@@ -138,7 +178,7 @@ module.exports = function (RED) {
|
|
|
138
178
|
));
|
|
139
179
|
const allowedCapabilitiesJson = JSON.stringify(allowedCapabilities);
|
|
140
180
|
|
|
141
|
-
let prompt = `Goal: ${
|
|
181
|
+
let prompt = `Goal: ${goalText}\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.
|
|
142
182
|
Return a JSON object with a "tasks" array. Each task should have:
|
|
143
183
|
- "id": a short string id (e.g., "t1", "t2")
|
|
144
184
|
- "type": the name of the REQUIRED capability from the list above
|
|
@@ -172,9 +212,9 @@ Example:
|
|
|
172
212
|
}`;
|
|
173
213
|
|
|
174
214
|
try {
|
|
175
|
-
node
|
|
176
|
-
const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
|
|
177
|
-
node
|
|
215
|
+
debugLog(node, 'Planning Prompt', prompt);
|
|
216
|
+
const response = await callAI(node, msg.aiagent, prompt, "You are an AI Orchestrator that creates non-linear plans with dependencies.");
|
|
217
|
+
debugLog(node, 'Planning Response', response);
|
|
178
218
|
const planData = parseJsonResponse(response);
|
|
179
219
|
msg.orchestration.plan = planData;
|
|
180
220
|
msg.orchestration.status = 'executing';
|
|
@@ -201,6 +241,10 @@ Example:
|
|
|
201
241
|
timestamp: new Date().toISOString()
|
|
202
242
|
});
|
|
203
243
|
|
|
244
|
+
if (Array.isArray(msg.orchestration.history) && msg.orchestration.history.length > node.maxHistory) {
|
|
245
|
+
msg.orchestration.history = msg.orchestration.history.slice(-node.maxHistory);
|
|
246
|
+
}
|
|
247
|
+
|
|
204
248
|
// Update task status in plan
|
|
205
249
|
const task = msg.orchestration.plan.tasks.find(t => t.id === currentTaskId);
|
|
206
250
|
if (task) {
|
|
@@ -228,7 +272,10 @@ Example:
|
|
|
228
272
|
? '3. If you need more information or approval from a human, add a task with type "human_approval".'
|
|
229
273
|
: '3. Do NOT request human approval tasks ("human_approval" is not available).';
|
|
230
274
|
|
|
231
|
-
const
|
|
275
|
+
const reflectionGoal = msg.orchestration.goal;
|
|
276
|
+
const reflectionGoalText = typeof reflectionGoal === 'string' ? reflectionGoal : JSON.stringify(reflectionGoal, null, 2);
|
|
277
|
+
|
|
278
|
+
const prompt = `Current Goal: ${reflectionGoalText}
|
|
232
279
|
Current Plan: ${JSON.stringify(msg.orchestration.plan)}
|
|
233
280
|
Last Task ID: ${currentTaskId}
|
|
234
281
|
Last Task ${isError ? 'Error' : 'Result'}: ${JSON.stringify(isError ? msg.error : taskResult)}
|
|
@@ -253,7 +300,7 @@ Return a JSON object:
|
|
|
253
300
|
}`;
|
|
254
301
|
|
|
255
302
|
try {
|
|
256
|
-
const response = await callAI(msg.aiagent, prompt, "You are an AI Orchestrator that reflects on progress and manages plan revisions.");
|
|
303
|
+
const response = await callAI(node, msg.aiagent, prompt, "You are an AI Orchestrator that reflects on progress and manages plan revisions.");
|
|
257
304
|
const reflection = parseJsonResponse(response);
|
|
258
305
|
|
|
259
306
|
msg.orchestration.status = reflection.status;
|
|
@@ -336,9 +383,9 @@ Return a JSON object:
|
|
|
336
383
|
* @returns {Promise<string>} The AI response content
|
|
337
384
|
* @throws {Error} If API call fails
|
|
338
385
|
*/
|
|
339
|
-
async function callAI(aiConfig, prompt, systemPrompt) {
|
|
386
|
+
async function callAI(node, aiConfig, prompt, systemPrompt) {
|
|
340
387
|
const response = await axios.post(
|
|
341
|
-
|
|
388
|
+
buildChatCompletionsUrl(node, aiConfig),
|
|
342
389
|
{
|
|
343
390
|
model: aiConfig.model,
|
|
344
391
|
messages: [
|
|
@@ -350,13 +397,28 @@ Return a JSON object:
|
|
|
350
397
|
{
|
|
351
398
|
headers: {
|
|
352
399
|
'Authorization': `Bearer ${aiConfig.apiKey}`,
|
|
353
|
-
'Content-Type': 'application/json'
|
|
354
|
-
|
|
400
|
+
'Content-Type': 'application/json',
|
|
401
|
+
'HTTP-Referer': 'https://nodered.org/',
|
|
402
|
+
'X-Title': 'Node-RED AI Orchestrator'
|
|
403
|
+
},
|
|
404
|
+
timeout: getTimeoutMs(node, aiConfig)
|
|
355
405
|
}
|
|
356
406
|
);
|
|
357
407
|
return response.data.choices[0]?.message?.content || '';
|
|
358
408
|
}
|
|
359
409
|
|
|
410
|
+
function buildChatCompletionsUrl(node, aiConfig) {
|
|
411
|
+
const baseUrl = (aiConfig && aiConfig.baseUrl) ? String(aiConfig.baseUrl) : String(node.providerBaseUrl);
|
|
412
|
+
return baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getTimeoutMs(node, aiConfig) {
|
|
416
|
+
if (aiConfig && aiConfig.timeoutMs !== undefined) {
|
|
417
|
+
return normalizePositiveInt(aiConfig.timeoutMs, node.timeoutMs, 1000, 300000);
|
|
418
|
+
}
|
|
419
|
+
return node.timeoutMs;
|
|
420
|
+
}
|
|
421
|
+
|
|
360
422
|
/**
|
|
361
423
|
* Extracts JSON from a text response
|
|
362
424
|
* @param {string} text - The text containing JSON
|
|
@@ -469,5 +531,154 @@ Return a JSON object:
|
|
|
469
531
|
return out.trim();
|
|
470
532
|
}
|
|
471
533
|
|
|
534
|
+
function debugLog(node, label, payload) {
|
|
535
|
+
if (!node || !node.debug || typeof node.warn !== 'function') return;
|
|
536
|
+
try {
|
|
537
|
+
const serialized = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
538
|
+
node.warn(`[AI Orchestrator] ${label}: ${serialized}`);
|
|
539
|
+
} catch (_err) {
|
|
540
|
+
node.warn(`[AI Orchestrator] ${label}: [unserializable payload]`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function normalizePositiveInt(value, fallback, min, max) {
|
|
545
|
+
const n = parseInt(value);
|
|
546
|
+
if (!Number.isFinite(n)) return fallback;
|
|
547
|
+
if (n < min) return min;
|
|
548
|
+
if (n > max) return max;
|
|
549
|
+
return n;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function validateNodeConfig(node) {
|
|
553
|
+
const errors = [];
|
|
554
|
+
if (!['simple', 'advanced'].includes(String(node.planningStrategy))) {
|
|
555
|
+
errors.push('planningStrategy must be "simple" or "advanced"');
|
|
556
|
+
}
|
|
557
|
+
if (!String(node.providerBaseUrl || '').trim()) {
|
|
558
|
+
errors.push('providerBaseUrl must be a non-empty string');
|
|
559
|
+
}
|
|
560
|
+
return errors;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function validateMessage(msg, node) {
|
|
564
|
+
const errors = [];
|
|
565
|
+
if (!msg || typeof msg !== 'object') {
|
|
566
|
+
errors.push('msg must be an object');
|
|
567
|
+
return errors;
|
|
568
|
+
}
|
|
569
|
+
if (!msg.aiagent) {
|
|
570
|
+
errors.push('AI Model configuration missing (msg.aiagent)');
|
|
571
|
+
return errors;
|
|
572
|
+
}
|
|
573
|
+
if (!msg.aiagent.apiKey || !String(msg.aiagent.apiKey).trim()) {
|
|
574
|
+
errors.push('AI Model API key not found (msg.aiagent.apiKey)');
|
|
575
|
+
}
|
|
576
|
+
if (!msg.aiagent.model || !String(msg.aiagent.model).trim()) {
|
|
577
|
+
errors.push('AI Model not found (msg.aiagent.model)');
|
|
578
|
+
}
|
|
579
|
+
if (msg.orchestration && (!msg.orchestration.goal || !String(msg.orchestration.goal).trim())) {
|
|
580
|
+
errors.push('Orchestration goal is empty');
|
|
581
|
+
}
|
|
582
|
+
return errors;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function validatePlan(plan, availableAgents) {
|
|
586
|
+
const errors = [];
|
|
587
|
+
if (!plan || typeof plan !== 'object') {
|
|
588
|
+
errors.push('Plan is missing');
|
|
589
|
+
return errors;
|
|
590
|
+
}
|
|
591
|
+
if (!Array.isArray(plan.tasks)) {
|
|
592
|
+
errors.push('Plan.tasks must be an array');
|
|
593
|
+
return errors;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const allowedCapabilities = new Set(
|
|
597
|
+
(availableAgents || [])
|
|
598
|
+
.flatMap(a => Array.isArray(a.capabilities) ? a.capabilities : [])
|
|
599
|
+
.map(c => String(c))
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const idSet = new Set();
|
|
603
|
+
for (const task of plan.tasks) {
|
|
604
|
+
if (!task || typeof task !== 'object') {
|
|
605
|
+
errors.push('Task must be an object');
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (!task.id || !String(task.id).trim()) {
|
|
609
|
+
errors.push('Task.id is required');
|
|
610
|
+
} else {
|
|
611
|
+
const id = String(task.id);
|
|
612
|
+
if (idSet.has(id)) errors.push(`Duplicate task id: ${id}`);
|
|
613
|
+
idSet.add(id);
|
|
614
|
+
}
|
|
615
|
+
if (!task.type || !String(task.type).trim()) {
|
|
616
|
+
errors.push(`Task ${String(task.id || '?')} is missing type`);
|
|
617
|
+
} else if (allowedCapabilities.size > 0 && !allowedCapabilities.has(String(task.type))) {
|
|
618
|
+
errors.push(`Task ${String(task.id || '?')} has unsupported type: ${String(task.type)}`);
|
|
619
|
+
}
|
|
620
|
+
if (!task.status) {
|
|
621
|
+
task.status = 'pending';
|
|
622
|
+
}
|
|
623
|
+
if (!['pending', 'completed', 'failed'].includes(String(task.status))) {
|
|
624
|
+
errors.push(`Task ${String(task.id || '?')} has invalid status: ${String(task.status)}`);
|
|
625
|
+
}
|
|
626
|
+
if (!Array.isArray(task.dependsOn)) {
|
|
627
|
+
task.dependsOn = task.dependsOn ? [task.dependsOn] : [];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const task of plan.tasks) {
|
|
632
|
+
for (const depId of (task.dependsOn || [])) {
|
|
633
|
+
const dep = String(depId);
|
|
634
|
+
if (!idSet.has(dep)) {
|
|
635
|
+
errors.push(`Task ${String(task.id || '?')} depends on non-existent task ${dep}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const cycle = findDependencyCycle(plan.tasks);
|
|
641
|
+
if (cycle) {
|
|
642
|
+
errors.push(`Circular dependencies detected: ${cycle.join(' -> ')}`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return errors;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function findDependencyCycle(tasks) {
|
|
649
|
+
const graph = new Map();
|
|
650
|
+
for (const t of tasks || []) {
|
|
651
|
+
if (!t || !t.id) continue;
|
|
652
|
+
graph.set(String(t.id), (t.dependsOn || []).map(String));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const visiting = new Set();
|
|
656
|
+
const visited = new Set();
|
|
657
|
+
|
|
658
|
+
function dfs(nodeId, path) {
|
|
659
|
+
if (visiting.has(nodeId)) {
|
|
660
|
+
const idx = path.indexOf(nodeId);
|
|
661
|
+
return idx >= 0 ? path.slice(idx).concat([nodeId]) : path.concat([nodeId]);
|
|
662
|
+
}
|
|
663
|
+
if (visited.has(nodeId)) return null;
|
|
664
|
+
|
|
665
|
+
visiting.add(nodeId);
|
|
666
|
+
visited.add(nodeId);
|
|
667
|
+
const deps = graph.get(nodeId) || [];
|
|
668
|
+
for (const dep of deps) {
|
|
669
|
+
const result = dfs(dep, path.concat([nodeId]));
|
|
670
|
+
if (result) return result;
|
|
671
|
+
}
|
|
672
|
+
visiting.delete(nodeId);
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (const nodeId of graph.keys()) {
|
|
677
|
+
const result = dfs(nodeId, []);
|
|
678
|
+
if (result) return result;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
472
683
|
RED.nodes.registerType('ai-orchestrator', AiOrchestratorNode);
|
|
473
684
|
};
|