Agents IA : boucle agentique et pattern ReAct

Construis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.

Publié le Mis à jour le , mis à jour le 24 min de lecture

Dans l'article précédentIA pour les développeurs · partie 116 minLLM pour les devs : du token au function callingComment fonctionne un LLM : tokens, température, types de messages et function calling. On construit ensuite un jeu de dés en TypeScript avec Mistral Small.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici., on a vu comment le function calling permet au LLM d'interagir avec l'extérieur. Mais un LLM qui appelle une fonction, c'est encore toi qui gères le flux. Un agent, c'est l'étape d'après : un LLM qui décide lui-même quoi faire, dans quel ordre, et quand s'arrêter. C'est là que ça devient intéressant, et un peu flippant.

Dans cette partie, on construit un agent de recherche en TypeScript, toujours avec Mistral Small : la boucle, les outils, puis ce qui le rend utilisable pour de vrai, à savoir la planification, la mémoire et les garde-fous.

Un chatbot répond, un agent agit

Un chatbot répond à tes questions. Tu poses une question, il répond. Tu en poses une autre, il répond. Du ping-pong.

Un agent accomplit des tâches. Tu lui donnes un objectif ("Trouve-moi les 3 meilleurs articles sur Rust parus cette semaine"), et il se débrouille : cherche sur le web, filtre les résultats, lit les articles, et te fait un résumé.

Chatbot : Question → Réponse → Question → Réponse

Agent : Objectif → [Réflexion → Action → Observation]* → Résultat

L'astérisque est important : la boucle se répète autant de fois que nécessaire. L'agent décide seul quand il a terminé.

Avec le function calling de l'article précédent, ton code ressemblait à ça :

TypeScript
// Tu sais d'avance ce qui va se passer
const response = await chat("Lance un d20");
// Le LLM appelle roll_dice, tu exécutes, il répond

Avec un agent, tu donnes juste l'objectif :

TypeScript
// L'agent choisit le chemin
const result = await runAgent("Quelles sont les nouveautés de TypeScript 5.4 ?", config);
// Il décide : une recherche, deux pages à lire, peut-être une seconde recherche, puis la synthèse

Tu ne sais pas à l'avance combien d'actions seront nécessaires, ni lesquelles. C'est pour ça qu'il faut des garde-fous (on y vient plus bas).

Le pattern ReAct : raisonner avant d'agir

La plupart des agents actuels s'appuient sur le pattern ReAct (Reasoning and Acting), introduit par Google en 2022. Le principe : faire écrire au LLM son raisonnement avant chaque action.

Le raisonnement écrit rend l'agent lisible

Sans ReAct, le LLM fonce tête baissée. Demande-lui de "trouver le bug dans ce code" et il te propose une correction dans la seconde, souvent à côté de la plaque.

Avec ReAct, il doit d'abord réfléchir :

THOUGHT: Le code plante à la ligne 42. L'erreur mentionne "undefined".
         Je dois d'abord comprendre d'où vient cette variable.
ACTION:  read_file({ path: "src/utils.ts", lines: "35-50" })
OBSERVATION: [contenu du fichier]
THOUGHT: La variable `user` vient de la fonction `getUser()` qui peut retourner null.
         Le bug est un accès sans vérification. Je vais proposer un fix.
ACTION:  [aucune - réponse finale]
ANSWER:  Le bug est à la ligne 42 : `user.name` sans vérifier que `user` existe...

Le raisonnement écrit noir sur blanc améliore les décisions, mais surtout il te permet de comprendre pourquoi l'agent a fait un truc bizarre. Quand ça part en vrille, c'est la première chose que je lis.

Avec le tool calling natif, seul le raisonnement reste à demander

Dans l'article de 2022, tout passait par du texte : le modèle écrivait ACTION: ... et le code parsait cette ligne pour savoir quoi exécuter. Aujourd'hui, le function calling prend en charge deux des trois étapes :

  • l'action est un tool_call structuré, plus besoin de parser du texte ;
  • l'observation est le message tool que tu renvoies avec le résultat.

Reste la réflexion. Le prompt système n'a donc plus à imposer un format THOUGHT / ACTION / OBSERVATION, seulement à demander au modèle d'écrire ce qu'il compte faire avant d'appeler un outil. Ce texte arrive dans le content du message qui porte le tool_call, et on le loggera dans la boucle.

