Mise à jour du projet

This commit is contained in:
Arnaud 2026-10-26 23:17:20 +01:00
parent 8d6f5fbf1c
commit 9b99844783
22 changed files with 327 additions and 52 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ dist
*.log
.env
.maia
fichier.log

View file

@ -1,7 +1,7 @@
---
# ════════════════════════════════════════════════════════════════════════════
# MODÈLE D'AGENT MAIA — créé par /createAgent
# Modifiez ce fichier : changez la description et les trois sections du bas.
# Modifiez ce fichier : changez la description et les sections du bas (Constraints est facultative).
# Les lignes qui commencent par # dans l'en-tête sont des commentaires.
# Pour activer une propriété facultative (exemples collés au #), supprimez simplement le # au début de sa ligne.
# ════════════════════════════════════════════════════════════════════════════
@ -62,6 +62,22 @@ Tu es un expert en révision de code.
- détecter les anti-patterns
- suggérer des améliorations
# Constraints
<!--
FACULTATIVE (mais recommandée). Les limites de l'agent : ce qu'il n'a PAS le droit de faire, et dans quelles
conditions il doit s'arrêter pour demander l'intervention de l'utilisateur ou d'un tiers (un humain, une équipe,
un autre service). Ce sont des règles strictes, au-dessus des missions.
L'agent travaille seul et ne peut pas poser de question : devant un cas prévu ici, il n'agit pas, s'arrête sur ce point
et le signale dans son résumé final (ce qui bloque, ce qui est attendu, et de qui).
Les interdits techniques (outils, commandes) se règlent aussi avec « tools » et « permissions » dans l'en-tête :
elles sont appliquées par MAIA, alors que cette section est une consigne donnée à l'agent.
-->
- ne modifie aucun fichier : lecture seule
- n'exécute aucune commande qui change l'état du projet ou du système
- ne divulgue jamais de secret (clé, jeton, mot de passe) trouvé dans le code ; signale seulement son emplacement
- si une faille CRITICAL est trouvée, ne tente pas de la corriger : signale-la en premier dans le résumé pour que l'utilisateur décide
- si la demande sort du périmètre (écriture de code, revue d'un autre langage), arrête-toi et indique qui doit prendre le relais
# Output
<!--
OBLIGATOIRE. Le format du résumé final de l'agent. C'est la SEULE chose renvoyée à l'agent principal :

View file

@ -1,6 +1,6 @@
# 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.
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` (à la question, `s` = « stop » : l'action n'est pas exécutée, le travail s'interrompt sans rien annuler pour vous laisser donner d'autres informations) — 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`).

View file

@ -234,7 +234,7 @@ Les permissions s'appliquent aux **outils** (intégrés ou non). Trois types de
| Type | Effet |
| --- | --- |
| `allow` | l'outil est utilisé sans approbation manuelle |
| `ask` | l'utilisateur doit confirmer chaque utilisation (badge `ASK`, réponse `o`/`n`) |
| `ask` | l'utilisateur doit confirmer chaque utilisation (badge `ASK`, réponse `o` (oui), `n` (non) ou `s` (stop, voir ci-dessous)) |
| `deny` | l'outil ne peut pas être utilisé (l'agent reçoit un refus) |
**Ordre d'évaluation : deny, puis ask, puis allow.** La première famille qui correspond décide, quelle que soit la précision des règles : un `allow` étroit comme `Bash(aws s3 ls)` ne fait pas exception à un `deny` large comme `Bash(aws *)`, ni à un `ask` plus large. **Sans règle applicable, c'est `ask` qui s'applique** (pour tous les outils, y compris Read, Glob, Grep…).
@ -267,6 +267,8 @@ Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règl
Attention : `./.maia/settings.json` d'un dépôt tiers peut contenir des règles `allow` larges ; relisez-le avant de lancer MAIA dans un projet que vous ne connaissez pas.
**Réponse « stop »** : à une demande d'autorisation `ASK … (o/n/s=stop)`, `s` signifie « je ne réponds pas à la question, je veux interrompre pour donner d'autres informations ou faire autre chose ». L'action demandée n'est **pas** exécutée (le modèle en est informé), les autres actions qui suivaient dans la même réponse ne sont pas lancées (celles déjà autorisées vont à leur terme), puis MAIA rend la main avec le message « Itération interrompue (stop) ». **Rien n'est annulé** : la conversation et les résultats déjà obtenus restent dans le contexte, et ce que vous tapez ensuite est ajouté à la suite (contrairement à Ctrl+C, qui restaure la conversation d'avant la demande). « stop » n'existe que pour les confirmations d'outils de l'agent principal (pas pour « Continuer ? » à la limite d'itérations, ni pour les confirmations d'un sous-agent, où `s` est ignoré).
La commande `/permissions` affiche les règles actives, fichier par fichier, avec un badge `ALLOW` (vert), `ASK` (jaune) et `DENY` (rouge) suivi des règles du type, une par ligne ; seuls les types non vides sont montrés. Après avoir modifié un `settings.json`, `/clear` recharge la configuration.
Exemple pour éviter les confirmations sur les outils en lecture seule : voir `settings.example.json`.
@ -284,7 +286,7 @@ Exemple pour éviter les confirmations sur les outils en lecture seule : voir `s
L'utilisateur peut créer ses propres outils programmatiques : le LLM les appelle comme n'importe quel autre outil quand leur description correspond au besoin. Ils sont chargés au démarrage et rechargés par `/clear` et `/new`.
- **Où** : uniquement dans `./.maia/tools` du dossier de lancement (jamais le dossier parent ni le dossier de l'utilisateur). Un sous-dossier = un outil : `./.maia/tools/addition/TOOL.json` (`tool.json` accepté aussi) et ses scripts. Un exemple complet est fourni dans `examples/tools/addition` ; exemple de sous-agent : `examples/agents/code_reviewer` (à copier dans `./.maia/agents`).(à copier dans `./.maia/tools/`).
- **Où** : `./.maia/tools` (dossier de lancement) et `~/.maia/tools` (utilisateur, valable pour tous les projets) ; jamais le dossier parent. À nom égal, l'outil du projet l'emporte. Un sous-dossier = un outil : `./.maia/tools/addition/TOOL.json` (`tool.json` accepté aussi) et ses scripts. Un exemple complet est fourni dans `examples/tools/addition` ; exemple de sous-agent : `examples/agents/code_reviewer` (à copier dans `./.maia/agents`).(à copier dans `./.maia/tools/`).
- **`TOOL.json`** :
```json
@ -371,11 +373,16 @@ Tu es un expert en révision de code.
- détecter les anti-patterns
- suggérer des améliorations
# Constraints (optionnelle)
- ne modifie aucun fichier : lecture seule
- si une faille CRITICAL est trouvée, ne tente pas de la corriger : signale-la en premier pour que l'utilisateur décide
- si la demande sort du périmètre, arrête-toi et indique qui doit prendre le relais
# Output
Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description, code concerné, solution recommandée.
```
- Les trois sections « Instructions system », « Missions » et « Output » sont obligatoires (titres Markdown de n'importe quel niveau) ; un agent invalide est ignoré avec un avertissement qui dit pourquoi.
- Les trois sections « Instructions system », « Missions » et « Output » sont obligatoires (titres Markdown de n'importe quel niveau). La section « Constraints » (ou « Contraintes »), à placer **après Missions**, est facultative : les limites de l'agent, c'est-à-dire ce qu'il n'a pas le droit de faire et dans quelles conditions il doit s'arrêter pour demander l'intervention de l'utilisateur ou d'un tiers. Un sous-agent ne pouvant pas poser de question, devant un de ces cas il n'agit pas et le signale dans son résumé (ce qui bloque, ce qui est attendu, de qui). C'est une consigne donnée à l'agent ; les interdits techniques se garantissent avec `tools` et `permissions` ; un agent invalide est ignoré avec un avertissement qui dit pourquoi.
- **Héritage** : sans `tools`, `skills` ou `permissions`, le sous-agent a les mêmes outils (y compris les outils personnalisés), skills et règles que l'agent principal. Les règles `permissions` de l'agent **s'ajoutent** aux règles héritées : `deny > ask > allow` reste vrai, un `deny` ou `ask` hérité n'est jamais levé. Un sous-agent ne peut pas lancer de sous-agent.
- **Résumé** : la dernière réponse du sous-agent suit la section `Output` ; s'il épuise son budget d'étapes sans conclure, un appel supplémentaire lui demande ce résumé.
- **Effort** : borne le nombre d'étapes du sous-agent (low 10, medium 25, high 50) et module son prompt.
@ -492,3 +499,6 @@ Journal des évolutions, dans l'ordre :
43. **`/createAgent`** (ex-`/newAgent`) : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés.
44. **Règles `Redirect`** : autoriser, demander ou interdire les redirections de sortie (`>`, `>>`, `2>`, `&>`) fichier par fichier ; sans règle, elles demandent confirmation ; `2>&1` et les `>` entre guillemets ne comptent pas ; guide mis à jour (§ 8).
45. **Serveurs MCP distants** : déclaration globale `~/.maia/mcp.json` (expansion `${VAR}`, `type` http/sse, `headers`, `timeout`, `includeTools`/`excludeTools`), aucun serveur actif par défaut, activation par `Mcp(serveur)` dans le `settings.json`, `Mcp(serveur,outil)` pour retirer/confirmer un outil, `Mcp(serveur,*)` en `deny` désactive sans connexion ; connexion parallèle après traitement des permissions ; outils `mcp__serveur__outil` ; commande `/mcp` (bandeaux `OK` vert / `FAIL` rouge).
46. **Outils personnalisés utilisateur** : chargés aussi depuis `~/.maia/tools` ; à nom égal l'outil du projet (`./.maia/tools`) l'emporte, sans avertissement ; pris en compte par les sous-agents.
47. **Réponse « stop »** aux demandes d'autorisation d'outil (`o`/`n`/`s`) : l'action n'est pas exécutée, l'itération s'arrête **sans rollback** (le contexte est conservé), puis la saisie suivante est ajoutée à la conversation ; sert à donner une information oubliée ou à changer de sujet.
48. **Section `# Constraints`** des agents (facultative, après `# Missions`) : limites et conditions d'intervention de l'utilisateur ou d'un tiers ; incluse dans le prompt du sous-agent avec la consigne de s'arrêter et de le signaler dans son résumé ; modèle `.ressources/AGENT.md` et exemple `code_reviewer` mis à jour.

View file

@ -177,6 +177,7 @@ Toute autre commande : « Commande inconnue ». (Les slash-commandes personnalis
- 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. À 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.
4. **Stop** : `AgentUI.ask?(message): Promise<"yes"|"no"|"stop">` (repli sur `confirm`). Réponse « stop » à une confirmation d'outil → `prepare` renvoie `stopped` ; `runToolCallsDetailed` (`runToolCalls` en est la version qui ne renvoie que les résultats) fournit un résultat par appel (l'API exige un `tool_result` pour chaque `tool_use`) : `STOP_FOR_MODEL` pour l'appel visé, `NOT_RUN_FOR_MODEL` pour les suivants ; en parallèle, les outils déjà lancés finissent. `runTurn` pousse ces résultats puis retourne `"stopped"` **sans rappeler le LLM et sans rien annuler** ; la saisie suivante est fusionnée dans ce dernier message utilisateur (même mécanisme que la limite d'itérations). Le CLI affiche « Itération interrompue (stop) » ; Ctrl+C reste un rollback.
Les événements `usage` du provider sont ignorés par le cœur.
@ -306,7 +307,7 @@ Au démarrage : message INFO (fichiers chargés, nombre de règles par fichier)
### 10.1 Chargement
`./.maia/tools/<dossier>/TOOL.json` (`tool.json` accepté) — **dossier de lancement uniquement** (ni parent, ni utilisateur). Chargement alphabétique au démarrage, rechargé par `/clear` et `/new`. Le journal liste les outils valides sous « Outils personnalisés chargés : » (un par ligne).
`./.maia/tools/<dossier>/TOOL.json` (`tool.json` accepté) et `~/.maia/tools/<dossier>/TOOL.json` (utilisateur) — pas le dossier parent ; à nom égal, l'outil du projet l'emporte (celui de l'utilisateur est ignoré sans avertissement). Chargement alphabétique au démarrage, rechargé par `/clear` et `/new`. Le journal liste les outils valides sous « Outils personnalisés chargés : » (un par ligne).
### 10.2 Définition
@ -515,7 +516,7 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
| 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.
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/s=stop)` (réponse `o`/`y`, `n`/Échap/Ctrl+C, ou `s` = stop ; `s` n'est proposé que par `ask`, pas par `confirm`). 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 |
@ -526,7 +527,7 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ
| `./.maia/config.json` | `model` du projet |
| `./.maia/settings.json` | permissions du projet |
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
| `./.maia/tools/<nom>/TOOL.json`, `~/.maia/tools/<nom>/TOOL.json` | outils personnalisés (projet, utilisateur) |
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
| `<racine de MAIA>/.ressources/AGENT.md` | modèle d'agent de `/createAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) |
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
@ -578,7 +579,7 @@ En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table im
| `omitMd` | ne pas ajouter `MAIA.md` / `CLAUDE.md` | `false` |
| `trace` | enregistrer la conversation | `false` |
Corps : trois sections obligatoires, repérées par leurs titres Markdown (niveau libre, casse et accents ignorés ; « Instructions system(e) », « Missions », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre des trois sections ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`.
Corps : trois sections obligatoires et une facultative (`Constraints` / `Contraintes`, après Missions : `AgentDefinition.constraints`), repérées par leurs titres Markdown (niveau libre, casse et accents ignorés, précision finale entre parenthèses ignorée : « Constraints (optionnelle) » ; « Instructions system(e) », « Missions », « Constraints », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre section reconnue ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`.
### 20.1b Création : `/createAgent` et le modèle `.ressources/AGENT.md`
@ -602,7 +603,7 @@ Le modèle est résolu par le parent (`resolveSubAgentModel` : identifiant exact
### 20.3 Dans le processus enfant
1. `initializeAgent({ subAgent })` recharge outils intégrés, outils personnalisés, skills et permissions depuis le disque, puis applique la définition : `tools` (insensible à la casse ; jamais `Agent`), `skills` (l'outil `Skill` n'existe que s'il reste des skills), permissions = règles héritées + règles de l'agent (union : un `deny`/`ask` hérité n'est jamais levé), contexte `MAIA.md`/`CLAUDE.md` sauf `omitMd`. Seuls les avertissements propres à la définition sont remontés (outil ou skill introuvable).
2. Prompt système : identité du sous-agent, autonomie (personne ne peut répondre), dossier de travail, indication d'effort, sections Instructions system / Missions / Output, consigne « la réponse finale respecte Output et suffit à elle-même », contexte du projet.
2. Prompt système : identité du sous-agent, autonomie (personne ne peut répondre), dossier de travail, indication d'effort, sections Instructions system / Missions / Constraints (si présente, suivie de la consigne : devant un cas prévu ou un interdit, ne pas agir, s'arrêter et le signaler dans le résumé) / Output, consigne « la réponse finale respecte Output et suffit à elle-même », contexte du projet.
3. `runTurn(session, task, signal, { maxIterations })` : 10 / 25 / 50 itérations selon l'effort. `runTurn` retourne `"done"` ou `"max_iterations"`.
4. Résumé = texte de la dernière réponse sans appel d'outil ; sinon (budget épuisé, réponse vide) `requestFinalSummary` ajoute un message demandant le résumé selon `Output` (outils déclarés mais inutilisables, 4000 tokens) et l'inscrit dans la conversation.
5. `done` est envoyé, le processus se termine. Perte du canal IPC : le processus s'arrête.

View file

@ -14,6 +14,12 @@ Tu es un expert en révision de code. Tu lis le code demandé, tu ne le modifies
- détecter les anti-patterns
- suggérer des améliorations
# Constraints
- ne modifie aucun fichier et n'exécute aucune commande qui change l'état du projet
- ne recopie jamais un secret (clé, jeton, mot de passe) : indique seulement son emplacement
- si une faille CRITICAL est trouvée, ne tente pas de la corriger : place-la en tête du résumé pour que l'utilisateur décide
- si la demande sort du périmètre (écrire ou corriger du code), arrête-toi et indique qui doit prendre le relais
# Output
Pour chaque problème trouvé :
- catégorie : CRITICAL, HIGH, MEDIUM ou LOW

View file

@ -64,6 +64,16 @@ describe("sections du corps", () => {
expect(s.missions).toBe("C");
expect(s.output).toBe("D");
});
it("Constraints : section facultative reconnue (aussi « Contraintes »), entre Missions et Output", () => {
const s = parseAgentSections("# Instructions system\nA\n# Missions\nC\n# Constraints\nne rien écrire\n# Output\nD");
expect(s.missions).toBe("C");
expect(s.constraints).toBe("ne rien écrire");
expect(s.output).toBe("D");
expect(parseAgentSections("# Contraintes :\nx").constraints).toBe("x");
const opt = parseAgentSections("# Missions\nC\n# Constraints (optionnelle)\nlimite\n# Output\nD");
expect(opt.missions).toBe("C");
expect(opt.constraints).toBe("limite");
});
it("un titre dans un bloc de code n'est pas une section", () => {
const s = parseAgentSections("# Output\n```\n# Missions\n```\nfin");
expect(s.missions).toBeUndefined();
@ -197,6 +207,28 @@ describe("prompt du sous-agent", () => {
});
});
describe("section Constraints", () => {
const withConstraints = `---\nname: a\ndescription: d\n---\n# Instructions system\nI\n# Missions\nM\n# Constraints\n- ne modifie rien\n- demande à l'équipe sécurité si un secret est trouvé\n# Output\nO`;
it("lue par parseAgentFile ; facultative (agent sans la section accepté)", () => {
const r = parseAgentFile("a", withConstraints, { dir: "/d", source: "projet" });
expect(r.errors).toEqual([]);
expect(r.agent!.constraints).toContain("ne modifie rien");
expect(r.agent!.output).toBe("O");
const sans = parseAgentFile("a", withConstraints.replace(/# Constraints[\s\S]*# Output/, "# Output"), { dir: "/d", source: "projet" });
expect(sans.errors).toEqual([]);
expect(sans.agent!.constraints).toBeUndefined();
});
it("placée dans le prompt entre Missions et Output, avec la consigne d'arrêt ; absente : rien", () => {
const def = parseAgentFile("a", withConstraints, { dir: "/d", source: "projet" }).agent!;
const p = buildSubAgentPrompt(def, "/w", null, "");
expect(p.indexOf("# Missions")).toBeLessThan(p.indexOf("# Constraints"));
expect(p.indexOf("# Constraints")).toBeLessThan(p.indexOf("# Output"));
expect(p).toContain("signale-le clairement dans ton résumé");
const sans = buildSubAgentPrompt({ ...def, constraints: undefined }, "/w", null, "");
expect(sans).not.toContain("# Constraints");
});
});
describe("initialisation d'un sous-agent", () => {
const baseSub = {
name: "s",

View file

@ -17,7 +17,7 @@ import { asBool, asList, asString, parseYamlHeader, type YamlMap } from "./yaml-
// omitMd: false (défaut false : MAIA.md / CLAUDE.md ajouté au contexte)
// trace: false (défaut false : true enregistre la conversation dans ./.maia/.sessions)
// ---
// # Instructions system … # Missions … # Output …
// # Instructions system … # Missions … # Constraints (facultatif) … # Output …
export type Effort = "low" | "medium" | "high";
@ -42,6 +42,8 @@ export interface SubAgentInit {
description: string;
instructions: string;
missions: string;
/** Limites de l'agent : interdits, conditions d'intervention de l'utilisateur ou d'un tiers (section facultative). */
constraints?: string;
output: string;
effort: Effort;
omitMd: boolean;
@ -81,14 +83,16 @@ const normalizeTitle = (title: string): string =>
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[:*_`]/g, "")
.replace(/\s*\([^)]*\)\s*$/, "") // « Constraints (optionnelle) » : la précision entre parenthèses est ignorée
.trim();
type SectionKey = "instructions" | "missions" | "output";
type SectionKey = "instructions" | "missions" | "constraints" | "output";
function sectionOf(title: string): SectionKey | null {
const t = normalizeTitle(title);
if (t.startsWith("instruction") || t === "system" || t === "system prompt" || t === "prompt system") return "instructions";
if (t === "mission" || t === "missions") return "missions";
if (t === "constraint" || t === "constraints" || t === "contrainte" || t === "contraintes") return "constraints";
if (t === "output" || t === "outputs" || t === "sortie" || t === "sorties") return "output";
return null;
}
@ -193,6 +197,7 @@ export function parseAgentFile(
source: meta.source,
instructions: sections.instructions!,
missions: sections.missions!,
constraints: sections.constraints || undefined,
output: sections.output!,
model: asString(data.model),
tools: asList(data.tools),
@ -272,7 +277,7 @@ export function resolveSubAgentModel(
};
}
/** Prompt système du sous-agent : définition de l'agent (3 sections), règles d'autonomie, contexte MAIA.md / CLAUDE.md. */
/** Prompt système du sous-agent : définition de l'agent (sections Instructions, Missions, Constraints, Output), règles d'autonomie, contexte MAIA.md / CLAUDE.md. */
export function buildSubAgentPrompt(
agent: SubAgentInit,
cwd: string,
@ -288,6 +293,13 @@ export function buildSubAgentPrompt(
EFFORT_HINTS[agent.effort],
`\n# Instructions system\n${agent.instructions}`,
`\n# Missions\n${agent.missions}`,
...(agent.constraints
? [
`\n# Constraints\n${agent.constraints}`,
"Les contraintes ci-dessus sont des limites strictes. Si la tâche exige de les franchir, ou si une condition d'intervention de l'utilisateur ou d'un tiers y est décrite et se réalise : n'agis pas, " +
"arrête-toi sur ce point et signale-le clairement dans ton résumé (ce qui bloque, ce qui est attendu, et de qui).",
]
: []),
`\n# Output\n${agent.output}`,
"\nTa réponse finale (celle qui n'appelle plus d'outil) est un résumé qui respecte exactement le format de la section Output. " +
"C'est la SEULE chose transmise à l'agent principal : elle doit se suffire à elle-même (il ne voit ni ta conversation ni tes fichiers lus).",

View file

@ -224,7 +224,7 @@ describe("loadCustomTools", () => {
expect(tools.map((t) => t.spec.name)).toEqual(["Addition", "hello"]);
});
it("ignore les outils du dossier parent et du dossier de l'utilisateur", async () => {
it("ignore les outils du dossier parent ; sans homeDir, ceux de l'utilisateur ne sont pas lus", async () => {
const { cwd, parent, home } = await project();
await addTool(parent, "parent-tool", SPEC_EXAMPLE);
await addTool(home, "home-tool", SPEC_EXAMPLE);
@ -233,6 +233,28 @@ describe("loadCustomTools", () => {
expect(tools.map((t) => t.spec.name)).toEqual(["local"]);
});
it("charge aussi ~/.maia/tools ; à nom égal l'outil du projet l'emporte, sans avertissement", async () => {
const { cwd, parent, home } = await project();
await addTool(parent, "parent-tool", SPEC_EXAMPLE);
await addTool(home, "home-tool", JSON.stringify({ description: "Utilisateur", command: { run: "node h.js" } }));
await addTool(home, "commun", JSON.stringify({ description: "Version utilisateur", command: { run: "node h.js" } }));
await addTool(cwd, "commun", JSON.stringify({ description: "Version projet", command: { run: "node p.js" } }));
await addTool(cwd, "local", JSON.stringify({ description: "Local", command: { run: "node x.js" } }));
const { tools, warnings } = await loadCustomTools(cwd, [], home);
expect(warnings).toEqual([]);
expect(tools.map((t) => t.spec.name)).toEqual(["commun", "local", "home-tool"]);
expect(tools[0]!.spec.description).toBe("Version projet");
});
it("lancé depuis le dossier de l'utilisateur : ses outils ne sont chargés qu'une fois ; un outil utilisateur ne peut pas reprendre un nom intégré", async () => {
const { home } = await project();
await addTool(home, "bash", JSON.stringify({ description: "faux", command: { run: "node a" } }));
await addTool(home, "ok", JSON.stringify({ description: "bon", command: { run: "node a" } }));
const { tools, warnings } = await loadCustomTools(home, ["Bash"], home);
expect(tools.map((t) => t.spec.name)).toEqual(["ok"]);
expect(warnings.length).toBe(1);
});
it("pas de dossier ./.maia/tools : rien, sans avertissement", async () => {
const { cwd } = await project();
expect(await loadCustomTools(cwd)).toEqual({ tools: [], warnings: [] });

View file

@ -1,4 +1,5 @@
// Outils personnalisés : ./.maia/tools/<nom>/TOOL.json (uniquement dans le dossier de lancement, ni parent ni utilisateur).
// Outils personnalisés : ./.maia/tools/<nom>/TOOL.json (dossier de lancement) et ~/.maia/tools/<nom>/TOOL.json (utilisateur) ; pas le dossier parent.
// À nom égal, l'outil du projet l'emporte sur celui de l'utilisateur.
//
// TOOL.json :
// {
@ -44,9 +45,9 @@ export interface CustomToolDef {
tokens: string[];
}
/** Dossier des outils personnalisés : celui du dossier de lancement uniquement. */
export function customToolsDir(cwd: string): string {
return join(cwd, ".maia", "tools");
/** Dossier des outils personnalisés d'un dossier de base (dossier de lancement, ou dossier de l'utilisateur). */
export function customToolsDir(base: string): string {
return join(base, ".maia", "tools");
}
// ───────────── Lecture de TOOL.json ─────────────
@ -296,24 +297,44 @@ export interface CustomToolsResult {
}
/**
* Charge les outils de <cwd>/.maia/tools : un sous-dossier = un outil, défini par son TOOL.json.
* Un outil invalide est ignoré avec un avertissement (les autres restent chargés).
* Charge les outils de <cwd>/.maia/tools (projet) puis de <homeDir>/.maia/tools (utilisateur) : un sous-dossier = un outil,
* défini par son TOOL.json. Le dossier parent n'est pas lu. À nom égal, l'outil du projet l'emporte (celui de l'utilisateur
* est ignoré sans avertissement). Un outil invalide est ignoré avec un avertissement (les autres restent chargés).
* @param reserved noms déjà pris (outils intégrés) : un outil personnalisé ne peut pas les remplacer.
* @param homeDir dossier de l'utilisateur ; absent : seuls les outils du projet sont chargés.
*/
export async function loadCustomTools(cwd: string, reserved: string[] = []): Promise<CustomToolsResult> {
const root = customToolsDir(cwd);
export async function loadCustomTools(cwd: string, reserved: string[] = [], homeDir?: string): Promise<CustomToolsResult> {
const tools: Tool[] = [];
const warnings: string[] = [];
const taken = new Set(reserved.map((n) => n.toLowerCase()));
const projectRoot = customToolsDir(cwd);
await loadFrom(projectRoot, "./.maia/tools", taken, new Set(), tools, warnings);
// Lancé depuis le dossier de l'utilisateur : même dossier, lu une seule fois
if (homeDir && resolve(customToolsDir(homeDir)) !== resolve(projectRoot)) {
const projectNames = new Set(tools.map((t) => t.spec.name.toLowerCase()));
await loadFrom(customToolsDir(homeDir), "~/.maia/tools", taken, projectNames, tools, warnings);
}
return { tools, warnings };
}
async function loadFrom(
root: string,
labelRoot: string,
taken: Set<string>,
overriddenBy: Set<string>,
tools: Tool[],
warnings: string[],
): Promise<void> {
let entries: string[];
try {
entries = (await readdir(root)).sort((a, b) => a.localeCompare(b));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT" || (err as NodeJS.ErrnoException).code === "ENOTDIR") return { tools, warnings };
return { tools, warnings: [`Outils personnalisés : dossier ${root} illisible (${(err as Error).message})`] };
if ((err as NodeJS.ErrnoException).code === "ENOENT" || (err as NodeJS.ErrnoException).code === "ENOTDIR") return;
warnings.push(`Outils personnalisés : dossier ${root} illisible (${(err as Error).message})`);
return;
}
const taken = new Set(reserved.map((n) => n.toLowerCase()));
for (const entry of entries) {
if (entry.startsWith(".")) continue;
const dir = join(root, entry);
@ -323,7 +344,7 @@ export async function loadCustomTools(cwd: string, reserved: string[] = []): Pro
continue;
}
const label = `./.maia/tools/${entry}`;
const label = `${labelRoot}/${entry}`;
let file: string | undefined;
let text = "";
for (const candidate of DEFINITION_FILES) {
@ -342,6 +363,7 @@ export async function loadCustomTools(cwd: string, reserved: string[] = []): Pro
try {
const def = parseToolDefinition(parseLenientJson(text), entry, dir);
if (overriddenBy.has(def.name.toLowerCase())) continue; // l'outil du projet de même nom l'emporte
if (taken.has(def.name.toLowerCase())) {
throw new Error(`le nom « ${def.name} » est déjà utilisé par un autre outil`);
}
@ -351,5 +373,4 @@ export async function loadCustomTools(cwd: string, reserved: string[] = []): Pro
warnings.push(`Outil « ${entry} » ignoré (${label}/${file}) : ${(err as Error).message}`);
}
}
return { tools, warnings };
}

View file

@ -76,7 +76,7 @@ export async function initializeAgent(ctx: AgentInitContext): Promise<AgentInitR
// Outils personnalisés : un sous-dossier de ./.maia/tools par outil (TOOL.json) ; les noms des outils intégrés sont réservés
const reserved = [...builtinTools.map((t) => t.spec.name), "Skill", "Agent"];
const { tools: customTools, warnings: toolWarnings } = await loadCustomTools(ctx.cwd, reserved);
const { tools: customTools, warnings: toolWarnings } = await loadCustomTools(ctx.cwd, reserved, ctx.homeDir);
for (const w of toolWarnings) ctx.warn(w);
if (customTools.length > 0) ctx.log(["Outils personnalisés chargés :", ...customTools.map((t) => t.spec.name)].join("\n"));
@ -145,7 +145,7 @@ async function initializeSubAgent(ctx: AgentInitContext, sub: SubAgentInit): Pro
}
const reserved = [...builtinTools.map((t) => t.spec.name), "Skill", "Agent"];
const { tools: customTools } = await loadCustomTools(ctx.cwd, reserved);
const { tools: customTools } = await loadCustomTools(ctx.cwd, reserved, ctx.homeDir);
let tools: Tool[] = [...builtinTools, ...customTools];
if (skills.length > 0) tools.push(createSkillTool(skills));
if (sub.tools) {

View file

@ -541,7 +541,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
const file = await createAgentFromTemplate({ scope, name: agentName, cwd, homeDir, templatePath: template });
ui.info(
`Agent « ${agentName} » créé (${scope === "global" ? "global" : "projet"}) : ${file}\n` +
"Éditez ce fichier (description, instructions, missions, output ; les propriétés facultatives sont expliquées en commentaires), " +
"Éditez ce fichier (description, instructions, missions, constraints, output ; les propriétés facultatives sont expliquées en commentaires), " +
"puis tapez /clear pour le charger. Vous le lancerez avec &" + agentName + " ou l'agent principal le proposera au besoin.",
);
} catch (err) {
@ -719,10 +719,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
let retried = false;
for (;;) {
try {
await runTurn(session, attempt, controller.signal, {
const end = 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 ?`),
});
// « stop » : rien n'est annulé, le contexte reste tel quel ; la prochaine saisie complète la conversation
if (end === "stopped") ui.info("Itération interrompue (stop). Donnez vos informations ou une nouvelle demande : le contexte est conservé.");
break;
} catch (err) {
if (controller.signal.aborted) {

View file

@ -81,6 +81,7 @@ async function runChild(
description: agent.description,
instructions: agent.instructions,
missions: agent.missions,
constraints: agent.constraints,
output: agent.output,
effort: agent.effort,
omitMd: agent.omitMd,

View file

@ -1,5 +1,5 @@
import { countContextTokens } from "./context";
import { runToolCalls, type ToolUseBlock } from "./tool-runner";
import { runToolCallsDetailed, type ToolUseBlock } from "./tool-runner";
import type {
AgentSkill,
AgentUI,
@ -60,7 +60,8 @@ export function createSession(opts: {
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";
/** done : réponse finale · max_iterations : limite atteinte · stopped : l'utilisateur a répondu « stop » (rien n'est annulé). */
export type TurnEnd = "done" | "max_iterations" | "stopped";
/** Un tour complet : message utilisateur → (LLM → outils)* → réponse finale. */
@ -159,9 +160,11 @@ export async function runTurn(
if (calls.length === 0) return "done";
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
const results = await runToolCalls(session, calls, signal);
const { results, stopped } = await runToolCallsDetailed(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
// « stop » : le contexte est conservé tel quel ; le prochain message de l'utilisateur s'ajoute à ces résultats
if (stopped) return "stopped";
}
session.ui.onToolResult("agent", `Arrêt : plus de ${iterations} itérations`, true);
return "max_iterations";

View file

@ -205,3 +205,49 @@ describe("Limiter / KeyedQueue", () => {
await expect(second).resolves.toBe("ok");
});
});
describe("réponse « stop » à une demande d'autorisation", () => {
it("un appel : rien n'est exécuté, le tour s'arrête sans rollback, la saisie suivante complète le contexte", async () => {
let ran = false;
const a = slow("A", 5, { permission: "ask", execute: async () => { ran = true; return "x"; } });
const { session, events } = setup([a], [{ name: "A", input: { v: 1 } }], { ask: async () => "stop" });
expect(await runTurn(session, "go")).toBe("stopped");
expect(ran).toBe(false);
// Contexte conservé : demande, appel d'outil, résultat « stop » (le LLM n'est pas rappelé)
expect(session.messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
expect(resultsOf(session)[0]).toMatch(/stop/);
expect(events).toContain("result:A:Arrêt demandé (stop) : action non exécutée:true");
// Nouvelle saisie : ajoutée au message des résultats (alternance user/assistant respectée), conversation reprise
expect(await runTurn(session, "voici une info oubliée")).toBe("done");
expect(session.messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
const merged = session.messages[2]!.content;
expect(merged.map((b) => b.type)).toEqual(["tool_result", "text"]);
});
it("plusieurs appels : les outils déjà autorisés finissent, le « stop » et les suivants ne sont pas lancés", async () => {
const asked: string[] = [];
const ask = (n: string): Tool => slow(n, 20, { permission: "ask" });
const { session } = setup([ask("A"), ask("B"), ask("C")], [
{ name: "A", input: { v: 1 } },
{ name: "B", input: { v: 2 } },
{ name: "C", input: { v: 3 } },
], {
ask: async (m: string) => {
asked.push(m);
return m.includes("B") ? "stop" : "yes";
},
});
expect(await runTurn(session, "go")).toBe("stopped");
expect(asked).toHaveLength(2); // C n'est même pas demandé
const r = resultsOf(session);
expect(r[0]).toBe("A:1");
expect(r[1]).toMatch(/« stop »/);
expect(r[2]).toMatch(/non exécutée/);
});
it("sans méthode ask, la confirmation booléenne sert (oui / non uniquement)", async () => {
const { session } = setup([slow("A", 5, { permission: "ask" })], [{ name: "A", input: { v: 1 } }], { confirm: async () => false });
expect(await runTurn(session, "go")).toBe("done");
expect(resultsOf(session)).toEqual(["L'utilisateur a refusé cette action."]);
});
});

View file

@ -14,7 +14,7 @@
// deux Edit sur le même fichier ne s'écrasent pas.
// - Au plus MAX_PARALLEL_TOOLS outils s'exécutent en même temps ; les suivants attendent une place.
import type { AgentSession } from "./agent";
import type { ContentBlock, Tool } from "./types";
import type { AgentUI, ContentBlock, ConfirmAnswer, Tool } from "./types";
export const MAX_PARALLEL_TOOLS = 8;
@ -41,7 +41,24 @@ interface Approved {
summary: string;
}
type Prepared = Refused | Approved;
/** L'utilisateur a répondu « stop » à la demande d'autorisation : l'action n'est pas exécutée et l'itération s'arrête. */
interface Stopped {
kind: "stopped";
summary: string;
}
type Prepared = Refused | Approved | Stopped;
/** Texte renvoyé au LLM pour l'action sur laquelle l'utilisateur a répondu « stop ». */
export const STOP_FOR_MODEL =
"L'utilisateur a répondu « stop » : cette action n'a PAS été exécutée. Il interrompt le travail en cours pour donner des " +
"informations complémentaires ou une autre demande. N'enchaîne pas d'autres actions : attends son prochain message.";
/** Texte renvoyé au LLM pour les actions qui suivaient et n'ont pas été lancées. */
export const NOT_RUN_FOR_MODEL =
"Action non exécutée : l'utilisateur a interrompu le travail (stop) avant celle-ci. Attends son prochain message.";
const askUser = async (ui: AgentUI, message: string): Promise<ConfirmAnswer> =>
ui.ask ? ui.ask(message) : (await ui.confirm(message)) ? "yes" : "no";
// ───────────── Outils de synchronisation ─────────────
@ -106,8 +123,12 @@ async function prepare(session: AgentSession, call: ToolUseBlock, announce: bool
};
}
const needsConfirm = verdict ? verdict.decision === "ask" : tool.permission === "ask";
if (needsConfirm && !(await ui.confirm(`Autoriser ${call.name} : ${summary} ?`))) {
return { kind: "refused", display: "Refusé par l'utilisateur", forModel: "L'utilisateur a refusé cette action.", summary };
if (needsConfirm) {
const answer = await askUser(ui, `Autoriser ${call.name} : ${summary} ?`);
if (answer === "stop") return { kind: "stopped", summary };
if (answer === "no") {
return { kind: "refused", display: "Refusé par l'utilisateur", forModel: "L'utilisateur a refusé cette action.", summary };
}
}
return { kind: "approved", tool, summary };
}
@ -139,17 +160,40 @@ const block = (call: ToolUseBlock, content: string, isError: boolean): ContentBl
// ───────────── Un tour d'outils ─────────────
export interface ToolRun {
/** Un résultat par appel, dans l'ordre des appels. */
results: ContentBlock[];
/** L'utilisateur a répondu « stop » à une demande d'autorisation : le tour s'arrête après ces résultats. */
stopped: boolean;
}
/** Exécute les appels d'outils d'une réponse du LLM et renvoie les résultats, dans l'ordre des appels. */
export async function runToolCalls(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ContentBlock[]> {
return (await runToolCallsDetailed(session, calls, signal)).results;
}
/** Comme `runToolCalls`, et indique si l'utilisateur a demandé l'arrêt (« stop »). */
export async function runToolCallsDetailed(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ToolRun> {
return calls.length >= 2 ? runInParallel(session, calls, signal) : runInForeground(session, calls, signal);
}
/** Premier plan (0 ou 1 appel) : annonce, confirmation, exécution et résultat, l'un après l'autre. */
async function runInForeground(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ContentBlock[]> {
async function runInForeground(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ToolRun> {
const { ui } = session;
const results: ContentBlock[] = [];
let stopped = false;
for (const call of calls) {
if (stopped) {
results.push(block(call, NOT_RUN_FOR_MODEL, true));
continue;
}
const prepared = await prepare(session, call, true);
if (prepared.kind === "stopped") {
stopped = true;
ui.onToolResult(call.name, "Arrêt demandé (stop) : action non exécutée", true);
results.push(block(call, STOP_FOR_MODEL, true));
continue;
}
if (prepared.kind === "refused") {
if (prepared.display !== null) ui.onToolResult(call.name, prepared.display, true);
results.push(block(call, prepared.forModel, true));
@ -159,11 +203,11 @@ async function runInForeground(session: AgentSession, calls: ToolUseBlock[], sig
ui.onToolResult(call.name, output, isError);
results.push(block(call, output, isError));
}
return results;
return { results, stopped };
}
/** Deux appels ou plus : un tâche de fond par outil, puis attente de toutes avant de répondre au LLM. */
async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ContentBlock[]> {
async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signal?: AbortSignal): Promise<ToolRun> {
const { ui } = session;
const limiter = new Limiter(MAX_PARALLEL_TOOLS);
const queue = new KeyedQueue();
@ -176,9 +220,16 @@ async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signa
const report = (): void => ui.onToolsRunning?.({ names: running, done });
let reporting = false;
let stopped = false;
for (const call of calls) {
const p = await prepare(session, call, false);
prepared.push(p);
if (p.kind === "stopped") {
// « stop » : les outils déjà autorisés continuent et finissent ; les suivants ne sont pas lancés
stopped = true;
tasks.push(null);
break;
}
if (p.kind === "refused") {
tasks.push(null);
continue;
@ -209,8 +260,14 @@ async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signa
const results: ContentBlock[] = [];
let next = 0;
for (const [i, call] of calls.entries()) {
const p = prepared[i]!;
if (p.kind === "refused") {
const p = prepared[i];
if (!p) {
results.push(block(call, NOT_RUN_FOR_MODEL, true)); // après le « stop » : jamais lancé
} else if (p.kind === "stopped") {
ui.onToolCall(call.name, p.summary);
ui.onToolResult(call.name, "Arrêt demandé (stop) : action non exécutée", true);
results.push(block(call, STOP_FOR_MODEL, true));
} else if (p.kind === "refused") {
if (p.summary !== null) ui.onToolCall(call.name, p.summary);
if (p.display !== null) ui.onToolResult(call.name, p.display, true);
results.push(block(call, p.forModel, true));
@ -221,5 +278,5 @@ async function runInParallel(session: AgentSession, calls: ToolUseBlock[], signa
results.push(block(call, output, isError));
}
}
return results;
return { results, stopped };
}

View file

@ -93,6 +93,9 @@ export interface Tool {
// ---------- UI (implémentée par le package `ui`, jamais importée par le core) ----------
/** Réponse à une demande d'autorisation : oui, non, ou stop (interrompre l'itération sans rien annuler). */
export type ConfirmAnswer = "yes" | "no" | "stop";
export interface AgentUI {
onThinking(active: boolean): void;
onText(text: string): void;
@ -100,6 +103,11 @@ export interface AgentUI {
onToolCall(name: string, summary: string): void;
onToolResult(name: string, output: string, isError: boolean): void;
confirm(message: string): Promise<boolean>;
/**
* Comme `confirm`, avec une troisième réponse « stop » : l'utilisateur ne répond pas à la question, il interrompt
* l'itération (pour donner des informations oubliées ou changer de sujet). Absente : seul `confirm` est utilisé.
*/
ask?(message: string): Promise<ConfirmAnswer>;
/** Taille courante du contexte (en tokens, comptée localement), après chaque réponse du LLM. */
onContextUsage?(tokens: number): void;
/** Outils lancés en parallèle (2 appels ou plus) : noms et nombre déjà terminés ; null : plus rien ne tourne. */

View file

@ -374,8 +374,9 @@ function SelectPrompt({ prompt }: { prompt: SelectPromptState }) {
function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
useInput((input, key) => {
if (input === "o" || input === "O" || input === "y" || input === "Y") prompt.resolve(true);
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
if (input === "o" || input === "O" || input === "y" || input === "Y") prompt.resolve("yes");
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve("no");
else if (prompt.allowStop && (input === "s" || input === "S")) prompt.resolve("stop");
// 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
@ -385,7 +386,7 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
<Badge color="green">ASK</Badge>
</Box>
<Text>
{prompt.message} <Text dimColor>(o/n)</Text>
{prompt.message} <Text dimColor>{prompt.allowStop ? "(o/n/s=stop)" : "(o/n)"}</Text>
</Text>
</InputFrame>
);

View file

@ -2,7 +2,7 @@ import React from "react";
import { render, type Instance } from "ink";
import type { AgentUI } from "@maia/core";
import { App } from "./components";
import { UIStore, type Choice, type McpServerView, type PermissionFile } from "./store";
import { UIStore, type ConfirmAnswer, type Choice, type McpServerView, type PermissionFile } from "./store";
/**
* Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes
@ -80,6 +80,12 @@ export class InkUI implements AgentUI {
this.confirmChain = run.catch(() => undefined);
return run;
}
/** Oui / non / stop (« stop » : interrompre l'itération sans rien annuler, pour donner d'autres informations). */
ask(message: string): Promise<ConfirmAnswer> {
const run = this.confirmChain.then(() => this.start().ask(message, true));
this.confirmChain = run.catch(() => undefined);
return run;
}
/** Ctrl+C pendant un tour de l'agent appelle ce gestionnaire (null : plus rien à interrompre). */
setInterruptHandler(handler: (() => void) | null): void {

View file

@ -92,11 +92,24 @@ describe("UIStore", () => {
it("confirm", async () => {
const s = new UIStore();
const answer = s.confirm("Autoriser Bash ?");
(s.getSnapshot().prompt as { resolve(v: boolean): void }).resolve(false);
expect((s.getSnapshot().prompt as { allowStop: boolean }).allowStop).toBe(false);
(s.getSnapshot().prompt as { resolve(v: string): void }).resolve("no");
expect(await answer).toBe(false);
expect(s.getSnapshot().prompt).toBeNull();
});
it("ask : oui / non / stop ; confirm reste booléen", async () => {
const s = new UIStore();
const answer = s.ask("Autoriser Bash ?");
const prompt = s.getSnapshot().prompt as { allowStop: boolean; resolve(v: string): void };
expect(prompt.allowStop).toBe(true);
prompt.resolve("stop");
expect(await answer).toBe("stop");
const yes = s.confirm("ok ?");
(s.getSnapshot().prompt as { resolve(v: string): void }).resolve("yes");
expect(await yes).toBe(true);
});
it("résultats d'outils : tronqués, erreurs distinguées, vides ignorés", () => {
const s = new UIStore();
s.toolCall("Read", "a.ts");

View file

@ -68,10 +68,15 @@ export interface SelectPromptState {
resolve(value: unknown): void;
}
/** Réponse à une demande d'autorisation (identique au type ConfirmAnswer du core). */
export type ConfirmAnswer = "yes" | "no" | "stop";
export interface ConfirmPromptState {
kind: "confirm";
message: string;
resolve(value: boolean): void;
/** Propose « s » (stop : interrompre l'itération sans rien annuler). */
allowStop: boolean;
resolve(value: ConfirmAnswer): void;
}
export type PromptState = InputPromptState | SelectPromptState | ConfirmPromptState;
@ -230,12 +235,14 @@ export class UIStore {
});
}
confirm(message: string): Promise<boolean> {
/** Oui / non (et stop si `allowStop`). */
ask(message: string, allowStop = true): Promise<ConfirmAnswer> {
return new Promise((resolve) => {
this.commit({
prompt: {
kind: "confirm",
message,
allowStop,
resolve: (value) => {
this.commit({ prompt: null });
resolve(value);
@ -245,6 +252,11 @@ export class UIStore {
});
}
/** Oui / non uniquement. */
async confirm(message: string): Promise<boolean> {
return (await this.ask(message, false)) === "yes";
}
// ----- Ctrl+C pendant un tour de l'agent -----
setInterruptHandler(handler: (() => void) | null): void {
this.interruptHandler = handler;

View file

@ -1,4 +1,9 @@
packages:
- "packages/*"
# esbuild (utilisé par vitest) a un script d'installation : on l'autorise explicitement.
# Deux syntaxes selon la version de pnpm (l'ancienne est ignorée par les versions récentes, et inversement).
onlyBuiltDependencies:
- esbuild
allowBuilds:
esbuild: set this to true or false
esbuild: true