<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feeds/rss-style.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Logs sporadiques</title>
        <link>https://microblog.vercel.app/</link>
        <description>Notes et articles.</description>
        <lastBuildDate>Thu, 20 Aug 2026 19:39:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>Astro Chiri Feed Generator</generator>
        <language>fr-FR</language>
        <copyright>Copyright © 2026 Julien La Vinh</copyright>
        <atom:link href="https://microblog.vercel.app/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Un agent IA from scratch]]></title>
            <link>https://microblog.vercel.app/agent-ia-from-scratch</link>
            <guid isPermaLink="false">https://microblog.vercel.app/agent-ia-from-scratch</guid>
            <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[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 n...]]></description>
            <content:encoded><![CDATA[<p>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.</p>
<p><strong>Objectif</strong>:<br />
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.</p>
<h2>La boucle de base</h2>
<pre><code class="language-mermaid">graph TD
  U([Message utilisateur]) --&gt; C[Transcript]
  C --&gt; L[Appel LLM]
  L --&gt; B{Réponse du modèle}
  B --&gt;|texte| R&gt;Réponse finale]
  B --&gt;|outils| D[Exécution du/des outils]
  D --&gt;|résultats| C
</code></pre>
<p>Le <strong>transcript</strong> c’est l’accumulation des messages user/model (la “conversation”).<br />
Un <strong>Run</strong>, c’est une exécution complète de cette stack — de l’entrée à la sortie, tous les tours inclus.</p>
<h2>Vue globale</h2>
<pre><code class="language-mermaid">---
config:
  flowchart:
    curve: stepBefore
---
graph TD
  A[Agent] --&gt; R[Runner] --&gt; L[Agent Loop]

  A --&gt; H[Historique]
  R --&gt; S[Steering]

  L --&gt; P[Parallélisme]
  L --&gt; AP[Approval]
  L --&gt; ST[Stream]
  L --&gt; HO[Hooks]
  L --&gt; SK[Toolsets]
  SK --&gt; Z1[Skills]
  SK --&gt; Z2[MCP]
  SK --&gt; Z3[Sandbox]
</code></pre>
<p>L’<strong>Agent</strong> est la façade simple (méthodes <code>run</code> / <code>runSync</code>). Derrière, il délègue au <strong>Runner</strong>, qui lance et arrête la <strong>Agent Loop</strong>. Le Runner est aussi le handle vivant du run : joignable pendant que la loop tourne, ce que l’Agent n’expose pas.</p>
<p>À côté, l’<strong>historique</strong> 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).</p>
<p>Le <strong>steering</strong> = 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.</p>
<p>Dans l’<strong>Agent Loop</strong> :</p>
<ul>
<li><strong>Parallélisme</strong> : exécuter plusieurs tools d’un même tour en parallèle</li>
<li><strong>Approval</strong> : avant un tool sensible, mettre la loop en pause jusqu’à un oui/non humain</li>
<li><strong>Stream</strong> : recevoir le texte du modèle morceau par morceau</li>
<li><strong>Hooks</strong> : code avant/après les appels LLM et tools</li>
<li><strong>Toolsets</strong> : Skills, MCP, Sandbox — même interface vis-à-vis de la loop</li>
</ul>
<h2>Découpage</h2>
<p>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.</p>
<p>Phase préparatoire de bootstrap : mise en place de l’outillage (TS, tests, Biome, hooks, docs).</p>
<ol>
<li><strong>Conversation</strong> : On définit comment parler au modèle. 1 appel, faux modèle.</li>
<li><strong>Boucle texte</strong> : On boucle : demander → recevoir du texte → s’arrêter.</li>
<li><strong>Outils</strong> : Le modèle peut appeler des tools. On exécute et on reboucle.</li>
<li><strong>Bornes</strong> : On limite le nombre de tours et on peut annuler.</li>
<li><strong>Façade Agent</strong> : Fournir une API simple : <code>Agent.run</code> / <code>runSync</code> + workspace.</li>
<li><strong>Skills</strong> : On transforme les <code>SKILL.md</code> en tools.</li>
<li><strong>MCP</strong> : Un simple serveur MCP stdio.</li>
<li><strong>MCP ++</strong> : La suite, mais les variantes en SSE / HTTP.</li>
<li><strong>Hooks</strong> : Des hooks avant et après les calls llm/tools (pour les quota, blocage…).</li>
<li><strong>Historique</strong> :compacter l’historique, limiter la taille des réponses des tools, rejouer des messages passés (sans casser les paires tool).</li>
<li><strong>Parallèle</strong> : Faire tourner plusieurs tools en parallèle durant un même tour.</li>
<li><strong>Runner public</strong> : On expose le Runner pour permettre le pilotage fin (ce que l’agent ne permet pas).</li>
<li><strong>Steering</strong> : Pilotage d’un run en cours en ajoutant Runner.sendSteering.</li>
<li><strong>Approval</strong> : Avant d’utiliser un tool sensible, on met la loop en pause et un humain approuve son utilisation.</li>
<li><strong>Sandbox local</strong> : L’agent peut read/write/exec dans un dossier.</li>
<li><strong>Sandbox remote</strong> : Même contrat mais à distance via d’autres moteurs (runtime distant / MCP exec …).</li>
<li><strong>Streaming</strong> : Le texte arrive morceau par morceau (<code>llm_delta</code>).</li>
<li><strong>Vrai modèle 1</strong> : Adaptateur HTTP (style OpenAI) avec timeout/retry et stream.</li>
<li><strong>Vrai modèle 2</strong> : Un autre adaptateur HTTP pour valider que ça marche.</li>
<li><strong>Samples</strong> : Des petites apps qui prouvent que l’API fonctionne de bout en bout et qui serviront d’exemples.</li>
</ol>
<h2>Le dev</h2>
<p>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.</p>
<blockquote>
<blockquote>
<blockquote></blockquote>
</blockquote>
</blockquote>
<p>Le <a href="https://github.com/tilap/agent-zero">repo du projet</a> est disponible sur GitHub.<br />
&lt;&lt;&lt;</p>
<p>J’ai laissé tout <a href="https://github.com/tilap/agent-zero/commits/main/">l’historique</a> sans squash, les <a href="https://github.com/tilap/agent-zero/pulls?q=is%3Apr+">PR</a> et les <a href="https://github.com/tilap/agent-zero/blob/main/docs/INDEX.md">worklogs</a>.<br />
Il y a aussi un répertoire <a href="https://github.com/tilap/agent-zero/tree/main/samples">samples</a> qui contient des petites apps qui prouvent que l’API fonctionne de bout en bout et qui servent d’exemples.</p>
<h2>Mes notes</h2>
<p><strong>Agent ≠ Runner</strong><br />
<code>Agent</code> est surtout une façade : il lance une exécution et expose soit un flux d’évenements, soit un <code>RunResult</code> final. <code>Runner</code>, 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 <code>Runner</code>, pas par <code>Agent</code>.</p>
<p><strong>Où injecter le steering</strong><br />
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 <code>chat</code>. Elles sont alors ajoutées au transcript sous forme de message <code>user</code>, entre deux tours.</p>
<p><strong>Approval ≠ hook</strong><br />
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 (<code>approve</code> ou <code>deny</code>). En cas de refus, aucunes exception n’est remontée à l’appelant. À la place, l’outil renvoie un résultat avec <code>isError</code>, que le modéle pourra lire au tour suivant.</p>
<p><strong>Hooks synchrones et porte d’attente</strong><br />
Les hookss <code>beforeTool</code> / <code>afterTool</code> 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.</p>
<p><strong>Outils en parallèle</strong><br />
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 <code>tool_call</code> 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.</p>
<p><strong>Annulation</strong><br />
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.</p>
<p><strong>Générateurs assynchrones et <code>for await</code></strong><br />
Un <code>yield</code> suspend le générateur exactement à cet endroit. Avec une boucle <code>for await</code>, le corp de la boucle est exécuté avant que le prochain <code>.next()</code> soit appelé. Du coup, tous effet de bord qui dépand de ce qui se passe aprés le <code>yield</code> arrive trop tard. C’est nottament ce qui ce passe avec l’enregistrement d’une demande d’approval : lorsq’on observe l’evenement <code>tool_call</code>, l’identifiant n’est pas encore enregistré comme etant en attente.</p>
<p><strong>Astuce <code>next</code> / approve / await</strong><br />
Le bon patern consiste à appeler <code>gen.next()</code> 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à, <code>approve(id)</code> ou <code>deny(id)</code> peut resoudre cette demande. On peut ensuite attendre la promesse du <code>.next()</code> pour recuperé la suite des évenements.</p>
<p><strong>Valeur de retour du générateur</strong><br />
Les valeurs envoyés avec <code>yield</code> correspondent au évenements de l’execution. Le <code>return</code> 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 <code>.next()</code>, celui qui renvoi <code>done: true</code>.</p>
<p><strong><code>stream: true</code></strong><br />
Avec le streaming, le runtime envoy des <code>llm_delta</code> 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.</p>
]]></content:encoded>
        </item>
    </channel>
</rss>