225 lines
9.1 KiB
TypeScript
225 lines
9.1 KiB
TypeScript
// 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 { ContentBlock, Tool } from "./types";
|
|
|
|
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;
|
|
}
|
|
|
|
type Prepared = Refused | Approved;
|
|
|
|
// ───────────── 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";
|
|
if (needsConfirm && !(await ui.confirm(`Autoriser ${call.name} : ${summary} ?`))) {
|
|
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<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 ─────────────
|
|
|
|
/** 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[]> {
|
|
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<ContentBlock[]> {
|
|
const { ui } = session;
|
|
const results: ContentBlock[] = [];
|
|
for (const call of calls) {
|
|
const prepared = await prepare(session, call, true);
|
|
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;
|
|
}
|
|
|
|
/** 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<ContentBlock[]> {
|
|
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;
|
|
|
|
for (const call of calls) {
|
|
const p = await prepare(session, call, false);
|
|
prepared.push(p);
|
|
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()) {
|
|
const p = prepared[i]!;
|
|
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;
|
|
}
|