TypeScript
const AGENT_SYSTEM_PROMPT = `Tu es un agent autonome capable d'accomplir des tâches complexes.

Méthode :
- Avant chaque appel d'outil, écris en une ou deux phrases ce que tu cherches et pourquoi.
- Une seule action à la fois : attends le résultat avant de décider de la suivante.
- Si un outil échoue ou ne donne rien, change d'approche au lieu de recommencer à l'identique.
- Quand l'objectif est atteint, ou si tu constates qu'il est impossible, réponds directement sans appeler d'outil.`;

Note

Selon le modèle, ce texte de réflexion accompagne l'appel d'outil ou reste vide. Les modèles dits "à raisonnement" réfléchissent en plus de leur côté, avant de répondre. Dans tous les cas, logge ce que tu reçois : c'est ta seule fenêtre sur les décisions de l'agent.

La boucle agentique

Un agent tourne autour d'une boucle, celle qu'on a croisée dans l'article sur le function calling. Elle continue tant que le LLM demande des outils et s'arrête quand il répond en texte.

Côté projet, on repart de la même base que dans la partie 1, avec mathjs en plus pour un des outils :

Terminal
mkdir agent-recherche && cd agent-recherche
npm init -y
npm pkg set type=module
npm install @mistralai/mistralai zod mathjs
npm install -D tsx typescript @types/node
export MISTRAL_API_KEY="ta-clé"

Un premier fichier regroupe ce que tous les autres vont partager : le client, le type d'un message et une fonction qui extrait le texte d'une réponse.

src/mistral.ts
import { Mistral } from "@mistralai/mistralai";
import type {
  AssistantMessage,
  ChatCompletionRequest,
} from "@mistralai/mistralai/models/components";

export const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });

// Le type d'un message, tel que le SDK l'attend
export type Message = ChatCompletionRequest["messages"][number];

// Le contenu d'une réponse est une string ou une liste de morceaux :
// on n'en garde que le texte
export function textOf(content: AssistantMessage["content"]): string {
  if (typeof content === "string") return content;
  return (content ?? [])
    .map((chunk) => (chunk.type === "text" ? chunk.text : ""))
    .join("");
}

Puis la boucle. Par rapport au chat() de la partie 1, trois différences : elle ne connaît aucun outil en particulier (elle reçoit leur liste et une fonction executeTool), elle s'arrête au bout de maxIterations tours, et elle logge la réflexion, l'action et l'observation.

src/agent.ts
import type { Tool } from "@mistralai/mistralai/models/components";
import { client, textOf, type Message } from "./mistral";

export interface AgentConfig {
  model: string;
  systemPrompt: string;
  tools: Tool[];
  // Exécute un outil et retourne son résultat (on l'écrit juste après)
  executeTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
  maxIterations?: number;
}

export async function runAgent(
  objective: string,
  config: AgentConfig
): Promise<string> {
  const maxIterations = config.maxIterations ?? 10;

  const messages: Message[] = [
    { role: "system", content: config.systemPrompt },
    { role: "user", content: objective },
  ];

  for (let iteration = 1; iteration <= maxIterations; iteration++) {
    console.log(`\n--- Itération ${iteration} ---`);

    const response = await client.chat.complete({
      model: config.model,
      messages,
      tools: config.tools,
      parallelToolCalls: false, // une seule action à la fois
    });

    const choice = response.choices[0];
    const assistantMessage = choice?.message;
    if (!assistantMessage) throw new Error("Pas de réponse du modèle");

    messages.push({ ...assistantMessage, role: "assistant" });

    // La réflexion, quand le modèle l'écrit avant d'agir
    const thought = textOf(assistantMessage.content);

    // Cas 1 : le LLM veut utiliser des outils
    if (choice.finishReason === "tool_calls" && assistantMessage.toolCalls) {
      if (thought) console.log(`💭 ${thought}`);

      for (const toolCall of assistantMessage.toolCalls) {
        const name = toolCall.function.name;
        const rawArgs = toolCall.function.arguments;
        const args = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;

        console.log(`🔧 Action: ${name}`, args);
        const result = await config.executeTool(name, args);

        // Un résultat trop long noierait le contexte : on tronque
        const content = (
          typeof result === "string" ? result : JSON.stringify(result)
        ).slice(0, 4000);
        console.log(`📋 Observation: ${content.slice(0, 200)}`);

        messages.push({ role: "tool", toolCallId: toolCall.id, name, content });
      }
      continue;
    }

    // Cas 2 : le LLM a terminé
    console.log(`✅ Agent terminé après ${iteration} itération(s)`);
    return thought;
  }

  throw new Error(`Agent arrêté après ${maxIterations} itérations`);
}

