diff --git a/GUIDE_PERMISSIONS.md b/GUIDE_PERMISSIONS.md new file mode 100644 index 0000000..e360e5b --- /dev/null +++ b/GUIDE_PERMISSIONS.md @@ -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)"]` | diff --git a/README.md b/README.md index bfc2ca1..ded7b6c 100644 --- a/README.md +++ b/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 ` · `/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 ` (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 | @@ -374,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é. @@ -430,3 +434,7 @@ Journal des évolutions, dans l'ordre : 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. diff --git a/SPECS_TECH.md b/SPECS_TECH.md index 543e41b..4dc6431 100644 --- a/SPECS_TECH.md +++ b/SPECS_TECH.md @@ -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). @@ -600,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`. @@ -614,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` | diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 58340ae..77d0368 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,9 +9,12 @@ import { ensureMaiaHome, findSkill, EMERGENCY_TOOL_RESULT_CHARS, + MAX_ITERATIONS, isCompactable, isContextTooLongError, normalizeSessionName, + DEFAULT_AGENT_TASK, + parseAgentInvocation, parseShellInvocation, resolveFileReferences, parseSkillInvocation, @@ -80,6 +83,7 @@ Commandes en session : /help /model /permissions /agents /clear /compact / Sessions : /rename /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//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)`; @@ -401,6 +405,45 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { 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//AGENT.md (ou ~/.maia/agents//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); @@ -591,6 +634,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { // Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation 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 @@ -612,11 +656,15 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { let retried = false; for (;;) { try { - await runTurn(session, attempt, controller.signal); + 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."); @@ -629,7 +677,8 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { const compacted = await compactNow({ emergency: true }); ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C if (compacted) { - mark = 0; // nouvelle conversation (résumé comme contexte de départ) + 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}`; diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ea48a6c..560babc 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -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,17 +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; + } = {}, ): Promise { 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 @@ -141,7 +163,7 @@ export async function runTurn( 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"; } diff --git a/packages/core/src/subagent.test.ts b/packages/core/src/subagent.test.ts index 41705f7..fd337c3 100644 --- a/packages/core/src/subagent.test.ts +++ b/packages/core/src/subagent.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/subagent.ts b/packages/core/src/subagent.ts index 6e1be84..f1bd6bc 100644 --- a/packages/core/src/subagent.ts +++ b/packages/core/src/subagent.ts @@ -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() }; +} diff --git a/packages/providers/src/litellm/litellm.test.ts b/packages/providers/src/litellm/litellm.test.ts index e3c8866..4599d5c 100644 --- a/packages/providers/src/litellm/litellm.test.ts +++ b/packages/providers/src/litellm/litellm.test.ts @@ -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 => { + 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 }); diff --git a/packages/providers/src/litellm/settings.ts b/packages/providers/src/litellm/settings.ts index bdfdff5..caeed2c 100644 --- a/packages/providers/src/litellm/settings.ts +++ b/packages/providers/src/litellm/settings.ts @@ -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 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 } : {}), }; } diff --git a/packages/ui/src/banner.tsx b/packages/ui/src/banner.tsx index 4aafd35..e42946f 100644 --- a/packages/ui/src/banner.tsx +++ b/packages/ui/src/banner.tsx @@ -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 }) { ))} + + + Arnaud VINCENT + (avec + ClaudeCode + ) + + );