Compare commits
2 commits
7a279a6065
...
9c403acff4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c403acff4 | ||
|
|
6ecf78a04c |
20 changed files with 607 additions and 90 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ dist
|
|||
*.log
|
||||
.env
|
||||
.maia
|
||||
HOW_To_Agents.md
|
||||
resume.md
|
||||
|
|
|
|||
256
GUIDE_PERMISSIONS.md
Normal file
256
GUIDE_PERMISSIONS.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# Guide des permissions de MAIA
|
||||
|
||||
MAIA demande une confirmation avant d'utiliser un outil sensible (Bash, Write, Edit, WebFetch, Agent…). Les **permissions** permettent d'autoriser, de demander ou d'interdire à l'avance des usages précis, pour ne plus répondre « o/n » à chaque `ls` — et pour bloquer définitivement ce qui ne doit jamais s'exécuter.
|
||||
|
||||
Les exemples de ce guide ont été vérifiés avec le moteur de permissions de MAIA (`packages/agent/src/permissions.ts`).
|
||||
|
||||
## 1. Où les définir
|
||||
|
||||
| Fichier | Portée |
|
||||
| --- | --- |
|
||||
| `~/.maia/settings.json` | toutes vos sessions (dossier de l'utilisateur) |
|
||||
| `./.maia/settings.json` | le projet (dossier de lancement de MAIA) |
|
||||
|
||||
Les deux fichiers s'**additionnent** (les listes sont fusionnées). Le fichier du dossier parent n'est pas lu. `~/.maia/settings.json` contient déjà la configuration LiteLLM : ajoutez simplement la propriété `permissions`.
|
||||
|
||||
```json
|
||||
{
|
||||
"LiteLLM_URL": "http://localhost:4000",
|
||||
"OpenAI_EndPoint": "/v1/chat/completions",
|
||||
"LiteLLM_Models": "/v1/models",
|
||||
|
||||
"permissions": {
|
||||
"allow": ["Bash(ls *)", "Read"],
|
||||
"ask": ["Bash(git push *)"],
|
||||
"deny": ["Bash(rm *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les trois listes sont facultatives. La commande `/permissions` affiche les règles chargées, par fichier (badges `ALLOW`, `ASK`, `DENY`). Une règle invalide est ignorée avec un avertissement au démarrage. Les règles sont lues au démarrage, à `/clear` et à `/new` : relancez ou tapez `/clear` après une modification.
|
||||
|
||||
## 2. Les trois familles et leur priorité
|
||||
|
||||
| Liste | Effet |
|
||||
| --- | --- |
|
||||
| `deny` | l'appel est **refusé** sans rien demander ; le modèle est prévenu de ne pas réessayer tel quel |
|
||||
| `ask` | l'appel demande **toujours** une confirmation |
|
||||
| `allow` | l'appel s'exécute **sans confirmation** |
|
||||
|
||||
Ordre d'évaluation : **`deny` > `ask` > `allow`**. La première famille qui correspond décide, quelle que soit la précision des règles : un `allow` très précis ne peut jamais lever un `deny` ou un `ask` plus large. Si **aucune** règle ne s'applique, MAIA demande une confirmation (c'est le comportement par défaut, pour tous les outils).
|
||||
|
||||
## 3. Syntaxe d'une règle
|
||||
|
||||
```
|
||||
Outil
|
||||
Outil(motif)
|
||||
```
|
||||
|
||||
- `Outil` seul (ex. `"Read"`) : **tous** les usages de l'outil.
|
||||
- `Outil(motif)` : seulement les usages qui correspondent au motif. Le nom de l'outil respecte la casse (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebFetch`, `Skill`, `Agent`, outils personnalisés…).
|
||||
- Chaque règle est une chaîne JSON : les guillemets doubles du motif doivent être échappés (`\"`).
|
||||
|
||||
Le contenu du motif dépend de l'outil :
|
||||
|
||||
| Outil | Le motif est… | Exemples |
|
||||
| --- | --- | --- |
|
||||
| `Bash` | une commande (voir § 4) | `Bash(ls *)`, `Bash(npm run test:*)` |
|
||||
| `Read`, `Write`, `Edit`, `Glob`, `Grep` | un motif de **chemin** relatif au dossier de lancement (glob `*`, `**`) ; un dossier couvre son contenu | `Read(src/**)`, `Edit(docs)`, `Write(*.md)`, `Read(.env)` |
|
||||
| `WebFetch` | `domain:nom.de.domaine` (le `*` est accepté) | `WebFetch(domain:docs.python.org)`, `WebFetch(domain:*.github.com)` |
|
||||
| `Skill` | le nom du skill | `Skill(api-docs)` |
|
||||
| `Agent` | le nom du sous-agent | `Agent(code_reviewer)` |
|
||||
| outils personnalisés | un motif (`*`) comparé aux valeurs des paramètres | `addition(*)` |
|
||||
|
||||
Exemples hors Bash :
|
||||
|
||||
```json
|
||||
"permissions": {
|
||||
"allow": ["Read", "Glob", "Grep", "Edit(src/**)", "WebFetch(domain:docs.python.org)", "Agent(code_reviewer)"],
|
||||
"deny": ["Read(.env)", "Write(.git/**)"]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Les règles `Bash`
|
||||
|
||||
### 4.1 Comment une commande est analysée
|
||||
|
||||
MAIA découpe la commande aux séparateurs `&&`, `||`, `;`, `|` et aux retours à la ligne, **sans tenir compte des guillemets**. Chaque morceau est une « sous-commande ».
|
||||
|
||||
- Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation.
|
||||
- Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`.
|
||||
- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`) ou une redirection (`>`, `>>`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours.
|
||||
|
||||
### 4.2 Écrire un motif
|
||||
|
||||
| Motif | Correspond à | Ne correspond pas à |
|
||||
| --- | --- | --- |
|
||||
| `ls` | `ls` exactement | `ls -la` |
|
||||
| `ls *` | `ls`, `ls -la`, `ls src` | `lsof` |
|
||||
| `npm run test:*` | tout ce qui **commence** par `npm run test:` (ancienne syntaxe « préfixe ») | `npm run build` |
|
||||
| `git * --help` | `git commit --help`, `git log --help` | `git commit` |
|
||||
| `git status` | `git status` exactement | `git status -s` |
|
||||
|
||||
Règle pratique : `commande *` autorise la commande avec ou sans arguments ; la commande seule la limite à elle-même. Les espaces comptent. Un `*` au milieu remplace n'importe quelle suite de caractères. `"Bash"` seul autorise **toute** commande : à éviter sauf dans un environnement jetable.
|
||||
|
||||
## 5. Exemple simple : autoriser `ls`
|
||||
|
||||
Pour ne plus confirmer `ls`, avec ou sans options :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(ls *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Résultats :
|
||||
|
||||
| Commande demandée par le modèle | Décision |
|
||||
| --- | --- |
|
||||
| `ls` | autorisée |
|
||||
| `ls -la src` | autorisée |
|
||||
| `lsof` | confirmation (autre commande) |
|
||||
| `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) |
|
||||
| `ls > sortie.txt` | confirmation (redirection) |
|
||||
| `ls $(pwd)` | confirmation (substitution) |
|
||||
|
||||
Pour autoriser plusieurs commandes de lecture :
|
||||
|
||||
```json
|
||||
"allow": ["Bash(ls *)", "Bash(cat *)", "Bash(grep *)", "Bash(git status)", "Bash(git diff *)", "Bash(git log *)"]
|
||||
```
|
||||
|
||||
Un tube entre commandes toutes autorisées passe : `cat a.txt | grep foo` est autorisé avec `Bash(cat *)` et `Bash(grep *)`.
|
||||
|
||||
## 6. Exemple complexe : `podman run … sh -c "… | grep …"`
|
||||
|
||||
Commande à autoriser :
|
||||
|
||||
```
|
||||
podman run --rm alpine sh -c "echo 'hello world' | grep 'll'"
|
||||
```
|
||||
|
||||
**Piège** : le `|` à l'intérieur des guillemets coupe quand même la commande en deux morceaux :
|
||||
|
||||
1. `podman run --rm alpine sh -c "echo 'hello world'`
|
||||
2. `grep 'll'"`
|
||||
|
||||
Une règle qui reproduit la commande entière n'est donc **jamais** reconnue, et une règle `podman … sh -c *` seule ne couvre que le premier morceau : MAIA redemande confirmation. (Les deux cas ont été vérifiés.) Chaque morceau doit avoir sa règle :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(podman run --rm alpine sh -c *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avec ces deux règles, la commande ci-dessus est autorisée sans confirmation. Remarques :
|
||||
|
||||
- `Bash(podman run --rm alpine sh -c *)` limite l'image (`alpine`) et l'option `--rm` : `podman run --privileged …` ou une autre image redemandent confirmation.
|
||||
- `Bash(grep *)` autorise `grep` partout, pas seulement dans ce conteneur. C'est le prix du découpage : si c'est trop large, ne l'autorisez pas et acceptez la confirmation pour cette commande.
|
||||
- Dans un fichier JSON, écrivez la règle telle quelle ; il n'y a pas de guillemets à échapper tant que le motif n'en contient pas. Si le motif en contient : `"Bash(echo \"hello\")"`.
|
||||
|
||||
Les morceaux non couverts restent bloqués : avec les mêmes règles, `podman run --rm alpine sh -c "echo x | rm -rf /tmp/y"` demande confirmation (le morceau `rm -rf /tmp/y"` n'est pas autorisé).
|
||||
|
||||
## 7. Interdire une commande, même cachée dans un tube
|
||||
|
||||
Pour interdire `rm` quoi qu'il arrive :
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"deny": ["Bash(rm *)", "Bash(* rm *)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les deux règles sont complémentaires :
|
||||
|
||||
- `Bash(rm *)` : un morceau qui **commence** par `rm`.
|
||||
- `Bash(* rm *)` : `rm` apparaît comme un mot **après** autre chose (`xargs rm`, `sudo rm`, `time rm`…).
|
||||
|
||||
Vérifications (avec ces deux règles) :
|
||||
|
||||
| Commande | Décision |
|
||||
| --- | --- |
|
||||
| `rm -rf build` | refusée |
|
||||
| `ls && rm -rf build` | refusée |
|
||||
| `echo ok > f; rm f` | refusée |
|
||||
| `cat f \| (rm -f g)` | refusée |
|
||||
| `echo $(rm -rf x)` | refusée |
|
||||
| `ls \| xargs rm` | refusée (grâce à `* rm *`) |
|
||||
| `podman run --rm alpine sh -c "echo x \| rm -rf /tmp/y"` | refusée : le découpage de `deny` entre aussi dans les guillemets |
|
||||
|
||||
Avec `Bash(rm *)` **seul**, `ls | xargs rm` n'est **pas** refusé (le morceau commence par `xargs`) : ajoutez `Bash(* rm *)`.
|
||||
|
||||
Un `deny` l'emporte toujours sur un `allow` : même avec `Bash(podman run --rm alpine sh -c *)` et `Bash(grep *)` autorisés, la commande contenant `| rm -rf …` est refusée si `Bash(rm *)` est dans `deny`.
|
||||
|
||||
Pour **demander confirmation** plutôt qu'interdire (ex. `git push`), utilisez `ask` :
|
||||
|
||||
```json
|
||||
"ask": ["Bash(git push *)", "Bash(git reset --hard *)"]
|
||||
```
|
||||
|
||||
Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation.
|
||||
|
||||
## 8. Limites : `deny` n'est pas un bac à sable
|
||||
|
||||
Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir :
|
||||
|
||||
- `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation.
|
||||
- Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible.
|
||||
- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` est une autre règle, à ajouter si besoin.
|
||||
|
||||
Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste.
|
||||
|
||||
## 9. Sous-agents
|
||||
|
||||
Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** :
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
allow: [Read, Grep]
|
||||
deny: [Bash]
|
||||
```
|
||||
|
||||
Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`.
|
||||
|
||||
## 10. Aide-mémoire
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read", "Glob", "Grep",
|
||||
"Edit(src/**)",
|
||||
"Bash(ls *)", "Bash(git status)", "Bash(git diff *)",
|
||||
"Bash(npm run test:*)",
|
||||
"WebFetch(domain:docs.python.org)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm *)", "Bash(* rm *)",
|
||||
"Bash(sudo *)", "Bash(* sudo *)",
|
||||
"Read(.env)", "Write(.git/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Je veux… | Règle |
|
||||
| --- | --- |
|
||||
| autoriser `ls` et ses options | `allow: ["Bash(ls *)"]` |
|
||||
| autoriser une commande exacte, sans options | `allow: ["Bash(git status)"]` |
|
||||
| autoriser un préfixe de scripts npm | `allow: ["Bash(npm run test:*)"]` |
|
||||
| autoriser une commande avec tube | une règle **par morceau** (§ 6) |
|
||||
| toujours confirmer une commande | `ask: ["Bash(git push *)"]` |
|
||||
| interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` |
|
||||
| protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` |
|
||||
| limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` |
|
||||
19
README.md
19
README.md
|
|
@ -63,7 +63,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
|
|||
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
||||
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
||||
| `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) |
|
||||
| `!commande` · `@fichier` · `§skill` | commande shell, fichier injecté, skill (voir plus bas) |
|
||||
| `!commande` · `@fichier` · `§skill` · `&agent` | commande shell, fichier injecté, skill (voir plus bas) |
|
||||
|
||||
Option de lancement : `--model <spec>` (ex. `litellm:gpt-4o`).
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `
|
|||
ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui).
|
||||
Un terminal interactif (TTY) est requis.
|
||||
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis trois textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles », puis la signature « Arnaud VINCENT » en jaune et « ClaudeCode » en orange). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
une ligne vide sépare chaque message.
|
||||
|
||||
Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
|
||||
|
|
@ -187,6 +187,7 @@ 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 : `MaxIterations` (entier, défaut 50) : étapes (appel du LLM puis des outils) par série avant que MAIA demande s'il faut continuer.
|
||||
- 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).
|
||||
|
|
@ -224,6 +225,8 @@ Conséquence : il n'y a plus de variables d'environnement venant de la configura
|
|||
|
||||
## Permissions
|
||||
|
||||
> Guide complet avec exemples (commande simple, commande avec tube, interdiction d'un `rm` caché) : [`GUIDE_PERMISSIONS.md`](GUIDE_PERMISSIONS.md).
|
||||
|
||||
Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de règles :
|
||||
|
||||
| Type | Effet |
|
||||
|
|
@ -334,6 +337,8 @@ L'appel est **bloquant** (pas de tâche de fond). Ctrl+C l'annule sans rien modi
|
|||
|
||||
Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`.
|
||||
|
||||
**Contexte refusé par le serveur** : si LiteLLM répond « Input is too long » (ou équivalent), MAIA compacte automatiquement la conversation (résultats d'outils tronqués à 1 500 caractères dans la demande de résumé, pour qu'elle tienne), puis reprend la demande une seule fois. Si le compactage échoue aussi, l'erreur d'origine est affichée (utilisez alors `/new`). Les sous-agents n'ont pas ce mécanisme.
|
||||
|
||||
## Sous-agents (`./.maia/agents`)
|
||||
|
||||
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
||||
|
|
@ -372,6 +377,7 @@ Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description
|
|||
- **Effort** : borne le nombre d'étapes du sous-agent (low 10, medium 25, high 50) et module son prompt.
|
||||
- **Modèle** : identifiant exact ou fragment (`sonnet` → premier modèle LiteLLM dont le nom contient « sonnet ») ; introuvable → modèle de l'agent principal, avec avertissement.
|
||||
- **Autorisations** : si le sous-agent doit demander une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]` (une question à la fois). Ctrl+C interrompt aussi les sous-agents en cours.
|
||||
- **Lancement par l'utilisateur** : une saisie qui **commence** par `&` lance un sous-agent directement : `&code_reviewer relis src/a.ts` (le nom est reconnu sans tenir compte de la casse ; sans tâche, l'agent exécute sa mission telle que décrite). `&` seul liste les agents. Aucune confirmation de l'outil `Agent` n'est demandée (c'est vous qui lancez), mais les autorisations des outils du sous-agent suivent les règles habituelles. Le résumé s'affiche et est joint à votre prochaine demande, comme le résultat d'une commande `!`. Ctrl+C interrompt le sous-agent.
|
||||
- **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes.
|
||||
- **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré.
|
||||
|
||||
|
|
@ -423,3 +429,12 @@ Journal des évolutions, dans l'ordre :
|
|||
31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux.
|
||||
32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu).
|
||||
33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`.
|
||||
34. **Compactage sur « Input is too long »** : réponse du serveur reconnue (`isContextTooLongError`), compactage d'urgence (résultats d'outils tronqués dans la demande de résumé), reprise de la demande une fois.
|
||||
35. **Barre de contexte pendant le travail de l'agent** : taille recomptée à chaque itération (après la demande, chaque réponse du LLM et chaque série de résultats d'outils) et barre affichée pendant les itérations, plus seulement à la saisie.
|
||||
36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré.
|
||||
37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés.
|
||||
38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende.
|
||||
39. **Limite d'itérations** : 50 par défaut, réglable avec `MaxIterations` dans `~/.maia/settings.json` ; à la limite, MAIA demande s'il faut continuer (o/n) pour une nouvelle série ; une demande tapée après un arrêt est fusionnée dans le dernier message utilisateur (alternance respectée).
|
||||
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
|
||||
41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27).
|
||||
42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte.
|
||||
|
|
|
|||
|
|
@ -172,11 +172,11 @@ Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalis
|
|||
### 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`) :
|
||||
2. Boucle d'au plus **50 itérations** par série (`MAX_ITERATIONS`, réglable par `MaxIterations`) :
|
||||
- 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 ».
|
||||
3. À la limite : si l'appelant fournit `onLimit` (le CLI pose la question « Continuer ? (o/n) »), `true` ajoute une série d'itérations ; sinon (ou refus) message d'erreur « Arrêt : plus de N itérations » et retour `"max_iterations"`. Une demande suivante est fusionnée dans le dernier message « utilisateur » (résultats d'outils) pour respecter l'alternance ; si Ctrl+C l'annule, le CLI restaure ce message.
|
||||
|
||||
Les événements `usage` du provider sont ignorés par le cœur.
|
||||
|
||||
|
|
@ -242,6 +242,7 @@ Erreurs : serveur injoignable (`LiteLLM injoignable (url) : …`), HTTP non 2xx
|
|||
| `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` |
|
||||
| `MaxIterations` (facultatif) | entier positif : itérations par série avant arrêt / question « Continuer ? » (défaut 50) |
|
||||
| `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).
|
||||
|
|
@ -426,6 +427,8 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio
|
|||
|
||||
Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`.
|
||||
|
||||
Pendant un tour, `runTurn` appelle `onContextUsage` après l'ajout de la demande, après chaque réponse du LLM et après chaque série de résultats d'outils ; la barre est affichée en permanence en bas de l'écran (saisie, itérations, outils, confirmations, listes) ; taille maximale inconnue : « N tokens · taille maximale inconnue », sans carrés.
|
||||
|
||||
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
|
||||
|
||||
### 14.1 Principe
|
||||
|
|
@ -469,6 +472,10 @@ En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inch
|
|||
|
||||
Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé.
|
||||
|
||||
### 14.5 Contexte refusé par le serveur
|
||||
|
||||
`isContextTooLongError(err)` reconnaît les erreurs « input is too long », « prompt is too long », « context_length_exceeded », « maximum context length »… Dans la boucle de `cli/src/index.ts`, hors interruption et une seule fois par demande : avertissement, `compactNow({ emergency: true })` (la demande de résumé utilise `shrinkToolResults(messages, EMERGENCY_TOOL_RESULT_CHARS = 1500)` pour tenir dans la fenêtre ; la session d'origine est sauvegardée avant), puis la demande est relancée avec une consigne de reprise sur la conversation compactée. Si le compactage échoue, l'erreur d'origine est affichée. Non couvert : sous-agents.
|
||||
|
||||
## 15. Interface (`packages/ui`)
|
||||
|
||||
### 15.1 Architecture
|
||||
|
|
@ -506,6 +513,8 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
|
|||
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter |
|
||||
| Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
|
||||
|
||||
|
||||
La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie active, grisée « Traitement en cours » pendant un tour (masquée à l'arrêt du programme), ou demande d'autorisation `ASK … (o/n)` (réponse `o`/`y` ou `n`/Échap/Ctrl+C). Les listes de choix (`/model`, `/load`) remplacent la zone le temps du choix. Au-dessus, une ligne d'état est toujours réservée (spinner « En réflexion », outils en cours ; vide sinon) : la zone de saisie garde la même position. La barre de contexte est toujours en dessous.
|
||||
## 16. Fichiers et variables
|
||||
|
||||
| Chemin / variable | Rôle |
|
||||
|
|
@ -592,6 +601,10 @@ Le modèle est résolu par le parent (`resolveSubAgentModel` : identifiant exact
|
|||
|
||||
Confirmations : l'interface de l'enfant n'affiche rien ; `confirm` est relayé au parent, qui pose la question (`[sous-agent nom] Autoriser … ?`) ; `InkUI.confirm` sérialise désormais toutes les questions (outils parallèles, plusieurs sous-agents).
|
||||
|
||||
### 20.3b Lancement direct par l'utilisateur (`&`)
|
||||
|
||||
`parseAgentInvocation(input)` (core) : uniquement si la saisie **commence** par `&` ; `&nom tâche` ou `& nom tâche`, `&` seul = liste. Le CLI trouve l'agent (nom insensible à la casse), appelle directement le lanceur (pas d'outil `Agent`, donc pas de confirmation d'outil ; spinner « Sous-agent … au travail », Ctrl+C interrompt), affiche le résumé et l'ajoute à `pendingContext` (joint à la demande suivante, comme `!`). Sans tâche : `DEFAULT_AGENT_TASK`. Agent inconnu : erreur avec la liste.
|
||||
|
||||
### 20.4 Fin, erreurs, interruption
|
||||
|
||||
- Fin normale : INFO « Sous-agent « x » terminé en N s (M appels d'outil). » (+ nom du fichier de trace) ; le résumé est le `tool_result`.
|
||||
|
|
@ -606,7 +619,7 @@ Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentT
|
|||
|
||||
| Constante | Valeur | Lieu |
|
||||
| --- | --- | --- |
|
||||
| Itérations maximales d'un tour | 25 | `agent.ts` |
|
||||
| Itérations par série d'un tour (réglable : `MaxIterations`) | 50 | `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` |
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
# Les Mystères de l'Univers Numérique
|
||||
|
||||
## Architecture Décentralisée et Résilience des Systèmes
|
||||
|
||||
Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
|
||||
|
||||
## Innovation Quantique et Calcul de Demain
|
||||
|
||||
La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
|
||||
|
||||
## Sécurité Cryptographique et Protection des Données
|
||||
|
||||
La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
|
||||
|
||||
## Intelligence Artificielle et Apprentissage Profond
|
||||
|
||||
Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
|
||||
|
||||
## Durabilité Écologique du Développement Logiciel
|
||||
|
||||
La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
|
||||
|
||||
## Collaboration Distribuée et Télétravail Technologique
|
||||
|
||||
Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
|
||||
|
||||
## Automatisation DevOps et Intégration Continue
|
||||
|
||||
Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
|
||||
|
||||
## Microservices et Architecture Modulaire
|
||||
|
||||
L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.
|
||||
|
|
@ -8,8 +8,13 @@ import {
|
|||
createSession,
|
||||
ensureMaiaHome,
|
||||
findSkill,
|
||||
EMERGENCY_TOOL_RESULT_CHARS,
|
||||
MAX_ITERATIONS,
|
||||
isCompactable,
|
||||
isContextTooLongError,
|
||||
normalizeSessionName,
|
||||
DEFAULT_AGENT_TASK,
|
||||
parseAgentInvocation,
|
||||
parseShellInvocation,
|
||||
resolveFileReferences,
|
||||
parseSkillInvocation,
|
||||
|
|
@ -78,6 +83,7 @@ Commandes en session : /help /model /permissions /agents /clear /compact /
|
|||
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
||||
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
|
||||
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
||||
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
|
||||
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
|
||||
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
|
||||
|
||||
|
|
@ -327,7 +333,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ;
|
||||
* 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
|
||||
*/
|
||||
const compactNow = async (): Promise<boolean> => {
|
||||
const compactNow = async (opts: { emergency?: boolean } = {}): Promise<boolean> => {
|
||||
if (!isCompactable(session)) {
|
||||
ui.info("Rien à compacter : la conversation est vide.");
|
||||
return false;
|
||||
|
|
@ -337,7 +343,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
ui.setBusy("Compactage de la conversation…");
|
||||
try {
|
||||
saveSession(); // la session d'origine reste complète dans son fichier
|
||||
const { summary, truncated } = await requestCompactSummary(session, controller.signal);
|
||||
const { summary, truncated } = await requestCompactSummary(
|
||||
session,
|
||||
controller.signal,
|
||||
// Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre
|
||||
opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {},
|
||||
);
|
||||
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
|
||||
session = fresh;
|
||||
let target = "";
|
||||
|
|
@ -394,6 +405,45 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Lancement direct d'un sous-agent : une saisie qui commence par « & » (&nom [tâche])
|
||||
const agentCall = parseAgentInvocation(input);
|
||||
if (agentCall) {
|
||||
if (!agentCall.name) {
|
||||
ui.info(
|
||||
agents.length > 0
|
||||
? ["Sous-agents disponibles :", ...agents.map((a) => ` &${a.name} — ${a.description}`)].join("\n")
|
||||
: "Aucun sous-agent : créez ./.maia/agents/<nom>/AGENT.md (ou ~/.maia/agents/<nom>/AGENT.md).",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const def = agents.find((a) => a.name.toLowerCase() === agentCall.name.toLowerCase());
|
||||
if (!def) {
|
||||
const names = agents.map((a) => `&${a.name}`).join(", ") || "aucun";
|
||||
ui.error(`Sous-agent inconnu : &${agentCall.name} (disponibles : ${names})`);
|
||||
continue;
|
||||
}
|
||||
const task = agentCall.task || DEFAULT_AGENT_TASK;
|
||||
const controller = new AbortController();
|
||||
ui.setInterruptHandler(() => controller.abort());
|
||||
ui.setBusy(`Sous-agent « ${def.name} » au travail…`);
|
||||
try {
|
||||
const summary = await launchSubAgent({ agent: def, task, signal: controller.signal });
|
||||
// Le résumé s'affiche et rejoint la conversation principale (joint à la prochaine demande, comme le résultat d'un « ! »)
|
||||
ui.onText(summary);
|
||||
ui.onTextEnd();
|
||||
session.pendingContext.push(`Résumé du sous-agent « ${def.name} » (lancé par l'utilisateur) pour la tâche : ${task}\n\n${summary}`);
|
||||
ui.info("Résumé ajouté au contexte : il sera joint à votre prochaine demande.");
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) ui.warn(`Sous-agent « ${def.name} » interrompu (Ctrl+C).`);
|
||||
else ui.error((err as Error).message);
|
||||
} finally {
|
||||
ui.setBusy(null);
|
||||
ui.setInterruptHandler(null);
|
||||
}
|
||||
showContext(session);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prompt = input;
|
||||
|
||||
const skillCall = parseSkillInvocation(input);
|
||||
|
|
@ -583,7 +633,8 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
await autoCompactIfNeeded();
|
||||
|
||||
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
|
||||
const mark = session.messages.length;
|
||||
let mark = session.messages.length;
|
||||
let lastBackup = session.messages.at(-1); // runTurn peut compléter ce message (reprise après la limite d'itérations)
|
||||
const contextBackup = [...session.pendingContext];
|
||||
|
||||
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
|
||||
|
|
@ -601,20 +652,44 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
}
|
||||
const controller = new AbortController();
|
||||
ui.setInterruptHandler(() => controller.abort());
|
||||
try {
|
||||
await runTurn(session, prompt, controller.signal);
|
||||
} catch (err) {
|
||||
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
|
||||
showContext(session);
|
||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||
} else {
|
||||
let attempt = prompt;
|
||||
let retried = false;
|
||||
for (;;) {
|
||||
try {
|
||||
await runTurn(session, attempt, controller.signal, {
|
||||
maxIterations: settings.MaxIterations,
|
||||
onLimit: (n) => ui.confirm(`${n} étapes effectuées sans réponse finale. Continuer pour ${settings.MaxIterations ?? MAX_ITERATIONS} étapes de plus ?`),
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
||||
if (mark > 0 && lastBackup) session.messages[mark - 1] = lastBackup;
|
||||
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
||||
showContext(session);
|
||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||
break;
|
||||
}
|
||||
if (!retried && isContextTooLongError(err)) {
|
||||
// Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois
|
||||
retried = true;
|
||||
ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande.");
|
||||
const compacted = await compactNow({ emergency: true });
|
||||
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
|
||||
if (compacted) {
|
||||
mark = 0;
|
||||
lastBackup = undefined; // nouvelle conversation (résumé comme contexte de départ)
|
||||
attempt =
|
||||
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
|
||||
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ui.error((err as Error).message);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
ui.setInterruptHandler(null);
|
||||
}
|
||||
ui.setInterruptHandler(null);
|
||||
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
|
||||
await autoCompactIfNeeded();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,11 +182,12 @@ describe("taille du contexte", () => {
|
|||
cwd: process.cwd(),
|
||||
});
|
||||
await runTurn(session, "go");
|
||||
expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale)
|
||||
// mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale
|
||||
expect(seen).toHaveLength(4);
|
||||
expect(seen[0]).toBeGreaterThan(0);
|
||||
expect(seen[1]).toBeGreaterThan(seen[0]!);
|
||||
expect(seen[1]).toBe(sessionContextTokens(session));
|
||||
expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
|
||||
for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
|
||||
expect(seen.at(-1)).toBe(sessionContextTokens(session));
|
||||
expect(seen.at(-1)!).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
|
||||
});
|
||||
|
||||
it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ export function createSession(opts: {
|
|||
};
|
||||
}
|
||||
|
||||
const MAX_ITERATIONS = 25;
|
||||
/** Nombre d'itérations par défaut avant de s'arrêter (ou de demander s'il faut continuer). */
|
||||
export const MAX_ITERATIONS = 50;
|
||||
|
||||
/** Fin d'un tour : réponse finale du modèle, ou arrêt faute d'itérations (le dernier message est alors un résultat d'outil). */
|
||||
export type TurnEnd = "done" | "max_iterations";
|
||||
|
|
@ -67,16 +68,38 @@ export async function runTurn(
|
|||
session: AgentSession,
|
||||
userText: string,
|
||||
signal?: AbortSignal,
|
||||
opts: { maxIterations?: number } = {},
|
||||
opts: {
|
||||
maxIterations?: number;
|
||||
/**
|
||||
* Appelée quand la limite d'itérations est atteinte sans réponse finale : true → continuer pour `maxIterations`
|
||||
* itérations de plus, false → s'arrêter. Absente : arrêt avec un message d'erreur.
|
||||
*/
|
||||
onLimit?: (iterations: number) => Promise<boolean>;
|
||||
} = {},
|
||||
): Promise<TurnEnd> {
|
||||
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||
const { ui } = session;
|
||||
// Le contexte en attente est joint à la demande (un seul message : certains providers exigent l'alternance user/assistant)
|
||||
const context = session.pendingContext.splice(0);
|
||||
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
|
||||
session.messages.push({ role: "user", content: [{ type: "text", text }] });
|
||||
// Après un arrêt à la limite d'itérations, le dernier message est déjà un message « utilisateur » (résultats d'outils) :
|
||||
// la nouvelle demande s'y ajoute (l'alternance user/assistant est exigée par certains serveurs)
|
||||
const last = session.messages.at(-1);
|
||||
if (last?.role === "user") {
|
||||
session.messages[session.messages.length - 1] = { ...last, content: [...last.content, { type: "text", text }] };
|
||||
} else {
|
||||
session.messages.push({ role: "user", content: [{ type: "text", text }] });
|
||||
}
|
||||
ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
let iterations = 0;
|
||||
let limit = maxIterations;
|
||||
for (;;) {
|
||||
if (iterations >= limit) {
|
||||
if (opts.onLimit && (await opts.onLimit(iterations))) limit += maxIterations;
|
||||
else break;
|
||||
}
|
||||
iterations++;
|
||||
const assistant: ContentBlock[] = [];
|
||||
let text = "";
|
||||
let truncated = false; // réponse coupée par la limite de tokens de sortie du modèle
|
||||
|
|
@ -138,8 +161,9 @@ export async function runTurn(
|
|||
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
|
||||
const results = await runToolCalls(session, calls, signal);
|
||||
session.messages.push({ role: "user", content: results });
|
||||
ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante
|
||||
}
|
||||
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
|
||||
session.ui.onToolResult("agent", `Arrêt : plus de ${iterations} itérations`, true);
|
||||
return "max_iterations";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSession } from "./agent";
|
||||
import { COMPACT_PROMPT, compactThreshold, isCompactable, requestCompactSummary, shouldCompact } from "./compact";
|
||||
import { COMPACT_PROMPT, compactThreshold, isCompactable, isContextTooLongError, requestCompactSummary, shouldCompact, shrinkToolResults } from "./compact";
|
||||
import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions";
|
||||
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
|
||||
|
||||
|
|
@ -190,3 +190,37 @@ describe("persistance d'une session compactée", () => {
|
|||
expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contexte refusé par le serveur", () => {
|
||||
it("reconnaît les messages « entrée trop longue » des principaux serveurs", () => {
|
||||
const msg = 'Échec (HTTP 400). {"error":{"code":"UPSTREAM_LLM_ERROR","message":"{\\"message\\":\\"Input is too long for requested model.\\"}"}}';
|
||||
expect(isContextTooLongError(new Error(msg))).toBe(true);
|
||||
expect(isContextTooLongError(new Error("prompt is too long: 250000 tokens > 200000 maximum"))).toBe(true);
|
||||
expect(isContextTooLongError(new Error("This model's maximum context length is 128000 tokens"))).toBe(true);
|
||||
expect(isContextTooLongError(new Error("context_length_exceeded"))).toBe(true);
|
||||
expect(isContextTooLongError(new Error("HTTP 401 Unauthorized"))).toBe(false);
|
||||
});
|
||||
it("shrinkToolResults : tronque les gros résultats d'outils sans toucher aux originaux", () => {
|
||||
const big = "x".repeat(5000);
|
||||
const original: Message[] = [
|
||||
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: big }, { type: "text", text: "garde" }] },
|
||||
];
|
||||
const out = shrinkToolResults(original, 100);
|
||||
const block = out[0]!.content[0] as { content: string };
|
||||
expect(block.content.length).toBeLessThan(200);
|
||||
expect(block.content).toContain("tronqué");
|
||||
expect(out[0]!.content[1]).toEqual({ type: "text", text: "garde" });
|
||||
expect((original[0]!.content[0] as { content: string }).content).toBe(big);
|
||||
});
|
||||
it("requestCompactSummary en urgence : la requête de résumé contient des résultats tronqués", async () => {
|
||||
const seen: ChatRequest[] = [];
|
||||
const s = sessionWith(providerReplying([{ type: "text_delta", text: "## R" }, { type: "done", stopReason: "end_turn" }], seen), [
|
||||
user("q"),
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "y".repeat(9000) }] },
|
||||
]);
|
||||
await requestCompactSummary(s, undefined, { shrinkToolResults: 500 });
|
||||
expect(JSON.stringify(seen[0]!.messages).length).toBeLessThan(3000);
|
||||
expect((s.messages[2]!.content[0] as { content: string }).content.length).toBe(9000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AgentSession } from "./agent";
|
||||
import type { Message } from "./types";
|
||||
import type { ContentBlock, Message } from "./types";
|
||||
|
||||
// Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ
|
||||
// est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ;
|
||||
|
|
@ -51,15 +51,41 @@ export function isCompactable(session: Pick<AgentSession, "messages" | "pendingC
|
|||
return session.messages.length > 0 || session.pendingContext.length > 0;
|
||||
}
|
||||
|
||||
/** Réponse du serveur « entrée trop longue » (HTTP 400 : Anthropic/Bedrock, OpenAI, LiteLLM…) : le contexte dépasse la fenêtre du modèle. */
|
||||
export function isContextTooLongError(err: unknown): boolean {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
return /input is too long|prompt is too long|too long for requested model|context[_ ]length[_ ]exceeded|maximum context length|exceeds? the (maximum )?context|context window/i.test(text);
|
||||
}
|
||||
|
||||
/** Nombre de caractères conservés par résultat d'outil quand le compactage est lancé en urgence (contexte déjà saturé). */
|
||||
export const EMERGENCY_TOOL_RESULT_CHARS = 1500;
|
||||
|
||||
/** Copie des messages dont les résultats d'outils sont tronqués à `max` caractères (les originaux ne sont pas modifiés). */
|
||||
export function shrinkToolResults(messages: Message[], max: number): Message[] {
|
||||
return messages.map((m) => ({
|
||||
...m,
|
||||
content: m.content.map((b): ContentBlock =>
|
||||
b.type === "tool_result" && b.content.length > max
|
||||
? { ...b, content: `${b.content.slice(0, max)}… (tronqué pour le compactage)` }
|
||||
: b,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée :
|
||||
* c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu).
|
||||
* Lève une erreur si le résumé est vide.
|
||||
*/
|
||||
export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise<CompactSummary> {
|
||||
export async function requestCompactSummary(
|
||||
session: AgentSession,
|
||||
signal?: AbortSignal,
|
||||
opts: { shrinkToolResults?: number } = {},
|
||||
): Promise<CompactSummary> {
|
||||
// Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer
|
||||
const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n");
|
||||
const messages: Message[] = [...session.messages];
|
||||
const messages: Message[] =
|
||||
opts.shrinkToolResults !== undefined ? shrinkToolResults(session.messages, opts.shrinkToolResults) : [...session.messages];
|
||||
const last = messages.at(-1);
|
||||
if (last?.role === "user") {
|
||||
// L'alternance user/assistant doit être respectée : on complète le dernier message
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createSession, runTurn } from "./agent";
|
||||
import { SessionStore, ensureMaiaHome, saveSubAgentTrace, subAgentSessionName } from "./sessions";
|
||||
import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, requestFinalSummary } from "./subagent";
|
||||
import { SUB_AGENT_SUMMARY_PROMPT, finalAssistantText, parseAgentInvocation, requestFinalSummary } from "./subagent";
|
||||
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
|
||||
|
||||
const ui: AgentUI = {
|
||||
|
|
@ -109,3 +109,44 @@ describe("trace d'un sous-agent", () => {
|
|||
expect(again).toBe(`${name}_2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("limite d'itérations : continuer et reprendre", () => {
|
||||
const mk = (replies: StreamEvent[][]) =>
|
||||
createSession({ provider: scripted(replies), init: { systemPrompt: "S", tools: [] }, ui, cwd: "." });
|
||||
|
||||
it("onLimit = true : le tour continue pour une nouvelle série d'itérations jusqu'à la réponse finale", async () => {
|
||||
const s = mk([callTool, callTool, callTool, say("fin")]);
|
||||
const asked: number[] = [];
|
||||
const end = await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async (n) => (asked.push(n), true) });
|
||||
expect(end).toBe("done");
|
||||
expect(asked).toEqual([2]);
|
||||
expect(finalAssistantText(s.messages)).toBe("fin");
|
||||
});
|
||||
it("onLimit = false : arrêt sur « max_iterations »", async () => {
|
||||
const s = mk([callTool]);
|
||||
expect(await runTurn(s, "q", undefined, { maxIterations: 2, onLimit: async () => false })).toBe("max_iterations");
|
||||
});
|
||||
it("nouvelle demande après un arrêt : fusionnée dans le dernier message utilisateur (alternance respectée)", async () => {
|
||||
const s = mk([callTool, say("ok")]);
|
||||
await runTurn(s, "q", undefined, { maxIterations: 1 });
|
||||
expect(s.messages.at(-1)!.role).toBe("user");
|
||||
await runTurn(s, "continue");
|
||||
s.messages.forEach((m, i) => expect(m.role).toBe(i % 2 === 0 ? "user" : "assistant"));
|
||||
const merged = s.messages[2]!;
|
||||
expect(merged.content.map((b) => b.type)).toEqual(["tool_result", "text"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lancement direct par « & »", () => {
|
||||
it("« &nom tâche » et « & nom tâche »", () => {
|
||||
expect(parseAgentInvocation("&Agent_1")).toEqual({ name: "Agent_1", task: "" });
|
||||
expect(parseAgentInvocation("&Agent_1 relis src/a.ts\net tests")).toEqual({ name: "Agent_1", task: "relis src/a.ts\net tests" });
|
||||
expect(parseAgentInvocation("& agent_1 fais X ")).toEqual({ name: "agent_1", task: "fais X" });
|
||||
});
|
||||
it("« & » seul : nom vide (liste) ; une saisie qui ne COMMENCE pas par & n'est pas un lancement", () => {
|
||||
expect(parseAgentInvocation("&")).toEqual({ name: "", task: "" });
|
||||
expect(parseAgentInvocation("lance &Agent_1")).toBeNull();
|
||||
expect(parseAgentInvocation(" &Agent_1")).toBeNull();
|
||||
expect(parseAgentInvocation("bonjour")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,3 +54,16 @@ export async function requestFinalSummary(session: AgentSession, signal?: AbortS
|
|||
messages.push({ role: "assistant", content: [{ type: "text", text: summary }] });
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Tâche donnée au sous-agent quand l'utilisateur le lance sans préciser de tâche. */
|
||||
export const DEFAULT_AGENT_TASK = "Exécute ta mission telle qu'elle est décrite dans tes instructions, puis rédige ton résumé final.";
|
||||
|
||||
/**
|
||||
* Lancement direct d'un sous-agent par l'utilisateur : une saisie qui COMMENCE par « & ».
|
||||
* « &nom tâche… » ou « & nom tâche… » ; « & » seul : name vide (liste des agents). Autre saisie : null.
|
||||
*/
|
||||
export function parseAgentInvocation(input: string): { name: string; task: string } | null {
|
||||
if (!input.startsWith("&")) return null;
|
||||
const m = /^&\s*(\S*)\s*([\s\S]*)$/.exec(input);
|
||||
return { name: m?.[1] ?? "", task: (m?.[2] ?? "").trim() };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,18 @@ describe("MaxOutputTokens", () => {
|
|||
await expect(loadSettings(await mk(',"MaxOutputTokens":"beaucoup"'))).rejects.toThrow("MaxOutputTokens");
|
||||
await expect(loadSettings(await mk(',"MaxOutputTokens":0'))).rejects.toThrow("MaxOutputTokens");
|
||||
});
|
||||
it("MaxIterations : entier positif, 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(""))).MaxIterations).toBe(undefined);
|
||||
expect((await loadSettings(await mk(',"MaxIterations":80'))).MaxIterations).toBe(80);
|
||||
await expect(loadSettings(await mk(',"MaxIterations":-1'))).rejects.toThrow("MaxIterations");
|
||||
});
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface MaiaSettings {
|
|||
* applique sa limite par défaut, parfois courte : un long fichier écrit par l'outil Write peut alors être coupé.
|
||||
*/
|
||||
MaxOutputTokens?: number;
|
||||
/** Optionnel : nombre d'itérations (appel du LLM puis des outils) par demande avant de s'arrêter ou de demander s'il faut continuer. Défaut : 50. */
|
||||
MaxIterations?: number;
|
||||
}
|
||||
|
||||
export const SETTINGS_EXAMPLE = `{
|
||||
|
|
@ -56,12 +58,17 @@ export async function loadSettings(home: string = homedir()): Promise<MaiaSettin
|
|||
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)})`);
|
||||
}
|
||||
const iterations = Number(json.MaxIterations);
|
||||
if (json.MaxIterations !== undefined && !(Number.isInteger(iterations) && iterations > 0)) {
|
||||
throw new Error(`${path} : MaxIterations doit être un entier positif (reçu : ${JSON.stringify(json.MaxIterations)})`);
|
||||
}
|
||||
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 } : {}),
|
||||
...(json.MaxIterations !== undefined ? { MaxIterations: iterations } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
|
||||
const GREEN = "#7BD74F";
|
||||
const PINK = "#E58AFF";
|
||||
const YELLOW = "#EEF527";
|
||||
const ORANGE = "#F57D27";
|
||||
const GREEN_LINES = ["Assistant IA en ligne de commande,", "propulsé par Ink et vos modèles de langage."];
|
||||
const PINK_LINES = ["Un seul agent, plusieurs modèles"];
|
||||
|
||||
|
|
@ -85,6 +87,14 @@ export function Banner({ title }: { title: string }) {
|
|||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text wrap="truncate">
|
||||
<Text color={YELLOW}>Arnaud VINCENT</Text>
|
||||
<Text> (avec </Text>
|
||||
<Text color={ORANGE}>ClaudeCode</Text>
|
||||
<Text>)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,15 @@ const DOTTED_BORDER = {
|
|||
right: "┊",
|
||||
} as const;
|
||||
|
||||
/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */
|
||||
function InputFrame({ color, children }: { color: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
|
||||
|
||||
function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
||||
|
|
@ -254,14 +263,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
|||
const { value, cursor } = view;
|
||||
const at = value[cursor] ?? " ";
|
||||
return (
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle={DOTTED_BORDER}
|
||||
borderColor="greenBright"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
paddingX={1}
|
||||
>
|
||||
<InputFrame color="greenBright">
|
||||
<Text color={INPUT_COLOR} bold>
|
||||
{`${frame} `}
|
||||
</Text>
|
||||
|
|
@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
|
|||
{value.slice(cursor + 1)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
|
||||
function WaitingInput() {
|
||||
return (
|
||||
<InputFrame color="gray">
|
||||
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -338,15 +349,16 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
|
|||
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
|
||||
// Entrée seule est ignorée : une action sensible exige une réponse explicite.
|
||||
});
|
||||
// La demande d'autorisation prend la place de la zone de saisie
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<InputFrame color="yellow">
|
||||
<Box marginRight={1} flexShrink={0}>
|
||||
<Badge color="green">ASK</Badge>
|
||||
</Box>
|
||||
<Text>
|
||||
{prompt.message} <Text dimColor>(o/n)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</InputFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +368,6 @@ 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} /> : null}
|
||||
</Box>
|
||||
);
|
||||
case "select":
|
||||
|
|
@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) {
|
|||
<Text>{state.streaming}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{state.busy ? (
|
||||
<Box marginTop={1}>
|
||||
<Spinner label={state.busy} />
|
||||
</Box>
|
||||
) : null}
|
||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null}
|
||||
{/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
|
||||
<Box marginTop={1} height={1}>
|
||||
{state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
|
||||
</Box>
|
||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
|
||||
{/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
|
||||
<ContextBar used={state.contextUsed} max={state.contextMax} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@ import { contextBarModel, formatTokens } from "./context-model";
|
|||
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
|
||||
const EMPTY_COLOR = "#555555";
|
||||
|
||||
export function ContextBar({ used, max }: { used: number; max: number }) {
|
||||
export function ContextBar({ used, max }: { used: number; max: number | null }) {
|
||||
const { stdout } = useStdout();
|
||||
if (max === null) {
|
||||
// Taille maximale inconnue : pas de carrés, seulement le contexte utilisé
|
||||
return (
|
||||
<Box justifyContent="flex-end" paddingRight={1}>
|
||||
<Text dimColor>{`${formatTokens(used)} tokens · taille maximale inconnue`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
|
||||
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class InkUI implements AgentUI {
|
|||
const app = this.app;
|
||||
if (!app) return;
|
||||
this.app = null;
|
||||
this.store.setClosing();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages
|
||||
const exited = app.waitUntilExit(); // à créer AVANT unmount()
|
||||
app.unmount();
|
||||
|
|
|
|||
|
|
@ -78,13 +78,15 @@ export interface UIState {
|
|||
contextUsed: number;
|
||||
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
|
||||
contextMax: number | null;
|
||||
/** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */
|
||||
closing: boolean;
|
||||
}
|
||||
|
||||
const RESULT_PREVIEW_MAX = 600;
|
||||
const SHELL_PREVIEW_MAX = 4000;
|
||||
|
||||
export class UIStore {
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false };
|
||||
private listeners = new Set<() => void>();
|
||||
private nextId = 1;
|
||||
private history: string[] = [];
|
||||
|
|
@ -99,6 +101,10 @@ export class UIStore {
|
|||
};
|
||||
};
|
||||
|
||||
setClosing(): void {
|
||||
this.commit({ closing: true });
|
||||
}
|
||||
|
||||
private commit(patch: Partial<UIState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const l of this.listeners) l();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
packages:
|
||||
- "packages/*"
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
|
|
|||
Loading…
Reference in a new issue