MagicCode/packages/cli/src/workflow-run.ts
2026-10-27 00:45:53 +01:00

377 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// `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);
}
}