91 lines
3.9 KiB
TypeScript
91 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 };
|
||
|
|
}
|