Les outils sont un registre séparé de la boucle

La boucle ne sait pas ce que font les outils, et c'est voulu : tu peux en changer sans y toucher. On les range dans un registre. Comme dans la partie 1, chaque outil associe un schéma Zod à son implémentation, et les arguments produits par le LLM sont validés avant d'être utilisés.

src/tools.ts
import { all, create } from "mathjs";
import { z } from "zod";
import type { Tool } from "@mistralai/mistralai/models/components";

// mathjs a son propre parseur : il n'exécute jamais de JavaScript.
// On retire quand même les fonctions qui modifient l'instance.
const math = create(all);
const evaluate = math.evaluate;
const disabled = () => {
  throw new Error("Fonction désactivée");
};
math.import(
  { import: disabled, createUnit: disabled, evaluate: disabled, parse: disabled },
  { override: true }
);

// Lie un schéma Zod à son implémentation : les arguments du LLM
// sont validés avant d'arriver dans execute
function defineTool<S extends z.ZodType>(def: {
  description: string;
  schema: S;
  dangerous?: boolean; // on s'en sert plus bas, dans les garde-fous
  execute: (args: z.infer<S>) => Promise<unknown>;
}) {
  return {
    description: def.description,
    dangerous: def.dangerous ?? false,
    parameters: z.toJSONSchema(def.schema, { io: "input" }),
    run: (rawArgs: unknown) => def.execute(def.schema.parse(rawArgs)),
  };
}

const toolRegistry: Record<string, ReturnType<typeof defineTool>> = {
  web_search: defineTool({
    description:
      "Recherche sur le web. Retourne une liste de résultats avec titre, URL et extrait.",
    schema: z.object({
      query: z.string().describe("La requête de recherche"),
    }),
    execute: async ({ query }) => {
      // Tavily ici, mais n'importe quelle API de recherche convient
      const response = await fetch("https://api.tavily.com/search", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.TAVILY_API_KEY}`,
        },
        body: JSON.stringify({ query, max_results: 5 }),
      });
      return response.json();
    },
  }),

  read_url: defineTool({
    description: "Lit le contenu d'une page web et retourne le texte.",
    schema: z.object({
      url: z.url({ protocol: /^https?$/ }).describe("L'URL à lire"),
    }),
    execute: async ({ url }) => {
      const response = await fetch(url);
      const html = await response.text();
      // Extraction basique (en prod, regarde du côté de @mozilla/readability)
      return html
        .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
        .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
        .replace(/<[^>]+>/g, " ")
        .replace(/\s+/g, " ")
        .trim();
    },
  }),

  calculate: defineTool({
    description: "Évalue une expression mathématique, par exemple (12 + 7) * 3.",
    schema: z.object({
      expression: z.string().max(200).describe("L'expression à calculer"),
    }),
    execute: async ({ expression }) => String(evaluate(expression)),
  }),
};

// La liste des outils, au format attendu par l'API
export const tools: Tool[] = Object.entries(toolRegistry).map(
  ([name, tool]) => ({
    type: "function",
    function: {
      name,
      description: tool.description,
      parameters: tool.parameters,
    },
  })
);

export async function executeToolCall(
  name: string,
  args: Record<string, unknown>
): Promise<unknown> {
  const tool = toolRegistry[name];
  if (!tool) {
    return { error: `Outil inconnu: ${name}` };
  }

  try {
    return await tool.run(args);
  } catch (error) {
    // L'erreur repart vers le LLM, qui peut corriger et réessayer
    return { error: String(error) };
  }
}

Dans la partie 1, une erreur d'outil faisait planter le programme. Ici, elle est renvoyée au modèle comme une observation. Un agent qui reçoit "Invalid URL" peut corriger son appel tout seul, alors qu'une exception arrête tout.

La recherche passe par Tavily, qui demande sa propre clé dans TAVILY_API_KEY. N'importe quelle API de recherche convient, tant que l'outil renvoie des titres, des URLs et des extraits.

Attention

N'évalue jamais une sortie de LLM avec eval ou Function(). Une première version de cet article calculait l'expression avec Function(...), ce qui revient à exécuter du JavaScript écrit par le modèle. Or l'agent lit des pages web : il suffit qu'une page contienne des instructions cachées (une prompt injection) pour que le modèle passe du code malveillant à la calculatrice. mathjs parse l'expression avec sa propre grammaire, et process ou require n'y existent pas.

Même logique pour read_url : le schéma n'accepte que http et https, mais l'outil peut toujours atteindre une adresse de ton réseau local. Sur un serveur, filtre les adresses internes avant de laisser un agent s'en servir.

La planification évite que l'agent se disperse

Sur un objectif complexe, un agent naïf part dans tous les sens : il commence une recherche, se fait distraire par un résultat, oublie ce qu'il cherchait. Une phase de planification avant d'agir règle une bonne partie du problème.

Un premier appel écrit le plan

C'est l'approche "plan-then-execute" : un premier appel au LLM, sans outil, produit la liste des étapes en JSON. Comme ce JSON vient d'un modèle, on le valide avec Zod avant de s'en servir.

src/planner.ts
import { z } from "zod";
import { runAgent, type AgentConfig } from "./agent";
import { client, textOf } from "./mistral";

const planSchema = z.object({
  plan: z.array(
    z.object({
      step: z.number(),
      description: z.string(),
      tools_needed: z.array(z.string()),
    })
  ),
});

type PlanStep = z.infer<typeof planSchema>["plan"][number];

function buildPlanningPrompt(config: AgentConfig): string {
  const toolsList = config.tools
    .map((t) => `- ${t.function.name}: ${t.function.description}`)
    .join("\n");

  return `Tu es un agent de planification.

Outils disponibles :
${toolsList}

Étant donné un objectif, crée un plan d'action.
Chaque étape doit être :
- Spécifique et actionnable
- Vérifiable (on peut savoir si c'est fait)
- Réalisable avec les outils listés ci-dessus

Réponds en JSON :
{
  "plan": [
    { "step": 1, "description": "...", "tools_needed": ["nom_outil"] },
    { "step": 2, "description": "...", "tools_needed": ["nom_outil"] }
  ]
}`;
}

async function planTask(objective: string, config: AgentConfig): Promise<PlanStep[]> {
  const response = await client.chat.complete({
    model: config.model,
    messages: [
      { role: "system", content: buildPlanningPrompt(config) },
      { role: "user", content: objective },
    ],
    responseFormat: { type: "json_object" },
  });

  const content = textOf(response.choices[0]?.message?.content);
  // Le JSON vient d'un LLM : on le valide avant de s'en servir
  return planSchema.parse(JSON.parse(content)).plan;
}

responseFormat: { type: "json_object" } force le modèle à répondre en JSON valide. Il faut quand même décrire le format attendu dans le prompt, comme ici.

Chaque étape devient un mini-objectif

Une fois le plan créé, chaque étape est confiée à runAgent, avec les résultats des étapes précédentes en contexte. Un dernier appel, sans outil, fait la synthèse.

src/planner.ts
export async function runPlanningAgent(
  objective: string,
  config: AgentConfig
): Promise<string> {
  console.log("📝 Phase de planification...");
  const plan = await planTask(objective, config);
  plan.forEach((step) => console.log(`  ${step.step}. ${step.description}`));

  const results: string[] = [];

  for (const step of plan) {
    console.log(`\n🎯 Étape ${step.step}: ${step.description}`);

    const stepResult = await runAgent(
      `Tu exécutes l'étape ${step.step} d'un plan plus large.
Objectif global : ${objective}
Ta tâche : ${step.description}
Résultats des étapes précédentes :
${results.join("\n")}`,
      config
    );

    results.push(`Étape ${step.step}: ${stepResult}`);
  }

  // Synthèse finale : un appel simple, sans outil
  const synthesis = await client.chat.complete({
    model: config.model,
    messages: [
      {
        role: "user",
        content: `Synthétise ces résultats pour répondre à l'objectif "${objective}" :\n${results.join("\n")}`,
      },
    ],
  });
  return textOf(synthesis.choices[0]?.message?.content);
}

La planification a un prix

Elle ajoute de la latence et des appels API, donc des coûts. Elle vaut le coup pour :

  • Les tâches multi-étapes (recherche + analyse + rédaction)
  • Les objectifs ambigus qui nécessitent une décomposition
  • Les cas où l'ordre des actions compte

