index

Je passe mes journées avec des agents. Cursor, Claude, du Hermes custom, des dizaines de MCP créés… Mais même si j’ai de bonnes bases sur la théorie, je n’ai pas vraiment exploré le coeur des agents ni les problèmatiques sous-jacentes. Je vais donc en coder un from scratch.

Objectif:
Je veux un runtime qui répondra à une question grâce à un modèle LLM ou en faisant appel à des outils. Je sors toutes les parties complexes et super élaborées (RAG, multi agent, etc.) et resterai sur un lib Typescript, zéro dépendance runtime.

La boucle de base

graph TD
  U([Message utilisateur]) --> C[Transcript]
  C --> L[Appel LLM]
  L --> B{Réponse du modèle}
  B -->|texte| R>Réponse finale]
  B -->|outils| D[Exécution du/des outils]
  D -->|résultats| C

Le transcript c’est l’accumulation des messages user/model (la “conversation”).
Un Run, c’est une exécution complète de cette stack — de l’entrée à la sortie, tous les tours inclus.

Vue globale

---
config:
  flowchart:
    curve: stepBefore
---
graph TD
  A[Agent] --> R[Runner] --> L[Agent Loop]

  A --> H[Historique]
  R --> S[Steering]

  L --> P[Parallélisme]
  L --> AP[Approval]
  L --> ST[Stream]
  L --> HO[Hooks]
  L --> SK[Toolsets]
  SK --> Z1[Skills]
  SK --> Z2[MCP]
  SK --> Z3[Sandbox]

L’Agent est la façade simple (méthodes run / runSync). Derrière, il délègue au Runner, qui lance et arrête la Agent Loop. Le Runner est aussi le handle vivant du run : joignable pendant que la loop tourne, ce que l’Agent n’expose pas.

À côté, l’historique protège la fenêtre de contexte (compaction, plafond sur les réponses des tools, rejeu de messages passés sans casser les paires tool).

Le steering = message injecté pendant que le run tourne encore. Ce message n’est pas consommé tout de suite : il est mis de côté, puis repris juste avant le prochain appel au modèle, et ajouté au transcript.

Dans l’Agent Loop :

  • Parallélisme : exécuter plusieurs tools d’un même tour en parallèle
  • Approval : avant un tool sensible, mettre la loop en pause jusqu’à un oui/non humain
  • Stream : recevoir le texte du modèle morceau par morceau
  • Hooks : code avant/après les appels LLM et tools
  • Toolsets : Skills, MCP, Sandbox — même interface vis-à-vis de la loop

Découpage

J’ai découpé le projet en étapes super simples. A noter que pour cette partie, je n’ai pas utilisé d’IA car c’était tout l’intérêt de l’exercice.

Phase préparatoire de bootstrap : mise en place de l’outillage (TS, tests, Biome, hooks, docs).

  1. Conversation : On définit comment parler au modèle. 1 appel, faux modèle.
  2. Boucle texte : On boucle : demander → recevoir du texte → s’arrêter.
  3. Outils : Le modèle peut appeler des tools. On exécute et on reboucle.
  4. Bornes : On limite le nombre de tours et on peut annuler.
  5. Façade Agent : Fournir une API simple : Agent.run / runSync + workspace.
  6. Skills : On transforme les SKILL.md en tools.
  7. MCP : Un simple serveur MCP stdio.
  8. MCP ++ : La suite, mais les variantes en SSE / HTTP.
  9. Hooks : Des hooks avant et après les calls llm/tools (pour les quota, blocage…).
  10. Historique
    l’historique, limiter la taille des réponses des tools, rejouer des messages passés (sans casser les paires tool).
  11. Parallèle : Faire tourner plusieurs tools en parallèle durant un même tour.
  12. Runner public : On expose le Runner pour permettre le pilotage fin (ce que l’agent ne permet pas).
  13. Steering : Pilotage d’un run en cours en ajoutant Runner.sendSteering.
  14. Approval : Avant d’utiliser un tool sensible, on met la loop en pause et un humain approuve son utilisation.
  15. Sandbox local : L’agent peut read/write/exec dans un dossier.
  16. Sandbox remote : Même contrat mais à distance via d’autres moteurs (runtime distant / MCP exec …).
  17. Streaming : Le texte arrive morceau par morceau (llm_delta).
  18. Vrai modèle 1 : Adaptateur HTTP (style OpenAI) avec timeout/retry et stream.
  19. Vrai modèle 2 : Un autre adaptateur HTTP pour valider que ça marche.
  20. Samples : Des petites apps qui prouvent que l’API fonctionne de bout en bout et qui serviront d’exemples.

