overmind-mcp
Version:
Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour
375 lines • 15.7 kB
JavaScript
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ OVERMIND BRIDGE — ScenarioLoader (Multi-Agent Orchestration) ║
* ║ ║
* ║ Charge un scénario depuis YAML/JSON qui décrit un workflow ║
* ║ multi-agents : séquentiel, parallèle, conditionnel, A2A. ║
* ║ ║
* ║ FORMAT JSON ║
* ║ ────────── ║
* ║ { ║
* ║ "name": "My workflow", ║
* ║ "vars": { "ticker": "BTC" }, ║
* ║ "steps": [ ║
* ║ { "id": "step1", "type": "run", "agent": "scout", ║
* ║ "runner": "kilo", "prompt": "Analyse ${ticker}" }, ║
* ║ { "id": "step2", "type": "a2a", "from": "scout", "to": ║
* ║ "analyst", "prompt": "Valide ${step1.output}" }, ║
* ║ { "id": "step3", "type": "parallel", "steps": [...] } ║
* ║ ] ║
* ║ } ║
* ╚══════════════════════════════════════════════════════════════════════╝
*/
import fs from 'node:fs/promises';
import { interpolate } from './utils.js';
// ─── Lazy YAML loader (optionnel) ──────────────────────────────────────────
//
// YAML est supporté si le module `yaml` est installé.
// Sinon on throw une erreur claire demandant `npm i yaml`.
//
// On utilise createRequire pour rester compatible ESM (NodeNext).
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
function tryLoadYaml() {
try {
const yaml = require('yaml');
return yaml.parse;
}
catch {
return undefined;
}
}
// ─── Loader ────────────────────────────────────────────────────────────────
/**
* Charge un scénario depuis un fichier. Supporte JSON et YAML.
*/
export async function loadScenario(filePath) {
const raw = await fs.readFile(filePath, 'utf-8');
const ext = filePath.toLowerCase().split('.').pop() ?? 'json';
let parsed;
if (ext === 'yaml' || ext === 'yml') {
const parseYaml = tryLoadYaml();
if (!parseYaml) {
throw new Error(`YAML scenario requested but 'yaml' module is not installed.\n` +
`Install it with: npm install yaml\n` +
`Or convert your scenario to JSON (.json) which is supported out-of-the-box.`);
}
try {
parsed = parseYaml(raw);
}
catch (err) {
throw new Error(`Invalid YAML in ${filePath}: ${err.message}`, { cause: err });
}
}
else {
try {
parsed = JSON.parse(raw);
}
catch (err) {
throw new Error(`Invalid JSON in ${filePath}: ${err.message}`, { cause: err });
}
}
return validateScenario(parsed);
}
/**
* Valide et normalise un objet scénario.
*/
function validateScenario(obj) {
if (!obj || typeof obj !== 'object') {
throw new Error('Scenario must be an object');
}
const s = obj;
if (typeof s.name !== 'string' || !s.name) {
throw new Error('Scenario.name is required');
}
if (!Array.isArray(s.steps)) {
throw new Error('Scenario.steps must be an array');
}
for (const [i, step] of s.steps.entries()) {
validateStep(step, `steps[${i}]`);
}
return {
name: s.name,
description: typeof s.description === 'string' ? s.description : undefined,
vars: s.vars ?? {},
steps: s.steps,
};
}
function validateStep(step, path) {
if (!step || typeof step !== 'object') {
throw new Error(`${path} must be an object`);
}
const s = step;
if (typeof s.id !== 'string' || !s.id) {
throw new Error(`${path}.id is required`);
}
const type = s.type;
if (type === 'run') {
if (typeof s.agent !== 'string')
throw new Error(`${path}.agent required for run`);
if (typeof s.runner !== 'string')
throw new Error(`${path}.runner required for run`);
if (typeof s.prompt !== 'string')
throw new Error(`${path}.prompt required for run`);
}
else if (type === 'a2a') {
if (typeof s.from !== 'string')
throw new Error(`${path}.from required for a2a`);
if (typeof s.to !== 'string')
throw new Error(`${path}.to required for a2a`);
if (typeof s.runner !== 'string')
throw new Error(`${path}.runner required for a2a`);
if (typeof s.prompt !== 'string')
throw new Error(`${path}.prompt required for a2a`);
}
else if (type === 'parallel') {
if (!Array.isArray(s.steps))
throw new Error(`${path}.steps required for parallel`);
s.steps.forEach((sub, j) => validateStep(sub, `${path}.steps[${j}]`));
}
else if (type === 'if') {
if (typeof s.condition !== 'string')
throw new Error(`${path}.condition required for if`);
if (!Array.isArray(s.then))
throw new Error(`${path}.then required for if`);
s.then.forEach((sub, j) => validateStep(sub, `${path}.then[${j}]`));
if (s.else && Array.isArray(s.else)) {
s.else.forEach((sub, j) => validateStep(sub, `${path}.else[${j}]`));
}
}
else if (type === 'wait') {
if (typeof s.ms !== 'number')
throw new Error(`${path}.ms required for wait`);
}
else if (type === 'kanban') {
if (typeof s.assignee !== 'string')
throw new Error(`${path}.assignee required for kanban`);
if (typeof s.title !== 'string')
throw new Error(`${path}.title required for kanban`);
}
else {
throw new Error(`${path}.type must be one of: run, a2a, parallel, if, wait (got: ${String(type)})`);
}
}
/**
* Exécute un scénario séquentiellement. Retourne tous les résultats.
*/
export async function runScenario(scenario, ctx) {
// Init vars (input + step outputs)
const vars = { ...(scenario.vars ?? {}) };
const localCtx = { ...ctx, vars };
const results = [];
for (const step of scenario.steps) {
const result = await runStep(step, localCtx);
results.push(result);
if (result.output !== undefined) {
// Expose output aux steps suivants : ${stepId.output} et ${stepId}
const outputStr = typeof result.output === 'string' ? result.output : JSON.stringify(result.output);
localCtx.vars[result.stepId] = outputStr;
localCtx.vars[`${result.stepId}.output`] = outputStr;
}
if (!result.success && step.stopOnError) {
break;
}
}
return results;
}
async function runStep(step, ctx) {
const startTime = Date.now();
const log = ctx.log ?? (() => { });
try {
switch (step.type) {
case 'run': {
const prompt = interpolate(step.prompt, ctx.vars);
log(`▶ [${step.id}] run ${step.agent} (${step.runner})`);
const r = await ctx.runAgent({
agentName: step.agent,
runner: step.runner,
prompt,
model: step.model,
mode: step.mode,
path: step.path,
});
log(r.isError ? `✗ [${step.id}] failed` : `✓ [${step.id}] done (${Date.now() - startTime}ms)`);
return {
stepId: step.id,
type: 'run',
success: !r.isError,
output: r.text,
durationMs: Date.now() - startTime,
};
}
case 'a2a': {
const prompt = interpolate(step.prompt, ctx.vars);
log(`▶ [${step.id}] a2a ${step.from} → ${step.to}`);
const r = await ctx.runA2A({
fromAgent: step.from,
toAgent: step.to,
runner: step.runner,
prompt,
model: step.model,
});
log(r.isError ? `✗ [${step.id}] failed` : `✓ [${step.id}] done (${Date.now() - startTime}ms)`);
return {
stepId: step.id,
type: 'a2a',
success: !r.isError,
output: r.text,
durationMs: Date.now() - startTime,
};
}
case 'parallel': {
const waitAll = step.waitAll ?? true;
log(`▶ [${step.id}] parallel (${step.steps.length} steps, waitAll=${waitAll})`);
let subResults;
if (waitAll) {
subResults = await Promise.all(step.steps.map((s) => runStep(s, ctx)));
}
else {
// waitAll=false : retourne dès qu'un step réussit
// On lance tout, et on retourne au premier succès (les autres continuent en background)
const promises = step.steps.map((s) => runStep(s, ctx));
// Promise.any retourne le 1er à RÉSOUDRE (donc succès, car on catch les erreurs dans runStep)
const first = await Promise.any(promises);
// Cancel le reste en best-effort (ils continuent mais on n'attend pas)
subResults = [first];
}
const success = subResults.every((r) => r.success);
return {
stepId: step.id,
type: 'parallel',
success,
output: subResults,
durationMs: Date.now() - startTime,
};
}
case 'if': {
const condStr = interpolate(step.condition, ctx.vars);
const truthy = evaluateCondition(condStr);
log(`▶ [${step.id}] if (${condStr}) → ${truthy ? 'then' : 'else'}`);
const branch = truthy ? step.then : (step.else ?? []);
const subResults = [];
for (const s of branch) {
subResults.push(await runStep(s, ctx));
}
return {
stepId: step.id,
type: 'if',
success: subResults.every((r) => r.success),
output: subResults,
durationMs: Date.now() - startTime,
};
}
case 'wait': {
log(`▶ [${step.id}] wait ${step.ms}ms`);
await new Promise((resolve) => setTimeout(resolve, step.ms));
return {
stepId: step.id,
type: 'wait',
success: true,
durationMs: Date.now() - startTime,
};
}
case 'kanban': {
const title = interpolate(step.title, ctx.vars);
const body = step.body ? interpolate(step.body, ctx.vars) : title;
const shouldWait = step.wait !== false; // default: true
log(`▶ [${step.id}] kanban → ${step.assignee}: "${title}"`);
// Dynamic import to avoid circular dependency
const { KanbanAdapter } = await import('../services/KanbanAdapter.js');
const kanban = new KanbanAdapter();
const { taskId } = await kanban.createTask({
title,
assignee: step.assignee,
body,
tenant: step.tenant,
});
if (!shouldWait) {
log(`✓ [${step.id}] kanban task created (fire-and-forget): ${taskId}`);
return {
stepId: step.id,
type: 'kanban',
success: true,
output: `kanban:${taskId}`,
durationMs: Date.now() - startTime,
};
}
const result = await kanban.wait(taskId, step.timeoutMs || 600000);
log(result.status === 'done'
? `✓ [${step.id}] kanban done (${Date.now() - startTime}ms)`
: `✗ [${step.id}] kanban ${result.status}: ${result.error || ''}`);
return {
stepId: step.id,
type: 'kanban',
success: result.status === 'done',
output: result.summary || result.error || `kanban:${taskId}`,
durationMs: Date.now() - startTime,
};
}
default: {
const _exhaustive = step;
throw new Error(`Unknown step type: ${JSON.stringify(_exhaustive)}`);
}
}
}
catch (err) {
log(`💥 [${step.id}] crashed: ${err.message}`);
return {
stepId: step.id,
type: step.type,
success: false,
error: err.message,
durationMs: Date.now() - startTime,
};
}
}
/**
* Évalue une condition simple. Supporte :
* - "value" (truthy si non-vide)
* - ${var} == "literal"
* - ${var} != "literal"
* - ${var} > 5, < 5
* - ${var} && ${other}
* - !${var}
*/
function evaluateCondition(expr) {
const trimmed = expr.trim();
if (!trimmed)
return false;
// ${var} == "literal"
let m = trimmed.match(/^(.+?)\s*==\s*["']([^"']*)["']$/);
if (m)
return m[1].trim() === m[2];
// ${var} != "literal"
m = trimmed.match(/^(.+?)\s*!=\s*["']([^"']*)["']$/);
if (m)
return m[1].trim() !== m[2];
// ${var} > N
m = trimmed.match(/^(.+?)\s*>\s*(\d+(?:\.\d+)?)$/);
if (m)
return Number(m[1].trim()) > Number(m[2]);
// ${var} < N
m = trimmed.match(/^(.+?)\s*<\s*(\d+(?:\.\d+)?)$/);
if (m)
return Number(m[1].trim()) < Number(m[2]);
// !${var}
if (trimmed.startsWith('!'))
return !evaluateCondition(trimmed.slice(1));
// ${a} && ${b}
if (trimmed.includes('&&')) {
return trimmed
.split('&&')
.map((p) => evaluateCondition(p.trim()))
.every(Boolean);
}
// ${a} || ${b}
if (trimmed.includes('||')) {
return trimmed
.split('||')
.map((p) => evaluateCondition(p.trim()))
.some(Boolean);
}
// Truthy simple
return Boolean(trimmed);
}
//# sourceMappingURL=ScenarioLoader.js.map