MagicCode/packages/core/src/agent.ts

165 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-10-18 23:02:44 +00:00
import { estimateTextTokens, estimateTokens } from "./context";
import { runToolCalls, type ToolUseBlock } from "./tool-runner";
2026-10-11 00:11:20 +00:00
import type {
AgentSkill,
AgentUI,
ContentBlock,
LLMProvider,
Message,
ModeInitResult,
PermissionPolicy,
2026-10-15 21:49:58 +00:00
PermissionSource,
2026-10-11 00:11:20 +00:00
SlashCommand,
Tool,
} from "./types";
export interface AgentSession {
provider: LLMProvider;
system: string;
tools: Map<string, Tool>;
commands: SlashCommand[];
skills: AgentSkill[];
permissions?: PermissionPolicy;
2026-10-15 21:49:58 +00:00
/** Fichiers dont viennent les règles de permissions (affichage avec /permissions). */
permissionSources: PermissionSource[];
2026-10-11 00:11:20 +00:00
env?: Record<string, string>;
messages: Message[];
2026-10-18 23:02:44 +00:00
/** Résumé compacté qui sert de contexte de départ (présent dans `system`) ; absent : contexte MAIA.md / CLAUDE.md. */
startContext?: string;
2026-10-15 22:54:08 +00:00
/** Taille du contexte (tokens) après la dernière réponse du LLM : valeur renvoyée par le serveur, sinon estimation. */
contextTokens: number;
2026-10-18 23:02:44 +00:00
/** true : contextTokens vient du serveur (exact) ; false : estimation. */
contextExact: boolean;
/** Facteur de calibration de l'estimation (tokens réels / tokens estimés), mesuré à chaque réponse du serveur. */
tokenScale: number;
/** 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;
2026-10-18 23:02:44 +00:00
startContext?: string;
2026-10-11 00:11:20 +00:00
}): 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,
2026-10-15 22:54:08 +00:00
permissionSources: opts.init.permissionSources ?? [],
2026-10-11 00:11:20 +00:00
env: opts.init.env,
messages: [],
2026-10-18 23:02:44 +00:00
startContext: opts.startContext,
2026-10-15 22:54:08 +00:00
contextTokens: 0,
2026-10-18 23:02:44 +00:00
contextExact: false,
tokenScale: 1,
pendingContext: [],
2026-10-11 00:11:20 +00:00
ui: opts.ui,
cwd: opts.cwd,
};
}
const MAX_ITERATIONS = 25;
2026-10-18 23:02:44 +00:00
const MIN_CALIBRATION_TOKENS = 200; // en dessous, le rapport réel/estimé est trop instable pour calibrer
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
2026-10-11 00:11:20 +00:00
/** 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 = "";
2026-10-15 22:54:08 +00:00
let usedTokens: number | null = null;
2026-10-18 23:02:44 +00:00
const sentEstimate = rawSessionEstimate(session); // taille estimée de la requête, pour calibrer l'estimation
2026-10-11 00:11:20 +00:00
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 });
2026-10-15 22:54:08 +00:00
} else if (ev.type === "usage") {
// Requête + réponse : c'est la taille du contexte une fois la réponse ajoutée à la conversation
usedTokens = ev.inputTokens + ev.outputTokens;
2026-10-18 23:02:44 +00:00
if (sentEstimate >= MIN_CALIBRATION_TOKENS && ev.inputTokens > 0) {
session.tokenScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, ev.inputTokens / sentEstimate));
}
2026-10-11 00:11:20 +00:00
}
// "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 });
2026-10-15 22:54:08 +00:00
session.contextTokens = usedTokens ?? estimateSessionTokens(session);
2026-10-18 23:02:44 +00:00
session.contextExact = usedTokens !== null;
ui.onContextUsage?.(session.contextTokens, session.contextExact);
2026-10-11 00:11:20 +00:00
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return;
2026-10-18 23:02:44 +00:00
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
const results = await runToolCalls(session, calls, signal);
2026-10-11 00:11:20 +00:00
session.messages.push({ role: "user", content: results });
}
session.ui.onToolResult("agent", `Arrêt : plus de ${MAX_ITERATIONS} itérations`, true);
}
2026-10-18 23:02:44 +00:00
type Estimable = Pick<AgentSession, "system" | "messages" | "tools">;
2026-10-11 00:11:20 +00:00
2026-10-18 23:02:44 +00:00
function rawSessionEstimate(session: Estimable): number {
return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec));
}
2026-10-11 00:11:20 +00:00
2026-10-18 23:02:44 +00:00
/** Estimation calibrée du contexte de la conversation (prompt système, outils, messages), sans le contexte en attente. */
export function estimateSessionTokens(session: Estimable & Pick<AgentSession, "tokenScale">): number {
return Math.round(rawSessionEstimate(session) * session.tokenScale);
2026-10-11 00:11:20 +00:00
}
2026-10-15 22:54:08 +00:00
2026-10-18 23:02:44 +00:00
/**
* Taille du contexte de la session en cours : dernier compte connu (serveur ou estimation) + contexte en attente
* (résultats de commandes « ! », fichiers cités) qui sera joint à la prochaine demande.
*/
export function sessionContextTokens(
session: Pick<AgentSession, "contextTokens" | "pendingContext" | "tokenScale">,
): number {
const pending = session.pendingContext.reduce((sum, text) => sum + estimateTextTokens(text), 0);
return session.contextTokens + Math.round(pending * session.tokenScale);
2026-10-15 22:54:08 +00:00
}