// Exécution des outils demandés par le LLM. // // - UN seul appel d'outil : exécuté au premier plan, comme avant (annonce, confirmation éventuelle, exécution, résultat). // - DEUX appels ou plus dans la même réponse : chaque outil est lancé dans sa propre tâche asynchrone, en arrière-plan, // dès qu'il est autorisé ; l'agent attend la fin de TOUTES les tâches, puis renvoie les résultats au LLM dans l'ordre // où il les a demandés. Le raisonnement du LLM, lui, reste bloquant (l'intégrité du contexte l'exige). // // Précisions : // - Les tâches sont des tâches asynchrones Node (pas des threads du système) : les outils qui lancent des processus (Bash, // outils personnalisés) tournent réellement en parallèle, et les outils de fichiers sont asynchrones. // - Les confirmations restent posées UNE par UNE (l'interface ne gère qu'une question à la fois) ; un outil autorisé démarre // sans attendre les réponses suivantes. // - Les outils qui déclarent la même `lockKey` (ex. le même fichier) s'exécutent l'un après l'autre, dans l'ordre demandé : // deux Edit sur le même fichier ne s'écrasent pas. // - Au plus MAX_PARALLEL_TOOLS outils s'exécutent en même temps ; les suivants attendent une place. import type { AgentSession } from "./agent"; import type { AgentUI, ContentBlock, ConfirmAnswer, Tool } from "./types"; export const MAX_PARALLEL_TOOLS = 8; export type ToolUseBlock = Extract; interface RunResult { output: string; isError: boolean; } /** Appel refusé ou impossible : pas d'exécution (outil inconnu, règle deny, refus de l'utilisateur). */ interface Refused { kind: "refused"; /** Texte affiché à l'utilisateur ; null : rien à afficher (outil inconnu, déjà annoncé nulle part). */ display: string | null; /** Texte renvoyé au LLM. */ forModel: string; summary: string | null; } interface Approved { kind: "approved"; tool: Tool; summary: string; } /** L'utilisateur a répondu « stop » à la demande d'autorisation : l'action n'est pas exécutée et l'itération s'arrête. */ interface Stopped { kind: "stopped"; summary: string; } type Prepared = Refused | Approved | Stopped; /** Texte renvoyé au LLM pour l'action sur laquelle l'utilisateur a répondu « stop ». */ export const STOP_FOR_MODEL = "L'utilisateur a répondu « stop » : cette action n'a PAS été exécutée. Il interrompt le travail en cours pour donner des " + "informations complémentaires ou une autre demande. N'enchaîne pas d'autres actions : attends son prochain message."; /** Texte renvoyé au LLM pour les actions qui suivaient et n'ont pas été lancées. */ export const NOT_RUN_FOR_MODEL = "Action non exécutée : l'utilisateur a interrompu le travail (stop) avant celle-ci. Attends son prochain message."; const askUser = async (ui: AgentUI, message: string): Promise => ui.ask ? ui.ask(message) : (await ui.confirm(message)) ? "yes" : "no"; // ───────────── Outils de synchronisation ───────────── /** Limite le nombre de tâches simultanées (file d'attente FIFO). */ export class Limiter { private active = 0; private readonly waiting: (() => void)[] = []; constructor(private readonly max: number) {} async run(fn: () => Promise): Promise { if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve)); this.active++; try { return await fn(); } finally { this.active--; this.waiting.shift()?.(); } } } /** Exécute les tâches de même clé l'une après l'autre, dans l'ordre d'arrivée ; clé absente : aucune contrainte. */ export class KeyedQueue { private readonly tails = new Map>(); run(key: string | undefined, fn: () => Promise): Promise { if (!key) return fn(); const previous = this.tails.get(key) ?? Promise.resolve(); const result = previous.then(fn, fn); const tail = result.catch(() => undefined); this.tails.set(key, tail); void tail.then(() => { if (this.tails.get(key) === tail) this.tails.delete(key); }); return result; } } // ───────────── Autorisation et exécution d'un appel ───────────── /** * Décide si l'appel peut s'exécuter : outil connu, règles deny > ask > allow, confirmation de l'utilisateur. * @param announce annonce l'appel à l'utilisateur dès que son résumé est connu (mode premier plan) */ async function prepare(session: AgentSession, call: ToolUseBlock, announce: boolean): Promise { const { ui } = session; const tool = session.tools.get(call.name); if (!tool) return { kind: "refused", display: null, forModel: `Outil inconnu : ${call.name}`, summary: null }; const summary = tool.describe?.(call.input) ?? JSON.stringify(call.input).slice(0, 120); if (announce) ui.onToolCall(call.name, summary); // Les règles de l'agent (deny > ask > allow) priment sur la permission par défaut de l'outil const verdict = session.permissions?.check(call.name, call.input, { cwd: session.cwd }); if (verdict?.decision === "deny") { const why = verdict.reason ? ` (${verdict.reason})` : ""; return { kind: "refused", display: `Refusé par la configuration${why}`, forModel: `Action refusée par les règles de permissions${why}. Ne réessaie pas telle quelle.`, summary, }; } const needsConfirm = verdict ? verdict.decision === "ask" : tool.permission === "ask"; if (needsConfirm) { const answer = await askUser(ui, `Autoriser ${call.name} : ${summary} ?`); if (answer === "stop") return { kind: "stopped", summary }; if (answer === "no") { return { kind: "refused", display: "Refusé par l'utilisateur", forModel: "L'utilisateur a refusé cette action.", summary }; } } return { kind: "approved", tool, summary }; } /** Exécute l'outil ; une erreur devient un résultat d'erreur (elle n'interrompt ni le tour ni les autres outils). */ async function execute(session: AgentSession, call: ToolUseBlock, tool: Tool, signal?: AbortSignal): Promise { try { const output = await tool.execute(call.input, { cwd: session.cwd, model: session.provider.model, signal, env: session.env }); return { output, isError: false }; } catch (err) { return { output: (err as Error).message, isError: true }; } } function lockKeyOf(session: AgentSession, tool: Tool, call: ToolUseBlock): string | undefined { try { return tool.lockKey?.(call.input, { cwd: session.cwd }); } catch { return undefined; // entrée invalide : l'outil la refusera lui-même à l'exécution } } const block = (call: ToolUseBlock, content: string, isError: boolean): ContentBlock => ({ type: "tool_result", toolUseId: call.id, content, isError, }); // ───────────── Un tour d'outils ───────────── export interface ToolRun { /** Un résultat par appel, dans l'ordre des appels. */ results: ContentBlock[]; /** L'utilisateur a répondu « stop » à une demande d'autorisation : le tour s'arrête après ces résultats. */ stopped: boolean; } /** Exécute les appels d'outils d'une réponse du LLM et renvoie les résultats, dans l'ordre des appels. */ export async function runToolCalls(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise { return (await runToolCallsDetailed(session, calls, signal)).results; } /** Comme `runToolCalls`, et indique si l'utilisateur a demandé l'arrêt (« stop »). */ export async function runToolCallsDetailed(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise { return calls.length >= 2 ? runInParallel(session, calls, signal) : runInForeground(session, calls, signal); } /** Premier plan (0 ou 1 appel) : annonce, confirmation, exécution et résultat, l'un après l'autre. */ async function runInForeground(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise { const { ui } = session; const results: ContentBlock[] = []; let stopped = false; for (const call of calls) { if (stopped) { results.push(block(call, NOT_RUN_FOR_MODEL, true)); continue; } const prepared = await prepare(session, call, true); if (prepared.kind === "stopped") { stopped = true; ui.onToolResult(call.name, "Arrêt demandé (stop) : action non exécutée", true); results.push(block(call, STOP_FOR_MODEL, true)); continue; } if (prepared.kind === "refused") { if (prepared.display !== null) ui.onToolResult(call.name, prepared.display, true); results.push(block(call, prepared.forModel, true)); continue; } const { output, isError } = await execute(session, call, prepared.tool, signal); ui.onToolResult(call.name, output, isError); results.push(block(call, output, isError)); } return { results, stopped }; } /** Deux appels ou plus : un tâche de fond par outil, puis attente de toutes avant de répondre au LLM. */ async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise { const { ui } = session; const limiter = new Limiter(MAX_PARALLEL_TOOLS); const queue = new KeyedQueue(); // Phase 1 — dans l'ordre : autorisation (confirmations une par une) ; chaque outil autorisé démarre aussitôt en arrière-plan const prepared: Prepared[] = []; const tasks: (Promise | null)[] = []; let done = 0; const running: string[] = []; const report = (): void => ui.onToolsRunning?.({ names: running, done }); let reporting = false; let stopped = false; for (const call of calls) { const p = await prepare(session, call, false); prepared.push(p); if (p.kind === "stopped") { // « stop » : les outils déjà autorisés continuent et finissent ; les suivants ne sont pas lancés stopped = true; tasks.push(null); break; } if (p.kind === "refused") { tasks.push(null); continue; } running.push(call.name); const key = lockKeyOf(session, p.tool, call); tasks.push( limiter .run(() => queue.run(key, () => execute(session, call, p.tool, signal))) .then((r) => { done++; if (reporting) report(); return r; }), ); } // Phase 2 — attente de toutes les tâches (le spinner indique ce qui tourne et combien ont terminé) const pending = tasks.filter((t): t is Promise => t !== null); if (pending.length > 0 && done < pending.length) { reporting = true; report(); } const outcomes = await Promise.all(pending); if (reporting) ui.onToolsRunning?.(null); // Phase 3 — affichage et résultats pour le LLM, dans l'ordre des appels const results: ContentBlock[] = []; let next = 0; for (const [i, call] of calls.entries()) { const p = prepared[i]; if (!p) { results.push(block(call, NOT_RUN_FOR_MODEL, true)); // après le « stop » : jamais lancé } else if (p.kind === "stopped") { ui.onToolCall(call.name, p.summary); ui.onToolResult(call.name, "Arrêt demandé (stop) : action non exécutée", true); results.push(block(call, STOP_FOR_MODEL, true)); } else if (p.kind === "refused") { if (p.summary !== null) ui.onToolCall(call.name, p.summary); if (p.display !== null) ui.onToolResult(call.name, p.display, true); results.push(block(call, p.forModel, true)); } else { const { output, isError } = outcomes[next++]!; ui.onToolCall(call.name, p.summary); ui.onToolResult(call.name, output, isError); results.push(block(call, output, isError)); } } return { results, stopped }; }