MagicCode/packages/cli/src/workflow-run.ts

378 lines
15 KiB
TypeScript
Raw Normal View History

2026-10-26 23:45:53 +00:00
// `maia run AGENT.md` : workflow autonome = un agent parent (orchestrateur) sans interface + ses sous-agents.
// L'état de chaque agent est tenu à jour dans le registre ~/.maia/workflows/<id>/ (voir core/src/workflow.ts).
import type { ChildProcess } from "node:child_process";
import {
AGENT_STATE_LABELS,
DEFAULT_AGENT_TASK,
WorkflowRegistry,
createSession,
finalAssistantText,
requestFinalSummary,
runTurn,
type AgentRecord,
type AgentState,
type AgentUI,
type LLMProvider,
type PermissionMode,
type Tool,
type WorkflowHandle,
} from "@maia/core";
import {
INTERVENTION_TOOL_NAME,
initializeOrchestrator,
loadOrchestrator,
resolveSubAgentModel,
} from "@maia/agent";
import { createProvider, loadModelChoices, loadSettings, type MaiaSettings } from "@maia/providers";
import { createSubAgentLauncher } from "./subagent-parent";
/** Délai maximal d'un sous-agent dans un workflow : celui du workflow (un sous-agent peut attendre une autorisation longtemps). */
const MAX_TIMER_MS = 2_147_483_647;
export const DEFAULT_POLL_MS = 1000;
export interface WorkflowRunOptions {
/** AGENT.md de l'agent parent. */
file: string;
task?: string;
model?: string;
name?: string;
permissionMode?: PermissionMode;
timeoutMs?: number;
/** Workflow déjà créé (mode --detach : le processus détaché reprend l'identifiant). */
workflowId?: string;
/** Dossier de lancement (sert de dossier de travail si le AGENT.md n'est pas dans un dossier .maia/agents). */
launchDir: string;
homeDir: string;
}
export interface WorkflowRunDeps {
settings?: MaiaSettings;
/** Modèles disponibles (sinon : interrogation de LiteLLM). */
availableModels?: string[];
createProvider?(model: string, settings: MaiaSettings): LLMProvider;
/** Processus d'un sous-agent (tests). */
spawnChild?: () => ChildProcess;
/** Progression lisible (une ligne par évènement). */
log?(line: string): void;
/** Annonce de l'identifiant dès sa création. */
onCreated?(id: string): void;
pollMs?: number;
}
export interface WorkflowRunResult {
id: string;
state: AgentState;
exitCode: number;
result: string;
error?: string;
}
const time = (): string => new Date().toTimeString().slice(0, 8);
/** États des agents d'un workflow, enregistrés à chaque changement. */
class AgentBook {
private readonly agents = new Map<string, AgentRecord & { waits: number }>();
constructor(private readonly handle: WorkflowHandle) {}
private save(): void {
this.handle.writeAgents([...this.agents.values()].map(({ waits: _w, ...a }) => a));
}
private get(name: string, role: AgentRecord["role"] = "sous-agent"): AgentRecord & { waits: number } {
let a = this.agents.get(name);
if (!a) {
a = { name, role, state: "pending", runs: 0, active: 0, waits: 0 };
this.agents.set(name, a);
}
return a;
}
declare(name: string, role: AgentRecord["role"], model?: string): void {
const a = this.get(name, role);
if (model) a.model = model;
this.save();
}
start(name: string, model: string, role: AgentRecord["role"] = "sous-agent"): void {
const a = this.get(name, role);
a.model = model;
a.runs++;
a.active++;
a.state = a.waits > 0 ? "waiting" : "running";
a.startedAt ??= new Date().toISOString();
delete a.endedAt;
delete a.error;
this.save();
}
end(name: string, ok: boolean, error?: string): void {
const a = this.get(name);
a.active = Math.max(0, a.active - 1);
if (!ok && error) a.error = error;
if (a.active === 0) {
a.state = ok && !a.error ? "done" : "error";
a.endedAt = new Date().toISOString();
}
this.save();
}
wait(name: string, delta: 1 | -1): void {
const a = this.get(name);
a.waits = Math.max(0, a.waits + delta);
if (a.active > 0) a.state = a.waits > 0 ? "waiting" : "running";
this.save();
}
/** Fin du workflow : un agent encore actif est interrompu. */
closeAll(reason: string): void {
for (const a of this.agents.values()) {
if (a.state === "running" || a.state === "waiting") {
a.state = "error";
a.error = reason;
a.active = 0;
a.waits = 0;
a.endedAt = new Date().toISOString();
}
}
this.save();
}
}
async function resolveModel(
requested: string | undefined,
settings: MaiaSettings,
available: string[] | undefined,
log: (line: string) => void,
): Promise<string> {
const catalog = available ?? (await loadModelChoices(settings)).choices.map((c) => c.value);
if (requested) {
const asSpec = requested.includes(":") ? requested : `litellm:${requested}`;
if (catalog.length === 0) return asSpec; // catalogue indisponible : le modèle demandé est pris tel quel
const r = resolveSubAgentModel(requested, asSpec, catalog);
if (r.warning) log(`Modèle « ${requested} » introuvable parmi les modèles disponibles : utilisé tel quel (${asSpec}).`);
return r.spec;
}
const first = catalog[0];
if (!first) throw new Error("Aucun modèle indiqué (--model, champ model de l'agent, DefaultModel du settings.json) et aucun modèle disponible auprès de LiteLLM.");
log(`Aucun modèle indiqué : ${first} (premier modèle disponible). Fixez « DefaultModel » dans ~/.maia/settings.json pour le choisir.`);
return first;
}
/** Exécute un workflow jusqu'au bout. Ne lève pas d'erreur : l'échec est porté par le résultat (et le registre). */
export async function runWorkflow(opts: WorkflowRunOptions, deps: WorkflowRunDeps = {}): Promise<WorkflowRunResult> {
const log = deps.log ?? (() => undefined);
const registry = new WorkflowRegistry(opts.homeDir);
let handle: WorkflowHandle | null = opts.workflowId ? registry.get(opts.workflowId) : null;
// Échec avant même la création du workflow (fichier, modèle…) : il est quand même enregistré, en erreur
const failEarly = (message: string): WorkflowRunResult => {
if (!handle) {
handle = registry.create({
name: opts.name ?? opts.file,
agent: opts.file,
agentFile: opts.file,
cwd: opts.launchDir,
task: opts.task ?? "",
model: opts.model ?? "",
permissionMode: opts.permissionMode ?? "wait",
pid: process.pid,
});
deps.onCreated?.(handle.id);
}
handle.updateWorkflow({ state: "error", error: message, endedAt: new Date().toISOString(), pid: process.pid });
handle.event("workflow", "error", message);
log(`[${time()}] Workflow en erreur : ${message}`);
return { id: handle.id, state: "error", exitCode: 1, result: "", error: message };
};
let settings: MaiaSettings;
let def: Awaited<ReturnType<typeof loadOrchestrator>>;
let model: string;
try {
settings = deps.settings ?? (await loadSettings(opts.homeDir));
def = await loadOrchestrator(opts.file, opts.launchDir);
model = await resolveModel(opts.model ?? def.agent.model ?? settings.DefaultModel, settings, deps.availableModels, log);
} catch (err) {
return failEarly((err as Error).message);
}
const parent = def.agent.name;
const permissionMode: PermissionMode = opts.permissionMode ?? def.permissionMode ?? "wait";
const timeoutMs = opts.timeoutMs ?? def.timeoutMs;
const task = opts.task?.trim() || DEFAULT_AGENT_TASK;
const record = {
name: opts.name ?? parent,
agent: parent,
agentFile: def.file,
cwd: def.cwd,
task,
model,
permissionMode,
pid: process.pid,
...(timeoutMs ? { timeoutMs } : {}),
};
if (handle) handle.updateWorkflow({ ...record, state: "running" });
else {
handle = registry.create(record);
handle.updateWorkflow({ state: "running" });
}
const wf: WorkflowHandle = handle;
deps.onCreated?.(wf.id);
const book = new AgentBook(wf);
const say = (agent: string, type: string, message: string): void => {
wf.event(agent, type, message);
log(`[${time()}] ${agent} › ${message}`);
};
for (const w of def.warnings) say(parent, "warn", w);
say("workflow", "start", `Workflow ${wf.id} démarré : agent ${parent}, modèle ${model}, autorisations « ${permissionMode} », dossier ${def.cwd}`);
const controller = new AbortController();
const stopped: { reason: string | null } = { reason: null };
const abort = (reason: string): void => {
if (stopped.reason) return;
stopped.reason = reason;
controller.abort(new Error(reason));
};
const pollMs = deps.pollMs ?? DEFAULT_POLL_MS;
const stopTimer = setInterval(() => {
if (wf.stopRequested()) abort("arrêté (maia stop)");
}, pollMs);
const timeoutTimer = timeoutMs ? setTimeout(() => abort(`délai maximal dépassé (${Math.round(timeoutMs / 60_000)} min)`), Math.min(timeoutMs, MAX_TIMER_MS)) : undefined;
/** Attend la réponse à une demande (fichier déposé par maia approve / reply) ; null si le workflow est arrêté. */
const waitAnswer = (requestId: string): Promise<{ approved?: boolean; text?: string } | null> =>
new Promise((resolve) => {
const tick = (): void => {
const answer = wf.readAnswer(requestId);
if (answer) return resolve(answer);
if (controller.signal.aborted) return resolve(null);
setTimeout(tick, pollMs);
};
tick();
});
/** Autorisation d'outil selon le mode : allow / deny immédiat, wait = demande enregistrée et attente de `maia approve`. */
const askPermission = async (agent: string, message: string): Promise<boolean> => {
if (permissionMode === "allow") {
say(agent, "permission", `autorisé automatiquement (mode allow) : ${message}`);
return true;
}
if (permissionMode === "deny") {
say(agent, "permission", `refusé (mode deny) : ${message}`);
return false;
}
const req = wf.createRequest({ agent, kind: "permission", message });
book.wait(agent, 1);
say(agent, "waiting", `EN ATTENTE ${req.id} : ${message} → maia approve ${wf.id} ${req.id} (ou --deny)`);
const answer = await waitAnswer(req.id);
book.wait(agent, -1);
const ok = answer?.approved === true;
say(agent, "permission", `${req.id} ${ok ? "autorisé" : "refusé"}${answer ? "" : " (workflow arrêté)"}`);
return ok;
};
const interventionTool: Tool = {
spec: {
name: INTERVENTION_TOOL_NAME,
description:
"Demande l'intervention d'un humain (l'utilisateur ou un tiers) et attend sa réponse : le workflow passe « en attente » jusqu'à " +
"`maia reply`. À utiliser quand une contrainte l'exige ou qu'une décision humaine est nécessaire. Retourne la réponse.",
inputSchema: {
type: "object",
properties: {
message: { type: "string", description: "Ce qui bloque et ce qui est attendu (décision, action, information)" },
recipient: { type: "string", description: "À qui s'adresse la demande (ex. utilisateur, équipe sécurité)" },
},
required: ["message"],
},
},
permission: "auto",
describe: (input) => String(input.message ?? "").slice(0, 120),
async execute(input) {
const message = typeof input.message === "string" ? input.message.trim() : "";
if (!message) throw new Error('Paramètre "message" manquant : décris ce qui est attendu.');
const recipient = typeof input.recipient === "string" && input.recipient.trim() ? input.recipient.trim() : undefined;
const req = wf.createRequest({ agent: parent, kind: "intervention", message, ...(recipient ? { recipient } : {}) });
book.wait(parent, 1);
say(parent, "waiting", `EN ATTENTE ${req.id} (intervention${recipient ? ` : ${recipient}` : ""}) : ${message} → maia reply ${wf.id} ${req.id} "réponse"`);
const answer = await waitAnswer(req.id);
book.wait(parent, -1);
if (!answer) throw new Error("Workflow arrêté pendant l'attente de l'intervention.");
say(parent, "reply", `${req.id} : réponse reçue`);
return `Réponse${recipient ? ` (${recipient})` : ""} : ${answer.text ?? (answer.approved === false ? "refusé" : "accepté")}`;
},
};
const traceCount = new Map<string, number>();
const launchSubAgent = createSubAgentLauncher({
cwd: def.cwd,
homeDir: opts.homeDir,
settings,
getModel: () => model,
getSessionName: () => null,
store: null,
confirm: (message, agent) => askPermission(agent ?? parent, message),
info: (m) => say(parent, "info", m),
warn: (m) => say(parent, "warn", m),
timeoutMs: MAX_TIMER_MS,
...(deps.spawnChild ? { spawn: deps.spawnChild } : {}),
onAgentState: (e) => {
if (e.state === "running") book.start(e.agent, e.model);
else book.end(e.agent, e.state === "done", e.error);
say(e.agent, "state", AGENT_STATE_LABELS[e.state] + (e.error ? ` : ${e.error}` : ""));
},
onTranscript: (e) => {
const n = (traceCount.get(e.agent) ?? 0) + 1;
traceCount.set(e.agent, n);
wf.writeTrace(n === 1 ? e.agent : `${e.agent}-${n}`, { agent: e.agent, model: e.model, startedAt: e.startedAt, ok: e.ok, messages: e.messages });
},
});
// L'agent parent n'a pas d'interface : progression dans le journal, autorisations selon le mode
const ui: AgentUI = {
onThinking() {},
onText() {},
onTextEnd() {},
onToolCall: (name, summary) => say(parent, "tool", `${name} ${summary}`),
onToolResult: (name, output, isError) => {
if (isError) say(parent, "tool-error", `${name} : ${output.slice(0, 300)}`);
},
confirm: (message) => askPermission(parent, message),
};
let init: Awaited<ReturnType<typeof initializeOrchestrator>> | null = null;
let session: ReturnType<typeof createSession> | null = null;
try {
init = await initializeOrchestrator({
def,
homeDir: opts.homeDir,
launchSubAgent,
extraTools: [interventionTool],
log: (m) => say(parent, "info", m),
warn: (m) => say(parent, "warn", m),
});
book.start(parent, model, "parent");
for (const name of init.mentioned) book.declare(name, "sous-agent");
const provider = (deps.createProvider ?? createProvider)(model, settings);
session = createSession({ provider, init, ui, cwd: def.cwd });
const end = await runTurn(session, task, controller.signal, { maxIterations: init.maxIterations });
if (stopped.reason) throw new Error(stopped.reason);
let result = end === "done" ? finalAssistantText(session.messages) : "";
if (!result) result = await requestFinalSummary(session, controller.signal);
wf.writeResult(result);
book.end(parent, true);
book.closeAll("interrompu à la fin du workflow");
wf.updateWorkflow({ state: "done", endedAt: new Date().toISOString() });
say("workflow", "done", "Workflow terminé");
return { id: wf.id, state: "done", exitCode: 0, result };
} catch (err) {
const message = stopped.reason ?? (err as Error).message;
book.end(parent, false, message);
book.closeAll(message);
wf.updateWorkflow({ state: "error", error: message, endedAt: new Date().toISOString() });
say("workflow", "error", `Workflow en erreur : ${message}`);
return { id: wf.id, state: "error", exitCode: stopped.reason?.startsWith("arrêté") ? 130 : 1, result: "", error: message };
} finally {
clearInterval(stopTimer);
clearTimeout(timeoutTimer);
if (session) wf.writeTrace(parent, { agent: parent, model, messages: session.messages });
await init?.close().catch(() => undefined);
}
}