MagicCode/packages/core/src/tool-runner.ts

283 lines
12 KiB
TypeScript
Raw Normal View History

2026-10-18 23:02:44 +00:00
// 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";
2026-10-26 22:17:20 +00:00
import type { AgentUI, ContentBlock, ConfirmAnswer, Tool } from "./types";
2026-10-18 23:02:44 +00:00
export const MAX_PARALLEL_TOOLS = 8;
export type ToolUseBlock = Extract<ContentBlock, { type: "tool_use" }>;
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;
}
2026-10-26 22:17:20 +00:00
/** 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<ConfirmAnswer> =>
ui.ask ? ui.ask(message) : (await ui.confirm(message)) ? "yes" : "no";
2026-10-18 23:02:44 +00:00
// ───────────── 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<T>(fn: () => Promise<T>): Promise<T> {
if (this.active >= this.max) await new Promise<void>((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<string, Promise<unknown>>();
run<T>(key: string | undefined, fn: () => Promise<T>): Promise<T> {
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<Prepared> {
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";
2026-10-26 22:17:20 +00:00
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 };
}
2026-10-18 23:02:44 +00:00
}
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<RunResult> {
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 ─────────────
2026-10-26 22:17:20 +00:00
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;
}
2026-10-18 23:02:44 +00:00
/** 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<ContentBlock[]> {
2026-10-26 22:17:20 +00:00
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<ToolRun> {
2026-10-18 23:02:44 +00:00
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. */
2026-10-26 22:17:20 +00:00
async function runInForeground(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ToolRun> {
2026-10-18 23:02:44 +00:00
const { ui } = session;
const results: ContentBlock[] = [];
2026-10-26 22:17:20 +00:00
let stopped = false;
2026-10-18 23:02:44 +00:00
for (const call of calls) {
2026-10-26 22:17:20 +00:00
if (stopped) {
results.push(block(call, NOT_RUN_FOR_MODEL, true));
continue;
}
2026-10-18 23:02:44 +00:00
const prepared = await prepare(session, call, true);
2026-10-26 22:17:20 +00:00
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;
}
2026-10-18 23:02:44 +00:00
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));
}
2026-10-26 22:17:20 +00:00
return { results, stopped };
2026-10-18 23:02:44 +00:00
}
/** Deux appels ou plus : un tâche de fond par outil, puis attente de toutes avant de répondre au LLM. */
2026-10-26 22:17:20 +00:00
async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ToolRun> {
2026-10-18 23:02:44 +00:00
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<RunResult> | null)[] = [];
let done = 0;
const running: string[] = [];
const report = (): void => ui.onToolsRunning?.({ names: running, done });
let reporting = false;
2026-10-26 22:17:20 +00:00
let stopped = false;
2026-10-18 23:02:44 +00:00
for (const call of calls) {
const p = await prepare(session, call, false);
prepared.push(p);
2026-10-26 22:17:20 +00:00
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;
}
2026-10-18 23:02:44 +00:00
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<RunResult> => 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()) {
2026-10-26 22:17:20 +00:00
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") {
2026-10-18 23:02:44 +00:00
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));
}
}
2026-10-26 22:17:20 +00:00
return { results, stopped };
2026-10-18 23:02:44 +00:00
}