// `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// (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(); 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 { 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 { 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>; 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 => { 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(); 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> | null = null; let session: ReturnType | 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); } }