Mise à jour locale des fichiers de travail
This commit is contained in:
parent
7a279a6065
commit
6ecf78a04c
14 changed files with 173 additions and 79 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ dist
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.maia
|
.maia
|
||||||
HOW_To_Agents.md
|
|
||||||
resume.md
|
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,8 @@ L'appel est **bloquant** (pas de tâche de fond). Ctrl+C l'annule sans rien modi
|
||||||
|
|
||||||
Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`.
|
Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`.
|
||||||
|
|
||||||
|
**Contexte refusé par le serveur** : si LiteLLM répond « Input is too long » (ou équivalent), MAIA compacte automatiquement la conversation (résultats d'outils tronqués à 1 500 caractères dans la demande de résumé, pour qu'elle tienne), puis reprend la demande une seule fois. Si le compactage échoue aussi, l'erreur d'origine est affichée (utilisez alors `/new`). Les sous-agents n'ont pas ce mécanisme.
|
||||||
|
|
||||||
## Sous-agents (`./.maia/agents`)
|
## Sous-agents (`./.maia/agents`)
|
||||||
|
|
||||||
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
||||||
|
|
@ -423,3 +425,8 @@ Journal des évolutions, dans l'ordre :
|
||||||
31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux.
|
31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux.
|
||||||
32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu).
|
32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu).
|
||||||
33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`.
|
33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`.
|
||||||
|
34. **Compactage sur « Input is too long »** : réponse du serveur reconnue (`isContextTooLongError`), compactage d'urgence (résultats d'outils tronqués dans la demande de résumé), reprise de la demande une fois.
|
||||||
|
35. **Barre de contexte pendant le travail de l'agent** : taille recomptée à chaque itération (après la demande, chaque réponse du LLM et chaque série de résultats d'outils) et barre affichée pendant les itérations, plus seulement à la saisie.
|
||||||
|
36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré.
|
||||||
|
37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés.
|
||||||
|
38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende.
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,8 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio
|
||||||
|
|
||||||
Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`.
|
Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`.
|
||||||
|
|
||||||
|
Pendant un tour, `runTurn` appelle `onContextUsage` après l'ajout de la demande, après chaque réponse du LLM et après chaque série de résultats d'outils ; la barre est affichée en permanence en bas de l'écran (saisie, itérations, outils, confirmations, listes) ; taille maximale inconnue : « N tokens · taille maximale inconnue », sans carrés.
|
||||||
|
|
||||||
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
|
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
|
||||||
|
|
||||||
### 14.1 Principe
|
### 14.1 Principe
|
||||||
|
|
@ -469,6 +471,10 @@ En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inch
|
||||||
|
|
||||||
Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé.
|
Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé.
|
||||||
|
|
||||||
|
### 14.5 Contexte refusé par le serveur
|
||||||
|
|
||||||
|
`isContextTooLongError(err)` reconnaît les erreurs « input is too long », « prompt is too long », « context_length_exceeded », « maximum context length »… Dans la boucle de `cli/src/index.ts`, hors interruption et une seule fois par demande : avertissement, `compactNow({ emergency: true })` (la demande de résumé utilise `shrinkToolResults(messages, EMERGENCY_TOOL_RESULT_CHARS = 1500)` pour tenir dans la fenêtre ; la session d'origine est sauvegardée avant), puis la demande est relancée avec une consigne de reprise sur la conversation compactée. Si le compactage échoue, l'erreur d'origine est affichée. Non couvert : sous-agents.
|
||||||
|
|
||||||
## 15. Interface (`packages/ui`)
|
## 15. Interface (`packages/ui`)
|
||||||
|
|
||||||
### 15.1 Architecture
|
### 15.1 Architecture
|
||||||
|
|
@ -506,6 +512,8 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
|
||||||
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter |
|
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter |
|
||||||
| Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
|
| Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
|
||||||
|
|
||||||
|
|
||||||
|
La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie active, grisée « Traitement en cours » pendant un tour (masquée à l'arrêt du programme), ou demande d'autorisation `ASK … (o/n)` (réponse `o`/`y` ou `n`/Échap/Ctrl+C). Les listes de choix (`/model`, `/load`) remplacent la zone le temps du choix. Au-dessus, une ligne d'état est toujours réservée (spinner « En réflexion », outils en cours ; vide sinon) : la zone de saisie garde la même position. La barre de contexte est toujours en dessous.
|
||||||
## 16. Fichiers et variables
|
## 16. Fichiers et variables
|
||||||
|
|
||||||
| Chemin / variable | Rôle |
|
| Chemin / variable | Rôle |
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Les Mystères de l'Univers Numérique
|
|
||||||
|
|
||||||
## Architecture Décentralisée et Résilience des Systèmes
|
|
||||||
|
|
||||||
Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
|
|
||||||
|
|
||||||
## Innovation Quantique et Calcul de Demain
|
|
||||||
|
|
||||||
La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
|
|
||||||
|
|
||||||
## Sécurité Cryptographique et Protection des Données
|
|
||||||
|
|
||||||
La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
|
|
||||||
|
|
||||||
## Intelligence Artificielle et Apprentissage Profond
|
|
||||||
|
|
||||||
Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
|
|
||||||
|
|
||||||
## Durabilité Écologique du Développement Logiciel
|
|
||||||
|
|
||||||
La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
|
|
||||||
|
|
||||||
## Collaboration Distribuée et Télétravail Technologique
|
|
||||||
|
|
||||||
Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
|
|
||||||
|
|
||||||
## Automatisation DevOps et Intégration Continue
|
|
||||||
|
|
||||||
Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
|
|
||||||
|
|
||||||
## Microservices et Architecture Modulaire
|
|
||||||
|
|
||||||
L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.
|
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
createSession,
|
createSession,
|
||||||
ensureMaiaHome,
|
ensureMaiaHome,
|
||||||
findSkill,
|
findSkill,
|
||||||
|
EMERGENCY_TOOL_RESULT_CHARS,
|
||||||
isCompactable,
|
isCompactable,
|
||||||
|
isContextTooLongError,
|
||||||
normalizeSessionName,
|
normalizeSessionName,
|
||||||
parseShellInvocation,
|
parseShellInvocation,
|
||||||
resolveFileReferences,
|
resolveFileReferences,
|
||||||
|
|
@ -327,7 +329,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ;
|
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ;
|
||||||
* 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
|
* 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
|
||||||
*/
|
*/
|
||||||
const compactNow = async (): Promise<boolean> => {
|
const compactNow = async (opts: { emergency?: boolean } = {}): Promise<boolean> => {
|
||||||
if (!isCompactable(session)) {
|
if (!isCompactable(session)) {
|
||||||
ui.info("Rien à compacter : la conversation est vide.");
|
ui.info("Rien à compacter : la conversation est vide.");
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -337,7 +339,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
ui.setBusy("Compactage de la conversation…");
|
ui.setBusy("Compactage de la conversation…");
|
||||||
try {
|
try {
|
||||||
saveSession(); // la session d'origine reste complète dans son fichier
|
saveSession(); // la session d'origine reste complète dans son fichier
|
||||||
const { summary, truncated } = await requestCompactSummary(session, controller.signal);
|
const { summary, truncated } = await requestCompactSummary(
|
||||||
|
session,
|
||||||
|
controller.signal,
|
||||||
|
// Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre
|
||||||
|
opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {},
|
||||||
|
);
|
||||||
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
|
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
|
||||||
session = fresh;
|
session = fresh;
|
||||||
let target = "";
|
let target = "";
|
||||||
|
|
@ -583,7 +590,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
await autoCompactIfNeeded();
|
await autoCompactIfNeeded();
|
||||||
|
|
||||||
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
|
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
|
||||||
const mark = session.messages.length;
|
let mark = session.messages.length;
|
||||||
const contextBackup = [...session.pendingContext];
|
const contextBackup = [...session.pendingContext];
|
||||||
|
|
||||||
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
|
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
|
||||||
|
|
@ -601,20 +608,39 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
ui.setInterruptHandler(() => controller.abort());
|
ui.setInterruptHandler(() => controller.abort());
|
||||||
try {
|
let attempt = prompt;
|
||||||
await runTurn(session, prompt, controller.signal);
|
let retried = false;
|
||||||
} catch (err) {
|
for (;;) {
|
||||||
if (controller.signal.aborted) {
|
try {
|
||||||
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
await runTurn(session, attempt, controller.signal);
|
||||||
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
break;
|
||||||
showContext(session);
|
} catch (err) {
|
||||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
if (controller.signal.aborted) {
|
||||||
} else {
|
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
||||||
|
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
||||||
|
showContext(session);
|
||||||
|
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!retried && isContextTooLongError(err)) {
|
||||||
|
// Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois
|
||||||
|
retried = true;
|
||||||
|
ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande.");
|
||||||
|
const compacted = await compactNow({ emergency: true });
|
||||||
|
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
|
||||||
|
if (compacted) {
|
||||||
|
mark = 0; // nouvelle conversation (résumé comme contexte de départ)
|
||||||
|
attempt =
|
||||||
|
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
|
||||||
|
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
ui.error((err as Error).message);
|
ui.error((err as Error).message);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
ui.setInterruptHandler(null);
|
|
||||||
}
|
}
|
||||||
|
ui.setInterruptHandler(null);
|
||||||
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
|
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
|
||||||
await autoCompactIfNeeded();
|
await autoCompactIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,11 +182,12 @@ describe("taille du contexte", () => {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
await runTurn(session, "go");
|
await runTurn(session, "go");
|
||||||
expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale)
|
// mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale
|
||||||
|
expect(seen).toHaveLength(4);
|
||||||
expect(seen[0]).toBeGreaterThan(0);
|
expect(seen[0]).toBeGreaterThan(0);
|
||||||
expect(seen[1]).toBeGreaterThan(seen[0]!);
|
for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
|
||||||
expect(seen[1]).toBe(sessionContextTokens(session));
|
expect(seen.at(-1)).toBe(sessionContextTokens(session));
|
||||||
expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
|
expect(seen.at(-1)!).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
|
||||||
});
|
});
|
||||||
|
|
||||||
it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => {
|
it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export async function runTurn(
|
||||||
const context = session.pendingContext.splice(0);
|
const context = session.pendingContext.splice(0);
|
||||||
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
|
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
|
||||||
session.messages.push({ role: "user", content: [{ type: "text", text }] });
|
session.messages.push({ role: "user", content: [{ type: "text", text }] });
|
||||||
|
ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens
|
||||||
|
|
||||||
for (let i = 0; i < maxIterations; i++) {
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
const assistant: ContentBlock[] = [];
|
const assistant: ContentBlock[] = [];
|
||||||
|
|
@ -138,6 +139,7 @@ export async function runTurn(
|
||||||
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
|
// 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);
|
const results = await runToolCalls(session, calls, signal);
|
||||||
session.messages.push({ role: "user", content: results });
|
session.messages.push({ role: "user", content: results });
|
||||||
|
ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante
|
||||||
}
|
}
|
||||||
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
|
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
|
||||||
return "max_iterations";
|
return "max_iterations";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createSession } from "./agent";
|
import { createSession } from "./agent";
|
||||||
import { COMPACT_PROMPT, compactThreshold, isCompactable, requestCompactSummary, shouldCompact } from "./compact";
|
import { COMPACT_PROMPT, compactThreshold, isCompactable, isContextTooLongError, requestCompactSummary, shouldCompact, shrinkToolResults } from "./compact";
|
||||||
import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions";
|
import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions";
|
||||||
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
|
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
|
||||||
|
|
||||||
|
|
@ -190,3 +190,37 @@ describe("persistance d'une session compactée", () => {
|
||||||
expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false);
|
expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("contexte refusé par le serveur", () => {
|
||||||
|
it("reconnaît les messages « entrée trop longue » des principaux serveurs", () => {
|
||||||
|
const msg = 'Échec (HTTP 400). {"error":{"code":"UPSTREAM_LLM_ERROR","message":"{\\"message\\":\\"Input is too long for requested model.\\"}"}}';
|
||||||
|
expect(isContextTooLongError(new Error(msg))).toBe(true);
|
||||||
|
expect(isContextTooLongError(new Error("prompt is too long: 250000 tokens > 200000 maximum"))).toBe(true);
|
||||||
|
expect(isContextTooLongError(new Error("This model's maximum context length is 128000 tokens"))).toBe(true);
|
||||||
|
expect(isContextTooLongError(new Error("context_length_exceeded"))).toBe(true);
|
||||||
|
expect(isContextTooLongError(new Error("HTTP 401 Unauthorized"))).toBe(false);
|
||||||
|
});
|
||||||
|
it("shrinkToolResults : tronque les gros résultats d'outils sans toucher aux originaux", () => {
|
||||||
|
const big = "x".repeat(5000);
|
||||||
|
const original: Message[] = [
|
||||||
|
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: big }, { type: "text", text: "garde" }] },
|
||||||
|
];
|
||||||
|
const out = shrinkToolResults(original, 100);
|
||||||
|
const block = out[0]!.content[0] as { content: string };
|
||||||
|
expect(block.content.length).toBeLessThan(200);
|
||||||
|
expect(block.content).toContain("tronqué");
|
||||||
|
expect(out[0]!.content[1]).toEqual({ type: "text", text: "garde" });
|
||||||
|
expect((original[0]!.content[0] as { content: string }).content).toBe(big);
|
||||||
|
});
|
||||||
|
it("requestCompactSummary en urgence : la requête de résumé contient des résultats tronqués", async () => {
|
||||||
|
const seen: ChatRequest[] = [];
|
||||||
|
const s = sessionWith(providerReplying([{ type: "text_delta", text: "## R" }, { type: "done", stopReason: "end_turn" }], seen), [
|
||||||
|
user("q"),
|
||||||
|
{ role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] },
|
||||||
|
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "y".repeat(9000) }] },
|
||||||
|
]);
|
||||||
|
await requestCompactSummary(s, undefined, { shrinkToolResults: 500 });
|
||||||
|
expect(JSON.stringify(seen[0]!.messages).length).toBeLessThan(3000);
|
||||||
|
expect((s.messages[2]!.content[0] as { content: string }).content.length).toBe(9000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { AgentSession } from "./agent";
|
import type { AgentSession } from "./agent";
|
||||||
import type { Message } from "./types";
|
import type { ContentBlock, Message } from "./types";
|
||||||
|
|
||||||
// Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ
|
// 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é ;
|
// est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ;
|
||||||
|
|
@ -51,15 +51,41 @@ export function isCompactable(session: Pick<AgentSession, "messages" | "pendingC
|
||||||
return session.messages.length > 0 || session.pendingContext.length > 0;
|
return session.messages.length > 0 || session.pendingContext.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Réponse du serveur « entrée trop longue » (HTTP 400 : Anthropic/Bedrock, OpenAI, LiteLLM…) : le contexte dépasse la fenêtre du modèle. */
|
||||||
|
export function isContextTooLongError(err: unknown): boolean {
|
||||||
|
const text = err instanceof Error ? err.message : String(err);
|
||||||
|
return /input is too long|prompt is too long|too long for requested model|context[_ ]length[_ ]exceeded|maximum context length|exceeds? the (maximum )?context|context window/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de caractères conservés par résultat d'outil quand le compactage est lancé en urgence (contexte déjà saturé). */
|
||||||
|
export const EMERGENCY_TOOL_RESULT_CHARS = 1500;
|
||||||
|
|
||||||
|
/** Copie des messages dont les résultats d'outils sont tronqués à `max` caractères (les originaux ne sont pas modifiés). */
|
||||||
|
export function shrinkToolResults(messages: Message[], max: number): Message[] {
|
||||||
|
return messages.map((m) => ({
|
||||||
|
...m,
|
||||||
|
content: m.content.map((b): ContentBlock =>
|
||||||
|
b.type === "tool_result" && b.content.length > max
|
||||||
|
? { ...b, content: `${b.content.slice(0, max)}… (tronqué pour le compactage)` }
|
||||||
|
: b,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée :
|
* 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).
|
* 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.
|
* Lève une erreur si le résumé est vide.
|
||||||
*/
|
*/
|
||||||
export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise<CompactSummary> {
|
export async function requestCompactSummary(
|
||||||
|
session: AgentSession,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
opts: { shrinkToolResults?: number } = {},
|
||||||
|
): Promise<CompactSummary> {
|
||||||
// Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer
|
// 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 request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n");
|
||||||
const messages: Message[] = [...session.messages];
|
const messages: Message[] =
|
||||||
|
opts.shrinkToolResults !== undefined ? shrinkToolResults(session.messages, opts.shrinkToolResults) : [...session.messages];
|
||||||
const last = messages.at(-1);
|
const last = messages.at(-1);
|
||||||
if (last?.role === "user") {
|
if (last?.role === "user") {
|
||||||
// L'alternance user/assistant doit être respectée : on complète le dernier message
|
// L'alternance user/assistant doit être respectée : on complète le dernier message
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,15 @@ const DOTTED_BORDER = {
|
||||||
right: "┊",
|
right: "┊",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */
|
||||||
|
function InputFrame({ color, children }: { color: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
|
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
|
||||||
|
|
||||||
function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
||||||
|
|
@ -254,14 +263,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
||||||
const { value, cursor } = view;
|
const { value, cursor } = view;
|
||||||
const at = value[cursor] ?? " ";
|
const at = value[cursor] ?? " ";
|
||||||
return (
|
return (
|
||||||
<Box
|
<InputFrame color="greenBright">
|
||||||
marginTop={1}
|
|
||||||
borderStyle={DOTTED_BORDER}
|
|
||||||
borderColor="greenBright"
|
|
||||||
borderLeft={false}
|
|
||||||
borderRight={false}
|
|
||||||
paddingX={1}
|
|
||||||
>
|
|
||||||
<Text color={INPUT_COLOR} bold>
|
<Text color={INPUT_COLOR} bold>
|
||||||
{`${frame} `}
|
{`${frame} `}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
||||||
{value.slice(cursor + 1)}
|
{value.slice(cursor + 1)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</InputFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
|
||||||
|
function WaitingInput() {
|
||||||
|
return (
|
||||||
|
<InputFrame color="gray">
|
||||||
|
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
|
||||||
|
</InputFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,15 +349,16 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
|
||||||
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
|
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
|
||||||
// Entrée seule est ignorée : une action sensible exige une réponse explicite.
|
// Entrée seule est ignorée : une action sensible exige une réponse explicite.
|
||||||
});
|
});
|
||||||
|
// La demande d'autorisation prend la place de la zone de saisie
|
||||||
return (
|
return (
|
||||||
<Box marginTop={1}>
|
<InputFrame color="yellow">
|
||||||
<Box marginRight={1} flexShrink={0}>
|
<Box marginRight={1} flexShrink={0}>
|
||||||
<Badge color="green">ASK</Badge>
|
<Badge color="green">ASK</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
<Text>
|
<Text>
|
||||||
{prompt.message} <Text dimColor>(o/n)</Text>
|
{prompt.message} <Text dimColor>(o/n)</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</InputFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,7 +368,6 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState })
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<InputPrompt prompt={prompt} />
|
<InputPrompt prompt={prompt} />
|
||||||
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
|
|
@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) {
|
||||||
<Text>{state.streaming}</Text>
|
<Text>{state.streaming}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{state.busy ? (
|
{/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1} height={1}>
|
||||||
<Spinner label={state.busy} />
|
{state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
|
||||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null}
|
{/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
|
||||||
|
<ContextBar used={state.contextUsed} max={state.contextMax} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,16 @@ import { contextBarModel, formatTokens } from "./context-model";
|
||||||
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
|
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
|
||||||
const EMPTY_COLOR = "#555555";
|
const EMPTY_COLOR = "#555555";
|
||||||
|
|
||||||
export function ContextBar({ used, max }: { used: number; max: number }) {
|
export function ContextBar({ used, max }: { used: number; max: number | null }) {
|
||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
|
if (max === null) {
|
||||||
|
// Taille maximale inconnue : pas de carrés, seulement le contexte utilisé
|
||||||
|
return (
|
||||||
|
<Box justifyContent="flex-end" paddingRight={1}>
|
||||||
|
<Text dimColor>{`${formatTokens(used)} tokens · taille maximale inconnue`}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
const columns = stdout?.columns ?? 80;
|
const columns = stdout?.columns ?? 80;
|
||||||
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
|
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
|
||||||
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export class InkUI implements AgentUI {
|
||||||
const app = this.app;
|
const app = this.app;
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
this.app = null;
|
this.app = null;
|
||||||
|
this.store.setClosing();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages
|
await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages
|
||||||
const exited = app.waitUntilExit(); // à créer AVANT unmount()
|
const exited = app.waitUntilExit(); // à créer AVANT unmount()
|
||||||
app.unmount();
|
app.unmount();
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,15 @@ export interface UIState {
|
||||||
contextUsed: number;
|
contextUsed: number;
|
||||||
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
|
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
|
||||||
contextMax: number | null;
|
contextMax: number | null;
|
||||||
|
/** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */
|
||||||
|
closing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESULT_PREVIEW_MAX = 600;
|
const RESULT_PREVIEW_MAX = 600;
|
||||||
const SHELL_PREVIEW_MAX = 4000;
|
const SHELL_PREVIEW_MAX = 4000;
|
||||||
|
|
||||||
export class UIStore {
|
export class UIStore {
|
||||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
|
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false };
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private history: string[] = [];
|
private history: string[] = [];
|
||||||
|
|
@ -99,6 +101,10 @@ export class UIStore {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setClosing(): void {
|
||||||
|
this.commit({ closing: true });
|
||||||
|
}
|
||||||
|
|
||||||
private commit(patch: Partial<UIState>): void {
|
private commit(patch: Partial<UIState>): void {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const l of this.listeners) l();
|
for (const l of this.listeners) l();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,2 @@
|
||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
allowBuilds:
|
|
||||||
esbuild: true
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue