Ajout des modifications
This commit is contained in:
parent
9c403acff4
commit
4562484f51
11 changed files with 398 additions and 7 deletions
70
.ressources/AGENT.md
Normal file
70
.ressources/AGENT.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# MODÈLE D'AGENT MAIA — créé par /newAgent
|
||||||
|
# Modifiez ce fichier : changez la description et les trois sections du bas.
|
||||||
|
# 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.
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# name (OBLIGATOIRE) — le nom de l'agent. Il doit être identique au nom du dossier.
|
||||||
|
# /newAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier.
|
||||||
|
name: code_reviewer
|
||||||
|
|
||||||
|
# description (OBLIGATOIRE) — une phrase courte : à quoi sert l'agent, et quand NE PAS l'utiliser.
|
||||||
|
# C'est ce que lit l'agent principal pour décider s'il doit lui confier une tâche.
|
||||||
|
description: Révise du code (sécurité, anti-patterns). Ne pas l'utiliser pour écrire du code.
|
||||||
|
|
||||||
|
# model (facultatif) — le modèle de langage de cet agent. Un nom exact ou un morceau de nom
|
||||||
|
# (« sonnet » prend un modèle dont le nom contient « sonnet »).
|
||||||
|
# Sans cette ligne : le modèle de l'agent principal.
|
||||||
|
#model: sonnet
|
||||||
|
|
||||||
|
# tools (facultatif) — les outils que l'agent a le droit d'utiliser, séparés par des virgules.
|
||||||
|
# Outils : Read, Write, Edit, Glob, Grep, Bash, TodoWrite, WebFetch, Skill (+ vos outils personnalisés).
|
||||||
|
# Sans cette ligne : les mêmes outils que l'agent principal (sauf Agent : un sous-agent ne lance pas d'agent).
|
||||||
|
#tools: Read, Grep, Write
|
||||||
|
|
||||||
|
# permissions (facultatif) — règles ajoutées à celles de l'agent principal (voir GUIDE_PERMISSIONS.md).
|
||||||
|
# allow : autorisé sans demander · ask : toujours demander · deny : interdit.
|
||||||
|
# Un deny ou un ask hérité de l'agent principal n'est jamais levé ici.
|
||||||
|
#permissions:
|
||||||
|
# allow: [Read, Grep]
|
||||||
|
# deny: [Bash]
|
||||||
|
|
||||||
|
# effort (facultatif) — le soin apporté à la tâche : low (rapide, 10 étapes au plus),
|
||||||
|
# medium (équilibré, 25 étapes) ou high (approfondi, 50 étapes). Défaut : medium.
|
||||||
|
effort: medium
|
||||||
|
|
||||||
|
# skills (facultatif) — les skills (./.maia/skills) que l'agent peut utiliser. Sans cette ligne : tous.
|
||||||
|
# Une liste vide [] n'en autorise aucun.
|
||||||
|
#skills: [api-docs, testing-guide]
|
||||||
|
|
||||||
|
# omitMd (facultatif) — false : le fichier MAIA.md (ou CLAUDE.md) du projet est ajouté au contexte de l'agent.
|
||||||
|
# true : l'agent ne le voit pas (utile pour un agent qui n'a pas besoin du contexte du projet). Défaut : false.
|
||||||
|
omitMd: false
|
||||||
|
|
||||||
|
# trace (facultatif) — true : la conversation complète de l'agent est enregistrée dans ./.maia/.sessions
|
||||||
|
# (fichier « session_…-nom_de_l_agent.json »), pratique pour comprendre ce qu'il a fait. Défaut : false.
|
||||||
|
trace: false
|
||||||
|
---
|
||||||
|
# Instructions system
|
||||||
|
<!--
|
||||||
|
OBLIGATOIRE. Qui est l'agent et comment il se comporte : son rôle, son expertise, son ton, ses règles.
|
||||||
|
L'agent travaille seul, dans sa propre conversation vide : il ne voit rien de la conversation principale.
|
||||||
|
-->
|
||||||
|
Tu es un expert en révision de code.
|
||||||
|
|
||||||
|
# Missions
|
||||||
|
<!--
|
||||||
|
OBLIGATOIRE. Ce que l'agent doit faire, sous forme de liste d'objectifs concrets.
|
||||||
|
-->
|
||||||
|
- identifier les problèmes de sécurité
|
||||||
|
- détecter les anti-patterns
|
||||||
|
- suggérer des améliorations
|
||||||
|
|
||||||
|
# Output
|
||||||
|
<!--
|
||||||
|
OBLIGATOIRE. Le format du résumé final de l'agent. C'est la SEULE chose renvoyée à l'agent principal :
|
||||||
|
décrivez précisément ce que le résumé doit contenir (structure, niveaux, champs).
|
||||||
|
-->
|
||||||
|
Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description, code concerné, solution recommandée.
|
||||||
|
|
@ -59,6 +59,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
|
||||||
| `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles |
|
| `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles |
|
||||||
| `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) |
|
| `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) |
|
||||||
| `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) |
|
| `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) |
|
||||||
|
| `/newAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom |
|
||||||
| `/agents` | liste les sous-agents chargés (nom, origine, description) |
|
| `/agents` | liste les sous-agents chargés (nom, origine, description) |
|
||||||
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
||||||
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
||||||
|
|
@ -343,7 +344,9 @@ Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `star
|
||||||
|
|
||||||
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
||||||
|
|
||||||
Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents/<nom>/` (dossier de lancement) ou `~/.maia/agents/<nom>/` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`.
|
Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents/<nom>/` (dossier de lancement) ou `~/.maia/agents/<nom>/` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/newAgent` (ou `/newAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `<dossier agents>/<nom>/AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`).
|
||||||
|
|
||||||
|
Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
|
|
@ -438,3 +441,4 @@ Journal des évolutions, dans l'ordre :
|
||||||
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
|
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).
|
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.
|
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.
|
||||||
|
43. **`/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.
|
||||||
|
|
|
||||||
|
|
@ -527,9 +527,10 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ
|
||||||
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
|
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
|
||||||
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
|
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
|
||||||
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
|
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
|
||||||
|
| `<racine de MAIA>/.ressources/AGENT.md` | modèle d'agent de `/newAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) |
|
||||||
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
|
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
|
||||||
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
|
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
|
||||||
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN` | variables d'environnement |
|
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement |
|
||||||
|
|
||||||
## 17. Sécurité
|
## 17. Sécurité
|
||||||
|
|
||||||
|
|
@ -578,6 +579,12 @@ En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table im
|
||||||
|
|
||||||
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, 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`.
|
||||||
|
|
||||||
|
### 20.1b Création : `/newAgent` et le modèle `.ressources/AGENT.md`
|
||||||
|
|
||||||
|
`/newAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `<racine de MAIA>/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section.
|
||||||
|
|
||||||
|
Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `<!-- … -->` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`.
|
||||||
|
|
||||||
### 20.2 Lancement
|
### 20.2 Lancement
|
||||||
|
|
||||||
Outil `Agent` (permission `ask`, règles `Agent` ou `Agent(nom)`), ajouté à l'agent principal quand au moins un agent existe ; sa description et le prompt système listent les agents. Paramètres : `agent` (énumération des noms), `task`. L'appel est bloquant et retourne « Résumé du sous-agent … » ; plusieurs appels `Agent` dans la même réponse passent par l'exécuteur parallèle (§ 6).
|
Outil `Agent` (permission `ask`, règles `Agent` ou `Agent(nom)`), ajouté à l'agent principal quand au moins un agent existe ; sa description et le prompt système listent les agents. Paramètres : `agent` (énumération des noms), `task`. L'appel est bloquant et retourne « Résumé du sous-agent … » ; plusieurs appels `Agent` dans la même réponse passent par l'exécuteur parallèle (§ 6).
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ export function parseAgentSections(body: string): Partial<Record<SectionKey, str
|
||||||
if (h && (h[1]!.length <= level || sectionOf(h[2]!) !== null)) break;
|
if (h && (h[1]!.length <= level || sectionOf(h[2]!) !== null)) break;
|
||||||
content.push(next);
|
content.push(next);
|
||||||
}
|
}
|
||||||
found[key] = content.join("\n").trim();
|
// Les commentaires HTML (explications du modèle /newAgent) ne font pas partie des instructions
|
||||||
|
found[key] = content.join("\n").replace(/<!--[\s\S]*?-->/g, "").trim();
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ export {
|
||||||
export { createAgentTool, type SubAgentLauncher } from "./subagent-tool";
|
export { createAgentTool, type SubAgentLauncher } from "./subagent-tool";
|
||||||
export { CONTEXT_FILES, loadContextFile } from "./memory";
|
export { CONTEXT_FILES, loadContextFile } from "./memory";
|
||||||
export { customToolsDir, loadCustomTools } from "./custom-tools";
|
export { customToolsDir, loadCustomTools } from "./custom-tools";
|
||||||
|
export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent";
|
||||||
|
|
|
||||||
129
packages/agent/src/new-agent.test.ts
Normal file
129
packages/agent/src/new-agent.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadAgents, parseAgentFile } from "./agents";
|
||||||
|
import { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName } from "./new-agent";
|
||||||
|
|
||||||
|
const TEMPLATE = `---
|
||||||
|
# commentaire du modèle
|
||||||
|
name: code_reviewer # doit être le nom du dossier
|
||||||
|
description: Révise du code. Ne pas l'utiliser pour écrire du code.
|
||||||
|
# model: sonnet
|
||||||
|
effort: medium
|
||||||
|
omitMd: false
|
||||||
|
trace: false
|
||||||
|
---
|
||||||
|
# Instructions system
|
||||||
|
<!-- explication à ne pas envoyer au modèle -->
|
||||||
|
Tu es un expert.
|
||||||
|
|
||||||
|
# Missions
|
||||||
|
- vérifier
|
||||||
|
|
||||||
|
# Output
|
||||||
|
Un résumé. name: ne change pas dans le corps
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "maia-na-"));
|
||||||
|
const home = join(root, "home");
|
||||||
|
const cwd = join(root, "proj");
|
||||||
|
await mkdir(home, { recursive: true });
|
||||||
|
await mkdir(cwd, { recursive: true });
|
||||||
|
const templatePath = join(root, "AGENT.md");
|
||||||
|
await writeFile(templatePath, TEMPLATE);
|
||||||
|
return { home, cwd, templatePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/newAgent : création depuis le modèle", () => {
|
||||||
|
it("dossiers : global = ~/.maia/agents, projet = ./.maia/agents", () => {
|
||||||
|
expect(agentsDirFor("global", "/p", "/h")).toBe("/h/.maia/agents");
|
||||||
|
expect(agentsDirFor("projet", "/p", "/h")).toBe("/p/.maia/agents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nom valide : lettres, chiffres, _ . - ; sinon message clair", () => {
|
||||||
|
expect(validateAgentName("agent_1")).toBeNull();
|
||||||
|
expect(validateAgentName("Revue-Code.v2")).toBeNull();
|
||||||
|
expect(validateAgentName("")).toContain("vide");
|
||||||
|
expect(validateAgentName("mon agent")).toContain("invalide");
|
||||||
|
expect(validateAgentName("a/b")).toContain("invalide");
|
||||||
|
expect(validateAgentName(".caché")).toContain("point");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyAgentName : remplace seulement le « name: » de l'en-tête, commentaires et corps conservés", () => {
|
||||||
|
const out = applyAgentName(TEMPLATE, "mon_agent");
|
||||||
|
expect(out).toContain("name: mon_agent\n");
|
||||||
|
expect(out).not.toContain("code_reviewer");
|
||||||
|
expect(out).toContain("# commentaire du modèle");
|
||||||
|
expect(out).toContain("# model: sonnet");
|
||||||
|
expect(out).toContain("name: ne change pas dans le corps");
|
||||||
|
expect(() => applyAgentName("---\ndescription: x\n---\ncorps", "a")).toThrow("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projet : crée ./.maia/agents/<nom>/AGENT.md, chargé ensuite sans erreur ni avertissement", async () => {
|
||||||
|
const { home, cwd, templatePath } = await setup();
|
||||||
|
const file = await createAgentFromTemplate({ scope: "projet", name: "agent_1", cwd, homeDir: home, templatePath });
|
||||||
|
expect(file).toBe(join(cwd, ".maia", "agents", "agent_1", "AGENT.md"));
|
||||||
|
expect(await readFile(file, "utf8")).toContain("name: agent_1");
|
||||||
|
const { agents, warnings } = await loadAgents(cwd, home);
|
||||||
|
expect(warnings).toEqual([]);
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]).toMatchObject({ name: "agent_1", source: "projet", effort: "medium", omitMd: false, trace: false });
|
||||||
|
expect(agents[0]!.tools).toBeUndefined();
|
||||||
|
expect(agents[0]!.instructions).toBe("Tu es un expert."); // commentaire HTML retiré
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global : crée ~/.maia/agents/<nom>/AGENT.md", async () => {
|
||||||
|
const { home, cwd, templatePath } = await setup();
|
||||||
|
const file = await createAgentFromTemplate({ scope: "global", name: "g", cwd, homeDir: home, templatePath });
|
||||||
|
expect(file).toBe(join(home, ".maia", "agents", "g", "AGENT.md"));
|
||||||
|
const { agents } = await loadAgents(cwd, home);
|
||||||
|
expect(agents[0]).toMatchObject({ name: "g", source: "utilisateur" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'écrase jamais un agent existant", async () => {
|
||||||
|
const { home, cwd, templatePath } = await setup();
|
||||||
|
const opts = { scope: "projet" as const, name: "a", cwd, homeDir: home, templatePath };
|
||||||
|
const file = await createAgentFromTemplate(opts);
|
||||||
|
await writeFile(file, "MES MODIFICATIONS");
|
||||||
|
await expect(createAgentFromTemplate(opts)).rejects.toThrow("existe déjà");
|
||||||
|
expect(await readFile(file, "utf8")).toBe("MES MODIFICATIONS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nom invalide ou modèle introuvable : erreur claire, rien de créé", async () => {
|
||||||
|
const { home, cwd, templatePath } = await setup();
|
||||||
|
await expect(createAgentFromTemplate({ scope: "projet", name: "a b", cwd, homeDir: home, templatePath })).rejects.toThrow("invalide");
|
||||||
|
await expect(
|
||||||
|
createAgentFromTemplate({ scope: "projet", name: "a", cwd, homeDir: home, templatePath: join(cwd, "absent.md") }),
|
||||||
|
).rejects.toThrow("introuvable");
|
||||||
|
expect((await loadAgents(cwd, home)).agents).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("commentaires de fin de ligne dans AGENT.md", () => {
|
||||||
|
it("name, description et listes avec ` # …` sont lus sans le commentaire", () => {
|
||||||
|
const raw = `---
|
||||||
|
name: a # doit être le nom du dossier
|
||||||
|
description: "Avec # dièse entre guillemets" # commentaire
|
||||||
|
tools: Read, Grep # outils
|
||||||
|
permissions: # règles
|
||||||
|
allow: [Read, Grep] # lecture
|
||||||
|
deny:
|
||||||
|
- Bash # jamais
|
||||||
|
effort: high # soin
|
||||||
|
---
|
||||||
|
# Instructions system
|
||||||
|
x
|
||||||
|
# Missions
|
||||||
|
y
|
||||||
|
# Output
|
||||||
|
z
|
||||||
|
`;
|
||||||
|
const r = parseAgentFile("a", raw, { dir: "/d", source: "projet" });
|
||||||
|
expect(r.errors).toEqual([]);
|
||||||
|
expect(r.agent).toMatchObject({ name: "a", description: "Avec # dièse entre guillemets", effort: "high" });
|
||||||
|
expect(r.agent!.tools).toEqual(["Read", "Grep"]);
|
||||||
|
expect(r.agent!.permissions).toEqual({ allow: ["Read", "Grep"], ask: [], deny: ["Bash"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
71
packages/agent/src/new-agent.ts
Normal file
71
packages/agent/src/new-agent.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { AGENT_NAME_RE } from "./agents";
|
||||||
|
|
||||||
|
export type AgentScope = "global" | "projet";
|
||||||
|
|
||||||
|
/** Dossier des agents d'un niveau : ~/.maia/agents (global) ou ./.maia/agents (projet). */
|
||||||
|
export function agentsDirFor(scope: AgentScope, cwd: string, homeDir: string): string {
|
||||||
|
return join(resolve(scope === "global" ? homeDir : cwd), ".maia", "agents");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne un message d'erreur si le nom ne peut pas être celui d'un agent (c'est aussi le nom de son dossier). */
|
||||||
|
export function validateAgentName(name: string): string | null {
|
||||||
|
if (!name) return "Le nom de l'agent est vide.";
|
||||||
|
if (name.length > 64) return "Nom trop long (64 caractères au maximum).";
|
||||||
|
if (!AGENT_NAME_RE.test(name)) return "Nom invalide : lettres, chiffres, _ . - uniquement (pas d'espace).";
|
||||||
|
if (name.startsWith(".")) return "Un nom d'agent ne peut pas commencer par un point.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplace la valeur de `name:` dans l'en-tête du modèle (les autres lignes, commentaires compris, sont conservées). */
|
||||||
|
export function applyAgentName(template: string, name: string): string {
|
||||||
|
const end = template.indexOf("\n---", 4);
|
||||||
|
const head = end === -1 ? template : template.slice(0, end);
|
||||||
|
const rest = end === -1 ? "" : template.slice(end);
|
||||||
|
const lines = head.split("\n");
|
||||||
|
let done = false;
|
||||||
|
const out = lines.map((line) => {
|
||||||
|
if (!done && /^name[ \t]*:/.test(line)) {
|
||||||
|
done = true;
|
||||||
|
return `name: ${name}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
if (!done) throw new Error('Le modèle d\'agent ne contient pas de ligne « name: » dans son en-tête.');
|
||||||
|
return out.join("\n") + rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée <dossier des agents>/<nom>/AGENT.md à partir du modèle. Refuse d'écraser un agent existant.
|
||||||
|
* Retourne le chemin du fichier créé.
|
||||||
|
*/
|
||||||
|
export async function createAgentFromTemplate(opts: {
|
||||||
|
scope: AgentScope;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
homeDir: string;
|
||||||
|
templatePath: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const invalid = validateAgentName(opts.name);
|
||||||
|
if (invalid) throw new Error(invalid);
|
||||||
|
|
||||||
|
let template: string;
|
||||||
|
try {
|
||||||
|
template = await readFile(opts.templatePath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Modèle d'agent introuvable : ${opts.templatePath} (${(err as Error).message})`);
|
||||||
|
}
|
||||||
|
const content = applyAgentName(template.replace(/\r\n/g, "\n"), opts.name);
|
||||||
|
|
||||||
|
const dir = join(agentsDirFor(opts.scope, opts.cwd, opts.homeDir), opts.name);
|
||||||
|
const file = join(dir, "AGENT.md");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await writeFile(file, content, { encoding: "utf8", flag: "wx" }); // wx : échoue si le fichier existe
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`L'agent « ${opts.name} » existe déjà : ${file}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,23 @@ interface Line {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retire un commentaire de fin de ligne (` # …`, le # doit être précédé d'un espace et hors guillemets). */
|
||||||
|
export function stripComment(text: string): string {
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i]!;
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
// une apostrophe au milieu d'un mot (l'agent) n'ouvre pas de guillemets
|
||||||
|
if (i === 0 || /[\s:,\[(]/.test(text[i - 1]!)) quote = ch;
|
||||||
|
} else if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]!))) {
|
||||||
|
return text.slice(0, i).trimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
function unquote(v: string): string {
|
function unquote(v: string): string {
|
||||||
const m = /^(["'])(.*)\1$/.exec(v.trim());
|
const m = /^(["'])(.*)\1$/.exec(v.trim());
|
||||||
return m ? (m[2] ?? "") : v.trim();
|
return m ? (m[2] ?? "") : v.trim();
|
||||||
|
|
@ -42,7 +59,7 @@ function parseList(lines: Line[], pos: { i: number }, indent: number): string[]
|
||||||
while (pos.i < lines.length) {
|
while (pos.i < lines.length) {
|
||||||
const line = lines[pos.i]!;
|
const line = lines[pos.i]!;
|
||||||
if (line.indent !== indent || !/^-(\s|$)/.test(line.text)) break;
|
if (line.indent !== indent || !/^-(\s|$)/.test(line.text)) break;
|
||||||
const item = unquote(line.text.replace(/^-\s*/, ""));
|
const item = unquote(stripComment(line.text.replace(/^-\s*/, "")));
|
||||||
if (item) items.push(item);
|
if (item) items.push(item);
|
||||||
pos.i++;
|
pos.i++;
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +79,7 @@ function parseMap(lines: Line[], pos: { i: number }, indent: number): YamlMap {
|
||||||
pos.i++;
|
pos.i++;
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const key = m[1]!.toLowerCase();
|
const key = m[1]!.toLowerCase();
|
||||||
const rest = (m[2] ?? "").trim();
|
const rest = stripComment((m[2] ?? "").trim());
|
||||||
|
|
||||||
if (rest !== "" && !/^[>|][+-]?$/.test(rest)) {
|
if (rest !== "" && !/^[>|][+-]?$/.test(rest)) {
|
||||||
map[key] = inlineValue(rest);
|
map[key] = inlineValue(rest);
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,20 @@ import {
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
type Message,
|
type Message,
|
||||||
} from "@maia/core";
|
} from "@maia/core";
|
||||||
import { initializeAgent, type AgentDefinition } from "@maia/agent";
|
import {
|
||||||
|
agentsDirFor,
|
||||||
|
createAgentFromTemplate,
|
||||||
|
initializeAgent,
|
||||||
|
validateAgentName,
|
||||||
|
type AgentDefinition,
|
||||||
|
type AgentScope,
|
||||||
|
} from "@maia/agent";
|
||||||
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
|
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
|
||||||
import { InkUI } from "@maia/ui";
|
import { InkUI } from "@maia/ui";
|
||||||
import { resolveConfig } from "./config";
|
import { resolveConfig } from "./config";
|
||||||
import { resolveLaunchDir } from "./launch-dir";
|
import { resolveLaunchDir } from "./launch-dir";
|
||||||
import { runUserShell } from "./user-shell";
|
import { runUserShell } from "./user-shell";
|
||||||
|
import { findResource } from "./resources";
|
||||||
import { createSubAgentLauncher } from "./subagent-parent";
|
import { createSubAgentLauncher } from "./subagent-parent";
|
||||||
import { SUBAGENT_FLAG } from "./subagent-protocol";
|
import { SUBAGENT_FLAG } from "./subagent-protocol";
|
||||||
|
|
||||||
|
|
@ -79,7 +87,7 @@ Usage : maia [options]
|
||||||
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
|
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
|
||||||
-h, --help Affiche cette aide
|
-h, --help Affiche cette aide
|
||||||
|
|
||||||
Commandes en session : /help /model /permissions /agents /clear /compact /exit
|
Commandes en session : /help /model /permissions /agents /newAgent /clear /compact /exit
|
||||||
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
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)
|
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
|
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
||||||
|
|
@ -486,6 +494,44 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
ui.showPermissions(session.permissionSources);
|
ui.showPermissions(session.permissionSources);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (name.toLowerCase() === "newagent") {
|
||||||
|
// /newAgent [nom] : crée ./.maia/agents/<nom>/AGENT.md (projet) ou ~/.maia/agents/<nom>/AGENT.md (global) depuis le modèle
|
||||||
|
const template = findResource("AGENT.md");
|
||||||
|
if (!template) {
|
||||||
|
ui.error("Modèle d'agent introuvable : .ressources/AGENT.md (dans le dossier de MAIA, ou dossier indiqué par MAIA_RESSOURCES).");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scope = await ui.select<AgentScope>("Où créer le nouvel agent ?", [
|
||||||
|
{ value: "projet", label: "Projet", hint: agentsDirFor("projet", cwd, homeDir) },
|
||||||
|
{ value: "global", label: "Global (utilisateur)", hint: agentsDirFor("global", cwd, homeDir) },
|
||||||
|
]);
|
||||||
|
if (!scope) continue;
|
||||||
|
let agentName = args.trim();
|
||||||
|
if (!agentName) {
|
||||||
|
ui.info("Nom du nouvel agent (lettres, chiffres, _ . - ; ce sera aussi le nom de son dossier) — saisie vide ou Ctrl+C : annuler");
|
||||||
|
agentName = (await ui.readInput()) ?? "";
|
||||||
|
if (!agentName) {
|
||||||
|
ui.info("Création annulée.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const invalid = validateAgentName(agentName);
|
||||||
|
if (invalid) {
|
||||||
|
ui.error(invalid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
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), " +
|
||||||
|
"puis tapez /clear pour le charger. Vous le lancerez avec &" + agentName + " ou l'agent principal le proposera au besoin.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
ui.error((err as Error).message);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (name === "agents") {
|
if (name === "agents") {
|
||||||
ui.info(
|
ui.info(
|
||||||
agents.length > 0
|
agents.length > 0
|
||||||
|
|
|
||||||
21
packages/cli/src/resources.test.ts
Normal file
21
packages/cli/src/resources.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { findResource } from "./resources";
|
||||||
|
|
||||||
|
describe("findResource", () => {
|
||||||
|
it("racine de MAIA = 3 niveaux au-dessus du programme ; MAIA_RESSOURCES prioritaire ; absent : null", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "maia-res-"));
|
||||||
|
await mkdir(join(root, ".ressources"), { recursive: true });
|
||||||
|
await writeFile(join(root, ".ressources", "AGENT.md"), "x");
|
||||||
|
const from = join(root, "packages", "cli", "dist", "index.js");
|
||||||
|
expect(findResource("AGENT.md", from, {})).toBe(join(root, ".ressources", "AGENT.md"));
|
||||||
|
|
||||||
|
const other = await mkdtemp(join(tmpdir(), "maia-res2-"));
|
||||||
|
await writeFile(join(other, "AGENT.md"), "y");
|
||||||
|
expect(findResource("AGENT.md", from, { MAIA_RESSOURCES: other })).toBe(join(other, "AGENT.md"));
|
||||||
|
|
||||||
|
expect(findResource("INCONNU.md", from, {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/cli/src/resources.ts
Normal file
24
packages/cli/src/resources.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Nom du dossier des ressources privées de MAIA (à la racine du projet MAIA, pas du dossier de travail de l'utilisateur). */
|
||||||
|
export const RESOURCES_DIR = ".ressources";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fichier de ressource privée de MAIA (ex. le modèle `AGENT.md`), cherché dans l'ordre :
|
||||||
|
* 1. $MAIA_RESSOURCES (dossier) ;
|
||||||
|
* 2. <racine de MAIA>/.ressources, la racine étant 3 niveaux au-dessus du programme (packages/cli/dist ou packages/cli/src) ;
|
||||||
|
* 3. juste à côté du programme (installation à plat).
|
||||||
|
* Retourne le chemin du premier fichier existant, sinon null.
|
||||||
|
*/
|
||||||
|
export function findResource(file: string, from: string = fileURLToPath(import.meta.url), env = process.env): string | null {
|
||||||
|
const here = dirname(from);
|
||||||
|
const dirs = [env.MAIA_RESSOURCES, join(here, "..", "..", "..", RESOURCES_DIR), join(here, RESOURCES_DIR)];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!dir) continue;
|
||||||
|
const path = join(dir, file);
|
||||||
|
if (existsSync(path)) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue