Add models list functionality and update related files

This commit is contained in:
Arnaud 2026-10-29 01:47:34 +01:00
parent b0dfba6000
commit 173170b68e
12 changed files with 141 additions and 15 deletions

View file

@ -42,7 +42,7 @@ tient compte de `MAIA_CWD`, puis de `INIT_CWD` (dossier où pnpm/npm a été lan
| `core` | Contrats (`LLMProvider`, `Tool`, `AgentUI`…), boucle agent, sessions, `!`, `@fichier`, `§skill` |
| `providers` | Adaptateurs LLM : `litellm` (provider unique : requêtes au format OpenAI vers LiteLLM, voir ci-dessous) |
| `ui` | Interface Ink/React (journal, saisie, listes de choix, confirmations, spinner) |
| `agent` | L'agent MAIA : contexte (MAIA.md / CLAUDE.md), skills `.maia/skills` + outils Read/Glob/Grep/Write/Edit/Bash/TodoWrite/WebFetch/CurrentModel |
| `agent` | L'agent MAIA : contexte (MAIA.md / CLAUDE.md), skills `.maia/skills` + outils Read/Glob/Grep/Write/Edit/Bash/TodoWrite/WebFetch/CurrentModel/ListModels |
| `cli` | Point d'entrée : configuration, boucle de conversation |
Résolution du modèle (pas de défaut : s'il n'est pas défini, il est demandé) : option `--model` > variable `MAIA_MODEL`
@ -59,6 +59,7 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
| `/help` | aide (commandes, skills) |
| `/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) |
| `/models` | affiche la liste des modèles disponibles auprès de LiteLLM (le modèle actif est signalé ●), sans changer de modèle |
| `/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) |
| `/createAgent [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) |
@ -202,7 +203,7 @@ Au démarrage, le fichier **`~/.maia/settings.json`** (dossier de l'utilisateur,
Si le fichier est absent ou incomplet, MAIA s'arrête avec un message qui en donne un exemple.
Choix du modèle : au démarrage (sauf si `--model` ou `MAIA_MODEL` le définit), MAIA récupère les modèles auprès de LiteLLM
et demande de choisir celui de la session. `/model` rouvre cette liste en cours de session (la conversation est conservée).
et demande de choisir celui de la session. `/model` rouvre cette liste en cours de session (la conversation est conservée) ; `/models` l'affiche seulement, sans rien changer.
Spécification explicite : `litellm:<nom du modèle>` (ou le nom seul).
## Agent MAIA : contexte et skills
@ -278,10 +279,11 @@ La commande `/permissions` affiche les règles actives, fichier par fichier, ave
Exemple pour éviter les confirmations sur les outils en lecture seule : voir `settings.example.json`.
## Outils TodoWrite, WebFetch et CurrentModel
## Outils TodoWrite, WebFetch, CurrentModel et ListModels
- **TodoWrite** : liste de tâches de la session (`pending` / `in_progress` / `completed`, forme `activeForm` pour la tâche en cours). Chaque appel remplace la liste ; le résultat (☐ ▶ ☑) est affiché à l'utilisateur et renvoyé au modèle. Un avertissement est ajouté si plusieurs tâches sont « en cours ».
- **CurrentModel** : renvoie le modèle LLM utilisé par la session (valeur à jour après un `/model`). Sans paramètre, sans confirmation ; le modèle est lu au moment de l'appel.
- **ListModels** : renvoie la liste des modèles disponibles auprès de LiteLLM (même source que `/models`, interrogée à chaque appel), en signalant celui de la session. Sans paramètre, sans confirmation. Permet à l'agent de répondre à « quels sont les modèles disponibles ? » ; il ne change pas de modèle (c'est `/model`, côté utilisateur). Disponible aussi pour les sous-agents et l'agent parent des workflows.
- **WebFetch** : télécharge une URL http(s) (HTML converti en Markdown avec `turndown`, JSON mis en forme, texte brut ; 5 Mo et 30 s maximum, `max_length` caractères renvoyés). Pas de JavaScript exécuté, pas de PDF ni d'images.
- Confirmation demandée par défaut ; règle `allow` possible : `WebFetch(domain:docs.example.com)` (voir « Permissions »).
- Une redirection vers un **autre domaine** n'est pas suivie : l'outil renvoie la nouvelle URL, que le modèle doit rappeler (ce qui repasse par la confirmation).
@ -572,3 +574,5 @@ Journal des évolutions, dans l'ordre :
56. **Diagnostic des touches** : `MAIA_DEBUG_KEYS=1 pnpm maia` écrit chaque touche reçue (texte + drapeaux Ink) dans `./.maia-keys.log`, pour comprendre pourquoi une touche (Maj+Entrée…) n'est pas reconnue.
57. **Barre de contexte : js-tiktoken uniquement.** L'essai avec `usage.total_tokens` du serveur a été retiré : la taille du contexte est toujours comptée localement avec js-tiktoken, la consommation du serveur ne sert qu'à détecter une réponse coupée.
58. **`/delete`** : suppression d'une ou plusieurs sessions (Espace pour cocher, Suppr pour supprimer, confirmation), sauf la session courante.
59. **`/models`** : liste des modèles disponibles auprès de LiteLLM (interrogé à chaque appel, `LiteLLM_URL` + `LiteLLM_Models`), modèle actif signalé ; aucun changement de modèle.
60. **Outil `ListModels`** : l'agent (et les sous-agents, les workflows) peut lister les modèles disponibles auprès de LiteLLM.

View file

@ -154,6 +154,7 @@ Sortie : `/exit`, Ctrl+D (saisie vide), Ctrl+C (saisie), SIGTERM, SIGHUP. La ses
| `/help` | aide (commandes, skills) |
| `/permissions` | règles chargées, par fichier (badges ALLOW / ASK / DENY) |
| `/model` | recharge la liste, change de modèle, conversation conservée ; redemande la taille de contexte |
| `/models` | affiche les modèles de LiteLLM (`listLiteLLMModels`, requête à chaque appel) via `formatModelList` (`cli/src/models-list.ts`) : un par ligne, actif marqué ●, URL interrogée ; erreur réseau/HTTP affichée avec le chemin du settings.json ; ne change rien |
| `/clear` | nouvel agent : MAIA.md / CLAUDE.md, skills, outils et permissions rechargés ; messages supprimés ; la session garde son nom |
| `/compact` | compactage (§ 14) |
| `/rename <nom>` | renomme la session courante |
@ -255,7 +256,7 @@ Ce fichier est dans le dossier de l'**utilisateur** (pas celui de lancement) ; i
## 8. Outils intégrés (`packages/agent/src/tools`)
Ordre de déclaration : Read, Glob, Grep, Write, Edit, Bash, TodoWrite, WebFetch, CurrentModel. Les chemins relatifs sont résolus depuis le dossier de lancement ; `~` et `~/…` désignent le dossier personnel ; un chemin complet est utilisé tel quel.
Ordre de déclaration : Read, Glob, Grep, Write, Edit, Bash, TodoWrite, WebFetch, CurrentModel, puis ListModels (si l'application fournit `listModels` dans le contexte d'initialisation : session principale, sous-agents, agent parent des workflows via `extraTools`). Les chemins relatifs sont résolus depuis le dossier de lancement ; `~` et `~/…` désignent le dossier personnel ; un chemin complet est utilisé tel quel.
| Outil | Entrée | Comportement | Permission par défaut | `lockKey` |
| --- | --- | --- | --- | --- |
@ -268,6 +269,7 @@ Ordre de déclaration : Read, Glob, Grep, Write, Edit, Bash, TodoWrite, WebFetch
| **TodoWrite** | liste de tâches (`pending` · `in_progress` · `completed`, forme `activeForm`) | remplace la liste à chaque appel ; rendu ☐ ▶ ☑ ; avertissement si plusieurs tâches « en cours » | auto | `todowrite` |
| **WebFetch** | `url`, `max_length?` | voir ci-dessous | ask | — |
| **CurrentModel** | aucune | renvoie le modèle LLM actif (lu à l'appel) | auto | — |
| **ListModels** | aucune | modèles disponibles auprès de LiteLLM (`listLiteLLMModels`, requête à chaque appel), modèle de la session signalé ; erreur réseau renvoyée au modèle ; nom réservé (outil personnalisé homonyme ignoré) | auto | — |
**WebFetch.** URL http(s) uniquement. HTML → Markdown via `turndown` (titres `atx`, blocs de code `fenced`, puces `-`, listes à puce « - a » et numérotées « 1. a » par règle `listItem` personnalisée ; balises `head`, `script`, `style`, `noscript`, `iframe`, `svg`, `template` supprimées) ; JSON mis en forme ; texte brut. Limites : **5 Mo**, **30 s**, 5 redirections, `max_length` par défaut **20 000** caractères (plafond **100 000**). Une redirection vers un **autre domaine** n'est pas suivie : l'URL est renvoyée au modèle, qui doit la rappeler (nouvelle confirmation). L'en-tête de résultat contient l'URL, le type, le titre et la mention « Contenu externe non fiable… ». Aucun JavaScript exécuté, ni PDF, ni images.

View file

@ -4,7 +4,7 @@ import type { SlashCommand } from "@maia/core";
import { parseFrontmatter } from "./frontmatter";
/** Noms réservés aux commandes internes de MAIA (gérées par la CLI). */
export const RESERVED_COMMANDS = new Set(["help", "clear", "model", "exit", "rename", "load", "new", "delete", "permissions"]);
export const RESERVED_COMMANDS = new Set(["help", "clear", "model", "models", "exit", "rename", "load", "new", "delete", "permissions"]);
async function listMarkdown(dir: string, prefix = ""): Promise<{ name: string; file: string }[]> {
let entries;

View file

@ -12,6 +12,7 @@ export {
type SubAgentInit,
} from "./agents";
export { createAgentTool, type SubAgentLauncher } from "./subagent-tool";
export { createListModelsTool, LIST_MODELS_TOOL_NAME } from "./tools/listmodels";
export { CONTEXT_FILES, loadContextFile } from "./memory";
export { customToolsDir, loadCustomTools } from "./custom-tools";
export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent";

View file

@ -7,6 +7,7 @@ import { createPermissionPolicy } from "./permissions";
import { loadPermissionSettings } from "./settings";
import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills";
import { builtinTools } from "./tools";
import { LIST_MODELS_TOOL_NAME, createListModelsTool } from "./tools/listmodels";
import { agentsPromptSection, createAgentTool, type SubAgentLauncher } from "./subagent-tool";
/** Contexte d'initialisation de l'agent MAIA : celui du core, plus ce qui concerne les sous-agents. */
@ -15,6 +16,8 @@ export interface AgentInitContext extends InitContext {
launchSubAgent?: SubAgentLauncher;
/** Initialisation d'un sous-agent (processus enfant) : définition, outils/skills/permissions restreints. */
subAgent?: SubAgentInit;
/** Fourni par l'application : modèles disponibles auprès de LiteLLM. Absent : pas d'outil ListModels. */
listModels?: () => Promise<string[]>;
}
export interface AgentInitResult extends ModeInitResult {
@ -58,7 +61,7 @@ function buildSystemPrompt(
* Initialise l'agent MAIA (il n'y a plus qu'un seul mode) :
* - contexte de départ : MAIA.md du dossier de lancement, sinon CLAUDE.md ; avertissement si aucun des deux n'existe ;
* - skills : .maia/skills (dossier de lancement, dossier parent, dossier utilisateur) ;
* - outils : Read, Write, Edit, Glob, Grep, Bash, TodoWrite, WebFetch, CurrentModel (+ Skill s'il y a des skills) ;
* - outils : Read, Write, Edit, Glob, Grep, Bash, TodoWrite, WebFetch, CurrentModel, ListModels (si fourni) (+ Skill s'il y a des skills) ;
* - outils personnalisés : ./.maia/tools/<nom>/TOOL.json du dossier de lancement uniquement (ni parent, ni utilisateur).
*/
export async function initializeAgent(ctx: AgentInitContext): Promise<AgentInitResult> {
@ -75,7 +78,7 @@ export async function initializeAgent(ctx: AgentInitContext): Promise<AgentInitR
if (skills.length > 0) ctx.log(["Skills chargés :", ...skills.map((s) => s.name)].join("\n"));
// 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 reserved = [...builtinTools.map((t) => t.spec.name), "Skill", "Agent", LIST_MODELS_TOOL_NAME];
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"));
@ -114,7 +117,14 @@ export async function initializeAgent(ctx: AgentInitContext): Promise<AgentInitR
systemPrompt:
buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) +
(agentTools.length > 0 ? agentsPromptSection(agents) : ""),
tools: [...builtinTools, ...(skills.length > 0 ? [createSkillTool(skills)] : []), ...agentTools, ...customTools, ...mcp.tools],
tools: [
...builtinTools,
...(ctx.listModels ? [createListModelsTool(ctx.listModels)] : []),
...(skills.length > 0 ? [createSkillTool(skills)] : []),
...agentTools,
...customTools,
...mcp.tools,
],
commands: [],
skills: toAgentSkills(skills),
permissions: createPermissionPolicy(settings.rules),
@ -144,9 +154,9 @@ async function initializeSubAgent(ctx: AgentInitContext, sub: SubAgentInit): Pro
}
}
const reserved = [...builtinTools.map((t) => t.spec.name), "Skill", "Agent"];
const reserved = [...builtinTools.map((t) => t.spec.name), "Skill", "Agent", LIST_MODELS_TOOL_NAME];
const { tools: customTools } = await loadCustomTools(ctx.cwd, reserved, ctx.homeDir);
let tools: Tool[] = [...builtinTools, ...customTools];
let tools: Tool[] = [...builtinTools, ...(ctx.listModels ? [createListModelsTool(ctx.listModels)] : []), ...customTools];
if (skills.length > 0) tools.push(createSkillTool(skills));
if (sub.tools) {
const wanted = new Set(sub.tools.map((t) => t.toLowerCase()));

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createListModelsTool } from "./listmodels";
describe("ListModels", () => {
it("liste les modèles et signale celui de la session", async () => {
const tool = createListModelsTool(async () => ["claude-haiku", "gpt-4.1"]);
const out = await tool.execute({}, { cwd: "/x", model: "litellm:gpt-4.1" });
expect(out).toContain("(2)");
expect(out).toContain("- claude-haiku\n");
expect(out).toContain("- gpt-4.1 (modèle de la session)");
});
it("aucun modèle", async () => {
expect(await createListModelsTool(async () => []).execute({}, { cwd: "/x" })).toContain("Aucun modèle");
});
it("erreur du serveur : remontée (l'exécuteur la transmet au modèle)", async () => {
const tool = createListModelsTool(async () => {
throw new Error("LiteLLM injoignable");
});
await expect(tool.execute({}, { cwd: "/x" })).rejects.toThrow("injoignable");
});
it("sans confirmation, sans paramètre", () => {
const tool = createListModelsTool(async () => []);
expect(tool.permission).toBe("auto");
expect(tool.spec.name).toBe("ListModels");
});
});

View file

@ -0,0 +1,32 @@
import type { Tool } from "@maia/core";
export const LIST_MODELS_TOOL_NAME = "ListModels";
/**
* Liste des modèles disponibles auprès de LiteLLM (même source que /models). La requête est fournie par l'application
* (le paquet agent ne connaît pas LiteLLM) : elle est refaite à chaque appel.
*/
export function createListModelsTool(listModels: () => Promise<string[]>): Tool {
return {
spec: {
name: LIST_MODELS_TOOL_NAME,
description:
"Renvoie la liste des modèles LLM disponibles auprès du serveur LiteLLM, en signalant le modèle de la session. " +
"À utiliser quand on te demande quels modèles sont disponibles ou utilisables : ne réponds pas de mémoire. " +
"Pour changer de modèle, l'utilisateur tape /model.",
inputSchema: { type: "object", properties: {} },
},
permission: "auto",
describe: () => "modèles disponibles",
async execute(_input, ctx) {
const names = await listModels();
if (names.length === 0) return "Aucun modèle déclaré par le serveur LiteLLM.";
const active = ctx.model?.startsWith("litellm:") ? ctx.model.slice("litellm:".length) : ctx.model;
return [
`Modèles disponibles auprès de LiteLLM (${names.length}) :`,
...names.map((n) => `- ${n}${n === active ? " (modèle de la session)" : ""}`),
"Changement de modèle : commande /model (par l'utilisateur).",
].join("\n");
},
};
}

View file

@ -35,7 +35,8 @@ import {
type AgentScope,
type McpServerStatus,
} from "@maia/agent";
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
import { createProvider, joinUrl, listLiteLLMModels, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
import { formatModelList } from "./models-list";
import { InkUI } from "@maia/ui";
import { resolveConfig } from "./config";
import { resolveLaunchDir } from "./launch-dir";
@ -90,7 +91,7 @@ Usage : maia [options]
-h, --help Affiche cette aide
run <AGENT.md> Lance un workflow autonome sans interface (voir plus bas : maia run --help)
Commandes en session : /help /model /permissions /mcp /workflows /agents /createAgent /clear /compact /exit
Commandes en session : /help /model /models /permissions /mcp /workflows /agents /createAgent /clear /compact /exit
Sessions : /rename <nom> /load [nom] /new /delete (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
/delete : Espace coche/décoche des sessions, Suppr les supprime du disque (après confirmation)
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
@ -226,6 +227,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
warn: (m) => ui.warn(m),
startContext: opts.startContext,
launchSubAgent,
listModels: async () => (await listLiteLLMModels(settings)).map((c) => c.label),
});
agents = init.agents;
const previousClose = closeMcp;
@ -747,6 +749,24 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
await compactNow();
continue;
}
if (name === "models") {
// Liste seule (aucun changement de modèle) : interroge LiteLLM à chaque appel
let names: string[];
try {
names = (
await ui.withSpinner("Chargement des modèles disponibles…", () => listLiteLLMModels(settings), "Modèles chargés")
).map((c) => c.label);
} catch (err) {
ui.error(`${(err as Error).message}\nVérifiez LiteLLM_URL et LiteLLM_Models dans ${settingsPath(homeDir)}.`);
continue;
}
if (names.length === 0) {
ui.warn(`LiteLLM ne déclare aucun modèle (${joinUrl(settings.LiteLLM_URL, settings.LiteLLM_Models)}).`);
continue;
}
ui.info(formatModelList(names, modelSpec, joinUrl(settings.LiteLLM_URL, settings.LiteLLM_Models)));
continue;
}
if (name === "model") {
// Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée.
const choice = await pickModel(modelSpec);

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formatModelList } from "./models-list";
describe("formatModelList (/models)", () => {
it("un modèle par ligne, le modèle actif signalé, nombre et URL en tête", () => {
const text = formatModelList(["claude-sonnet", "gpt-4o"], "litellm:gpt-4o", "http://localhost:4000/v1/models");
expect(text.split("\n")).toEqual([
"Modèles disponibles auprès de LiteLLM (2) — http://localhost:4000/v1/models",
" ○ claude-sonnet",
" ● gpt-4o (actif)",
"/model : changer de modèle",
]);
});
it("modèle actif absent de la liste : aucun n'est signalé", () => {
expect(formatModelList(["a"], "litellm:b", "u")).not.toContain("actif");
});
});

View file

@ -0,0 +1,8 @@
// Affichage de /models : liste des modèles disponibles auprès de LiteLLM (sans rien changer à la session).
/** Texte affiché par /models : un modèle par ligne, le modèle actif signalé. */
export function formatModelList(names: string[], current: string | undefined, url: string): string {
const active = current?.startsWith("litellm:") ? current.slice("litellm:".length) : current;
const lines = names.map((n) => (n === active ? ` ● ${n} (actif)` : ` ○ ${n}`));
return [`Modèles disponibles auprès de LiteLLM (${names.length}) — ${url}`, ...lines, "/model : changer de modèle"].join("\n");
}

View file

@ -7,7 +7,7 @@ import {
type Message,
} from "@maia/core";
import { EFFORT_ITERATIONS, initializeAgent } from "@maia/agent";
import { createProvider, loadSettings } from "@maia/providers";
import { createProvider, listLiteLLMModels, loadSettings } from "@maia/providers";
import type { ChildMessage, ChildSpec, ParentMessage } from "./subagent-protocol";
// Processus d'un sous-agent : démarré par le parent avec `maia --subagent`, il reçoit sa mission par IPC, travaille avec
@ -93,6 +93,7 @@ export async function runSubAgentChild(): Promise<void> {
log: () => undefined,
warn: (message) => void send({ type: "log", level: "warn", message }),
subAgent: spec.agent,
listModels: async () => (await listLiteLLMModels(settings)).map((c) => c.label),
});
const session = createSession({ provider: createProvider(spec.model, settings), init, ui, cwd: spec.cwd });
messages = session.messages;

View file

@ -20,11 +20,12 @@ import {
} from "@maia/core";
import {
INTERVENTION_TOOL_NAME,
createListModelsTool,
initializeOrchestrator,
loadOrchestrator,
resolveSubAgentModel,
} from "@maia/agent";
import { createProvider, loadModelChoices, loadSettings, type MaiaSettings } from "@maia/providers";
import { createProvider, listLiteLLMModels, loadModelChoices, loadSettings, type MaiaSettings } from "@maia/providers";
import { createSubAgentLauncher } from "./subagent-parent";
/** Délai maximal d'un sous-agent dans un workflow : celui du workflow (un sous-agent peut attendre une autorisation longtemps). */
@ -342,7 +343,10 @@ export async function runWorkflow(opts: WorkflowRunOptions, deps: WorkflowRunDep
def,
homeDir: opts.homeDir,
launchSubAgent,
extraTools: [interventionTool],
extraTools: [
interventionTool,
createListModelsTool(async () => (await listLiteLLMModels(settings)).map((c) => c.label)),
],
log: (m) => say(parent, "info", m),
warn: (m) => say(parent, "warn", m),
});