Pour les tâches simples ("Quelle heure est-il à Tokyo ?"), un agent sans planification suffit.

Un agent a deux mémoires

Un agent jongle avec deux mémoires : ce qu'il sait dans la conversation en cours, et ce qu'il devrait retenir d'une session à l'autre.

Mémoire court terme : le contexte

C'est l'historique des messages de la conversation en cours. Chaque action et chaque observation s'y ajoutent.

Le problème, c'est que la context window a une taille finie, et que chaque token renvoyé se paie à chaque tour. Un agent qui enchaîne 50 actions finit par traîner un historique énorme. Deux parades classiques : ne garder que les messages récents, ou résumer les anciens.

src/memory.ts
import { client, textOf, type Message } from "./mistral";

// 1. Fenêtre glissante : on garde la consigne, l'objectif et les N derniers messages
export function trimMessages(messages: Message[], maxRecent: number): Message[] {
  const head = messages.slice(0, 2); // system + objectif
  const rest = messages.slice(2);
  if (rest.length <= maxRecent) return messages;

  let start = rest.length - maxRecent;
  // Un message "tool" sans l'appel qui le précède est refusé par l'API :
  // on avance jusqu'au prochain message qui n'est pas un résultat d'outil
  while (start < rest.length && rest[start].role === "tool") start++;

  return [...head, ...rest.slice(start)];
}

// 2. Résumé : on condense les vieux messages en un seul texte
export async function summarizeContext(messages: Message[]): Promise<string> {
  const response = await client.chat.complete({
    model: "mistral-small-latest",
    messages: [
      {
        role: "system",
        content:
          "Résume cet échange en gardant les faits établis, les sources et ce qu'il reste à faire.",
      },
      { role: "user", content: JSON.stringify(messages) },
    ],
  });
  return textOf(response.choices[0]?.message?.content);
}

Attention

Une fenêtre glissante naïve (messages.slice(-N)) finit par couper entre un tool_call et son résultat. L'historique commence alors par un message tool orphelin, et l'API rejette la requête. C'est pour ça que trimMessages décale la coupe, et qu'elle garde toujours l'objectif de départ : un agent qui oublie ce qu'on lui a demandé ne va nulle part.

Mémoire long terme : au-delà de la session

Pour qu'un agent se souvienne entre les sessions, il faut stocker ses souvenirs quelque part et savoir retrouver les bons. On utilise pour ça des embeddings : des vecteurs numériques qui représentent le sens d'un texte. Deux textes qui parlent de la même chose ont des vecteurs proches, ce qui permet de chercher par le sens plutôt que par mots-clés.

src/memory.ts
interface Memory {
  id: string;
  content: string;
  embedding: number[];
  timestamp: Date;
  metadata: Record<string, unknown>;
}

export class LongTermMemory {
  private memories: Memory[] = [];

  async store(content: string, metadata: Record<string, unknown> = {}): Promise<void> {
    this.memories.push({
      id: crypto.randomUUID(),
      content,
      embedding: await this.embed(content),
      timestamp: new Date(),
      metadata,
    });
  }

  async recall(query: string, limit = 5): Promise<Memory[]> {
    const queryEmbedding = await this.embed(query);

    // Recherche par similarité cosinus
    return this.memories
      .map((memory) => ({
        memory,
        score: this.cosineSimilarity(queryEmbedding, memory.embedding),
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, limit)
      .map((result) => result.memory);
  }

  private async embed(text: string): Promise<number[]> {
    const response = await client.embeddings.create({
      model: "mistral-embed",
      inputs: [text],
    });
    const embedding = response.data[0]?.embedding;
    if (!embedding) throw new Error("Pas d'embedding retourné");
    return embedding;
  }

  private cosineSimilarity(a: number[], b: number[]): number {
    const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
    const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
    const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
    return dot / (normA * normB);
  }
}

Ici les souvenirs vivent dans un tableau en mémoire, donc ils disparaissent à l'arrêt du programme. En vrai, tu les mettrais dans une base qui sait chercher des vecteurs. Retrouver des textes proches d'une question pour les donner au modèle, c'est le principe du RAG (Retrieval-Augmented Generation).

Les garde-fous se posent dès la première version

Plus un agent est autonome, plus il faut l'encadrer. Voici les pièges dans lesquels je suis tombé, et ce que je mets en place maintenant.

RisqueGarde-fouOù
L'agent ne s'arrête jamaisLimite d'itérationsrunAgent
Il répète la même actionDétection de boucledetectLoop
Il fait une action irréversibleConfirmation de l'utilisateurexecuteToolCall
La facture grimpeBudget maximumCostTracker
Le contexte débordeFenêtre glissantetrimMessages

Détecter l'agent qui tourne en rond

L'agent relance la même recherche avec les mêmes mots, et ne progresse plus. La limite d'itérations finit par l'arrêter, mais après avoir payé dix appels pour rien. On peut le repérer plus tôt en gardant la trace des actions demandées.

src/guards.ts
// Trois fois de suite la même action avec les mêmes arguments : l'agent tourne en rond
export function detectLoop(actionHistory: string[]): boolean {
  const last3 = actionHistory.slice(-3);
  return last3.length === 3 && last3.every((action) => action === last3[0]);
}

Demander confirmation avant une action sensible

Un agent qui a accès au filesystem ou à des APIs peut faire des dégâts. Un fichier écrasé ou un mail envoyé au mauvais destinataire, ça ne s'annule pas. Pour ces outils-là, on demande l'accord de l'utilisateur avant d'exécuter.

src/guards.ts
import * as readline from "node:readline/promises";

export async function askUserConfirmation(prompt: string): Promise<boolean> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
  const answer = await rl.question(`${prompt} (o/n) : `);
  rl.close();
  return answer.trim().toLowerCase() === "o";
}

Pour avoir un cas concret, on ajoute au registre un outil qui écrit sur le disque, marqué dangerous :

src/tools.ts
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { askUserConfirmation } from "./guards";

// À ajouter dans toolRegistry
  save_report: defineTool({
    description: "Enregistre un rapport en Markdown dans le dossier rapports/.",
    dangerous: true,
    schema: z.object({
      filename: z.string().describe("Nom du fichier, par exemple typescript.md"),
      content: z.string().describe("Le contenu du rapport"),
    }),
    execute: async ({ filename, content }) => {
      // basename : le LLM ne choisit pas le dossier d'écriture
      const target = path.join("rapports", path.basename(filename));
      await mkdir("rapports", { recursive: true });
      await writeFile(target, content);
      return `Rapport enregistré dans ${target}`;
    },
  }),

Et executeToolCall vérifie ce drapeau avant d'exécuter. Un refus repart vers le modèle comme une erreur ordinaire, ce qui lui laisse la possibilité de terminer autrement.

src/tools.ts
export async function executeToolCall(
  name: string,
  args: Record<string, unknown>
): Promise<unknown> {
  const tool = toolRegistry[name];
  if (!tool) {
    return { error: `Outil inconnu: ${name}` };
  }

  if (tool.dangerous) {
    console.log(`⚠️  Action sensible: ${name}`, args);
    const confirmed = await askUserConfirmation("Autoriser cette action ?");
    if (!confirmed) {
      return { error: "Action refusée par l'utilisateur" };
    }
  }

  try {
    return await tool.run(args);
  } catch (error) {
    return { error: String(error) };
  }
}

Plafonner le budget

Chaque itération est un appel API, et chaque appel renvoie tout l'historique. Un agent bavard, qui relit dix pages pour une question simple, peut coûter cher sans que tu t'en rendes compte. L'API indique dans usage le nombre de tokens consommés par chaque appel : il suffit de les additionner.

src/guards.ts
export class CostTracker {
  private inputTokens = 0;
  private outputTokens = 0;

  // Prix de Mistral Small 4 en novembre 2026, en dollars par million de tokens
  constructor(
    private readonly maxBudgetUSD: number,
    private readonly inputPricePerMillion = 0.15,
    private readonly outputPricePerMillion = 0.6
  ) {}

  track(usage: { promptTokens?: number; completionTokens?: number }): void {
    this.inputTokens += usage.promptTokens ?? 0;
    this.outputTokens += usage.completionTokens ?? 0;
  }

  getCost(): number {
    return (
      (this.inputTokens * this.inputPricePerMillion +
        this.outputTokens * this.outputPricePerMillion) /
      1_000_000
    );
  }

  isOverBudget(): boolean {
    return this.getCost() > this.maxBudgetUSD;
  }