Le dev

Pour chaque étape, j’ai détailé un peu plus le framing. Je m’en suis servi comme input pour définir le plan de chaque phase et les prompter en prenant le temps de lire et creuser.

Le repo du projet est disponible sur GitHub.

J’ai laissé tout l’historique sans squash, les PR et les worklogs.
Il y a aussi un répertoire samples qui contient des petites apps qui prouvent que l’API fonctionne de bout en bout et qui servent d’exemples.

Mes notes

Agent ≠ Runner
Agent est surtout une façade : il lance une exécution et expose soit un flux d’évenements, soit un RunResult final. Runner, lui, possède réellement l’execution en cour, et l’appelant le garde pendant toute sa duré. Les operations qui nécessitent d’intervenir pendant l’exécution (steering, approve, deny) doivent donc passer par Runner, pas par Agent.

Où injecter le steering
Le steearing ne vient pas s’insérer au mileu d’un appel fournisseur deja lancé. Les lignes mises en atente sont récupérées au debut du tour suivant, juste avant le prochain chat. Elles sont alors ajoutées au transcript sous forme de message user, entre deux tours.

Approval ≠ hook
Un hook intervient imméditament : il peut interrompre l’execution, remplacer un résultat ou simplment la laisser continuer. L’approval fonctione autrement : l’outil est mis en pause jusqu’à ce qu’une décision externe arrive (approve ou deny). En cas de refus, aucunes exception n’est remontée à l’appelant. À la place, l’outil renvoie un résultat avec isError, que le modéle pourra lire au tour suivant.

Hooks synchrones et porte d’attente
Les hookss beforeTool / afterTool font partie du pipeline de l’appel d’outil et se résolvent directemnt dans ce pipeline. L’approval est plutot une porte d’attente : une prommesses est ouverte et l’execution ne reprend qu’une fois la décision prise. Ce n’est donc ni le meme moment dans la chronologie, ni le meme contrat d’API.

Outils en parallèle
Quand le modele renvoie plusieurs appels d’outils dans un meme tour, le runtime les démarre enssemble, puis attend que tout le lot soit terminé. Les evenements tool_call sont émis en premier, dans l’ordre donné par le modèle. Les resultats sont ensuite réinjectés dans le trasncript dans ce meme ordre, meme si certaines promesses se terminent avant les autre.

Annulation
Avec l’exécution en parallele, le signal d’annulation est verifié avant de lancer le lot, puis une fois celui-ci terminé. Il n’est pas verifié entre chaques appels : une fois le lot lancé, tout les appels sont deja en cours. L’unité d’annulation est donc le lot, et non chaques appel d’outil pris séparément.

Générateurs assynchrones et for await
Un yield suspend le générateur exactement à cet endroit. Avec une boucle for await, le corp de la boucle est exécuté avant que le prochain .next() soit appelé. Du coup, tous effet de bord qui dépand de ce qui se passe aprés le yield arrive trop tard. C’est nottament ce qui ce passe avec l’enregistrement d’une demande d’approval : lorsq’on observe l’evenement tool_call, l’identifiant n’est pas encore enregistré comme etant en attente.

Astuce next / approve / await
Le bon patern consiste à appeler gen.next() sans attendre imméditament son resultat. La partie synchrone du gennérateur peut ainsi reprendre jusqu’à l’enregistrement de la demande en attente. À ce moment là, approve(id) ou deny(id) peut resoudre cette demande. On peut ensuite attendre la promesse du .next() pour recuperé la suite des évenements.

Valeur de retour du générateur
Les valeurs envoyés avec yield correspondent au évenements de l’execution. Le return final du générateur, lui, ne fait pas partie de ce flux. Pour le récuperer, il faut attendre que le generateur soit complètement terminé, generalement avec le dernier .next(), celui qui renvoi done: true.

stream: true
Avec le streaming, le runtime envoy des llm_delta au fur et à mesure que le texte est generé. Les appels d’outils ne suivent pas ce découppage : ils reste atomiques et n’apparaissent qu’une fois la repoonse final du tour disponible. On peut donc affiché le texte en direct, mais un appel d’outil doit toujour être traité comme un objet complet.