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` | | `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) | | `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) | | `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 | | `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` 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) | | `/help` | aide (commandes, skills) |
| `/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) |
| `/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) | | `/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 | | `/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) | | `/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. 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 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). Spécification explicite : `litellm:<nom du modèle>` (ou le nom seul).
## Agent MAIA : contexte et skills ## 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`. 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 ». - **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. - **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. - **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 »). - 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). - 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. 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. 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. 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) | | `/help` | aide (commandes, skills) |
| `/permissions` | règles chargées, par fichier (badges ALLOW / ASK / DENY) | | `/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 | | `/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 | | `/clear` | nouvel agent : MAIA.md / CLAUDE.md, skills, outils et permissions rechargés ; messages supprimés ; la session garde son nom |
| `/compact` | compactage (§ 14) | | `/compact` | compactage (§ 14) |
| `/rename <nom>` | renomme la session courante | | `/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`) ## 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` | | 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` | | **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 | — | | **WebFetch** | `url`, `max_length?` | voir ci-dessous | ask | — |
| **CurrentModel** | aucune | renvoie le modèle LLM actif (lu à l'appel) | auto | — | | **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. **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"; import { parseFrontmatter } from "./frontmatter";
/** Noms réservés aux commandes internes de MAIA (gérées par la CLI). */ /** 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 }[]> { async function listMarkdown(dir: string, prefix = ""): Promise<{ name: string; file: string }[]> {
let entries; let entries;

View file

@ -12,6 +12,7 @@ export {
type SubAgentInit, type SubAgentInit,
} from "./agents"; } from "./agents";
export { createAgentTool, type SubAgentLauncher } from "./subagent-tool"; export { createAgentTool, type SubAgentLauncher } from "./subagent-tool";
export { createListModelsTool, LIST_MODELS_TOOL_NAME } from "./tools/listmodels";
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"; 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 { loadPermissionSettings } from "./settings";
import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills"; import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills";
import { builtinTools } from "./tools"; import { builtinTools } from "./tools";
import { LIST_MODELS_TOOL_NAME, createListModelsTool } from "./tools/listmodels";
import { agentsPromptSection, createAgentTool, type SubAgentLauncher } from "./subagent-tool"; 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. */ /** 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; launchSubAgent?: SubAgentLauncher;
/** Initialisation d'un sous-agent (processus enfant) : définition, outils/skills/permissions restreints. */ /** Initialisation d'un sous-agent (processus enfant) : définition, outils/skills/permissions restreints. */
subAgent?: SubAgentInit; 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 { export interface AgentInitResult extends ModeInitResult {
@ -58,7 +61,7 @@ function buildSystemPrompt(
* Initialise l'agent MAIA (il n'y a plus qu'un seul mode) : * 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 ; * - 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) ; * - 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). * - 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> { 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")); 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 // 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); const { tools: customTools, warnings: toolWarnings } = await loadCustomTools(ctx.cwd, reserved, ctx.homeDir);
for (const w of toolWarnings) ctx.warn(w); 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")); 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: systemPrompt:
buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) + buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) +
(agentTools.length > 0 ? agentsPromptSection(agents) : ""), (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: [], commands: [],
skills: toAgentSkills(skills), skills: toAgentSkills(skills),
permissions: createPermissionPolicy(settings.rules), 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); 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 (skills.length > 0) tools.push(createSkillTool(skills));
if (sub.tools) { if (sub.tools) {
const wanted = new Set(sub.tools.map((t) => t.toLowerCase())); 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 AgentScope,
type McpServerStatus, type McpServerStatus,
} from "@maia/agent"; } 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 { InkUI } from "@maia/ui";
import { resolveConfig } from "./config"; import { resolveConfig } from "./config";
import { resolveLaunchDir } from "./launch-dir"; import { resolveLaunchDir } from "./launch-dir";
@ -90,7 +91,7 @@ Usage : maia [options]
-h, --help Affiche cette aide -h, --help Affiche cette aide
run <AGENT.md> Lance un workflow autonome sans interface (voir plus bas : maia run --help) 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) 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) /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) 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), warn: (m) => ui.warn(m),
startContext: opts.startContext, startContext: opts.startContext,
launchSubAgent, launchSubAgent,
listModels: async () => (await listLiteLLMModels(settings)).map((c) => c.label),
}); });
agents = init.agents; agents = init.agents;
const previousClose = closeMcp; const previousClose = closeMcp;
@ -747,6 +749,24 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
await compactNow(); await compactNow();
continue; 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") { if (name === "model") {
// Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée. // Recharge la liste (les déploiements ont pu changer) ; la conversation en cours est conservée.
const choice = await pickModel(modelSpec); 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, type Message,
} from "@maia/core"; } from "@maia/core";
import { EFFORT_ITERATIONS, initializeAgent } from "@maia/agent"; 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"; 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 // 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, log: () => undefined,
warn: (message) => void send({ type: "log", level: "warn", message }), warn: (message) => void send({ type: "log", level: "warn", message }),
subAgent: spec.agent, 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 }); const session = createSession({ provider: createProvider(spec.model, settings), init, ui, cwd: spec.cwd });
messages = session.messages; messages = session.messages;

View file

@ -20,11 +20,12 @@ import {
} from "@maia/core"; } from "@maia/core";
import { import {
INTERVENTION_TOOL_NAME, INTERVENTION_TOOL_NAME,
createListModelsTool,
initializeOrchestrator, initializeOrchestrator,
loadOrchestrator, loadOrchestrator,
resolveSubAgentModel, resolveSubAgentModel,
} from "@maia/agent"; } 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"; 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). */ /** 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, def,
homeDir: opts.homeDir, homeDir: opts.homeDir,
launchSubAgent, launchSubAgent,
extraTools: [interventionTool], extraTools: [
interventionTool,
createListModelsTool(async () => (await listLiteLLMModels(settings)).map((c) => c.label)),
],
log: (m) => say(parent, "info", m), log: (m) => say(parent, "info", m),
warn: (m) => say(parent, "warn", m), warn: (m) => say(parent, "warn", m),
}); });