  report(): string {
    return `Tokens: ${this.inputTokens} in / ${this.outputTokens} out (~$${this.getCost().toFixed(6)})`;
  }
}

Les prix sont ceux affichés sur la fiche de Mistral Small 4 en novembre 2026. Ils changent à chaque nouvelle version du modèle, d'où les deux paramètres du constructeur.

Limiter les hallucinations en cascade

Un agent qui hallucine à l'étape 2 construit les étapes 3, 4 et 5 sur du sable. Le résultat final est faux, et présenté avec un aplomb désarmant. Aucun bout de code ne règle ça à lui seul. Ce qui aide :

  • Vérification croisée (plusieurs sources)
  • Retour aux sources primaires quand possible
  • Humilité dans le prompt ("Si tu n'es pas sûr, dis-le")

La boucle avec tous ses garde-fous

Il reste à brancher tout ça dans runAgent. Voici la version finale du fichier, qui remplace celle du début :

src/agent.ts
import type { Tool } from "@mistralai/mistralai/models/components";
import { CostTracker, detectLoop } from "./guards";
import { trimMessages } from "./memory";
import { client, textOf, type Message } from "./mistral";

export interface AgentConfig {
  model: string;
  systemPrompt: string;
  tools: Tool[];
  executeTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
  maxIterations?: number;
  maxBudgetUSD?: number;
}

export async function runAgent(
  objective: string,
  config: AgentConfig
): Promise<string> {
  const maxIterations = config.maxIterations ?? 10;
  const costs = new CostTracker(config.maxBudgetUSD ?? 0.05);
  const actionHistory: string[] = [];

  let messages: Message[] = [
    { role: "system", content: config.systemPrompt },
    { role: "user", content: objective },
  ];

  for (let iteration = 1; iteration <= maxIterations; iteration++) {
    console.log(`\n--- Itération ${iteration} ---`);

    // Garde-fou 1 : le contexte ne grossit pas indéfiniment
    messages = trimMessages(messages, 20);

    const response = await client.chat.complete({
      model: config.model,
      messages,
      tools: config.tools,
      parallelToolCalls: false,
    });

    // Garde-fou 2 : le budget
    costs.track(response.usage);
    if (costs.isOverBudget()) {
      throw new Error(`Budget dépassé. ${costs.report()}`);
    }

    const choice = response.choices[0];
    const assistantMessage = choice?.message;
    if (!assistantMessage) throw new Error("Pas de réponse du modèle");

    messages.push({ ...assistantMessage, role: "assistant" });
    const thought = textOf(assistantMessage.content);

    if (choice.finishReason === "tool_calls" && assistantMessage.toolCalls) {
      if (thought) console.log(`💭 ${thought}`);

      // Garde-fou 3 : la même action trois fois de suite
      actionHistory.push(
        JSON.stringify(assistantMessage.toolCalls.map((call) => call.function))
      );
      if (detectLoop(actionHistory)) {
        throw new Error("Boucle détectée : l'agent répète la même action");
      }

      for (const toolCall of assistantMessage.toolCalls) {
        const name = toolCall.function.name;
        const rawArgs = toolCall.function.arguments;
        const args = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;

        console.log(`🔧 Action: ${name}`, args);
        // Garde-fou 4 : executeTool demande confirmation pour les outils sensibles
        const result = await config.executeTool(name, args);

        const content = (
          typeof result === "string" ? result : JSON.stringify(result)
        ).slice(0, 4000);
        console.log(`📋 Observation: ${content.slice(0, 200)}`);

        messages.push({ role: "tool", toolCallId: toolCall.id, name, content });
      }
      continue;
    }

    console.log(`✅ Terminé en ${iteration} itération(s). ${costs.report()}`);
    return thought;
  }

  // Garde-fou 5 : la limite d'itérations
  throw new Error(`Agent arrêté après ${maxIterations} itérations`);
}

L'agent de recherche, assemblé

Tout est en place. Le point d'entrée se réduit à un prompt système et un appel à runAgent :

src/main.ts
import { runAgent } from "./agent";
import { executeToolCall, tools } from "./tools";

const SYSTEM_PROMPT = `Tu es un agent de recherche. Tu aides l'utilisateur à trouver des informations fiables.

Méthode :
- Avant chaque appel d'outil, écris en une ou deux phrases ce que tu cherches et pourquoi.
- Commence par une recherche web, puis lis les 2 ou 3 pages les plus prometteuses.
- Si un outil échoue ou ne donne rien, change d'approche au lieu de recommencer à l'identique.
- Quand tu as assez d'éléments, réponds directement, sans appeler d'outil.

Règles :
- Privilégie les sources officielles et récentes, et cite-les.
- Distingue ce qui vient des sources de ce qui est ton analyse.
- Si tu ne trouves pas l'information, dis-le.`;

const answer = await runAgent(
  process.argv[2] ?? "Quelles sont les nouveautés de TypeScript 5.4 ?",
  {
    model: "mistral-small-latest",
    systemPrompt: SYSTEM_PROMPT,
    tools,
    executeTool: executeToolCall,
    maxIterations: 10,
    maxBudgetUSD: 0.05,
  }
);

console.log("\n📝 Réponse finale :\n", answer);
Terminal
export TAVILY_API_KEY="ta-clé-tavily"
npx tsx src/main.ts "Quelles sont les nouveautés de TypeScript 5.4 ?"

Une exécution ressemble à ça (sortie raccourcie) :

--- Itération 1 ---
🔧 Action: web_search { query: 'TypeScript 5.4 new features' }
📋 Observation: {"results":[{"title":"Announcing TypeScript 5.4","url":"https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/", ...

--- Itération 2 ---
🔧 Action: read_url { url: 'https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/' }
📋 Observation: TypeScript 5.4 brings several improvements including...

--- Itération 3 ---
✅ Terminé en 3 itération(s).

📝 Réponse finale :
TypeScript 5.4 (mars 2024) apporte plusieurs nouveautés :

1. Preserved Narrowing in Closures : le type narrowing est maintenant préservé dans les closures après la dernière assignation.

2. NoInfer Utility Type : nouveau type utilitaire pour empêcher l'inférence sur certains paramètres génériques.

3. Object.groupBy et Map.groupBy : support de ces nouvelles méthodes.

Source : Blog officiel TypeScript (https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/)

Trois itérations : une recherche, une lecture de page, la synthèse. Pas besoin de plus pour une question comme celle-là. Pour une question plus large, remplace runAgent par runPlanningAgent, qui prend les mêmes arguments.

Aller plus loin

Tout ce qu'on a construit ici tient dans un seul LLM qui fait tout. On peut aller plus loin : plusieurs agents spécialisés qui collaborent, avec un manager qui distribue les tâches à des workers. Ou un agent qui évalue son propre résultat et recommence s'il n'est pas satisfait.

J'y reviendrai dans de futurs articles, une fois que j'aurai assez de recul sur ces patterns en conditions réelles.

Si je devais retenir une chose de tout ça, ce serait les garde-fous. La limite d'itérations et la confirmation sur les actions sensibles, je les mets dès la première version, sans attendre le premier incident.

Il reste un défaut à cet agent : ses outils sont enfermés dans son code. Dans la partie suivante, on voit MCPIA pour les développeurs · partie 310 minMCP pour les devs : relier tes LLM au monde réelMCP (Model Context Protocol) évite d'écrire un connecteur par client LLM et par source de données. Son architecture, puis un premier serveur MCP en TypeScript.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici, section 8. (Model Context Protocol), un standard pour écrire des outils une fois et les brancher sur n'importe quel agent, y compris celui-ci.

IA pour les développeurs · partie 2 sur 3

La suite, partie 3MCP pour les devs : relier tes LLM au monde réel

MCP (Model Context Protocol) évite d'écrire un connecteur par client LLM et par source de données. Son architecture, puis un premier serveur MCP en TypeScript.

10 min de lecture

Voir en Markdown

À lire ensuite

IA pour les développeurs · 1

LLM pour les devs : du token au function calling

Comment fonctionne un LLM : tokens, température, types de messages et function calling. On construit ensuite un jeu de dés en TypeScript avec Mistral Small.

Cité dans cet article

MCP en pratique · 1

MCP remote : quand stdio ne suffit plus

Stdio suffit sur ta machine, pas au-delà. Streamable HTTP sans session depuis la spec de juillet 2026, un serveur MCP remote en TypeScript, puis OAuth 2.1.

Projet

Inkfate : quand l'IA devient maître de jeu

Inkfate est un jeu de rôle solo où une IA tient le rôle du maître de jeu. Les règles du Year Zero Engine, et le pipeline LangGraph et Mistral derrière le MJ.

Alexandre Simonet, développeur fullstack.
J'écris ici sur les serveurs MCP, les agents et l'infra pour les faire tourner.