Update .gitignore and add SPECS_TECH.md with project modifications
- Add HOW_To_Agents.md, generated_page.md, llm_avis.md to .gitignore - Add SPECS_TECH.md technical specifications - Add test files for bash and write tools - Update various core, agent, provider and UI components
This commit is contained in:
parent
fe094c118e
commit
9f3badd79d
25 changed files with 1162 additions and 241 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@ dist
|
|||
*.log
|
||||
.env
|
||||
.maia
|
||||
HOW_To_Agents.md
|
||||
generated_page.md
|
||||
llm_avis.md
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -49,6 +49,8 @@ Résolution du modèle (pas de défaut : s'il n'est pas défini, il est demandé
|
|||
|
||||
Il n'y a plus de notion de mode ni d'extensions : un seul agent, MAIA.
|
||||
|
||||
Spécifications techniques détaillées (contrats, formats, constantes) : [`SPECS_TECH.md`](SPECS_TECH.md).
|
||||
|
||||
## Commandes
|
||||
|
||||
| Saisie | Effet |
|
||||
|
|
@ -89,7 +91,7 @@ Les couleurs sont dans `packages/ui/src/colors.ts`. Une fois la réponse validé
|
|||
|
||||
Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`).
|
||||
- **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier.
|
||||
- **Contexte utilisé** = tokens de tout ce que la session enverrait au modèle : prompt système (MAIA.md…), outils, messages, et contexte en attente (résultats de commandes `!`, ajoutés à la barre dès leur exécution). Après chaque réponse du LLM (donc aussi après chaque appel d'outil intermédiaire), la valeur est celle renvoyée par LiteLLM (`stream_options.include_usage`) : tokens de la requête + tokens de la réponse conservés dans la conversation (les tokens de raisonnement, non renvoyés au tour suivant, sont exclus). Entre deux réponses (démarrage, `/clear`, `/new`, `/load`, tour interrompu, commande `!`), la valeur est une estimation, signalée par un `~` devant le nombre ; elle est calibrée à chaque réponse sur le rapport réel/estimé mesuré par le serveur.
|
||||
- **Contexte utilisé** = tokens de tout ce que la session enverrait au modèle : prompt système (MAIA.md…), définition des outils, messages (texte, appels d'outils et leurs résultats, +4 tokens de structure par message) et contexte en attente (résultats de commandes `!`, fichiers `@`). Le calcul est **local et déterministe**, avec [js-tiktoken](https://github.com/dqbd/tiktoken) (vocabulaire `o200k_base`) ; la consommation renvoyée par le serveur n'est plus utilisée, car elle faisait varier la valeur. C'est une **somme** des comptes de chaque élément : la valeur ne fait qu'augmenter quand la conversation s'enrichit, et ne diminue que si le contexte est réduit (`/clear`, `/compact`, tour interrompu, autre session). Le comptage est refait **avant chaque saisie** de l'utilisateur (et après chaque réponse du LLM) ; le compte de chaque message est mémorisé, donc le recalcul reste instantané. Pour les modèles qui n'utilisent pas ce vocabulaire (Claude, Mistral…), la valeur est une bonne approximation, pas le compte exact du serveur.
|
||||
|
||||
La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit.
|
||||
À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`).
|
||||
|
|
@ -184,6 +186,9 @@ Au démarrage, le fichier **`~/.maia/settings.json`** (dossier de l'utilisateur,
|
|||
- `OpenAI_EndPoint` : suffixe ajouté à `LiteLLM_URL` pour envoyer une requête au format OpenAI.
|
||||
- `LiteLLM_Models` : suffixe ajouté à `LiteLLM_URL` pour récupérer la liste des modèles disponibles.
|
||||
- Optionnel : `LiteLLM_API_Key` (ou variable d'environnement `LITELLM_API_KEY`), envoyée en `Authorization: Bearer …`. Ce fichier peut donc contenir un secret : ne pas le versionner.
|
||||
- Optionnel : `MaxOutputTokens` (entier, ex. `16000`) : `max_tokens` envoyé à chaque requête. Sans lui, le serveur applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil `Write` peut être coupé (le paramètre `content` arrive alors absent). MAIA affiche un avertissement quand la réponse est coupée (finish_reason `length`, ou sortie qui tombe pile sur une limite usuelle 4096/8192… avec un appel d'outil : certains serveurs « réparent » alors l'appel et annoncent `tool_calls`).
|
||||
Cas constaté avec `anthropic--claude-4.5-sonnet` : `completion_tokens: 4096` et `Write` reçu avec `path` seul → fixer par ex. `"MaxOutputTokens": 32000`.
|
||||
- Diagnostic : `MAIA_DEBUG_LLM=1 maia` enregistre le flux brut renvoyé par LiteLLM dans `maia-llm-debug.log` (dossier temporaire ; `MAIA_DEBUG_LLM=/chemin/fichier.log` pour choisir le fichier).
|
||||
|
||||
Si le fichier est absent ou incomplet, MAIA s'arrête avec un message qui en donne un exemple.
|
||||
|
||||
|
|
@ -369,3 +374,9 @@ Journal des évolutions, dans l'ordre :
|
|||
24. **Outils personnalisés** : `./.maia/tools/<nom>/TOOL.json` (dossier de lancement uniquement) ; paramètres `number`/`string` avec valeurs par défaut, `run` avec `{{paramètre}}` exécuté sans shell dans le dossier du tool (aucune injection possible), permission `ask` par défaut ; module `packages/agent/src/custom-tools.ts`, exemple `examples/tools/addition`.
|
||||
25. **Threading async des outils** : 1 outil au premier plan ; dès 2 outils, une tâche de fond par outil, attente de toutes les fins avant de répondre au LLM (résultats dans l'ordre demandé, 8 en parallèle au plus, verrou par fichier).
|
||||
26. **Compactage** : `/compact` et compactage automatique au seuil `Max_Token_Context - (4000 + 500)` ; résumé en 5 rubriques comme contexte de départ (à la place de MAIA.md / CLAUDE.md), session renommée `xxx__n` (cycle 1..1000), barre de contexte recalculée.
|
||||
27. **Correction de la barre de contexte** : comptage local avec js-tiktoken (`o200k_base`) de tout le contexte (système, outils, messages, contexte en attente), recalculé avant chaque saisie ; fin du mélange consommation du serveur / estimation calibrée (cause des variations) et du marqueur `~`.
|
||||
28. **Documentation** : `SPECS_TECH.md`, spécifications techniques de toutes les fonctionnalités implémentées.
|
||||
29. **Outil Write plus tolérant** : message d'erreur qui liste les paramètres reçus (tous les outils), alias de `content` acceptés, description du paramètre précisée.
|
||||
30. **Appels d'outils coupés ou mal assemblés** : paramètre optionnel `MaxOutputTokens`, avertissement quand la réponse est coupée (`max_tokens`), assemblage plus tolérant des fragments d'arguments d'appels d'outils du flux SSE, journal de diagnostic `MAIA_DEBUG_LLM`.
|
||||
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).
|
||||
|
|
|
|||
571
SPECS_TECH.md
Normal file
571
SPECS_TECH.md
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
# MAIA — Spécifications techniques
|
||||
|
||||
Document de référence des fonctionnalités **implémentées** à ce jour. Il décrit le comportement attendu, les contrats entre modules, les formats de fichiers et les constantes. Le `README.md` reste le guide d'utilisation ; ce document est destiné à qui développe ou maintient MAIA.
|
||||
|
||||
> État de vérification : les suites vitest, le typecheck, Ink et un vrai serveur LiteLLM n'ont jamais été exécutés dans l'environnement de développement (voir § 19). Les comportements décrits ici sont ceux du code ; ils sont couverts par des tests écrits mais à lancer après `pnpm install`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objet et périmètre
|
||||
|
||||
MAIA est un assistant IA en ligne de commande (CLI) :
|
||||
|
||||
- **un seul agent** (MAIA) ; il n'y a ni mode ni extension ;
|
||||
- **accès aux LLM uniquement via LiteLLM**, au format OpenAI (`chat/completions`, streaming, appels d'outils) ;
|
||||
- interface de terminal **Ink** (React) ;
|
||||
- l'agent lit, cherche, modifie des fichiers et exécute des commandes dans le **dossier de lancement** grâce à des outils, soumis à des permissions ;
|
||||
- sessions persistantes, skills, outils personnalisés, exécution parallèle des outils, compactage du contexte.
|
||||
|
||||
Hors périmètre actuel : sous-agents / outil Task, hooks, serveurs MCP, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18).
|
||||
|
||||
## 2. Pile technique
|
||||
|
||||
| Élément | Choix |
|
||||
| --- | --- |
|
||||
| Langage | TypeScript 5.6+, mode `strict` + `noUncheckedIndexedAccess`, ESM (`"type": "module"`) |
|
||||
| Exécution | Node ≥ 20 (`engines`), `tsx` en développement |
|
||||
| Gestionnaire de paquets | `pnpm@12.14.0` (champ `packageManager`), workspaces `packages/*` |
|
||||
| Build | `tsup` (un seul bundle ESM, cible `node20`, shebang `#!/usr/bin/env node`) |
|
||||
| Tests | `vitest` ^2.1 (`vitest run` à la racine) |
|
||||
| Interface | `ink` ^7.0.3, `react` ^19.2, `@inkjs/ui` ^2 (badges), `cli-spinners` |
|
||||
| Markdown | `markdansi` ^0.2 (isolé dans un composant) |
|
||||
| Comptage de tokens | `js-tiktoken` ^1.0.21, vocabulaire `o200k_base` (`js-tiktoken/lite`) |
|
||||
| HTML → Markdown | `turndown` ^7.2 |
|
||||
| Fichiers | `ignore` (`.gitignore`), `picomatch` (motifs glob) |
|
||||
|
||||
`tsconfig.base.json` : `target ES2022`, `module ESNext`, `moduleResolution Bundler`, `jsx react-jsx`, `noEmit`.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Paquets
|
||||
|
||||
| Paquet | Rôle | Dépend de |
|
||||
| --- | --- | --- |
|
||||
| `@maia/core` | Contrats (`LLMProvider`, `Tool`, `AgentUI`…), boucle agent, exécuteur d'outils, sessions, commande `!`, références `@`, invocation `§`, comptage de contexte, compactage | — (+ `js-tiktoken`) |
|
||||
| `@maia/providers` | Adaptateur LiteLLM (format OpenAI, SSE), liste des modèles, `settings.json` utilisateur | `core` |
|
||||
| `@maia/ui` | Interface Ink : store sans React, composants, bannière, barre de contexte, rendu markdown | `core` |
|
||||
| `@maia/agent` | L'agent MAIA : initialisation, `MAIA.md`/`CLAUDE.md`, skills, outils intégrés, outils personnalisés, permissions | `core` |
|
||||
| `@maia/cli` | Point d'entrée : arguments, configuration, boucle de conversation, commandes `/…` | tous |
|
||||
|
||||
Règle de dépendance : **`core` n'importe jamais `ui`, `providers` ni `agent`**. L'interface est injectée sous forme du contrat `AgentUI`.
|
||||
|
||||
### 3.2 Flux général
|
||||
|
||||
```
|
||||
saisie ─► cli (commandes / ! @ §) ─► runTurn(core)
|
||||
│ ┌───────────────────────────┐
|
||||
├─►│ provider.chat (LiteLLM) │ bloquant
|
||||
│ └───────────────────────────┘
|
||||
└─► runToolCalls (1 = avant-plan, ≥ 2 = parallèle)
|
||||
└─► Tool.execute (agent)
|
||||
AgentUI (ui) ◄── événements : texte, appels, résultats, contexte
|
||||
```
|
||||
|
||||
## 4. Contrats (`packages/core/src/types.ts`)
|
||||
|
||||
### 4.1 Messages (format neutre, indépendant des providers)
|
||||
|
||||
```ts
|
||||
type ContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: "tool_result"; toolUseId: string; content: string; isError?: boolean };
|
||||
interface Message { role: "user" | "assistant"; content: ContentBlock[] }
|
||||
```
|
||||
|
||||
### 4.2 `LLMProvider`
|
||||
|
||||
`{ id, model, capabilities: { tools }, chat(req, signal?) → AsyncIterable<StreamEvent> }`.
|
||||
|
||||
`ChatRequest` = `{ system, messages, tools: ToolSpec[], maxTokens? }`. `StreamEvent` :
|
||||
|
||||
| Événement | Sens |
|
||||
| --- | --- |
|
||||
| `text_delta { text }` | morceau de texte de la réponse |
|
||||
| `tool_use { id, name, input }` | appel d'outil complet (émis à la fin du flux) |
|
||||
| `usage { inputTokens, outputTokens }` | consommation renvoyée par le serveur (ignorée pour la barre de contexte, voir § 13) |
|
||||
| `done { stopReason }` | `end_turn` · `tool_use` · `max_tokens` · `other` |
|
||||
|
||||
### 4.3 `Tool`
|
||||
|
||||
```ts
|
||||
interface Tool {
|
||||
spec: { name; description; inputSchema /* JSON Schema */ };
|
||||
permission: "auto" | "ask"; // permission par défaut de l'outil
|
||||
describe?(input): string; // résumé court (UI, confirmation)
|
||||
lockKey?(input, { cwd }): string | undefined; // exclusion mutuelle (voir § 6.3)
|
||||
execute(input, ctx: ToolContext): Promise<string>; // ctx = { cwd, model?, signal?, env? }
|
||||
}
|
||||
```
|
||||
|
||||
Un outil **renvoie une chaîne** ; une exception est convertie en résultat d'erreur (`isError: true`) par l'exécuteur.
|
||||
|
||||
### 4.4 `AgentUI`
|
||||
|
||||
`onThinking(bool)`, `onText`, `onTextEnd`, `onToolCall(name, summary)`, `onToolResult(name, output, isError)`, `confirm(message) → Promise<boolean>`, et les optionnels `onContextUsage?(tokens)` et `onToolsRunning?({ names, done } | null)`.
|
||||
|
||||
### 4.5 Initialisation de l'agent
|
||||
|
||||
`InitContext` = `{ cwd, homeDir, log, warn, startContext? }`. `ModeInitResult` = `{ systemPrompt, tools, commands?, skills?, permissions?, permissionSources?, env? }`.
|
||||
|
||||
`AgentSkill` = `{ name, description, invoke(args) → string | Promise<string> }`.
|
||||
|
||||
## 5. Démarrage et configuration (`packages/cli`)
|
||||
|
||||
### 5.1 Ligne de commande
|
||||
|
||||
`maia [--model <spec>] [-h|--help]`. Un **TTY** est obligatoire (sinon message d'erreur, code de sortie 1).
|
||||
|
||||
### 5.2 Dossier de lancement
|
||||
|
||||
Base de **tous** les chemins relatifs (outils, `MAIA.md`, `.maia/…`, permissions). Résolu une fois au démarrage : `MAIA_CWD` > `INIT_CWD` (pnpm/npm) > `process.cwd()`. Il est affiché (« Dossier de travail : … »).
|
||||
|
||||
### 5.3 Résolution du modèle
|
||||
|
||||
Priorité : option `--model` > variable `MAIA_MODEL` > `./.maia/config.json` > `~/.maia/config.json` (propriété `model`). **Pas de défaut** : sans valeur, la liste des modèles LiteLLM est proposée. Forme acceptée : `litellm:<nom>` ou `<nom>` (le nom peut contenir `:` ou `/`).
|
||||
|
||||
### 5.4 Séquence de démarrage
|
||||
|
||||
1. Lecture de `~/.maia/settings.json` (voir § 7.3) ; erreur explicite avec exemple si absent/invalide, arrêt du programme.
|
||||
2. Résolution de la configuration et du modèle (choix interactif si nécessaire).
|
||||
3. Création de `./.maia` et `./.maia/.sessions` (échec → sessions désactivées, avertissement).
|
||||
4. `initializeAgent` (contexte, skills, outils, permissions), création de la session d'agent.
|
||||
5. Question « taille maximale du contexte » au LLM (§ 13.2).
|
||||
6. Boucle de conversation (§ 5.5).
|
||||
|
||||
### 5.5 Boucle de conversation
|
||||
|
||||
À chaque itération : **comptage du contexte et mise à jour de la barre** → saisie (`readInput`) → traitement dans cet ordre :
|
||||
|
||||
1. `!commande` (§ 11.2) ;
|
||||
2. `§skill` (§ 11.3) ;
|
||||
3. commandes `/…` (§ 5.6) ;
|
||||
4. sinon : prompt libre. Les références `@fichier` de la saisie sont résolues et ajoutées au contexte en attente (§ 11.4) ;
|
||||
5. compactage automatique éventuel (§ 14.3) ;
|
||||
6. `runTurn` avec un `AbortController` ; **Ctrl+C** interrompt la requête, restaure `messages` et `pendingContext` d'avant la demande et recompte le contexte ;
|
||||
7. compactage automatique éventuel après le tour.
|
||||
|
||||
Sortie : `/exit`, Ctrl+D (saisie vide), Ctrl+C (saisie), SIGTERM, SIGHUP. La session est sauvegardée (écriture synchrone sur `exit`).
|
||||
|
||||
### 5.6 Commandes slash
|
||||
|
||||
| Commande | Effet |
|
||||
| --- | --- |
|
||||
| `/help` | aide (commandes, skills) |
|
||||
| `/permissions` | règles chargées, par fichier (badges ALLOW / ASK / DENY) |
|
||||
| `/model` | recharge la liste, change de modèle, conversation conservée ; redemande la taille de contexte |
|
||||
| `/clear` | nouvel agent : MAIA.md / CLAUDE.md, skills, outils et permissions rechargés ; messages supprimés ; la session garde son nom |
|
||||
| `/compact` | compactage (§ 14) |
|
||||
| `/rename <nom>` | renomme la session courante |
|
||||
| `/load [nom]` | recharge une session (liste de choix si sans argument) |
|
||||
| `/new` | nouvelle session (nouveau contexte), l'ancienne est sauvegardée |
|
||||
| `/exit` | quitte |
|
||||
|
||||
Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalisées d'un agent sont prévues par le contrat `SlashCommand` mais l'agent MAIA n'en fournit plus.)
|
||||
|
||||
## 6. Boucle agent et exécution des outils (`core/src/agent.ts`, `tool-runner.ts`)
|
||||
|
||||
### 6.1 `AgentSession`
|
||||
|
||||
`{ provider, system, tools: Map<string, Tool>, commands, skills, permissions?, permissionSources, env?, messages, startContext?, pendingContext: string[], ui, cwd }`.
|
||||
|
||||
### 6.2 `runTurn(session, userText, signal?)`
|
||||
|
||||
1. Le `pendingContext` est vidé et **joint** au texte utilisateur (`contexte\n\ntexte`) en **un seul message** `user` (certains providers exigent l'alternance user/assistant).
|
||||
2. Boucle d'au plus **25 itérations** (`MAX_ITERATIONS`) :
|
||||
- appel `provider.chat({ system, messages, tools })` (**bloquant** : le raisonnement du LLM n'est jamais mis en tâche de fond, pour préserver l'intégrité du contexte) ;
|
||||
- le texte est diffusé via `ui.onText` ; le message `assistant` (texte + `tool_use`) est ajouté ; `ui.onContextUsage(sessionContextTokens(session))` ;
|
||||
- sans appel d'outil → fin du tour ; sinon `runToolCalls`, ajout d'un message `user` portant les `tool_result`, itération suivante.
|
||||
3. Au-delà de 25 itérations : message d'erreur « Arrêt : plus de 25 itérations ».
|
||||
|
||||
Les événements `usage` du provider sont ignorés par le cœur.
|
||||
|
||||
### 6.3 `runToolCalls` (exécuteur)
|
||||
|
||||
Les résultats sont **toujours renvoyés au LLM dans l'ordre des appels**.
|
||||
|
||||
**1 appel (ou 0) — avant-plan.** Séquence : annonce (`onToolCall`) → permission → confirmation éventuelle → exécution → `onToolResult`.
|
||||
|
||||
**2 appels ou plus — parallèle.**
|
||||
|
||||
1. *Phase 1 (séquentielle)* : pour chaque appel, autorisation (outil connu, règles de permissions, confirmation utilisateur **une par une**) ; chaque outil autorisé **démarre immédiatement** en arrière-plan.
|
||||
2. *Phase 2* : `ui.onToolsRunning({ names, done })` à chaque fin d'outil, puis attente de **toutes** les tâches (`Promise.all`), puis `onToolsRunning(null)`.
|
||||
3. *Phase 3* : affichage (`onToolCall` + `onToolResult`) et construction des `tool_result`, dans l'ordre des appels.
|
||||
|
||||
Règles :
|
||||
|
||||
- une exception, un refus ou un outil inconnu **n'annule pas** les autres outils ;
|
||||
- au plus **8 outils simultanés** (`MAX_PARALLEL_TOOLS`, file FIFO `Limiter`) ;
|
||||
- `Tool.lockKey` : deux appels de même clé s'exécutent **l'un après l'autre dans l'ordre demandé** (`KeyedQueue`). Clés actuelles : `file:<chemin absolu>` pour Read, Write, Edit ; `todowrite` pour TodoWrite. Une clé qui lève une exception est ignorée ;
|
||||
- ce sont des **tâches asynchrones Node**, pas des threads du système (les processus enfants lancés par Bash ou les outils personnalisés sont, eux, réellement parallèles).
|
||||
|
||||
**Autorisation d'un appel** (`prepare`) :
|
||||
|
||||
| Cas | Résultat renvoyé au LLM |
|
||||
| --- | --- |
|
||||
| Outil inconnu | `Outil inconnu : <nom>` (erreur) |
|
||||
| Règle `deny` | `Action refusée par les règles de permissions (…). Ne réessaie pas telle quelle.` |
|
||||
| `ask` (règle) ou, sans règle applicable, permission `ask` de l'outil, et refus utilisateur | `L'utilisateur a refusé cette action.` |
|
||||
|
||||
## 7. Accès aux LLM : LiteLLM (`packages/providers`)
|
||||
|
||||
### 7.1 Requête
|
||||
|
||||
`POST {LiteLLM_URL}{OpenAI_EndPoint}` (jointure sans doubler ni oublier le `/`), en-têtes `Content-Type: application/json`, `Accept: text/event-stream`, `Authorization: Bearer <clé>` si une clé est définie.
|
||||
|
||||
Corps (`buildChatBody`) : `{ model, messages, stream: true, stream_options: { include_usage: true }, [max_tokens], [tools] }`.
|
||||
|
||||
Correspondance du format neutre vers OpenAI :
|
||||
|
||||
| Format MAIA | Format OpenAI |
|
||||
| --- | --- |
|
||||
| `system` | message `role: "system"` en tête |
|
||||
| `assistant` texte + `tool_use` | `role: "assistant"`, `content` (ou `null`), `tool_calls[{ id, type: "function", function: { name, arguments: JSON } }]` |
|
||||
| `user` avec `tool_result` | un message `role: "tool"` (`tool_call_id`) **par résultat**, placés **avant** le texte utilisateur éventuel (`role: "user"`) |
|
||||
| `ToolSpec` | `tools[{ type: "function", function: { name, description, parameters } }]` |
|
||||
|
||||
### 7.2 Réponse (SSE)
|
||||
|
||||
`parseSse` traite les lignes `data:` ; `[DONE]` et le JSON invalide sont ignorés ; un champ `error` lève `Erreur du serveur LiteLLM : …`. Le texte est émis au fil de l'eau ; les appels d'outils sont **accumulés par `index`** (nom et arguments concaténés) et émis **à la fin**, triés par index (arguments JSON invalides → objet vide, l'outil renverra une erreur explicite ; identifiant manquant → `call_N`). Le morceau `usage` (liste `choices` vide) devient l'événement `usage` (les tokens de raisonnement sont retirés de `outputTokens`).
|
||||
|
||||
`stopReason` : `tool_calls`/`function_call` → `tool_use` ; `length` → `max_tokens` ; `stop` → `tool_use` si des appels existent, sinon `end_turn` ; autre → `tool_use` ou `other`.
|
||||
|
||||
Assemblage tolérant : un fragment d'arguments **sans identifiant ni nom** sur un index inconnu est rattaché à l'appel précédent ; un **nouvel identifiant** sur un index déjà pris crée un nouvel appel (comportements observés chez certains fournisseurs derrière LiteLLM). Si le serveur termine par `length`, ou si un appel d'outil arrive avec une sortie de **exactement** 1024/2048/4096/8192/16384/32768/65536 tokens (serveur qui « répare » l'appel coupé et annonce `tool_calls`), le cœur avertit l'utilisateur (« Réponse coupée par la limite de tokens de sortie… »). Diagnostic : `MAIA_DEBUG_LLM=1` (ou un chemin de fichier) enregistre le flux SSE brut (défaut : `maia-llm-debug.log` dans le dossier temporaire).
|
||||
|
||||
Erreurs : serveur injoignable (`LiteLLM injoignable (url) : …`), HTTP non 2xx (500 premiers caractères du corps), réponse sans corps. Une annulation (`AbortSignal`) est relancée telle quelle.
|
||||
|
||||
### 7.3 `~/.maia/settings.json`
|
||||
|
||||
| Clé | Rôle |
|
||||
| --- | --- |
|
||||
| `LiteLLM_URL` (obligatoire) | URL du serveur |
|
||||
| `OpenAI_EndPoint` (obligatoire) | suffixe de la requête de chat, ex. `/v1/chat/completions` |
|
||||
| `LiteLLM_Models` (obligatoire) | suffixe de la liste des modèles, ex. `/v1/models` |
|
||||
| `LiteLLM_API_Key` (facultatif) | clé API ; sinon variable `LITELLM_API_KEY` |
|
||||
| `MaxOutputTokens` (facultatif) | entier positif : `max_tokens` envoyé à chaque requête (sauf si la requête fixe sa propre valeur, ex. le résumé de compactage à 4000) |
|
||||
|
||||
Ce fichier est dans le dossier de l'**utilisateur** (pas celui de lancement) ; il peut contenir un secret. La même propriété `permissions` y est lue (§ 9).
|
||||
|
||||
### 7.4 Liste des modèles
|
||||
|
||||
`GET {LiteLLM_URL}{LiteLLM_Models}` ; les noms sont lus dans `data[].id` (sinon `model_name`), dédoublonnés et triés ; valeur de choix `litellm:<nom>`. Les erreurs sont non bloquantes (avertissement) ; sans modèle disponible, message d'erreur.
|
||||
|
||||
## 8. Outils intégrés (`packages/agent/src/tools`)
|
||||
|
||||
Ordre de déclaration : Read, Glob, Grep, Write, Edit, Bash, TodoWrite, WebFetch, CurrentModel. Les chemins relatifs sont résolus depuis le dossier de lancement ; `~` et `~/…` désignent le dossier personnel ; un chemin complet est utilisé tel quel.
|
||||
|
||||
| Outil | Entrée | Comportement | Permission par défaut | `lockKey` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Read** | `path`, `offset?` (1), `limit?` (2000) | contenu avec numéros de ligne (6 colonnes + tabulation) | auto | fichier |
|
||||
| **Write** | `path`, `content` | crée ou écrase le fichier (crée les dossiers parents) | ask | fichier |
|
||||
| **Edit** | `path`, `old_string`, `new_string` | remplacement unique : erreur si `old_string` est absent ou présent plusieurs fois | ask | fichier |
|
||||
| **Glob** | `pattern`, `path?` | fichiers correspondants, **100 résultats maximum**, JavaScript pur | auto | — |
|
||||
| **Grep** | `pattern` (regex JS), `path?`, `glob?`, `output_mode?` (`files_with_matches` · `content` · `count`), `-i`, `-n`, `-A`, `-B`, `-C`, `head_limit?` (250 ; 0 = illimité) | ignore `.git`, `node_modules`, fichiers du `.gitignore`, binaires ; fichiers > 2 Mo ignorés ; lignes coupées à 500 caractères | auto | — |
|
||||
| **Bash** | `command` | exécute via `runShell` dans le dossier de lancement avec l'environnement de l'agent ; timeout **2 min** ; la commande tourne dans son propre groupe de processus : timeout et Ctrl+C arrêtent aussi ses sous-processus (SIGTERM puis SIGKILL après 1 s) ; stdout et stderr fusionnés dans l'ordre d'arrivée ; sortie coupée à **30 000** caractères ; dernière ligne `[code de sortie : N]`, `[délai dépassé (120 s)]`, `[interrompu (Ctrl+C)]` ou `[échec du lancement de la commande]` | ask | — |
|
||||
| **TodoWrite** | liste de tâches (`pending` · `in_progress` · `completed`, forme `activeForm`) | remplace la liste à chaque appel ; rendu ☐ ▶ ☑ ; avertissement si plusieurs tâches « en cours » | auto | `todowrite` |
|
||||
| **WebFetch** | `url`, `max_length?` | voir ci-dessous | ask | — |
|
||||
| **CurrentModel** | aucune | renvoie le modèle LLM actif (lu à l'appel) | auto | — |
|
||||
|
||||
**WebFetch.** URL http(s) uniquement. HTML → Markdown via `turndown` (titres `atx`, blocs de code `fenced`, puces `-`, listes à puce « - a » et numérotées « 1. a » par règle `listItem` personnalisée ; balises `head`, `script`, `style`, `noscript`, `iframe`, `svg`, `template` supprimées) ; JSON mis en forme ; texte brut. Limites : **5 Mo**, **30 s**, 5 redirections, `max_length` par défaut **20 000** caractères (plafond **100 000**). Une redirection vers un **autre domaine** n'est pas suivie : l'URL est renvoyée au modèle, qui doit la rappeler (nouvelle confirmation). L'en-tête de résultat contient l'URL, le type, le titre et la mention « Contenu externe non fiable… ». Aucun JavaScript exécuté, ni PDF, ni images.
|
||||
|
||||
Pour toute entrée invalide, l'outil lève une exception renvoyée comme erreur au modèle, qui liste les paramètres reçus (ex. `Paramètre "content" manquant ou invalide (…). Paramètres reçus : path (string).`). **Write** accepte aussi `contents`, `text`, `file_text`, `file_content`, `data` ou `body` à la place de `content`, et un tableau de chaînes (joint par des sauts de ligne).
|
||||
|
||||
## 9. Permissions (`agent/src/permissions.ts`, `settings.ts`)
|
||||
|
||||
### 9.1 Sources
|
||||
|
||||
`~/.maia/settings.json` (utilisateur) et `./.maia/settings.json` (dossier de lancement) — **pas** le dossier parent. Propriété `permissions` : `{ allow?: string[], ask?: string[], deny?: string[] }`. Les listes des deux fichiers s'additionnent. JSON invalide ou règle mal formée (`Outil` ou `Outil(motif)`, `Outil` = `[A-Za-z0-9_.-]+`) : avertissement, élément ignoré. Les dossiers `.claude` ne sont plus lus.
|
||||
|
||||
### 9.2 Évaluation
|
||||
|
||||
**deny, puis ask, puis allow** : la première famille qui correspond décide, quelle que soit la précision des règles. **Sans règle applicable : `ask`**, pour tous les outils (y compris ceux dont la permission par défaut est `auto`). Le verdict de la politique **remplace** la permission par défaut de l'outil quand il existe.
|
||||
|
||||
### 9.3 Motifs par outil
|
||||
|
||||
| Outil | Le motif porte sur |
|
||||
| --- | --- |
|
||||
| `Bash` | la commande : `*` = suite quelconque ; `cmd *` couvre `cmd` avec ou sans arguments (pas `cmdx`) ; sans `*` : commande exacte |
|
||||
| `Read`, `Write`, `Edit`, `Glob`, `Grep` | le chemin (relatif au dossier de lancement, motifs glob) |
|
||||
| `WebFetch` | `domain:hôte` exact ou avec `*` (le domaine nu ne couvre pas ses sous-domaines) |
|
||||
| `Skill` | le nom du skill |
|
||||
| autres outils (dont personnalisés) | une valeur texte de l'entrée (`*` accepté) |
|
||||
|
||||
### 9.4 Sécurité des commandes Bash composées
|
||||
|
||||
- `deny` et `ask` s'appliquent dès qu'**une** partie de la commande correspond (`&&`, `;`, `|`, substitutions `$(…)`, backticks, parenthèses, redirections — découpe fine) ;
|
||||
- `allow` ne s'applique que si **toutes** les parties sont couvertes ;
|
||||
- un `allow` ne couvre **jamais** une commande avec redirection (`>`) ou substitution : elle repasse en `ask`.
|
||||
|
||||
Les `deny` sont du « meilleur effort », pas un bac à sable. Les commandes `!` et les références `@` saisies par l'utilisateur **ne passent pas** par ces règles.
|
||||
|
||||
Au démarrage : message INFO (fichiers chargés, nombre de règles par fichier) ou « Aucune règle de permission définie : chaque utilisation d'un outil demande une confirmation ».
|
||||
|
||||
## 10. Outils personnalisés (`agent/src/custom-tools.ts`)
|
||||
|
||||
### 10.1 Chargement
|
||||
|
||||
`./.maia/tools/<dossier>/TOOL.json` (`tool.json` accepté) — **dossier de lancement uniquement** (ni parent, ni utilisateur). Chargement alphabétique au démarrage, rechargé par `/clear` et `/new`. Le journal liste les outils valides sous « Outils personnalisés chargés : » (un par ligne).
|
||||
|
||||
### 10.2 Définition
|
||||
|
||||
```json
|
||||
{ "name": "Addition", "description": "…",
|
||||
"command": { "parameters": [ { "name": "param1", "type": "number", "default": "0", "description": "…" } ],
|
||||
"run": "node ./test.js {{param1}}" } }
|
||||
```
|
||||
|
||||
| Champ | Règle |
|
||||
| --- | --- |
|
||||
| `description` | **obligatoire** |
|
||||
| `name` | facultatif (nom du dossier par défaut) ; `^[A-Za-z0-9_-]{1,64}$` ; ne peut pas reprendre un nom intégré (Read, Bash… et `Skill`) ni un doublon |
|
||||
| `parameters[].name` | identifiant `^[A-Za-z_][A-Za-z0-9_]*$` |
|
||||
| `parameters[].type` | `"number"` ou `"string"` |
|
||||
| `parameters[].default` | valeur utilisée si le LLM n'en fournit pas ; sans `default`, le paramètre est **obligatoire** |
|
||||
| `run` | ligne de commande ; `{{param}}` remplacé par la valeur ; `./` = dossier de l'outil, qui est aussi le dossier d'exécution |
|
||||
|
||||
Le `inputSchema` exposé au LLM est dérivé des paramètres (`number` → `number`, `string` → `string`). Une virgule en trop avant `]` ou `}` est tolérée. Une définition invalide (description absente, type inconnu, `{{x}}` non déclaré, JSON cassé…) donne un **WARN** et l'outil est ignoré, les autres restent chargés.
|
||||
|
||||
### 10.3 Exécution
|
||||
|
||||
- **Sans shell** (`execFile`) : la ligne `run` est découpée en arguments (guillemets simples ou doubles) ; chaque valeur envoyée par le LLM reste **un seul argument** (aucune injection : `; rm -rf /` ou `$(cmd)` sont reçus littéralement) ;
|
||||
- le programme (premier mot) ne peut pas être un `{{paramètre}}` ; pas de tubes, redirections ni variables (appeler un script `sh ./script.sh {{p}}` pour cela) ; sous Windows, les `.cmd`/`.bat` ne sont pas lancés directement ;
|
||||
- timeout **2 min**, sortie limitée à **30 000** caractères ; code de sortie non nul → erreur renvoyée au LLM avec la sortie d'erreur du script ;
|
||||
- variables d'environnement ajoutées : `MAIA_CWD` (dossier de lancement) et `MAIA_TOOL_DIR` ;
|
||||
- permission **`ask`** par défaut ; les règles de permissions s'appliquent avec le nom exact de l'outil (`"Addition"`, `"Addition(5)"`) ;
|
||||
- les outils personnalisés font partie des outils du contexte (ils sont dans `tools` envoyé au LLM et comptés dans le contexte).
|
||||
|
||||
Exemple fourni : `examples/tools/addition/` (`TOOL.json` + `test.js`).
|
||||
|
||||
## 11. Contexte, skills, commandes utilisateur
|
||||
|
||||
### 11.1 Contexte de départ (`agent/src/memory.ts`, `init.ts`)
|
||||
|
||||
`MAIA.md` dans le dossier de lancement, sinon `CLAUDE.md` ; seul ce dossier est regardé. INFO si chargé, WARN si aucun. Le contenu est ajouté au **prompt système** sous `# Instructions (<chemin>)`.
|
||||
|
||||
Prompt système (en français) : identité MAIA, aide à la programmation en CLI, réponses concises dans la langue de l'utilisateur, lire avant de modifier, préférer Glob/Grep à Bash, TodoWrite pour ≥ 3 étapes, contenu WebFetch non fiable, rappel du dossier de travail ; puis le contexte (`MAIA.md` / `CLAUDE.md` ou résumé compacté), puis la section des skills.
|
||||
|
||||
### 11.2 Commande `!` (`core/src/shell.ts`, `cli/src/user-shell.ts`)
|
||||
|
||||
| Saisie | Commande |
|
||||
| --- | --- |
|
||||
| `!ls -la` | tout ce qui suit `!` |
|
||||
| `!'ls -la'` | texte entre guillemets simples |
|
||||
| `!'ls' -la src` | texte entre guillemets + ce qui suit : `ls -la src` |
|
||||
| `!'echo "l\'ami"'` | `\'` = guillemet simple littéral |
|
||||
| `!'ls -la` | erreur (guillemet non fermé), rien d'exécuté |
|
||||
| `!` | aide |
|
||||
|
||||
Exécution (`runShell`) : shell du système, dossier de lancement, environnement de l'agent, timeout **120 s**, capture limitée à 1 000 000 de caractères, stdout et stderr dans l'ordre d'arrivée. La commande tourne dans **son propre groupe de processus** (hors Windows) : timeout ou Ctrl+C arrêtent toute l'arborescence (`SIGTERM` puis `SIGKILL` après 1 s ; `taskkill /T /F` sous Windows). `runShell` ne rejette jamais ; le statut est décrit par `describeShellStatus` (interrompu, échec de lancement, délai dépassé, signal, code de sortie).
|
||||
|
||||
Sans confirmation (c'est l'utilisateur qui la saisit). Le résultat est affiché (aperçu jusqu'à 4000 caractères) et ajouté au **contexte en attente** (jusqu'à 30 000 caractères, milieu tronqué), joint à la prochaine demande. Les programmes interactifs ne sont pas pris en charge.
|
||||
|
||||
### 11.3 Skills et `§`
|
||||
|
||||
Emplacements : `~/.maia/skills`, `<parent>/.maia/skills`, `<lancement>/.maia/skills` ; le plus spécifique l'emporte en cas de même nom. Un skill = dossier `<nom>/SKILL.md` (ou `skill.md`) avec un en-tête YAML : `description` (obligatoire), `name` (facultatif, doit égaler le nom du dossier, qui fait foi).
|
||||
|
||||
Le prompt système contient la liste (nom + description) ; le contenu est chargé à la demande par l'outil `Skill` (créé s'il y a des skills), ou lancé directement par `§nom [arguments]` (contrat `AgentSkill.invoke`, `$ARGUMENTS` / `ARGUMENTS:`). `§` seul liste les skills ; un skill inconnu donne une erreur avec la liste des disponibles.
|
||||
|
||||
### 11.4 Références `@fichier` (`core/src/fileref.ts`)
|
||||
|
||||
Le contenu des fichiers cités est injecté dans le contexte de la demande (le texte de la saisie n'est pas modifié).
|
||||
|
||||
- Formes : `@chemin`, `@ chemin` (avec espace), `@'chemin avec espaces'` (`\'` = guillemet littéral), `@~/…`, `@/chemin/complet` ; un dossier injecte la liste de ses entrées (**200 maximum**).
|
||||
- Un `@mot` qui ne désigne aucun fichier reste du texte (`@mention`, `jean@exemple.fr`) ; un avertissement n'apparaît que si le mot ressemble à un chemin.
|
||||
- Limites : **2 Mo** par fichier, **50 000** caractères injectés par fichier (milieu tronqué), **150 000** par saisie ; binaires ignorés ; doublons injectés une fois.
|
||||
- Le contenu est présenté au modèle comme une donnée, pas comme des instructions. Sans confirmation ni règle de permission.
|
||||
- Les références ne sont résolues que pour la saisie de l'utilisateur (pas dans le corps d'un prompt de skill).
|
||||
|
||||
## 12. Sessions (`core/src/sessions.ts`)
|
||||
|
||||
### 12.1 Stockage
|
||||
|
||||
`./.maia/.sessions/<nom>.json`, créé avec `./.maia` au lancement. Écriture **atomique** (fichier temporaire `.tmp-<pid>` puis renommage). `.maia/` est ajouté au `.gitignore` du dépôt.
|
||||
|
||||
```ts
|
||||
interface SavedSession {
|
||||
version: 1; name: string; createdAt: string; updatedAt: string; // ISO 8601
|
||||
model: string; cwd: string;
|
||||
messages: Message[]; pendingContext: string[];
|
||||
startContext?: string; // résumé compacté servant de contexte de départ (§ 14)
|
||||
}
|
||||
```
|
||||
|
||||
À la lecture, le **nom du fichier fait foi**, les champs manquants reçoivent des valeurs par défaut ; un fichier illisible ou au mauvais format lève une erreur claire (ignoré avec avertissement dans les listes).
|
||||
|
||||
### 12.2 Noms
|
||||
|
||||
Par défaut `session_AAAA-MM-JJ_HH_MM_SS` (heure locale). Un nom saisi est normalisé (espaces et guillemets externes retirés) et validé : non vide, ≤ 100 caractères, sans `\ / : * ? " < > |` ni caractère de contrôle, ne commence ni ne finit par `.`. Une session déjà existante sous un autre fichier fait ajouter un suffixe `_2`, `_3`… à l'enregistrement (jamais d'écrasement par inadvertance). Insensible à la casse pour la recherche (`find`) ; `suggest` propose des noms proches.
|
||||
|
||||
### 12.3 Cycle de vie
|
||||
|
||||
- sauvegarde à la sortie et avant tout changement de session ; **une session sans message ni `startContext` n'est jamais enregistrée** ; sauvegarde ignorée si rien n'a changé ;
|
||||
- `/rename` : effectif à la sauvegarde, l'ancien fichier est supprimé (même session) ; changement de casse seul = renommage de fichier ;
|
||||
- `/load` : sauvegarde la session quittée (message d'avertissement), recharge `messages` et `pendingContext`, **conserve le modèle courant**, reconstruit l'agent si `startContext` diffère, recompte le contexte et réaffiche l'historique (résultats d'outils tronqués à 600 caractères, messages utilisateur à 1500) ;
|
||||
- `/new` : sauvegarde, nouvel agent (contexte rechargé), nouveau nom.
|
||||
|
||||
## 13. Taille du contexte et barre de progression
|
||||
|
||||
### 13.1 Comptage (`core/src/context.ts`)
|
||||
|
||||
`countContextTokens({ system, messages, tools?, pending? })` = somme de :
|
||||
|
||||
- le prompt système ;
|
||||
- la définition des outils (`JSON.stringify(tools)`) ;
|
||||
- pour chaque message : **4 tokens** de structure + le compte de chacun de ses blocs (texte ; `tool_use` = nom + arguments JSON ; `tool_result` = contenu) ;
|
||||
- chaque texte du contexte en attente.
|
||||
|
||||
Tokenizer : `js-tiktoken/lite`, vocabulaire **`o200k_base`**, `encode(texte, [], [])` (un texte qui ressemble à un jeton spécial est compté comme du texte). Les comptes de blocs sont mémorisés (`WeakMap`) ; le prompt système et les outils ont une mémoire de la dernière valeur. Le calcul est **local, déterministe, et strictement additif** : ajouter un message ne peut qu'augmenter le total. La valeur ne diminue que si le contexte est réduit (`/clear`, `/compact`, tour interrompu, autre session). Pour les modèles qui n'utilisent pas ce vocabulaire, c'est une approximation.
|
||||
|
||||
`sessionContextTokens(session)` applique ce calcul à la session. Il est exécuté **avant chaque saisie**, après chaque réponse du LLM, au démarrage d'un agent et après chaque commande `!`. La consommation renvoyée par le serveur n'est plus utilisée (elle mélangeait deux comptes différents et faisait varier la valeur).
|
||||
|
||||
### 13.2 Taille maximale
|
||||
|
||||
Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la question « Taille maximale du contexte pour ce modèle, en tokens ? Réponds uniquement par un nombre entier… » (un appel par modèle et par exécution, timeout **60 s**, Ctrl+C l'abandonne). `parseTokenCount` extrait la valeur (`128000`, `128 000 tokens`, `131,072`, `1.000.000`, `200k`, `1M`, `1,5 million`, `2 millions`) ; plage plausible **1 000 – 100 000 000** ; une valeur avec unité l'emporte sur une valeur plus grande sans unité ; sinon la plus grande. La valeur est annoncée (INFO). Réponse illisible, erreur ou délai : avertissement, **barre masquée**, et le compactage automatique est désactivé (`/compact` reste disponible).
|
||||
|
||||
### 13.3 Barre (`ui/src/context-model.ts`, `context-bar.tsx`)
|
||||
|
||||
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`.
|
||||
|
||||
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
|
||||
|
||||
### 14.1 Principe
|
||||
|
||||
Résumer toute la conversation, vider le contexte comme `/clear`, et utiliser le résumé **à la place** de `MAIA.md` / `CLAUDE.md` comme contexte de départ. L'appel est **bloquant** (pas de tâche de fond).
|
||||
|
||||
### 14.2 Déroulement (`compactNow`)
|
||||
|
||||
1. Rien à compacter (aucun message ni contexte en attente) → information, fin.
|
||||
2. La session courante est sauvegardée telle quelle sous son nom.
|
||||
3. `requestCompactSummary` : le LLM reçoit la conversation, plus un dernier message `user` = contexte en attente + prompt de résumé :
|
||||
|
||||
```
|
||||
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]
|
||||
```
|
||||
|
||||
Si le dernier message de la conversation est déjà `user`, le prompt lui est ajouté (alternance respectée). Le prompt système reçoit « n'appelle aucun outil » ; les outils restent déclarés (l'historique contient des appels d'outils) mais les `tool_use` reçus sont ignorés. `maxTokens` = **4000**. Un résumé vide → erreur ; arrêt par `max_tokens` → avertissement « résumé peut être incomplet ».
|
||||
4. Nouvel agent (`startSession` avec `startContext = résumé`) : prompt système sans `MAIA.md`/`CLAUDE.md`, section « Contexte de départ : résumé compacté de la conversation précédente », outils/skills/permissions rechargés, `messages` vides, `pendingContext` vide.
|
||||
5. Persistance (§ 14.4), puis affichage du résumé et du nouveau nom ; le contexte est recompté et la barre mise à jour.
|
||||
|
||||
En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inchangée.
|
||||
|
||||
### 14.3 Déclenchement
|
||||
|
||||
- **Manuel** : `/compact`.
|
||||
- **Automatique** : `Seuil_max = Max_Token_Context − (4000 + 500)` (4000 = tokens du résumé, 500 = marge d'erreur). Déclenché quand `contexte > Seuil_max` (strictement), si le maximum est connu et si le seuil est positif. Vérifié **avant l'envoi de chaque demande et après chaque tour** (pas au milieu d'un tour : un tour très long avec de nombreux outils peut dépasser le seuil avant d'être compacté). Un échec affiche un avertissement et la conversation continue.
|
||||
|
||||
### 14.4 Nom et persistance
|
||||
|
||||
`nextCompactedName(nom)` : `xxx` → `xxx__1` → `xxx__2` → … → `xxx__1000` → **`xxx__1`** (le fichier existant est écrasé). Un suffixe n'est un numéro de compactage que si `1 ≤ n ≤ 1000` (`xxx__0`, `xxx__1001`, `a__b` → suffixe ajouté). L'origine est tronquée si le nom dépasserait 100 caractères. La numérotation découle du nom de la session courante (renommer `xxx__2` en `foo` donne `foo__1`).
|
||||
|
||||
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é.
|
||||
|
||||
## 15. Interface (`packages/ui`)
|
||||
|
||||
### 15.1 Architecture
|
||||
|
||||
`InkUI` (façade impérative : `info`, `warn`, `error`, `select`, `confirm`, `readInput`, `withSpinner`, `setBusy`, `showUser`, `onShell`, `showPermissions`, `setContextMax`, `setInterruptHandler`, et le contrat `AgentUI`) au-dessus d'un **`UIStore`** sans dépendance à React (testable) ; les composants React affichent l'état du store (`useSyncExternalStore`). Le journal garde les éléments par entrée ; aperçu des résultats d'outils **600** caractères, des sorties `!` **4000**.
|
||||
|
||||
### 15.2 Journal et badges (`@inkjs/ui`)
|
||||
|
||||
| Badge | Usage | Couleur du texte |
|
||||
| --- | --- | --- |
|
||||
| `INFO` | information | bleu clair `#7EC8FF` |
|
||||
| `WARN` | avertissement | par défaut |
|
||||
| `FAIL` | erreur | `#DC8ADD` |
|
||||
| `ASK` | question (liste, confirmation) | gras |
|
||||
|
||||
Une ligne vide sépare chaque message. Une fois la réponse validée, la question est remplacée par `◆ Question : choix`.
|
||||
|
||||
### 15.3 Réponses de l'agent
|
||||
|
||||
Rendu markdown par `markdansi` (`render(markdown, { width })`) — **tout est dans `markdown.tsx`** (retrait : supprimer le fichier, la dépendance, et remplacer `<Markdown />` par `<Text>`). Le texte en cours de streaming reste brut ; la mise en forme apparaît à la fin de la réponse. `MAIA_NO_MARKDOWN=1` désactive le rendu ; un échec de `markdansi` retombe sur le texte brut.
|
||||
|
||||
### 15.4 Bannière
|
||||
|
||||
Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de palette), rendue en demi-blocs `▀▄█` (32 colonnes × 16 lignes), à gauche du titre « Maia » en grands caractères pixel (police 5 × 7) avec ombre et dégradé arc-en-ciel ; deux textes (vert : « Assistant IA en ligne de commande, propulsé par Ink et vos modèles de langage. » ; rose : « Un seul agent, plusieurs modèles »). Version complète à partir de **58 colonnes** ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré. Données du masque générées par `packages/ui/scripts/make-mask.py` (Pillow) depuis `assets/mask.png` vers `src/mask-data.ts`.
|
||||
|
||||
### 15.5 Saisie, attente, listes
|
||||
|
||||
- Zone de saisie : bordure en pointillés `┈` **au-dessus et au-dessous** seulement, `greenBright`, spinner `arrow3` à gauche, texte d'aide « Votre demande — /help : aide · § : skills · Ctrl+C : quitter » ; collage multi-lignes conservé ; historique ↑/↓.
|
||||
- Attente après validation : « En réflexion » + spinner `dots10` en `yellowBright` (aussi pour chargement des modèles, exécution `!`, compactage, outils parallèles « Outils en parallèle : n/total terminés (…) »).
|
||||
- Listes de choix : fenêtre de 10 lignes, ↑/↓ (ou j/k), Entrée valide, Échap annule.
|
||||
- Confirmation d'action : `o` autorise, `n` ou Échap refuse, **Entrée seule est ignorée**.
|
||||
|
||||
| Contexte | Touches |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
|
||||
## 16. Fichiers et variables
|
||||
|
||||
| Chemin / variable | Rôle |
|
||||
| --- | --- |
|
||||
| `~/.maia/settings.json` | LiteLLM (§ 7.3) + permissions utilisateur |
|
||||
| `~/.maia/config.json` | `model` par défaut de l'utilisateur |
|
||||
| `~/.maia/skills/` | skills de l'utilisateur |
|
||||
| `./.maia/config.json` | `model` du projet |
|
||||
| `./.maia/settings.json` | permissions du projet |
|
||||
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
|
||||
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
|
||||
| `./.maia/.sessions/<nom>.json` | sessions |
|
||||
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
|
||||
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN` | variables d'environnement |
|
||||
|
||||
## 17. Sécurité
|
||||
|
||||
- **Confirmation par défaut** de tout outil sans règle (`ask`) ; ordre deny > ask > allow ; analyse fine des commandes Bash composées (§ 9.4).
|
||||
- **Outils personnalisés sans shell** : pas d'injection par les paramètres (§ 10.3).
|
||||
- **WebFetch** : contenu signalé non fiable, pas de redirection inter-domaines suivie, limites de taille et de durée.
|
||||
- Contenus injectés (`@fichier`, sorties `!`, WebFetch) présentés au modèle comme des **données** et non des instructions.
|
||||
- `settings.json` utilisateur peut contenir une clé API : ne pas le versionner. Les `settings.json` d'un dépôt tiers peuvent contenir des règles `allow` larges : à relire.
|
||||
- Les `deny` ne remplacent pas un bac à sable.
|
||||
|
||||
## 18. Limites connues et pistes
|
||||
|
||||
- **Provider LiteLLM non validé sur un vrai serveur** (SSE et appels d'outils testés sur des flux simulés) ; compatibilité `@inkjs/ui` / Ink 7 à confirmer à l'usage.
|
||||
- La taille de contexte est obtenue en **interrogeant le LLM** (peut être fausse) ; la route `/model/info` de LiteLLM (`max_input_tokens`) ou un réglage manuel dans `settings.json` serait plus fiable.
|
||||
- Comptage `o200k_base` : approximation pour les modèles non OpenAI.
|
||||
- Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3).
|
||||
- Parallélisme : tâches asynchrones, 8 simultanées, confirmations une par une.
|
||||
- Non réalisés : sous-agents + outil Task, hooks, serveurs MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts.
|
||||
|
||||
## 19. Tests
|
||||
|
||||
Suites vitest (fichiers `*.test.ts`) : boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement.
|
||||
|
||||
Commandes : `pnpm install`, `pnpm test`, `pnpm typecheck`. Les suites ont été écrites et vérifiées par un lanceur de test minimal dans l'environnement de développement (sans vitest, Ink ni LiteLLM réels) ; leur exécution complète est à faire dans l'environnement du projet.
|
||||
|
||||
## 20. Annexe — constantes
|
||||
|
||||
| Constante | Valeur | Lieu |
|
||||
| --- | --- | --- |
|
||||
| Itérations maximales d'un tour | 25 | `agent.ts` |
|
||||
| Outils simultanés | 8 | `tool-runner.ts` |
|
||||
| Timeout `!` / Bash / outil personnalisé | 120 s | `shell.ts`, `bash.ts`, `custom-tools.ts` |
|
||||
| Sortie Bash / outil personnalisé | 30 000 caractères | `bash.ts`, `custom-tools.ts` |
|
||||
| Contexte `!` joint à la demande | 30 000 caractères | `shell.ts` |
|
||||
| Capture `!` | 1 000 000 caractères | `shell.ts` |
|
||||
| `@fichier` : fichier / par fichier / par saisie / entrées de dossier | 2 Mo / 50 000 / 150 000 / 200 | `fileref.ts` |
|
||||
| Read : lignes par défaut | 2000 | `read.ts` |
|
||||
| Glob : résultats | 100 | `glob.ts` |
|
||||
| Grep : fichier max, ligne max, `head_limit` | 2 Mo, 500 caractères, 250 | `grep.ts` |
|
||||
| WebFetch : octets / durée / redirections / caractères (défaut, plafond) | 5 Mo / 30 s / 5 / 20 000, 100 000 | `webfetch.ts` |
|
||||
| Tokens de structure par message | 4 | `context.ts` |
|
||||
| Échelle de la barre | 10 000 tokens par carré ; jaune ≥ 60 %, rouge ≥ 85 % | `context-model.ts` |
|
||||
| Plage valide d'une taille de contexte | 1 000 – 100 000 000 | `context.ts` |
|
||||
| Question de taille de contexte : délai | 60 s | `cli/src/index.ts` |
|
||||
| Tokens du résumé / marge / seuil | 4000 / 500 / max − 4500 | `compact.ts` |
|
||||
| Compactages numérotés | 1 – 1000 (cyclique) | `sessions.ts` |
|
||||
| Nom de session : longueur maximale | 100 | `sessions.ts` |
|
||||
| Largeur minimale de la bannière complète | 58 colonnes | `banner.tsx` |
|
||||
| Aperçu d'un résultat d'outil / d'une sortie `!` | 600 / 4000 caractères | `store.ts` |
|
||||
47
packages/agent/src/tools/bash.test.ts
Normal file
47
packages/agent/src/tools/bash.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { bashTool } from "./bash";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const run = (command: string, extra: { signal?: AbortSignal; env?: Record<string, string> } = {}) =>
|
||||
bashTool.execute({ command }, { cwd, ...extra });
|
||||
|
||||
describe("Bash (runShell)", () => {
|
||||
it("succès : sortie puis « code de sortie : 0 »", async () => {
|
||||
expect(await run("echo bonjour")).toBe("bonjour\n[code de sortie : 0]");
|
||||
});
|
||||
|
||||
it("échec : code de sortie non nul indiqué, sortie conservée", async () => {
|
||||
const r = await run("echo oups; exit 3");
|
||||
expect(r).toContain("oups");
|
||||
expect(r).toContain("[code de sortie : 3]");
|
||||
});
|
||||
|
||||
it("stderr est inclus", async () => {
|
||||
expect(await run("echo erreur 1>&2")).toContain("erreur");
|
||||
});
|
||||
|
||||
it("variables d'environnement de l'agent transmises", async () => {
|
||||
const r = await run('node -e "console.log(process.env.MAIA_TEST_VAR)"', { env: { MAIA_TEST_VAR: "valeur-x" } });
|
||||
expect(r).toContain("valeur-x");
|
||||
});
|
||||
|
||||
it("sortie coupée à 30 000 caractères", async () => {
|
||||
const r = await run('node -e "process.stdout.write(\'a\'.repeat(40000))"');
|
||||
expect(r).toContain("… (tronqué)");
|
||||
expect(r.length).toBeLessThan(30_100);
|
||||
});
|
||||
|
||||
it("Ctrl+C : arrête la commande ET ses sous-processus, sans attendre", async () => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 200);
|
||||
const t0 = Date.now();
|
||||
const r = await run('sh -c "sleep 10; echo jamais"; echo apres', { signal: controller.signal });
|
||||
expect(Date.now() - t0).toBeLessThan(3000);
|
||||
expect(r).toContain("interrompu (Ctrl+C)");
|
||||
expect(r).not.toContain("jamais");
|
||||
});
|
||||
|
||||
it("s’exécute dans le dossier de lancement", async () => {
|
||||
expect(await run("pwd")).toContain(cwd);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { exec } from "node:child_process";
|
||||
import type { Tool } from "@maia/core";
|
||||
import { describeShellStatus, runShell, type Tool } from "@maia/core";
|
||||
import { str } from "./helpers";
|
||||
|
||||
const OUTPUT_MAX = 30_000;
|
||||
export const BASH_TIMEOUT_MS = 120_000;
|
||||
|
||||
// Exécution par runShell (cœur) : la commande tourne dans son propre groupe de processus ; au délai dépassé ou à Ctrl+C,
|
||||
// tout ce qu'elle a lancé est arrêté (SIGTERM, puis SIGKILL), et le résultat est rendu sans attendre la sortie des orphelins.
|
||||
export const bashTool: Tool = {
|
||||
spec: {
|
||||
name: "Bash",
|
||||
|
|
@ -16,25 +18,13 @@ export const bashTool: Tool = {
|
|||
},
|
||||
permission: "ask",
|
||||
describe: (i) => String(i.command ?? ""),
|
||||
execute(input, ctx) {
|
||||
async execute(input, ctx) {
|
||||
const command = str(input, "command");
|
||||
return new Promise((resolve) => {
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
cwd: ctx.cwd,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
signal: ctx.signal,
|
||||
env: { ...process.env, ...ctx.env },
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
let out = `${stdout}${stderr ? `\n[stderr]\n${stderr}` : ""}`.trim();
|
||||
if (out.length > OUTPUT_MAX) out = `${out.slice(0, OUTPUT_MAX)}\n… (tronqué)`;
|
||||
const code = error ? ((error as { code?: number }).code ?? 1) : 0;
|
||||
resolve(`${out}\n[code de sortie : ${code}]`.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
const result = await runShell(command, { cwd: ctx.cwd, env: ctx.env, signal: ctx.signal, timeoutMs: BASH_TIMEOUT_MS });
|
||||
let out = result.output;
|
||||
if (out.length > OUTPUT_MAX) out = `${out.slice(0, OUTPUT_MAX)}\n… (tronqué)`;
|
||||
// Dernière ligne : état de la commande, lisible par le modèle (« code de sortie : 0 » en cas de succès)
|
||||
const status = describeShellStatus(result) ?? "code de sortie : 0";
|
||||
return `${out}\n[${status}]`.trim();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
/** Paramètres reçus avec leur type, ex. « path (string), mode (number) » : aide le modèle à corriger son appel. */
|
||||
export function describeInput(input: Record<string, unknown>): string {
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length === 0) return "aucun";
|
||||
return keys.map((k) => `${k} (${Array.isArray(input[k]) ? "array" : input[k] === null ? "null" : typeof input[k]})`).join(", ");
|
||||
}
|
||||
|
||||
export function str(input: Record<string, unknown>, key: string): string {
|
||||
const v = input[key];
|
||||
if (typeof v !== "string") throw new Error(`Paramètre "${key}" manquant ou invalide`);
|
||||
if (typeof v !== "string") {
|
||||
throw new Error(`Paramètre "${key}" manquant ou invalide (une chaîne de texte est attendue). Paramètres reçus : ${describeInput(input)}.`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
|
|||
45
packages/agent/src/tools/write.test.ts
Normal file
45
packages/agent/src/tools/write.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeInput, str } from "./helpers";
|
||||
import { contentOf, writeTool } from "./write";
|
||||
|
||||
describe("Write : contenu du fichier", () => {
|
||||
it("écrit le contenu (même vide) et crée les dossiers", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "maia-write-"));
|
||||
const r = await writeTool.execute({ path: "docs/HOW_To_Agent.md", content: "# Titre\n\ntexte" }, { cwd });
|
||||
expect(r).toContain("14 caractères");
|
||||
expect(await readFile(join(cwd, "docs", "HOW_To_Agent.md"), "utf8")).toBe("# Titre\n\ntexte");
|
||||
await writeTool.execute({ path: "vide.md", content: "" }, { cwd });
|
||||
expect(await readFile(join(cwd, "vide.md"), "utf8")).toBe("");
|
||||
});
|
||||
|
||||
it("accepte un nom de paramètre courant à la place de « content »", () => {
|
||||
expect(contentOf({ path: "a.md", text: "bonjour" })).toBe("bonjour");
|
||||
expect(contentOf({ path: "a.md", file_text: "x" })).toBe("x");
|
||||
expect(contentOf({ path: "a.md", contents: "y" })).toBe("y");
|
||||
});
|
||||
|
||||
it("un tableau de lignes est joint par des sauts de ligne", () => {
|
||||
expect(contentOf({ path: "a.md", content: ["# T", "", "ligne"] })).toBe("# T\n\nligne");
|
||||
});
|
||||
|
||||
it("content absent ou invalide : erreur explicite qui liste les paramètres reçus", () => {
|
||||
expect(() => contentOf({ path: "a.md" })).toThrow("Paramètres reçus : path (string)");
|
||||
expect(() => contentOf({ path: "a.md", content: null })).toThrow("content (null)");
|
||||
expect(() => contentOf({ path: "a.md", content: { x: 1 } })).toThrow("content (object)");
|
||||
expect(() => contentOf({})).toThrow("aucun");
|
||||
});
|
||||
|
||||
it("str : l'erreur indique les paramètres reçus", () => {
|
||||
expect(() => str({ chemin: "x" }, "path")).toThrow("chemin (string)");
|
||||
expect(describeInput({ a: 1, b: [1], c: null })).toBe("a (number), b (array), c (null)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Write : conseil en cas de contenu absent", () => {
|
||||
it("l'erreur suggère d'écrire le fichier en plusieurs morceaux", () => {
|
||||
expect(() => contentOf({ path: "a.md" })).toThrow("plusieurs morceaux");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,25 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { Tool } from "@maia/core";
|
||||
import { fileLockKey, resolvePath, str } from "./helpers";
|
||||
import { describeInput, fileLockKey, resolvePath, str } from "./helpers";
|
||||
|
||||
// Noms que certains modèles donnent au contenu du fichier à la place de « content »
|
||||
const CONTENT_ALIASES = ["contents", "text", "file_text", "file_content", "data", "body"];
|
||||
|
||||
/** Contenu à écrire : `content` (chaîne), sinon un alias courant ; un tableau de chaînes est joint par des sauts de ligne. */
|
||||
export function contentOf(input: Record<string, unknown>): string {
|
||||
for (const key of ["content", ...CONTENT_ALIASES]) {
|
||||
const v = input[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string")) return v.join("\n");
|
||||
}
|
||||
throw new Error(
|
||||
'Paramètre "content" manquant ou invalide : il doit contenir le texte COMPLET du fichier, sous forme de chaîne. ' +
|
||||
"Si ta réponse a été coupée par la limite de tokens de sortie (fichier trop long), écris le fichier en plusieurs morceaux plus courts : " +
|
||||
"Write pour la première partie, puis ajoute la suite (Bash avec `cat >> fichier` ou Edit). " +
|
||||
`Paramètres reçus : ${describeInput(input)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export const writeTool: Tool = {
|
||||
spec: {
|
||||
|
|
@ -11,7 +29,7 @@ export const writeTool: Tool = {
|
|||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Chemin du fichier : complet, ou relatif au dossier de lancement de MAIA" },
|
||||
content: { type: "string" },
|
||||
content: { type: "string", description: "Texte complet du fichier à écrire (obligatoire, chaîne de caractères)" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
|
|
@ -21,7 +39,7 @@ export const writeTool: Tool = {
|
|||
lockKey: fileLockKey,
|
||||
async execute(input, ctx) {
|
||||
const file = resolvePath(ctx.cwd, str(input, "path"));
|
||||
const content = str(input, "content");
|
||||
const content = contentOf(input);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, content, "utf8");
|
||||
return `Fichier écrit : ${file} (${content.length} caractères)`;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
compactThreshold,
|
||||
createSession,
|
||||
ensureMaiaHome,
|
||||
estimateSessionTokens,
|
||||
findSkill,
|
||||
isCompactable,
|
||||
normalizeSessionName,
|
||||
|
|
@ -155,11 +154,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
|
||||
/**
|
||||
* Démarre un agent. `startContext` (session compactée) : le résumé sert de contexte de départ à la place de MAIA.md / CLAUDE.md.
|
||||
* `tokenScale` : calibration de l'estimation des tokens, conservée d'une session à l'autre.
|
||||
*/
|
||||
const startSession = async (
|
||||
modelSpec: string,
|
||||
opts: { startContext?: string; tokenScale?: number; quiet?: boolean } = {},
|
||||
opts: { startContext?: string; quiet?: boolean } = {},
|
||||
): Promise<AgentSession> => {
|
||||
const init = await initializeAgent({
|
||||
cwd,
|
||||
|
|
@ -175,22 +173,16 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
cwd,
|
||||
startContext: opts.startContext,
|
||||
});
|
||||
if (opts.tokenScale) created.tokenScale = opts.tokenScale;
|
||||
refreshContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
|
||||
showContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
|
||||
return created;
|
||||
};
|
||||
|
||||
/** Affiche dans la barre le contexte de la session : dernier compte connu + contexte en attente (commandes « ! »). */
|
||||
/**
|
||||
* Compte les tokens du contexte de la session (js-tiktoken : prompt système, outils, messages, contexte en attente)
|
||||
* et met la barre de progression à jour. Appelé avant CHAQUE saisie de l'utilisateur, et à chaque changement de session.
|
||||
*/
|
||||
const showContext = (target: AgentSession): void => {
|
||||
const exact = target.contextExact && target.pendingContext.length === 0;
|
||||
ui.onContextUsage(sessionContextTokens(target), exact);
|
||||
};
|
||||
|
||||
/** Recalcule (estimation) la taille du contexte : nouvelle conversation, session rechargée, tour interrompu. */
|
||||
const refreshContext = (target: AgentSession): void => {
|
||||
target.contextTokens = estimateSessionTokens(target);
|
||||
target.contextExact = false;
|
||||
showContext(target);
|
||||
ui.onContextUsage(sessionContextTokens(target));
|
||||
};
|
||||
|
||||
// Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution)
|
||||
|
|
@ -319,7 +311,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
try {
|
||||
saveSession(); // la session d'origine reste complète dans son fichier
|
||||
const { summary, truncated } = await requestCompactSummary(session, controller.signal);
|
||||
const fresh = await startSession(modelSpec, { startContext: summary, tokenScale: session.tokenScale, quiet: true });
|
||||
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
|
||||
session = fresh;
|
||||
let target = "";
|
||||
if (sessions) {
|
||||
|
|
@ -359,6 +351,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
|
||||
// 4. Boucle de conversation
|
||||
for (;;) {
|
||||
showContext(session); // taille du contexte recomptée avant chaque saisie
|
||||
const input = await ui.readInput();
|
||||
if (input === null || input === "/exit") break;
|
||||
if (!input) continue;
|
||||
|
|
@ -488,7 +481,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
if ((saved.startContext ?? null) !== (session.startContext ?? null)) {
|
||||
// Session compactée (ou retour à une session normale) : le contexte de départ n'est pas le même
|
||||
try {
|
||||
session = await startSession(modelSpec, { startContext: saved.startContext, tokenScale: session.tokenScale, quiet: true });
|
||||
session = await startSession(modelSpec, { startContext: saved.startContext, quiet: true });
|
||||
} catch (err) {
|
||||
ui.error(`Chargement impossible : ${(err as Error).message}`);
|
||||
continue;
|
||||
|
|
@ -499,7 +492,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
session.pendingContext.length = 0;
|
||||
session.pendingContext.push(...saved.pendingContext);
|
||||
sessions.restore(saved);
|
||||
refreshContext(session);
|
||||
showContext(session);
|
||||
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
|
||||
if (session.pendingContext.length > 0) {
|
||||
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
|
||||
|
|
@ -576,7 +569,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
if (controller.signal.aborted) {
|
||||
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
|
||||
refreshContext(session);
|
||||
showContext(session);
|
||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||
} else {
|
||||
ui.error((err as Error).message);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,8 @@
|
|||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"js-tiktoken": "^1.0.21"
|
||||
},
|
||||
"scripts": { "typecheck": "tsc -p ." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createSession, estimateSessionTokens, runTurn, sessionContextTokens } from "./agent";
|
||||
import { createSession, runTurn, sessionContextTokens } from "./agent";
|
||||
import { countTextTokens } from "./context";
|
||||
import type { AgentUI, LLMProvider, PermissionPolicy, StreamEvent, Tool } from "./types";
|
||||
|
||||
const silentUI: AgentUI = {
|
||||
|
|
@ -127,106 +128,142 @@ describe("taille du contexte", () => {
|
|||
const echo: Tool = {
|
||||
spec: { name: "echo", description: "echo", inputSchema: { type: "object" } },
|
||||
permission: "auto",
|
||||
execute: async () => "ok",
|
||||
execute: async () => "résultat de l'outil",
|
||||
};
|
||||
|
||||
it("utilise la consommation renvoyée par le serveur après chaque réponse", async () => {
|
||||
const seen: number[] = [];
|
||||
let call = 0;
|
||||
const provider: LLMProvider = {
|
||||
/** Provider qui répond par un texte, après un appel d'outil au 1er tour ; il renvoie des consommations absurdes (ignorées). */
|
||||
function chatty(): LLMProvider {
|
||||
let n = 0;
|
||||
return {
|
||||
id: "fake",
|
||||
model: "fake",
|
||||
capabilities: { tools: true },
|
||||
async *chat(): AsyncIterable<StreamEvent> {
|
||||
call++;
|
||||
if (call === 1) {
|
||||
yield { type: "tool_use", id: "t1", name: "echo", input: {} };
|
||||
yield { type: "usage", inputTokens: 1000, outputTokens: 50 };
|
||||
async *chat(req): AsyncIterable<StreamEvent> {
|
||||
n++;
|
||||
const last = req.messages.at(-1);
|
||||
const afterTool = last?.content.some((b) => b.type === "tool_result");
|
||||
if (!afterTool && n % 2 === 1) {
|
||||
yield { type: "tool_use", id: `t${n}`, name: "echo", input: { n } };
|
||||
yield { type: "usage", inputTokens: 7, outputTokens: 1 };
|
||||
yield { type: "done", stopReason: "tool_use" };
|
||||
} else {
|
||||
yield { type: "text_delta", text: "fin" };
|
||||
yield { type: "usage", inputTokens: 1200, outputTokens: 30 };
|
||||
yield { type: "text_delta", text: `réponse numéro ${n} `.repeat(5) };
|
||||
yield { type: "usage", inputTokens: 999_999 - n * 10_000, outputTokens: 3 };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
}
|
||||
yield { type: "done", stopReason: call === 1 ? "tool_use" : "end_turn" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("ne fait que croître quand la conversation s'enrichit (aucune variation due au serveur)", async () => {
|
||||
const reported: number[] = [];
|
||||
const session = createSession({
|
||||
provider,
|
||||
init: { systemPrompt: "t", tools: [echo] },
|
||||
ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
|
||||
provider: chatty(),
|
||||
init: { systemPrompt: "prompt système", tools: [echo] },
|
||||
ui: { ...silentUI, onContextUsage: (n) => reported.push(n) },
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await runTurn(session, "go");
|
||||
expect(seen).toEqual([1050, 1230]);
|
||||
expect(session.contextTokens).toBe(1230);
|
||||
const beforePrompts: number[] = [sessionContextTokens(session)];
|
||||
for (const prompt of ["premier message", "deuxième message un peu plus long", "troisième", "quatrième"]) {
|
||||
await runTurn(session, prompt);
|
||||
beforePrompts.push(sessionContextTokens(session)); // valeur affichée avant la saisie suivante
|
||||
}
|
||||
for (let i = 1; i < beforePrompts.length; i++) expect(beforePrompts[i]!).toBeGreaterThan(beforePrompts[i - 1]!);
|
||||
for (let i = 1; i < reported.length; i++) expect(reported[i]!).toBeGreaterThan(reported[i - 1]!);
|
||||
expect(reported.at(-1)).toBe(beforePrompts.at(-1));
|
||||
});
|
||||
|
||||
it("sans consommation renvoyée : estimation à partir de la conversation", async () => {
|
||||
it("la valeur envoyée à l'UI après chaque réponse est le comptage local de la session", async () => {
|
||||
const seen: number[] = [];
|
||||
const session = createSession({
|
||||
provider: fakeProvider("echo"),
|
||||
provider: chatty(),
|
||||
init: { systemPrompt: "x".repeat(4000), tools: [echo] },
|
||||
ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await runTurn(session, "go");
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).toBeGreaterThanOrEqual(1000); // prompt système de 4000 caractères
|
||||
expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale)
|
||||
expect(seen[0]).toBeGreaterThan(0);
|
||||
expect(seen[1]).toBeGreaterThan(seen[0]!);
|
||||
expect(session.contextTokens).toBe(seen[1]);
|
||||
});
|
||||
|
||||
it("valeur du serveur : marquée exacte ; estimation : marquée estimée", async () => {
|
||||
const flags: (boolean | undefined)[] = [];
|
||||
let call = 0;
|
||||
const withUsage: LLMProvider = {
|
||||
id: "f",
|
||||
model: "f",
|
||||
capabilities: { tools: false },
|
||||
async *chat(): AsyncIterable<StreamEvent> {
|
||||
call++;
|
||||
yield { type: "text_delta", text: "ok" };
|
||||
yield { type: "usage", inputTokens: 500, outputTokens: 5 };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
};
|
||||
const a = createSession({ provider: withUsage, init: { systemPrompt: "t", tools: [] }, ui: { ...silentUI, onContextUsage: (_n, exact) => flags.push(exact) }, cwd: "." });
|
||||
await runTurn(a, "x");
|
||||
const b = createSession({ provider: fakeProvider("echo"), init: { systemPrompt: "t", tools: [echo] }, ui: { ...silentUI, onContextUsage: (_n, exact) => flags.push(exact) }, cwd: "." });
|
||||
await runTurn(b, "x");
|
||||
expect(call).toBe(1);
|
||||
expect(flags).toEqual([true, false, false]);
|
||||
expect(a.contextExact).toBe(true);
|
||||
expect(b.contextExact).toBe(false);
|
||||
});
|
||||
|
||||
it("calibre l'estimation sur la consommation réelle du serveur", async () => {
|
||||
// Le serveur compte 2× moins de tokens que l'estimation brute : l'estimation suivante doit en tenir compte
|
||||
const provider: LLMProvider = {
|
||||
id: "f",
|
||||
model: "f",
|
||||
capabilities: { tools: false },
|
||||
async *chat(req): AsyncIterable<StreamEvent> {
|
||||
const raw = Math.ceil(req.system.length / 4);
|
||||
yield { type: "text_delta", text: "ok" };
|
||||
yield { type: "usage", inputTokens: Math.round(raw / 2) + 10, outputTokens: 1 };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
};
|
||||
const session = createSession({ provider, init: { systemPrompt: "x".repeat(8000), tools: [] }, ui: silentUI, cwd: "." });
|
||||
expect(session.tokenScale).toBe(1);
|
||||
await runTurn(session, "salut");
|
||||
expect(session.tokenScale).toBeGreaterThan(0.4);
|
||||
expect(session.tokenScale).toBeLessThan(0.6);
|
||||
expect(estimateSessionTokens(session)).toBeLessThan(1200); // 8000 caractères ≈ 2000 tokens bruts, ≈ 1000 calibrés
|
||||
expect(seen[1]).toBe(sessionContextTokens(session));
|
||||
expect(seen[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", () => {
|
||||
const session = createSession({ provider: fakeProvider("echo"), init: { systemPrompt: "t", tools: [] }, ui: silentUI, cwd: "." });
|
||||
session.contextTokens = 1000;
|
||||
expect(sessionContextTokens(session)).toBe(1000);
|
||||
session.pendingContext.push("y".repeat(4000)); // ≈ 1000 tokens
|
||||
expect(sessionContextTokens(session)).toBe(2000);
|
||||
session.tokenScale = 0.5;
|
||||
expect(sessionContextTokens(session)).toBe(1500);
|
||||
const session = createSession({ provider: chatty(), init: { systemPrompt: "t", tools: [] }, ui: silentUI, cwd: "." });
|
||||
const base = sessionContextTokens(session);
|
||||
session.pendingContext.push("y ".repeat(2000));
|
||||
expect(sessionContextTokens(session)).toBe(base + countTextTokens("y ".repeat(2000)));
|
||||
session.pendingContext.length = 0;
|
||||
expect(sessionContextTokens(session)).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe("réponse coupée par la limite de tokens de sortie", () => {
|
||||
it("avertit l'utilisateur quand le serveur signale max_tokens", async () => {
|
||||
const results: string[] = [];
|
||||
const provider: LLMProvider = {
|
||||
id: "f",
|
||||
model: "f",
|
||||
capabilities: { tools: true },
|
||||
async *chat(): AsyncIterable<StreamEvent> {
|
||||
yield { type: "text_delta", text: "début de réponse" };
|
||||
yield { type: "done", stopReason: "max_tokens" };
|
||||
},
|
||||
};
|
||||
const session = createSession({
|
||||
provider,
|
||||
init: { systemPrompt: "t", tools: [] },
|
||||
ui: { ...silentUI, onToolResult: (name, out, isError) => void results.push(`${name}|${isError}|${out}`) },
|
||||
cwd: ".",
|
||||
});
|
||||
await runTurn(session, "écris un long fichier");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toContain("agent|true|Réponse");
|
||||
expect(results[0]).toContain("MaxOutputTokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appel d'outil « réparé » par le serveur après une coupure", () => {
|
||||
const writeTool: Tool = {
|
||||
spec: { name: "Write", description: "w", inputSchema: { type: "object" } },
|
||||
permission: "auto",
|
||||
execute: async () => "ok",
|
||||
};
|
||||
function run(outputTokens: number) {
|
||||
const results: string[] = [];
|
||||
let n = 0;
|
||||
const provider: LLMProvider = {
|
||||
id: "f",
|
||||
model: "f",
|
||||
capabilities: { tools: true },
|
||||
async *chat(): AsyncIterable<StreamEvent> {
|
||||
n++;
|
||||
if (n === 1) {
|
||||
yield { type: "tool_use", id: "t", name: "Write", input: { path: "a.md" } };
|
||||
yield { type: "usage", inputTokens: 47_000, outputTokens };
|
||||
yield { type: "done", stopReason: "tool_use" };
|
||||
} else {
|
||||
yield { type: "text_delta", text: "fin" };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
}
|
||||
},
|
||||
};
|
||||
const session = createSession({
|
||||
provider,
|
||||
init: { systemPrompt: "t", tools: [writeTool] },
|
||||
ui: { ...silentUI, onToolResult: (name, out) => void results.push(`${name}|${out}`) },
|
||||
cwd: ".",
|
||||
});
|
||||
return runTurn(session, "écris").then(() => results);
|
||||
}
|
||||
|
||||
it("sortie de 4096 tokens pile avec un appel d'outil : avertissement", async () => {
|
||||
const r = await run(4096);
|
||||
expect(r.some((x) => x.startsWith("agent|Réponse probablement coupée"))).toBe(true);
|
||||
});
|
||||
it("sortie ordinaire : pas d'avertissement", async () => {
|
||||
const r = await run(137);
|
||||
expect(r.some((x) => x.startsWith("agent|"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { estimateTextTokens, estimateTokens } from "./context";
|
||||
import { countContextTokens } from "./context";
|
||||
import { runToolCalls, type ToolUseBlock } from "./tool-runner";
|
||||
import type {
|
||||
AgentSkill,
|
||||
|
|
@ -26,12 +26,6 @@ export interface AgentSession {
|
|||
messages: Message[];
|
||||
/** Résumé compacté qui sert de contexte de départ (présent dans `system`) ; absent : contexte MAIA.md / CLAUDE.md. */
|
||||
startContext?: string;
|
||||
/** Taille du contexte (tokens) après la dernière réponse du LLM : valeur renvoyée par le serveur, sinon estimation. */
|
||||
contextTokens: number;
|
||||
/** 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[];
|
||||
ui: AgentUI;
|
||||
|
|
@ -56,9 +50,6 @@ export function createSession(opts: {
|
|||
env: opts.init.env,
|
||||
messages: [],
|
||||
startContext: opts.startContext,
|
||||
contextTokens: 0,
|
||||
contextExact: false,
|
||||
tokenScale: 1,
|
||||
pendingContext: [],
|
||||
ui: opts.ui,
|
||||
cwd: opts.cwd,
|
||||
|
|
@ -66,9 +57,6 @@ export function createSession(opts: {
|
|||
}
|
||||
|
||||
const MAX_ITERATIONS = 25;
|
||||
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;
|
||||
|
||||
|
||||
/** Un tour complet : message utilisateur → (LLM → outils)* → réponse finale. */
|
||||
|
|
@ -86,8 +74,8 @@ export async function runTurn(
|
|||
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
||||
const assistant: ContentBlock[] = [];
|
||||
let text = "";
|
||||
let usedTokens: number | null = null;
|
||||
const sentEstimate = rawSessionEstimate(session); // taille estimée de la requête, pour calibrer l'estimation
|
||||
let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle
|
||||
let reportedOutput = 0; // tokens de sortie annoncés par le serveur
|
||||
|
||||
ui.onThinking(true);
|
||||
let first = true;
|
||||
|
|
@ -110,13 +98,12 @@ export async function runTurn(
|
|||
} else if (ev.type === "tool_use") {
|
||||
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
|
||||
} 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;
|
||||
if (sentEstimate >= MIN_CALIBRATION_TOKENS && ev.inputTokens > 0) {
|
||||
session.tokenScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, ev.inputTokens / sentEstimate));
|
||||
}
|
||||
reportedOutput = ev.outputTokens;
|
||||
} else if (ev.type === "done" && ev.stopReason === "max_tokens") {
|
||||
truncated = true;
|
||||
}
|
||||
// "done" : la fin du flux suffit, les appels d'outils décident de la suite
|
||||
// La consommation du serveur ne sert pas à la taille du contexte (comptée localement), seulement à repérer une coupure.
|
||||
// "done" : la fin du flux suffit, les appels d'outils décident de la suite.
|
||||
}
|
||||
} finally {
|
||||
if (first) ui.onThinking(false);
|
||||
|
|
@ -127,9 +114,18 @@ export async function runTurn(
|
|||
assistant.unshift({ type: "text", text });
|
||||
}
|
||||
session.messages.push({ role: "assistant", content: assistant });
|
||||
session.contextTokens = usedTokens ?? estimateSessionTokens(session);
|
||||
session.contextExact = usedTokens !== null;
|
||||
ui.onContextUsage?.(session.contextTokens, session.contextExact);
|
||||
ui.onContextUsage?.(sessionContextTokens(session));
|
||||
// Certains serveurs (LiteLLM derrière Bedrock/Anthropic…) « réparent » un appel d'outil coupé par la limite de sortie et
|
||||
// annoncent « tool_calls » au lieu de « length » : une sortie qui tombe pile sur une limite usuelle (4096…) est suspecte.
|
||||
if (!truncated && assistant.some((b) => b.type === "tool_use") && isUsualOutputLimit(reportedOutput)) truncated = true;
|
||||
if (truncated) {
|
||||
ui.onToolResult(
|
||||
"agent",
|
||||
"Réponse probablement coupée par la limite de tokens de sortie du modèle : un texte ou un appel d'outil (ex. le contenu d'un fichier à écrire) peut être incomplet. " +
|
||||
"Demandez un contenu plus court ou, si la limite du serveur est basse, fixez « MaxOutputTokens » dans ~/.maia/settings.json.",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
|
||||
if (calls.length === 0) return;
|
||||
|
|
@ -141,24 +137,24 @@ export async function runTurn(
|
|||
session.ui.onToolResult("agent", `Arrêt : plus de ${MAX_ITERATIONS} itérations`, true);
|
||||
}
|
||||
|
||||
type Estimable = Pick<AgentSession, "system" | "messages" | "tools">;
|
||||
|
||||
function rawSessionEstimate(session: Estimable): number {
|
||||
return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Taille du contexte de la session en cours, comptée avec js-tiktoken : prompt système, outils, messages, plus le
|
||||
* 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">,
|
||||
session: Pick<AgentSession, "system" | "messages" | "tools" | "pendingContext">,
|
||||
): number {
|
||||
const pending = session.pendingContext.reduce((sum, text) => sum + estimateTextTokens(text), 0);
|
||||
return session.contextTokens + Math.round(pending * session.tokenScale);
|
||||
return countContextTokens({
|
||||
system: session.system,
|
||||
messages: session.messages,
|
||||
tools: [...session.tools.values()].map((t) => t.spec),
|
||||
pending: session.pendingContext,
|
||||
});
|
||||
}
|
||||
|
||||
const USUAL_OUTPUT_LIMITS = new Set([1024, 2048, 4096, 8192, 16384, 32768, 65536]);
|
||||
|
||||
/** Vrai si le nombre de tokens de sortie est exactement une limite usuelle (réponse très probablement coupée). */
|
||||
export function isUsualOutputLimit(outputTokens: number): boolean {
|
||||
return USUAL_OUTPUT_LIMITS.has(outputTokens);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { CONTEXT_WINDOW_QUESTION, estimateTokens, parseTokenCount, queryContextWindow } from "./context";
|
||||
import type { LLMProvider, StreamEvent } from "./types";
|
||||
import { CONTEXT_WINDOW_QUESTION, countContextTokens, countTextTokens, parseTokenCount, queryContextWindow } from "./context";
|
||||
import type { LLMProvider, Message, StreamEvent } from "./types";
|
||||
|
||||
describe("parseTokenCount", () => {
|
||||
it.each([
|
||||
|
|
@ -36,12 +36,62 @@ describe("parseTokenCount", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("≈ 4 caractères par token, outils compris", () => {
|
||||
expect(estimateTokens("a".repeat(400), [])).toBeGreaterThanOrEqual(100);
|
||||
const without = estimateTokens("s", []);
|
||||
const withTools = estimateTokens("s", [], [{ name: "T", description: "d".repeat(400), inputSchema: {} }]);
|
||||
expect(withTools).toBeGreaterThan(without + 90);
|
||||
describe("comptage des tokens (js-tiktoken)", () => {
|
||||
it("texte vide : 0 ; plus de texte : plus de tokens", () => {
|
||||
expect(countTextTokens("")).toBe(0);
|
||||
expect(countTextTokens("bonjour")).toBeGreaterThan(0);
|
||||
expect(countTextTokens("bonjour ".repeat(50))).toBeGreaterThan(countTextTokens("bonjour ".repeat(5)));
|
||||
});
|
||||
it("un texte qui ressemble à un jeton spécial est compté comme du texte (pas d'exception)", () => {
|
||||
expect(countTextTokens("a <|endoftext|> b")).toBeGreaterThan(3);
|
||||
});
|
||||
it("résultat stable d'un appel à l'autre", () => {
|
||||
expect(countTextTokens("même texte")).toBe(countTextTokens("même texte"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("countContextTokens", () => {
|
||||
const user = (text: string): Message => ({ role: "user", content: [{ type: "text", text }] });
|
||||
const assistant = (text: string): Message => ({ role: "assistant", content: [{ type: "text", text }] });
|
||||
const tools = [{ name: "T", description: "outil de lecture ".repeat(40), inputSchema: {} }];
|
||||
|
||||
it("somme : système + outils + messages (+ 4 tokens par message) + contexte en attente", () => {
|
||||
const messages = [user("bonjour à tous"), assistant("salut, comment puis-je aider ?")];
|
||||
const total = countContextTokens({ system: "Tu es MAIA", messages, tools, pending: ["sortie de commande"] });
|
||||
const expected =
|
||||
countTextTokens("Tu es MAIA") +
|
||||
countTextTokens(JSON.stringify(tools)) +
|
||||
countTextTokens("bonjour à tous") +
|
||||
countTextTokens("salut, comment puis-je aider ?") +
|
||||
2 * 4 +
|
||||
countTextTokens("sortie de commande");
|
||||
expect(total).toBe(expected);
|
||||
});
|
||||
it("compte les appels d'outils (nom et arguments) et leurs résultats", () => {
|
||||
const base: Message[] = [user("lis le fichier")];
|
||||
const withTool: Message[] = [
|
||||
...base,
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: { path: "a.txt" } }] },
|
||||
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "contenu du fichier ".repeat(20) }] },
|
||||
];
|
||||
expect(countContextTokens({ system: "s", messages: withTool })).toBeGreaterThan(
|
||||
countContextTokens({ system: "s", messages: base }) + 50,
|
||||
);
|
||||
});
|
||||
it("ajouter un message ne fait qu'augmenter le total", () => {
|
||||
const messages: Message[] = [];
|
||||
let previous = countContextTokens({ system: "s", messages, tools });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
messages.push(i % 2 === 0 ? user(`question ${i}`) : assistant(`réponse ${i} avec du texte`));
|
||||
const now = countContextTokens({ system: "s", messages, tools });
|
||||
expect(now).toBeGreaterThan(previous);
|
||||
previous = now;
|
||||
}
|
||||
});
|
||||
it("les outils comptent", () => {
|
||||
expect(countContextTokens({ system: "s", messages: [], tools })).toBeGreaterThan(
|
||||
countContextTokens({ system: "s", messages: [] }) + 50,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,75 @@
|
|||
// Taille du contexte : estimation locale, lecture d'une taille en tokens, question posée au LLM.
|
||||
import type { LLMProvider, Message, ToolSpec } from "./types";
|
||||
// Taille du contexte : comptage local des tokens (js-tiktoken), lecture d'une taille en tokens, question posée au LLM.
|
||||
import { Tiktoken } from "js-tiktoken/lite";
|
||||
import o200k_base from "js-tiktoken/ranks/o200k_base";
|
||||
import type { ContentBlock, LLMProvider, Message, ToolSpec } from "./types";
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const MESSAGE_OVERHEAD_TOKENS = 4; // balises de rôle autour de chaque message
|
||||
|
||||
/** Estimation d'un texte (≈ 4 caractères par token). */
|
||||
export function estimateTextTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
// Encodeur o200k_base (js-tiktoken, version « lite » : seul ce vocabulaire est embarqué), créé à la première utilisation
|
||||
let encoder: Tiktoken | undefined;
|
||||
|
||||
/** Nombre de tokens d'un texte (js-tiktoken, o200k_base). Un texte qui ressemble à un jeton spécial est compté comme du texte. */
|
||||
export function countTextTokens(text: string): number {
|
||||
if (text === "") return 0;
|
||||
encoder ??= new Tiktoken(o200k_base);
|
||||
return encoder.encode(text, [], []).length;
|
||||
}
|
||||
|
||||
// Un bloc de la conversation ne change plus une fois ajouté : son compte est mémorisé (le contexte est recompté avant
|
||||
// CHAQUE saisie de l'utilisateur, sans re-tokeniser toute la conversation à chaque fois).
|
||||
const blockCounts = new WeakMap<object, number>();
|
||||
|
||||
function countBlock(block: ContentBlock): number {
|
||||
let n = blockCounts.get(block);
|
||||
if (n === undefined) {
|
||||
n =
|
||||
block.type === "text"
|
||||
? countTextTokens(block.text)
|
||||
: block.type === "tool_use"
|
||||
? countTextTokens(block.name + JSON.stringify(block.input))
|
||||
: countTextTokens(block.content);
|
||||
blockCounts.set(block, n);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** countTextTokens avec mémoire de la dernière valeur (prompt système et outils changent rarement). */
|
||||
function lastValueCounter(): (text: string) => number {
|
||||
let lastText: string | undefined;
|
||||
let lastCount = 0;
|
||||
return (text) => {
|
||||
if (text !== lastText) {
|
||||
lastCount = countTextTokens(text);
|
||||
lastText = text;
|
||||
}
|
||||
return lastCount;
|
||||
};
|
||||
}
|
||||
const countSystem = lastValueCounter();
|
||||
const countTools = lastValueCounter();
|
||||
|
||||
export interface ContextParts {
|
||||
system: string;
|
||||
messages: Message[];
|
||||
tools?: ToolSpec[];
|
||||
/** Textes ajoutés au contexte mais pas encore envoyés (commandes « ! », fichiers cités). */
|
||||
pending?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimation brute de la taille d'une requête (prompt système, outils, messages) quand le serveur ne renvoie pas la
|
||||
* consommation réelle. Seul le contenu compte (texte, arguments et résultats d'outils), pas la structure JSON interne.
|
||||
* Elle est ensuite calibrée sur les valeurs réelles du serveur (voir AgentSession.tokenScale).
|
||||
* Nombre de tokens du contexte : prompt système + définition des outils + messages (+ contexte en attente).
|
||||
* Calcul local et déterministe, SOMME des comptes de chaque élément : ajouter un message ne fait donc qu'augmenter le
|
||||
* total (il ne diminue que si la conversation est réduite : /clear, /compact, annulation d'un tour, autre session).
|
||||
*/
|
||||
export function estimateTokens(system: string, messages: Message[], tools: ToolSpec[] = []): number {
|
||||
let chars = system.length;
|
||||
for (const m of messages) {
|
||||
for (const b of m.content) {
|
||||
if (b.type === "text") chars += b.text.length;
|
||||
else if (b.type === "tool_use") chars += b.name.length + JSON.stringify(b.input).length;
|
||||
else chars += b.content.length;
|
||||
}
|
||||
export function countContextTokens(parts: ContextParts): number {
|
||||
let total = countSystem(parts.system);
|
||||
if (parts.tools && parts.tools.length > 0) total += countTools(JSON.stringify(parts.tools));
|
||||
for (const m of parts.messages) {
|
||||
total += MESSAGE_OVERHEAD_TOKENS;
|
||||
for (const b of m.content) total += countBlock(b);
|
||||
}
|
||||
if (tools.length > 0) chars += JSON.stringify(tools).length;
|
||||
return Math.ceil(chars / CHARS_PER_TOKEN) + messages.length * MESSAGE_OVERHEAD_TOKENS;
|
||||
for (const p of parts.pending ?? []) total += countTextTokens(p);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Question posée au LLM après le choix du modèle (la consigne finale facilite la lecture de la réponse). */
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@ export interface AgentUI {
|
|||
onToolCall(name: string, summary: string): void;
|
||||
onToolResult(name: string, output: string, isError: boolean): void;
|
||||
confirm(message: string): Promise<boolean>;
|
||||
/** Taille courante du contexte (en tokens), après chaque réponse du LLM. `exact` : valeur du serveur, sinon estimation. */
|
||||
onContextUsage?(tokens: number, exact?: boolean): void;
|
||||
/** Taille courante du contexte (en tokens, comptée localement), après chaque réponse du LLM. */
|
||||
onContextUsage?(tokens: number): void;
|
||||
/** Outils lancés en parallèle (2 appels ou plus) : noms et nombre déjà terminés ; null : plus rien ne tourne. */
|
||||
onToolsRunning?(state: { names: string[]; done: number } | null): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { LLMProvider } from "@maia/core";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildChatBody, parseSse } from "./openai";
|
||||
import { authHeaders, joinUrl, type MaiaSettings } from "./settings";
|
||||
|
||||
|
|
@ -13,6 +16,32 @@ async function errorText(response: Response): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic : MAIA_DEBUG_LLM=1 (ou MAIA_DEBUG_LLM=/chemin/fichier.log) enregistre le flux brut renvoyé par LiteLLM
|
||||
* (fichier par défaut : maia-llm-debug.log dans le dossier temporaire). Sans la variable, ne fait rien.
|
||||
*/
|
||||
async function* debugTee(body: AsyncIterable<Uint8Array>, model: string, messageCount: number): AsyncGenerator<Uint8Array> {
|
||||
const target = process.env.MAIA_DEBUG_LLM;
|
||||
if (!target) {
|
||||
yield* body;
|
||||
return;
|
||||
}
|
||||
const file = target === "1" ? join(tmpdir(), "maia-llm-debug.log") : target;
|
||||
const log = (text: string): void => {
|
||||
try {
|
||||
appendFileSync(file, text);
|
||||
} catch {
|
||||
// le diagnostic ne doit jamais casser une requête
|
||||
}
|
||||
};
|
||||
const decoder = new TextDecoder();
|
||||
log(`\n===== ${new Date().toISOString()} · modèle ${model} · ${messageCount} messages =====\n`);
|
||||
for await (const part of body) {
|
||||
log(decoder.decode(part, { stream: true }));
|
||||
yield part;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider unique : toutes les requêtes des agents partent au format OpenAI vers le endpoint OpenAI de LiteLLM
|
||||
* (LiteLLM_URL + OpenAI_EndPoint), quel que soit le modèle choisi.
|
||||
|
|
@ -29,7 +58,7 @@ export function createLiteLLMProvider(model: string, settings: MaiaSettings): LL
|
|||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...authHeaders(settings) },
|
||||
body: JSON.stringify(buildChatBody(model, req)),
|
||||
body: JSON.stringify(buildChatBody(model, { ...req, maxTokens: req.maxTokens ?? settings.MaxOutputTokens })),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -40,7 +69,7 @@ export function createLiteLLMProvider(model: string, settings: MaiaSettings): LL
|
|||
throw new Error(`Échec de l'appel au modèle ${model} (HTTP ${response.status} ${response.statusText}). ${await errorText(response)}`);
|
||||
}
|
||||
if (!response.body) throw new Error("Réponse LiteLLM sans contenu.");
|
||||
yield* parseSse(response.body as unknown as AsyncIterable<Uint8Array>);
|
||||
yield* parseSse(debugTee(response.body as unknown as AsyncIterable<Uint8Array>, model, req.messages.length));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,28 @@ describe("format OpenAI", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("streaming : fragments d'arguments sans identifiant ni nom sur un nouvel index = suite du même appel", async () => {
|
||||
const raw =
|
||||
sse({ choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "Write", arguments: '{"path":"a.md",' } }] } }] }) +
|
||||
sse({ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '"content":"# Titre"}' } }] } }] }) +
|
||||
sse({ choices: [{ delta: {}, finish_reason: "tool_calls" }] });
|
||||
expect(await collect(parseSse(chunks(raw)))).toEqual([
|
||||
{ type: "tool_use", id: "c1", name: "Write", input: { path: "a.md", content: "# Titre" } },
|
||||
{ type: "done", stopReason: "tool_use" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("streaming : deux appels qui reprennent le même index mais avec des identifiants différents restent distincts", async () => {
|
||||
const raw =
|
||||
sse({ choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "Read", arguments: '{"path":"a"}' } }] } }] }) +
|
||||
sse({ choices: [{ delta: { tool_calls: [{ index: 0, id: "c2", function: { name: "Read", arguments: '{"path":"b"}' } }] }, finish_reason: "tool_calls" }] });
|
||||
expect(await collect(parseSse(chunks(raw)))).toEqual([
|
||||
{ type: "tool_use", id: "c1", name: "Read", input: { path: "a" } },
|
||||
{ type: "tool_use", id: "c2", name: "Read", input: { path: "b" } },
|
||||
{ type: "done", stopReason: "tool_use" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("streaming : consommation (tokens) lue dans le dernier morceau, avant « done »", async () => {
|
||||
const raw =
|
||||
sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) +
|
||||
|
|
@ -122,3 +144,23 @@ describe("format OpenAI", () => {
|
|||
await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MaxOutputTokens", () => {
|
||||
it("lu dans settings.json, entier positif obligatoire ; absent par défaut", async () => {
|
||||
const base = '"LiteLLM_URL":"http://x","OpenAI_EndPoint":"/c","LiteLLM_Models":"/m"';
|
||||
const mk = async (extra: string): Promise<string> => {
|
||||
const h = await mkdtemp(join(tmpdir(), "maia-"));
|
||||
await mkdir(join(h, ".maia"));
|
||||
await writeFile(join(h, ".maia", "settings.json"), `{${base}${extra}}`);
|
||||
return h;
|
||||
};
|
||||
expect((await loadSettings(await mk(""))).MaxOutputTokens).toBe(undefined);
|
||||
expect((await loadSettings(await mk(',"MaxOutputTokens":16000'))).MaxOutputTokens).toBe(16000);
|
||||
await expect(loadSettings(await mk(',"MaxOutputTokens":"beaucoup"'))).rejects.toThrow("MaxOutputTokens");
|
||||
await expect(loadSettings(await mk(',"MaxOutputTokens":0'))).rejects.toThrow("MaxOutputTokens");
|
||||
});
|
||||
it("max_tokens est ajouté à la requête quand il est fourni", () => {
|
||||
const body = buildChatBody("m", { system: "s", messages: [], tools: [], maxTokens: 16000 });
|
||||
expect(body).toMatchObject({ max_tokens: 16000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
|
|||
const decoder = new TextDecoder();
|
||||
const calls = new Map<number, { id: string; name: string; args: string }>();
|
||||
let finish: string | null | undefined;
|
||||
let lastIndex: number | undefined; // index du dernier appel d'outil reçu
|
||||
// objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas)
|
||||
const seen: { usage: { inputTokens: number; outputTokens: number } | null } = { usage: null };
|
||||
let buffer = "";
|
||||
|
|
@ -95,7 +96,14 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
|
|||
const delta = choice.delta;
|
||||
if (delta?.content) yield { type: "text_delta", text: delta.content };
|
||||
for (const tc of delta?.tool_calls ?? []) {
|
||||
const i = tc.index ?? 0;
|
||||
let i = tc.index ?? 0;
|
||||
// Serveurs/modèles qui s'écartent du format OpenAI (vu via certains fournisseurs de LiteLLM) :
|
||||
// - fragment d'arguments sans identifiant ni nom sur un index inconnu → suite de l'appel précédent ;
|
||||
// - nouvel identifiant sur un index déjà pris par un autre appel → nouvel appel.
|
||||
if (!tc.id && !tc.function?.name && !calls.has(i) && lastIndex !== undefined) i = lastIndex;
|
||||
const existing = calls.get(i);
|
||||
if (tc.id && existing?.id && tc.id !== existing.id) i = Math.max(...calls.keys()) + 1;
|
||||
lastIndex = i;
|
||||
const cur = calls.get(i) ?? { id: "", name: "", args: "" };
|
||||
if (tc.id) cur.id = tc.id;
|
||||
if (tc.function?.name) cur.name += tc.function.name;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ export interface MaiaSettings {
|
|||
LiteLLM_Models: string;
|
||||
/** Optionnel : clé d'API LiteLLM (sinon variable d'environnement LITELLM_API_KEY). */
|
||||
LiteLLM_API_Key?: string;
|
||||
/**
|
||||
* Optionnel : nombre maximal de tokens de SORTIE demandé à chaque requête (`max_tokens`). Sans cette valeur, le serveur
|
||||
* applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil Write peut alors être coupé.
|
||||
*/
|
||||
MaxOutputTokens?: number;
|
||||
}
|
||||
|
||||
export const SETTINGS_EXAMPLE = `{
|
||||
|
|
@ -47,11 +52,16 @@ export async function loadSettings(home: string = homedir()): Promise<MaiaSettin
|
|||
throw new Error(`Paramètre(s) manquant(s) ou vide(s) dans ${path} : ${missing.join(", ")}\nExemple de contenu :\n${SETTINGS_EXAMPLE}`);
|
||||
}
|
||||
const apiKey = typeof json.LiteLLM_API_Key === "string" ? json.LiteLLM_API_Key.trim() : "";
|
||||
const max = Number(json.MaxOutputTokens);
|
||||
if (json.MaxOutputTokens !== undefined && !(Number.isInteger(max) && max > 0)) {
|
||||
throw new Error(`${path} : MaxOutputTokens doit être un entier positif (reçu : ${JSON.stringify(json.MaxOutputTokens)})`);
|
||||
}
|
||||
return {
|
||||
LiteLLM_URL: (json.LiteLLM_URL as string).trim(),
|
||||
OpenAI_EndPoint: (json.OpenAI_EndPoint as string).trim(),
|
||||
LiteLLM_Models: (json.LiteLLM_Models as string).trim(),
|
||||
...(apiKey ? { LiteLLM_API_Key: apiKey } : {}),
|
||||
...(json.MaxOutputTokens !== undefined ? { MaxOutputTokens: max } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState })
|
|||
return (
|
||||
<Box flexDirection="column">
|
||||
<InputPrompt prompt={prompt} />
|
||||
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} exact={state.contextExact} /> : null}
|
||||
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
|
||||
</Box>
|
||||
);
|
||||
case "select":
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { contextBarModel, formatTokens } from "./context-model";
|
|||
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
|
||||
const EMPTY_COLOR = "#555555";
|
||||
|
||||
export function ContextBar({ used, max, exact = true }: { used: number; max: number; exact?: boolean }) {
|
||||
export function ContextBar({ used, max }: { used: number; max: number }) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
|
||||
const label = `${exact ? "" : "~"}${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
||||
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
||||
// Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés
|
||||
const model = contextBarModel(used, max, Math.max(1, columns - label.length - 2));
|
||||
const scale = model.tokensPerSquare === 10_000 ? "" : ` (1■ = ${formatTokens(model.tokensPerSquare)})`;
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@ export class InkUI implements AgentUI {
|
|||
this.start().setBusy(`Outils en parallèle : ${state.done}/${total} terminés (${names})`);
|
||||
}
|
||||
/** Taille du contexte (tokens) après chaque réponse du LLM : met à jour la barre de progression. */
|
||||
onContextUsage(tokens: number, exact = true): void {
|
||||
this.start().setContextUsed(tokens, exact);
|
||||
onContextUsage(tokens: number): void {
|
||||
this.start().setContextUsed(tokens);
|
||||
}
|
||||
|
||||
/** Taille maximale du contexte du modèle (null : inconnue, la barre est masquée). */
|
||||
|
|
|
|||
|
|
@ -126,9 +126,7 @@ describe("UIStore", () => {
|
|||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 0, contextMax: null });
|
||||
s.setContextMax(200_000);
|
||||
s.setContextUsed(45_678.4);
|
||||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 45_678, contextMax: 200_000, contextExact: true });
|
||||
s.setContextUsed(46_000, false); // estimation
|
||||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 46_000, contextExact: false });
|
||||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 45_678, contextMax: 200_000 });
|
||||
s.setContextMax(null);
|
||||
expect(s.getSnapshot().contextMax).toBeNull();
|
||||
s.setContextMax(0);
|
||||
|
|
|
|||
|
|
@ -76,8 +76,6 @@ export interface UIState {
|
|||
prompt: PromptState | null;
|
||||
/** Tokens du contexte de la conversation (mis à jour après chaque réponse du LLM). */
|
||||
contextUsed: number;
|
||||
/** true : valeur donnée par le serveur ; false : estimation (la barre affiche alors « ~ »). */
|
||||
contextExact: boolean;
|
||||
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
|
||||
contextMax: number | null;
|
||||
}
|
||||
|
|
@ -86,7 +84,7 @@ const RESULT_PREVIEW_MAX = 600;
|
|||
const SHELL_PREVIEW_MAX = 4000;
|
||||
|
||||
export class UIStore {
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextExact: false, contextMax: null };
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
|
||||
private listeners = new Set<() => void>();
|
||||
private nextId = 1;
|
||||
private history: string[] = [];
|
||||
|
|
@ -143,8 +141,8 @@ export class UIStore {
|
|||
}
|
||||
|
||||
/** Contexte actuel de la conversation, en tokens. */
|
||||
setContextUsed(tokens: number, exact = true): void {
|
||||
this.commit({ contextUsed: Math.max(0, Math.round(tokens)), contextExact: exact });
|
||||
setContextUsed(tokens: number): void {
|
||||
this.commit({ contextUsed: Math.max(0, Math.round(tokens)) });
|
||||
}
|
||||
|
||||
/** Taille maximale du contexte du modèle (null : inconnue, barre masquée). */
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ importers:
|
|||
version: 22.20.5
|
||||
tsup:
|
||||
specifier: ^8.3.0
|
||||
version: 8.5.1(postcss@8.5.30)(tsx@4.23.15)(typescript@5.9.3)
|
||||
version: 8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3)
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.23.15
|
||||
|
|
@ -180,7 +180,7 @@ importers:
|
|||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.9(@types/node@22.20.5)
|
||||
version: 2.1.9(@types/node@22.20.5)(supports-color@10.2.2)
|
||||
|
||||
packages/agent:
|
||||
dependencies:
|
||||
|
|
@ -247,7 +247,11 @@ importers:
|
|||
specifier: ^5.0.5
|
||||
version: 5.0.6
|
||||
|
||||
packages/core: {}
|
||||
packages/core:
|
||||
dependencies:
|
||||
js-tiktoken:
|
||||
specifier: ^1.0.21
|
||||
version: 1.0.21
|
||||
|
||||
packages/providers:
|
||||
dependencies:
|
||||
|
|
@ -271,7 +275,7 @@ importers:
|
|||
version: 7.1.1(@types/react@19.3.0)(react@19.3.1)
|
||||
markdansi:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.1
|
||||
version: 0.2.1(supports-color@10.2.2)
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.3.1
|
||||
|
|
@ -973,6 +977,9 @@ packages:
|
|||
resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
bundle-require@5.1.0:
|
||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -1181,6 +1188,9 @@ packages:
|
|||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -2095,6 +2105,8 @@ snapshots:
|
|||
|
||||
auto-bind@5.0.1: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bundle-require@5.1.0(esbuild@0.27.7):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
|
|
@ -2151,9 +2163,11 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
|
|
@ -2338,6 +2352,10 @@ snapshots:
|
|||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tiktoken@1.0.21:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
|
@ -2352,12 +2370,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
markdansi@0.2.1:
|
||||
markdansi@0.2.1(supports-color@10.2.2):
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-gfm: 3.1.0
|
||||
micromark: 4.0.3
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-gfm: 3.1.0(supports-color@10.2.2)
|
||||
micromark: 4.0.3(supports-color@10.2.2)
|
||||
micromark-extension-gfm: 3.0.0
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
string-width: 8.3.0
|
||||
|
|
@ -2375,14 +2393,14 @@ snapshots:
|
|||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
mdast-util-from-markdown@2.1.0:
|
||||
mdast-util-from-markdown@2.1.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
mdast-util-to-string: 4.0.0
|
||||
micromark: 4.0.3
|
||||
micromark: 4.0.3(supports-color@10.2.2)
|
||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||
micromark-util-decode-string: 2.0.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
|
|
@ -2400,51 +2418,51 @@ snapshots:
|
|||
mdast-util-find-and-replace: 3.0.3
|
||||
micromark-util-character: 2.1.1
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-strikethrough@2.0.1:
|
||||
mdast-util-gfm-strikethrough@2.0.1(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-table@2.0.0:
|
||||
mdast-util-gfm-table@2.0.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
markdown-table: 3.0.4
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-task-list-item@2.0.0:
|
||||
mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm@3.1.0:
|
||||
mdast-util-gfm@3.1.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-from-markdown: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-gfm-autolink-literal: 2.0.1
|
||||
mdast-util-gfm-footnote: 2.1.0
|
||||
mdast-util-gfm-strikethrough: 2.0.1
|
||||
mdast-util-gfm-table: 2.0.0
|
||||
mdast-util-gfm-task-list-item: 2.0.0
|
||||
mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2)
|
||||
mdast-util-gfm-strikethrough: 2.0.1(supports-color@10.2.2)
|
||||
mdast-util-gfm-table: 2.0.0(supports-color@10.2.2)
|
||||
mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2)
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -2646,10 +2664,10 @@ snapshots:
|
|||
|
||||
micromark-util-types@2.0.3: {}
|
||||
|
||||
micromark@4.0.3:
|
||||
micromark@4.0.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.4
|
||||
|
|
@ -2857,13 +2875,13 @@ snapshots:
|
|||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsup@8.5.1(postcss@8.5.30)(tsx@4.23.15)(typescript@5.9.3):
|
||||
tsup@8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.7)
|
||||
cac: 6.7.14
|
||||
chokidar: 4.0.3
|
||||
consola: 3.4.2
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
esbuild: 0.27.7
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
|
|
@ -2924,10 +2942,10 @@ snapshots:
|
|||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
vite-node@2.1.9(@types/node@22.20.5):
|
||||
vite-node@2.1.9(@types/node@22.20.5)(supports-color@10.2.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@22.20.5)
|
||||
|
|
@ -2951,7 +2969,7 @@ snapshots:
|
|||
'@types/node': 22.20.5
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@2.1.9(@types/node@22.20.5):
|
||||
vitest@2.1.9(@types/node@22.20.5)(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.5))
|
||||
|
|
@ -2961,7 +2979,7 @@ snapshots:
|
|||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
|
@ -2971,7 +2989,7 @@ snapshots:
|
|||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.21(@types/node@22.20.5)
|
||||
vite-node: 2.1.9(@types/node@22.20.5)
|
||||
vite-node: 2.1.9(@types/node@22.20.5)(supports-color@10.2.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.5
|
||||
|
|
|
|||
Loading…
Reference in a new issue