MagicCode/packages/core/src/agent.ts

160 lines
4.9 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
import type {
AgentSkill,
AgentUI,
ContentBlock,
LLMProvider,
Message,
ModeInitResult,
PermissionPolicy,
SlashCommand,
Tool,
} from "./types";
export interface AgentSession {
provider: LLMProvider;
system: string;
tools: Map<string, Tool>;
commands: SlashCommand[];
skills: AgentSkill[];
permissions?: PermissionPolicy;
env?: Record<string, string>;
messages: Message[];
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
pendingContext: string[];
2026-10-11 00:11:20 +00:00
ui: AgentUI;
cwd: string;
}
export function createSession(opts: {
provider: LLMProvider;
init: ModeInitResult;
ui: AgentUI;
cwd: string;
}): AgentSession {
return {
provider: opts.provider,
system: opts.init.systemPrompt,
tools: new Map(opts.init.tools.map((t) => [t.spec.name, t])),
commands: opts.init.commands ?? [],
skills: opts.init.skills ?? [],
permissions: opts.init.permissions,
env: opts.init.env,
messages: [],
pendingContext: [],
2026-10-11 00:11:20 +00:00
ui: opts.ui,
cwd: opts.cwd,
};
}
const MAX_ITERATIONS = 25;
type ToolUseBlock = Extract<ContentBlock, { type: "tool_use" }>;
/** Un tour complet : message utilisateur → (LLM → outils)* → réponse finale. */
export async function runTurn(
session: AgentSession,
userText: string,
signal?: AbortSignal,
): Promise<void> {
const { ui } = session;
// Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant)
const context = session.pendingContext.splice(0);
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
session.messages.push({ role: "user", content: [{ type: "text", text }] });
2026-10-11 00:11:20 +00:00
for (let i = 0; i < MAX_ITERATIONS; i++) {
const assistant: ContentBlock[] = [];
let text = "";
ui.onThinking(true);
let first = true;
try {
for await (const ev of session.provider.chat(
{
system: session.system,
messages: session.messages,
tools: [...session.tools.values()].map((t) => t.spec),
},
signal,
)) {
if (first) {
ui.onThinking(false);
first = false;
}
if (ev.type === "text_delta") {
text += ev.text;
ui.onText(ev.text);
} else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
}
// "done" : la fin du flux suffit, les appels d'outils décident de la suite
}
} finally {
if (first) ui.onThinking(false);
}
if (text) {
ui.onTextEnd();
assistant.unshift({ type: "text", text });
}
session.messages.push({ role: "assistant", content: assistant });
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return;
const results: ContentBlock[] = [];
for (const call of calls) {
results.push(await executeToolCall(session, call, signal));
}
session.messages.push({ role: "user", content: results });
}
session.ui.onToolResult("agent", `Arrêt : plus de ${MAX_ITERATIONS} itérations`, true);
}
async function executeToolCall(
session: AgentSession,
call: ToolUseBlock,
signal?: AbortSignal,
): Promise<ContentBlock> {
const { ui } = session;
const tool = session.tools.get(call.name);
const result = (content: string, isError = false): ContentBlock => ({
type: "tool_result",
toolUseId: call.id,
content,
isError,
});
if (!tool) return result(`Outil inconnu : ${call.name}`, true);
const summary = tool.describe?.(call.input) ?? JSON.stringify(call.input).slice(0, 120);
ui.onToolCall(call.name, summary);
// Les règles de l'agent (deny > ask > allow) priment sur la permission par défaut de l'outil
2026-10-11 00:11:20 +00:00
const verdict = session.permissions?.check(call.name, call.input, { cwd: session.cwd });
if (verdict?.decision === "deny") {
const why = verdict.reason ? ` (${verdict.reason})` : "";
ui.onToolResult(call.name, `Refusé par la configuration${why}`, true);
return result(`Action refusée par les règles de permissions${why}. Ne réessaie pas telle quelle.`, true);
}
const needsConfirm = verdict ? verdict.decision === "ask" : tool.permission === "ask";
if (needsConfirm) {
const ok = await ui.confirm(`Autoriser ${call.name} : ${summary} ?`);
if (!ok) {
ui.onToolResult(call.name, "Refusé par l'utilisateur", true);
return result("L'utilisateur a refusé cette action.", true);
}
}
try {
const output = await tool.execute(call.input, { cwd: session.cwd, model: session.provider.model, signal, env: session.env });
2026-10-11 00:11:20 +00:00
ui.onToolResult(call.name, output, false);
return result(output);
} catch (err) {
const msg = (err as Error).message;
ui.onToolResult(call.name, msg, true);
return result(msg, true);
}
}