MagicCode/packages/core/src/compact.ts
2026-10-19 01:02:44 +02:00

90 lines
3.9 KiB
TypeScript

import type { AgentSession } from "./agent";
import type { Message } from "./types";
// Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ
// est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ;
// la nouvelle session (prompt système, persistance « nom__N ») est construite par l'application.
/** Prompt envoyé au LLM pour obtenir le résumé. */
export const COMPACT_PROMPT = `Faire un résumé compacté de nos échanges. Retourne le résultat sous le format suivant :
## Contexte du projet
- [Description brève du projet]
## Objectif actuel
- [Ce qu'on cherche à faire]
## Fichiers pertinents
- [Liste des fichiers importants]
## Décisions/Conclusions
- [Points clés établis]
## Prochaines étapes
- [Ce qui reste à faire]`;
/** Nombre maximal de tokens que le LLM peut utiliser pour écrire le résumé. */
export const COMPACT_SUMMARY_TOKENS = 4000;
/** Marge d'erreur (estimation des tokens). */
export const COMPACT_MARGIN_TOKENS = 500;
/** Seuil de déclenchement automatique : Seuil_max = Max_Token_Context - (4000 + 500). */
export function compactThreshold(maxContextTokens: number): number {
return maxContextTokens - (COMPACT_SUMMARY_TOKENS + COMPACT_MARGIN_TOKENS);
}
/** Vrai si la taille du contexte franchit le seuil (jamais si le seuil n'a pas de sens, ex. fenêtre minuscule). */
export function shouldCompact(contextTokens: number, maxContextTokens: number | null): boolean {
if (maxContextTokens === null) return false;
const threshold = compactThreshold(maxContextTokens);
return threshold > 0 && contextTokens > threshold;
}
export interface CompactSummary {
/** Résumé au format demandé. */
summary: string;
/** true si le LLM a été coupé par la limite de 4000 tokens (le résumé peut être incomplet). */
truncated: boolean;
}
/** Rien à résumer : conversation vide et aucun contexte en attente. */
export function isCompactable(session: Pick<AgentSession, "messages" | "pendingContext">): boolean {
return session.messages.length > 0 || session.pendingContext.length > 0;
}
/**
* Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée :
* c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu).
* Lève une erreur si le résumé est vide.
*/
export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise<CompactSummary> {
// Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer
const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n");
const messages: Message[] = [...session.messages];
const last = messages.at(-1);
if (last?.role === "user") {
// L'alternance user/assistant doit être respectée : on complète le dernier message
messages[messages.length - 1] = { role: "user", content: [...last.content, { type: "text", text: request }] };
} else {
messages.push({ role: "user", content: [{ type: "text", text: request }] });
}
let summary = "";
let truncated = false;
for await (const ev of session.provider.chat(
{
// Les outils restent déclarés (l'historique contient des appels d'outils) mais ne doivent pas être utilisés
system: `${session.system}\n\nPour cette demande, n'appelle aucun outil : réponds uniquement par le résumé demandé.`,
messages,
tools: [...session.tools.values()].map((t) => t.spec),
maxTokens: COMPACT_SUMMARY_TOKENS,
},
signal,
)) {
if (ev.type === "text_delta") summary += ev.text;
else if (ev.type === "done" && ev.stopReason === "max_tokens") truncated = true;
}
summary = summary.trim();
if (!summary) throw new Error("Le modèle a renvoyé un résumé vide.");
return { summary, truncated };
}