Un flux de travail d’agent LLM local construit à partir de dossiers et de fichiers Markdown
Créez un pipeline d’agents locaux à plusieurs étapes avec des contrôles d’accès, où chaque étape correspond à un dossier, les prompts sont en markdown modifiable, et un humain examine le résultat avant l’étape coûteuse.
Sources: Build Your Own Local LLM Agent Workflow in 400 Lines of Python
Un agent local peut disposer d’un flux de travail reproductible et inspectable, sans avoir recours à un framework. La configuration ici exécute une tâche en plusieurs étapes, où chaque étape correspond à un dossier ; l’instruction associée à chaque étape est un fichier Markdown simple que quiconque peut lire et modifier, et une personne vérifie les résultats entre les étapes. L’agent ne change pas ; seul le mode de stockage de son contexte évolue, et c’est là l’essentiel.
Pourquoi un agent local a besoin de cela
L’agent gère déjà la recherche sur le web, la lecture des pages et les actions via une fenêtre de navigateur persistante. Ce qui manquait, c’était un moyen d’exécuter une tâche de manière répétée avec une vérification par une personne à mi-parcours.
La raison en était que tout ce qui le guidait se trouvait dans du code. Une instruction système était assemblée au moment de l’importation, et une seule liste d’outils était créée au démarrage pour être transmise au modèle à chaque tour. Pour faire en sorte que l’agent joue un rôle différent à une étape donnée, il fallait modifier le Python. Il n’existait aucun moyen d’arrêter l’exécution pour examiner les résultats intermédiaires avant une étape coûteuse. Cela est acceptable pour un cycle de conversation, mais gênant pour une tâche composée de plusieurs étapes.
Le résultat est un pipeline de recherche et d’élaboration de cours compact. Quatre étapes permettent de passer d’une simple phrase décrivant un cours à une version préliminaire de mini-cours, avec un point de contrôle après chaque étape. Des dossiers numérotés contenant du markdown pur stockent désormais le contexte qui guide l’agent, ce qui permet de lire et de corriger l’organigramme avant de commencer à rédiger, sans avoir à modifier le moindre code au cours du processus.
La structure suit une méthode appelée Interpretable Context Methodology, ou ICM, qui remplace l’orchestration au niveau du framework par une structure de système de fichiers.
Le diagramme représente le flux de travail sous forme d’un dossier nommé workspace. Chaque dossier numéroté sous pipeline/ correspond à une étape ; le fichier CONTEXT.md de chaque étape en constitue l’instruction, tandis que proposed/ est une zone de stockage temporaire où un utilisateur peut valider manuellement les modifications.
Les dossiers numérotés agissent comme des étapes, les fichiers Markdown contiennent le contexte propre à chaque étape, et les scripts locaux s’occupent des tâches mécaniques ne nécessitant pas de modèle. Cette conception s’inspire des pipelines Unix et des compilateurs à plusieurs étapes, où la sortie d’une étape alimente la suivante et où chaque résultat intermédiaire est un fichier ordinaire que l’on peut ouvrir.
Ce dont vous avez besoin pour le faire fonctionner
Pour le modèle qwen3.5:9b, il fonctionne sous Ollama avec la réflexion désactivée et une température de 0,1. La navigation et la recherche réutilisent des solutions existantes : camofox-browser écoute sur le port 9500 et SearXNG sur 8090, chacun dans Docker. Faites fonctionner ces deux conteneurs, ainsi qu’Ollama et un modèle prenant en charge les appels d’outils, avant de commencer.
La construction se décompose en cinq étapes :
- L’étape 1 lance camofox-browser et SearXNG dans Docker, que les services locaux des étapes suivantes appellent.
- L’étape 2 crée le prompt système de l’agent à partir d’un dossier contenant des fichiers markdown, en remplaçant une chaîne de caractères prédefinie.
- L’étape 3 fournit le pilote, avec des contrôles de validation et des limites d’outils appliqués à chaque étape.
- L’étape 4 correspond au contrôle de l’aperçu : une étape utilise deux résultats précédents et une personne valide le plan avant d’entreprendre l’étape coûteuse.
proposed/ permettant des extensions ultérieures.Installation
Chaque phase se trouve dans son propre sous-dossier et est accessible via un script de console défini dans pyproject.toml ; ainsi, après une seule installation, vous pouvez exécuter n’importe quelle phase en utilisant son nom. La première étape consiste à cloner le répertoire et à l’installer en mode éditable, en utilisant un environnement virtuel :
git clone https://github.com/jfjensen/local-LLM-agent-icm-workflow.git
cd local-LLM-agent-icm-workflow
python -m venv .venv
# Windows PowerShell:
.\.venv\Scripts\Activate.ps1
pip install -e .
L’installation intègre mcp, ollama et httpx ainsi que des scripts de console. Les étapes 2 à 5 disposent chacune d’un exécuteur de flux de travail (mcp-workflow-stage2 à mcp-workflow-stage5) et d’un agent (mcp-agent-stage2 à mcp-agent-stage5). Deux serveurs MCP, mcp-search-part3 et mcp-browser-stage5, sont copiés depuis des itérations précédentes, ce qui permet au répertoire de rester autonome.
La configuration se trouve dans config.toml au niveau racine du répertoire, où est ajoutée une nouvelle section [workflow]. Comme chaque étape lit son dossier workspace/ et écrit son dossier history/ par rapport au répertoire courant, il est préférable de lancer une étape depuis son propre dossier :
cd stage5
mcp-workflow-stage5
Étape 1 : les services locaux
Aucun Python n’est impliqué ici. Cette étape lance le couple de services utilisés par l’agent lors de la recherche : camofox-browser, le navigateur furtif sur lequel se trouvent les outils de lecture, et SearXNG, le moteur de recherche sous search. Toute personne ayant déjà exécuté la configuration précédente peut conserver le même docker-compose.yml.
cd stage1
docker compose up -d
docker compose logs -f
Quelques détails importants à connaître :
- Camofox écoute sur le port 9500 et SearXNG sur le port 8090, qui sont ceux attendus par
config.toml, ce qui permet à l’agent de les détecter sans configuration supplémentaire. - Le fichier compose effectue un montage bind de
searxng/settings.yml, ce qui active l’API JSON et désactive le limiteur de vitesse, permettant ainsi des recherches dès la première exécution. - L’image de Camofox est compilée une seule fois à partir de son répertoire source. La première compilation est lente en raison de la taille importante du binaire Camoufox, et
stage1/README.mddécrit les étapes à suivre.
Pour vérifier leur état de fonctionnement, utilisez curl pour demander http://localhost:9500/health et http://localhost:8090/search?q=ollama&format=json. Les deux requêtes doivent renvoyer du JSON.
La phase 1 ne comprend que ces deux services. Une fois que Camofox répond sur le port 9500 et SearXNG sur le port 8090, la plateforme dispose de ce dont elle a besoin pour effectuer des recherches.
Phase 2 : contexte provenant de fichiers
C’est le seul véritable changement de code. L’agent utilisait auparavant un prompt système, une chaîne au niveau du module générée au démarrage et envoyée telle quelle à chaque tour, quel que soit la tâche. Permettre à l’agent d’assumer un rôle différent à chaque étape signifie que le prompt doit provenir d’un endroit modifiable, et le lire depuis des fichiers est l’option la plus simple.
Ainsi, le premier changement permet de remplacer le prompt système :
def build_messages_for_model(self) -> list[dict[str, Any]]:
system = self.system_text if self.system_text is not None else SYSTEM_PROMPT
return [{"role": "system", "content": system}] + self.messages
Le comportement est simple :
- Lorsque
system_textn’est pas défini, l’agent recourt au mêmeSYSTEM_PROMPTpréenregistré qu’auparavant, de sorte que l’agent interactifmcp-agent-stage2fonctionne exactement comme avant. - Lorsque
system_textest défini, il devient le prompt système pour l’exécution. Le moteur de workflow le définit étape par étape à partir de fichiers, sans affecter la classe de l’agent.
Quant aux fichiers eux-mêmes, un espace de travail ICM n’est rien de plus qu’un dossier. stage2/workspace/ contient un CLAUDE.md qui décrit l’espace de travail et son organisation, un CONTEXT.md qui énumère les étapes dans l’ordre, un répertoire _config/ contenant des documents de référence restant inchangés entre les exécutions, ainsi qu’un répertoire pipeline/ regroupant les étapes numérotées. Chaque répertoire d’étape dispose de son propre fichier CONTEXT.md ainsi que d’un répertoire output/.
Les deux fichiers de niveau supérieur assurent l’identification et le routage. CLAUDE.md constitue la couche 0, c’est-à-dire le premier élément que lit l’agent :
# Workspace: course-research
You are a single agent operating inside an ICM workspace. This file tells
you where you are and how the workspace is laid out. Read it first.
This workspace turns a one-line course idea into a drafted mini-course,
through four stages that run one at a time with a human review between each.
Layout:
- `CONTEXT.md` (next to this file): the workspace routing. It lists the
stages in order and the shared resources they draw on.
- `_config/`: shared reference material that stays the same across runs
(the audience profile, the writing voice). This is the factory.
- `pipeline/`: the numbered stages. Each stage folder holds a `CONTEXT.md`
contract, an `output/` folder for what that stage produces, and
sometimes a `references/` folder for stage-local reference material.
- `pipeline/proposed/`: a staging area, not a stage. A human moves
improvements from here into `_config/` once they are happy with them.
You only ever work on one stage at a time. The stage's own `CONTEXT.md`
tells you exactly what to read, what to do, and what to write. Do not read
files the stage contract does not list.
À côté de celui-ci, CONTEXT.md correspond à la couche 1, c’est-à-dire le routage qui énumère les étapes :
# Routing
The course-research pipeline runs in four stages, in this order:
1. `01-scope`: turn the one-line course idea and the audience profile into
a short list of scoped learning objectives.
2. `02-research`: for each objective, search the web and read sources, and
write structured notes with links. This is the only stage that uses
tools.
3. `03-outline`: turn the objectives and the notes into a module-by-module
course outline. This is the gate before the expensive stage, so the
outline is worth a careful human read.
4. `04-draft`: draft each module from the approved outline and the notes.
Each stage reads from the stage before it (its `output/` folder) and
writes to its own `output/` folder. A human reviews each output before the
next stage runs.
Shared resources (in `_config/`, the same every run):
- `audience.md`: who the course is for. Every stage should respect it.
- `voice.md`: how the course should read. The draft stage follows it.
Le matériel de référence partagé se trouve dans _config/ ; il est configuré une seule fois et utilisé à chaque exécution. Ci-dessous se trouve audience.md, un fichier de niveau 3 que les étapes de cadrage, de recherche et d’élaboration du plan consultent toutes :
# Audience
The course is for working software developers who are comfortable with
Python and the command line, but who have not run a language model on
their own machine before.
They want practical, hands-on knowledge they can apply the same day, not
a survey of the field. Assume they can install software, edit a config
file, and read a stack trace. Do not assume they know any machine-learning
theory, model formats, or GPU terminology.
Keep examples concrete and runnable. Prefer one worked example over three
abstract options. When a trade-off matters, state it plainly and give a
default.
Le contrat 01-scope/CONTEXT.md constitue le cœur de la conception. Il s’agit de la version complète pour la première étape du pipeline :
# Stage: 01-scope
## Inputs
- Layer 4 (working): input.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## Process
Read the one-line course idea and the audience profile. Produce a short
list of scoped learning objectives for the course, four to six of them.
Each objective should be one sentence, start with a verb, and describe
something the learner will be able to do by the end. Keep the scope tight:
this is a short course, so leave out anything that does not fit a learner
going from zero to a working local model. Do not pad the list to hit a
number. Fewer, sharper objectives are better.
Write the objectives as a numbered markdown list, nothing else.
## Outputs
- objectives.md -> output/
Le seul fichier d’entrée de niveau 4, 01-scope/input.md, constitue le matériel de travail pour l’exécution, l’idée en une seule ligne qui sera transformée en cours :
# Course idea
A short, practical course that takes a developer from zero to running a
local large language model on their own machine with Ollama, and calling
it from a small Python script.
Quelques notes sur la manière de lire le contrat :
- Le tableau des Entrées liste les fichiers exacts que chaque étape lit et les marque chacun d’une couche : Couche 3 pour le matériel de référence stable entre les exécutions (le profil du public), Couche 4 pour le matériel de travail de cette exécution (l’idée en une phrase). Aucun autre élément du espace de travail n’est chargé.
- La ligne Outils indique
aucun, car transformer une idée en objectifs ne nécessite pas d’outils. L’étape 3 revient à cela. - La section Processus contient les instructions que l’agent suit, et cela devient son tour en tant qu’utilisateur.
- La ligne Sorties indique le nom du fichier que l’étape écrit dans son propre dossier
output/.
D’après le contrat, le chargeur transforme le tableau Inputs en véritables contenus de fichiers et construit le contexte à partir d’eux. Les références et les matériaux de travail restent séparés car ils remplissent des rôles différents : les premières constituent un ensemble de règles que le modèle doit suivre, tandis que les seconds représentent des données brutes qu’il doit transformer.
# System text: identity + routing + the reference material (Layer 3).
parts = []
if layer0:
parts.append(layer0.strip())
if layer1:
parts.append(layer1.strip())
refs3 = [r for r in inputs if r.layer == 3 and r.exists]
if refs3:
block = ["# Reference material (follow these as constraints)"]
for r in refs3:
block.append(f"\n## {r.rel_path}\n\n{r.text.strip()}")
parts.append("\n".join(block))
system_text = "\n\n".join(parts).strip()
Le comportement du chargeur se décompose comme suit :
- D’abord viennent l’identité et le routage (
CLAUDE.md,CONTEXT.md), qui indiquent à l’agent où il se trouve et quels étapes existent. - Viennent ensuite les fichiers de référence du niveau 3, présentés sous un titre les décrivant comme des contraintes. Les fichiers de travail du niveau 4 sont assemblés séparément, en dessous du texte décrivant le processus de l’étape, afin d’indiquer ce sur quoi l’agent doit agir.
- Le résultat est le prompt système pour cette étape, construit à partir de fichiers simples ne contenant rien que l’étape n’a pas demandé.
Au moment de faire fonctionner quoi que ce soit sur le modèle, il est utile de vérifier ce que produit le chargeur, car cette partie est déterministe. Le répertoire fournit une petite sonde, probe_loader.py, qui affiche le contexte assemblé pour une étape avec un comptage approximatif des tokens et qui ne lance jamais le modèle. Exécutez-la depuis le dossier stage2 sur la première étape :
cd .\stage2\
python ..\probe_loader.py 01-scope
L’image d’écran montre les premières lignes du rapport de l’outil pour l’étape de ciblage, ainsi que la taille de la fenêtre de contexte. Cette étape se termine avec environ 900 tokens, tous pertinents, ce qui rend l’idée de l’ICM concrète : une petite fenêtre de ciblage par étape, sans prompt géant contenant tout.
Pour exécuter réellement cette étape, utilisez mcp-workflow-stage2. Cet outil analyse le contrat, exécute l’agent une fois et crée le fichier output/objectives.md. Pour l’exemple présenté, un court cours pratique sur l’exécution d’un LLM local avec Ollama a donné cinq objectifs bien structurés, du type que l’on parcourt avant de les conserver ou de les supprimer.
La bannière dans l’image d’écran indique le contexte ciblé prédit par l’outil, soit près de 900 tokens, et le résultat de l’étape est le fichier objectives.md. Les objectifs d’une exécution apparaissent ci-dessous ; une deuxième exécution les formulerait légèrement différemment :
1. Install the necessary software stack (Ollama) and verify it is successfully pulling and serving a local LLM like `llama3`.
2. Configure environment variables to securely expose the local model's API endpoint for external access.
3. Write a Python script using the `requests` library to send prompts to Ollama and parse JSON responses into readable text.
4. Implement error handling in your script to gracefully manage connection timeouts or out-of-memory errors common with local inference.
5. Build an interactive loop that allows you to chat with the model directly from a terminal-based Python application without using a GUI framework.
Avec le contexte provenant désormais des fichiers, l’élément manquant est d’exécuter les étapes dans l’ordre et de s’arrêter entre elles.
Étape 3 : le pilote, les portes et la délimitation des outils
Un flux de travail comprend des étapes ordonnées, avec un point de contrôle humain entre chacune. Le pilote parcourt donc les dossiers numérotés, exécute la première étape avec un résultat vide et s’arrête.
async def run_pipeline(workspace_dir: Path, run_all: bool = False) -> None:
stages = stage_dirs(workspace_dir)
for sd in stages:
if stage_is_done(sd):
print(f"[skip] {sd.name}: output already present")
continue
await run_stage(workspace_dir, sd.name)
if run_all:
continue
# The review gate. Stop here so the human can read and edit the
# output before the next stage reads it.
print("\n--- review gate ---")
print(f"Review and edit if needed:\n {sd / 'output'}")
return
Fonctionnement :
- L’état est stocké sur le disque. Une étape est considérée comme terminée lorsque son dossier
output/contient un fichier réel, il n’y a donc pas de stockage d’état séparé. Le système de fichiers constitue l’état. - Le mécanisme de contrôle n’est qu’un retour prématuré. Une fois qu’une étape est terminée, le pilote indique où chercher puis s’arrête. Vous lisez le résultat, le modifiez si nécessaire et exécutez à nouveau la même commande pour continuer.
--allest une option qui permet d’exécuter tout le processus sans s’arrêter, à utiliser lorsque vous faites confiance au résultat. La valeur par défaut implique des contrôles, car c’est justement leur rôle.
Pour exécuter le pipeline, utilisez mcp-workflow-stage3, lisez ce qu’il a écrit, puis exécutez-le à nouveau pour avancer. La première exécution couvre 01-scope et s’arrête ; la deuxième couvre la phase de recherche et s’arrête également, et ainsi de suite.
Chaque invocation gère une étape, indique où chercher et s’arrête à la porte de contrôle ; exécuter la même commande la reprend.
Cadre des outils
La recherche est l’étape qui utilise des outils, et c’est là que les premiers problèmes réels sont apparus. L’agent se connecte aux serveurs de recherche et de navigation, et la version précédente fusionnait tous les outils provenant de chaque serveur en une seule liste plate, présentant l’ensemble des outils à chaque tour. Il s’agissait d’environ quinze outils, dont la recherche en a besoin d’environ cinq. Le reste correspond à des outils de tabulation et d’action pour travailler sur une page, que la recherche n’utilise jamais.
Lorsque les quinze outils étaient tous disponibles, le modèle 9B s’est tourné vers les outils d’interaction. Il a choisi open_tab et read_tab plutôt que les lecteurs, et read_tab ne affiche que la partie supérieure d’une page. Les documents d’Ollama sont hébergés sur Mintlify, ce qui fait que cette partie supérieure est identique partout : le même texte de navigation. Le modèle a continué à lire ce menu, ouvrant une nouvelle fenêtre après l’autre, jusqu’à ce que la limite de nombre d’appels mette fin à son exécution en affichant comme sortie des excuses de 143 caractères.
La solution consiste à limiter l’accès aux outils à chaque étape. Un contrat indique les outils nécessaires, et ce ne sont que ceux-ci qui sont mis à disposition en temps de exécution. Voici le contrat de recherche complet :
# Stage: 02-research
## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 3 (reference): ../../_config/audience.md
- Layer 3 (reference): references/tool-use.md
## Tools
search-server: search
browser-server: summarize, extract, fetch_snippet, fetch_urls
## Process
For each learning objective, find and read one or two good current sources,
then write short, factual notes the outline and draft stages can rely on.
Work objective by objective. For each one: run a web search, then read the
most promising result with `summarize` or `extract`. Use `summarize(url,
question=...)` for an open question about a page, and `extract(url, schema)`
when you can name the fields you want (a version number, a default port).
These read the whole page; do not try to page through a site by hand. Read
each page at most once and do not re-read a page you have already read. Once
you have one or two sources per objective, stop calling tools and write the
notes. Pull out the concrete facts that matter for teaching this objective
(commands, version notes, default ports, common pitfalls). Prefer official
documentation and project sources over blog aggregators.
Write the notes as markdown, grouped under a heading per objective. Under
each heading, a few bullet points of facts, then a "Sources:" line with
the URLs you actually read. Keep it factual. Do not draft course prose here;
that happens later.
## Outputs
- notes.md -> output/
La nouvelle section Outils comprend la fonction recherche sur le serveur de recherche ainsi que seulement quatre outils provenant du serveur navigateur, tous des lecteurs. Le temps de exécution applique ce filtre à la liste des outils avant que le modèle ne l’obtienne :
def rebuild_ollama_tools(self, allowed: dict[str, set[str] | None] | None = None):
out = []
self._tool_to_server = {}
for server_name, tools in self.mcp_tools_by_server.items():
allow_set = allowed.get(server_name) if allowed else None
for tool in tools:
if allow_set is not None and tool.name not in allow_set:
continue
prefixed = f"{server_name}_{tool.name}"
self._tool_to_server[prefixed] = server_name
out.append({
"type": "function",
"function": {
"name": prefixed,
"description": tool.description or "",
"parameters": tool.inputSchema,
},
})
self.ollama_tools = out
Le filtre fonctionne de la manière suivante :
- La carte d’autorisation est spécifique à chaque serveur. La valeur
Noneindique que tous les outils du serveur sont autorisés, tandis qu’une liste de noms désigne uniquement ces outils. - Tout outil hors de la liste autorisée est ignoré, de sorte que le modèle ne le voit jamais. La recherche prend en compte cinq outils, tandis que scope, outline et draft n’en prennent aucun en compte.
- La carte est reconstruite étape par étape, ce qui empêche également l’appel d’un outil caché ; une tentative d’appel hallucinée de
open_tabrenverrait simplement un outil inconnu.
Le contrat de recherche inclut également sa propre référence de niveau 3, references/tool-use.md, limitée à cette étape, qui explique comment utiliser les lecteurs restants :
# Tool use (research stage only)
You have two MCP servers for this stage. Use them like this.
- `search-server_search(query, max_results)`: a web search. Start here for
any lookup. It returns URLs with titles and snippets. After a search,
your next action must be a browser-server call on the most promising URL.
Do not stop after a search and do not answer from the snippets alone.
The browser-server reader tools each return a small slice of a page, not
the raw page:
- `browser-server_fetch_snippet(url)`: the head of a page, for a quick look.
- `browser-server_fetch_urls(url)`: the page's links, to decide what to read next.
- `browser-server_fetch_structure(url)`: the page's heading outline.
- `browser-server_extract(url, schema)`: pull named fields you can list in
advance (a version number, a default port) using a JSON Schema.
- `browser-server_summarize(url, question="")`: a prose summary of a whole
page; pass `question` to focus it on what this objective needs.
Prefer `extract` when you know the field names, `summarize` for an open
question about a long page, and `fetch_snippet` for a quick confirmation.
Prefer official documentation and project sources over blog aggregators.
Encore une fois, le système de fichiers remplit le rôle du framework. Dans un framework, l’ensemble d’outils de l’agent est constitué de code ; ici, il s’agit d’une seule ligne Markdown que n’importe qui peut modifier. Avec les outils de lecture activés et ceux en mode tab désactivés, les modèles summarize et extract, qui lisent des pages entières à l’aide du lecteur par blocs, ainsi que les notes, contenaient des informations concrètes : la commande d’installation, le port par défaut 11434 et la manière d’utiliser la bibliothèque Python, chacune accompagnée d’un lien source.
Lorsque le modèle ne peut pas s’arrêter
Un deuxième problème était plus subtil. Équipé des bons outils, le modèle lisait bien les pages, mais ne reconnaissait jamais quand il fallait s’arrêter. Lors d’une exécution, la même page a été résumée avec la même question une douzaine de fois sans aucun progrès jusqu’à atteinte du plafond d’appels. Les informations utiles se trouvaient dans l’historique des messages, mais comme aucune réponse finale n’avait été écrite, la sortie était à nouveau une excuse.
Afin de gérer cela, le moteur effectue désormais une étape supplémentaire chaque fois qu’un tour est interrompu : un dernier appel sans outils, visant à instruire le modèle d’écrire sa réponse en se basant sur ce qu’il a collecté.
async def _finalize(self, stop_reason: str) -> dict:
if not self.synthesize_on_stop:
self.messages.append({"role": "assistant", "content": stop_reason})
return {"role": "assistant", "content": stop_reason}
self.messages.append({
"role": "user",
"content": ("Stop using tools now. Using only the information you have "
"already gathered above, write the final output the task "
"asked for. Do not call any tools."),
})
resp = ollama.chat(model=MODEL_NAME, messages=self.build_messages_for_model(),
options={"temperature": MODEL_TEMPERATURE}, think=MODEL_THINKING)
msg = resp["message"]
content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "")
final = {"role": "assistant", "content": content or stop_reason}
self.messages.append(final)
return final
Trois détails sont importants :
- C’est une option activable manuellement. L’agent interactif laisse désactivé et se contente de signaler l’arrêt, comme précédemment, tandis que le flux de travail l’active afin qu’une étape écrive toujours un résultat réel.
- Cela permet de sauver le travail. Les résultats de l’outil se trouvent déjà dans l’historique, donc l’appel sans outil peut en extraire des notes, transformant une séquence dérapée en un
notes.mdutilisable au lieu d’une simple excuse. - Un mécanisme de protection contre les boucles infinies est utile. Un même appel répété trois fois de suite termine désormais prématurément la séquence, qu’il y ait une erreur ou non, ce qui permet au modèle de tourner moins de temps avant que la synthèse ne prenne le relais.
Une recherche menée correctement a duré environ sept minutes et a produit un peu plus de 10 000 caractères de notes issues de sources pour les cinq objectifs.
L’aperçu d’écran montre que la deuxième exécution de l’outil est en cours de finalisation et demande une revue des résultats. Avec les étapes de contrôle, la définition du périmètre et l’analyse des données en place, la recherche est suffisamment fiable pour être développée davantage, et la prochaine étape revêt une importance capitale.
Étape 4 : l’étape de l’esquisse
De toutes les étapes, l’élaboration de l’esquisse mérite le plus d’attention, car c’est ensuite que vient la rédaction, qui est l’étape la plus coûteuse. Une erreur détectée à ce stade est la moins chère à corriger. Cette étape combine deux résultats précédents : les objectifs issus de 01-scope et les notes de recherche provenant de 02-research, pour en créer une esquisse organisée par module. Le contrat stipule :
# Stage: 03-outline
## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## Process
Turn the objectives and the research notes into a module-by-module course
outline. One module per objective is a reasonable default, but merge or
split where the notes suggest it.
For each module, give a short title and three to five bullet points naming
what it covers, drawn from the notes. Order the modules so each one builds
on the one before it. Note any module that depends on something from an
earlier module.
This outline is the last checkpoint before the course is drafted, so make
it easy for a human to read and correct. Write it as markdown headings with
bullet points. Do not draft the module text yet.
## Outputs
- outline.md -> output/
Ces deux lignes de niveau 4 constituent la nouveauté. Elles rendent concrète la transmission en plusieurs étapes décrite dans le papier ICM, puisque la sortie d’une étape alimente l’étape suivante, qui combine deux résultats précédents. Le chargeur intègre les deux comme matériel de travail, et le modèle génère l’esquisse.
Démarrez-le avec mcp-workflow-stage4. Comme la phase de définition du périmètre et de recherche est terminée, l’exécution lance 03-outline, écrit outline.md puis s’arrête à ce point de contrôle.
En exécutant mcp-workflow-stage4, on obtient l’esquisse et on s’arrête au point de contrôle, qui représente le dernier moment économique pour ajuster le plan avant la rédaction finale.
Le portail a immédiatement prouvé sa valeur. Lors d’une session, la recherche a collecté trop de données. Le cours s’adresse à ceux qui passent de zéro à un modèle de traitement de données, mais les notes ont abordé des sujets tels que les clés API cloud et la quantification du cache KV, que le modèle avait trouvés dans les documents et en avait fidèlement consigné les informations. L’organigramme a hérité de cela et a ajouté un module entier intitulé « Sécuriser votre connexion avec des variables d’environnement », rempli de contenus inutiles pour les débutants.
Rien dans le flux de travail n’a posé de problème. L’excédent s’est manifesté sous forme d’un en-tête lisible dans un fichier texte, avant même que le texte du cours ne soit rédigé. Il suffit de supprimer ce module, de restructurer ses leçons ou de le rediriger ailleurs ; la rédaction n’y consacre jamais de temps. La correction consiste en une seule modification dans un fichier Markdown, tandis qu’après la rédaction, il faudrait relire cinq modules et de nombreuses leçons pour trouver celui qui ne devrait pas y être.
Un plan d’exemple est ouvert pour être modifié dans VS Code à l’étape de contrôle, où les modules ou leçons hors champ sont supprimés avant même que la phase de brouillon ne les voie.
À cette étape, réduisez le plan aux modules et leçons que vous souhaitez, puis relancez l’opération. Le nombre de modules correspond simplement au nombre de titres ## Module présents dans le fichier, donc la simplification du plan détermine également la durée du cours.
Étape 5 : rédaction par module
Lors de la rédaction, chaque module est créé à partir du plan approuvé, en utilisant les informations provenant des notes. La première tentative visait à rédiger tout le cours en une seule fois, mais elle échoua : certaines exécutions produisaient quatre modules, d’autres un seul et déclaraient la victoire. Un petit modèle considère « rédiger tout le cours » comme une tâche énorme et a tendance à s’arrêter prématurément.
Ainsi, la phase de brouillon parcourt plutôt les modules un par un. Son contrat complet inclut une directive For each qui définit cette boucle :
# Stage: 04-draft
## Inputs
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/voice.md
- Layer 3 (reference): ../../_config/audience.md
## Tools
none
## For each
module in ../03-outline/output/outline.md
## Process
Draft the one module shown below, and only that module. Use the research
notes for the facts (commands, ports, pitfalls) rather than inventing them,
and follow the voice and audience guides. Write a few short paragraphs of
teaching prose plus any commands the learner should run. Do not write the
other modules. Start your output with the module's heading, and output only
the drafted module, nothing else.
This is a first draft for a human to revise, so aim for a clear, correct
scaffold rather than a finished, polished module.
## Outputs
- draft.md -> output/
La phase de rédaction est la seule où l’on consulte voice.md, le fichier final de niveau 3 du espace de travail, qui définit le ton du cours :
# Voice
Write plainly, as one developer explaining something to another at the
next desk. Short instructional sentences, followed by a longer one that
explains why.
- Lead with what to do, then explain the reasoning.
- Use concrete names: real tools, real commands, real file paths.
- Hedge honestly when something is a judgement call or can fail.
- No marketing language. No hype. No "unlock", "seamless", "robust".
- One idea per paragraph. Bullets only for listing the parts of a thing.
La ligne For each est ce qui transforme une séquence ouverte en boucle, et le moteur d’exécution exécute le processus une fois par module :
fe = ctx.for_each
print(f" for each: {len(fe.modules)} {fe.item}(s) from {fe.rel_path}")
pieces = []
for i, (heading, body) in enumerate(fe.modules, 1):
print(f" [{i}/{len(fe.modules)}] {heading}")
agent.messages = []
item_block = (f"# The {fe.item} to write now\n\n"
f"## {heading}\n\n{body}")
agent.messages.append({"role": "user", "content": ctx.user_text + "\n\n" + item_block})
final = await agent.run_turn()
pieces.append((final.get("content", "") or "").strip())
content = "\n\n".join(p for p in pieces if p)
Fonctionnement de la boucle :
- Le plan est divisé en modules aux titres
##, et la boucle s’exécute une fois pour chacun d’eux. Le nombre de modules n’est pas fixé à cinq ; quatre modules signifient quatre itérations et six, six itérations, en fonction de ce qui a survécu au filtrage. - Chaque appel débute à zéro.
agent.messagesest vidé à chaque fois, de sorte qu’un module est rédigé à partir des notes disponibles sans aucun souvenir des modules précédents, ce qui permet à chaque étape de rester concise et autonome. - Les résultats sont concaténés dans un seul fichier
draft.md. Un appel limité par module, au lieu d’une seule demande de « rédiger le cours », permet au modèle 9B de les traiter tous.
La directive est générale et ne prend pas en compte de cas particuliers pour la rédaction. N’importe quelle étape peut déclarer For each, bien que les trois autres n’aient pas de directive et s’exécutent en une seule passe. L’exécution de mcp-workflow-stage5 affiche le décompte allant de [1/5] à [5/5], et l’exécution de exemple a généré un peu plus de 15 000 caractères au total sur cinq modules.
L’étape de rédaction parcourt l’aperçu en effectuant une appel au modèle par module. Le nombre d’appels correspond au nombre de modules restants, donc quatre ou six modules signifient quatre ou six exécutions.
L’espace de travail contient également un dossier proposed/ que le pipeline n’exécute jamais. Il s’agit d’une zone de stockage : lorsque l’exécution suggère une amélioration qui devrait perdurer au-delà de celle-ci, comme un objectif plus précis ou une règle vocale qui continue de poser des problèmes, la suggestion est enregistrée là sous forme de fichier, et une personne peut la déplacer dans _config/ si elle est d’accord. L’intervention humaine est essentielle ici. Rien ne modifie l’espace de travail tant que quelqu’un n’applique pas manuellement la modification, ce qui permet de conserver l’évaluation telle que prévue par ICM et offre aux automatisations ultérieures un point d’ancrage.
Ce qui fonctionne et ce qui ne fonctionne pas
Un modèle de 9 milliards de paramètres qui rédige les modules produit des structures fonctionnelles, mais pas un enseignement complet. L’avantage réside dans le flux de travail et son interprétabilité, tandis qu’un modèle local qui rédige un cours sans surveillance n’est pas l’objectif visé.
Les défauts sont réels et se manifestent lors des exécutions :
- Le projet a généré de manière imprudente une unité systemd défectueuse, dans laquelle les lignes
[Unit]et[Service]se sont fusionnées en une seule, et les variables d’environnement ont été insérées dansExecStart. - Une chaîne de version apparaissait, qui n’était mentionnée nulle part dans les notes, et les noms de modèles variaient entre
gemma2,gemma3etgemma3:2bselon les modules. - Chaque module ayant été écrit de manière indépendante sans tenir compte des autres, les modules 1 et 2 se chevauchaient fortement, chacun traitant à la fois des variables d’environnement et de systemd.
- La recherche prend environ sept minutes, car chaque page est analysée par le résumateur en blocs ; rien que les FAQ d’Ollama ont généré 41 000 caractères répartis en huit blocs.
- L’une des sources était un mur de vérification par bot de Cloudflare. Le lecteur a correctement indiqué qu’il ne contenait aucun contenu réel, si bien que le modèle a poursuivi sans en inventer.
Rien de tout cela ne fait d’agent un processus déterministe, et quiconque en a besoin devrait écrire un script. L’avantage est que les directives se trouvent dans des fichiers que quelqu’un peut lire et corriger entre les étapes, de sorte qu’un brouillon approximatif est corrigé à un contrôle et ne surprend pas plus tard. La mention des « quatre à six objectifs » dans le contrat de cahier des charges n’est qu’une indication pour le modèle tant que le fichier n’a pas été modifié, devenant ensuite un fait avéré. La distance entre ce que le modèle a tendance à faire et ce qu’une personne définit précisément est précisément la raison d’existence des contrôles.
Tout ensemble
Finalement, il y a un dossier de workspace par pipeline, à côté de l’agent, contenant un contrat CONTEXT.md pour chaque étape ainsi qu’un dossier output/ qui conserve les résultats. L’agent est le même agent multi-serveur que précédemment, et le moteur de exécution qui le pilote — c’est-à-dire le chargeur, le driver à contrôle, la délimitation des outils et la boucle par module — tient dans un seul fichier d’environ 400 lignes.
En résumé, les éléments sont les suivants :
- Un espace de travail où le contexte de chaque étape est stocké dans des dossiers numérotés au format markdown simple, modifiables sans avoir à toucher au code.
- Un chargeur qui construit le prompt système de chaque étape à partir de fichiers, en séparant les références des matériaux de travail, dans une fenêtre ciblée d’environ 900 à 1 500 tokens par étape.
- Un driver doté de mécanismes de contrôle qui exécute une étape, pause pour permettre à une personne d’intervenir, puis reprend l’exécution en conservant son état sur disque.
Le fait qu’un espace de travail ne soit qu’un dossier signifie qu’un autre projet n’a besoin que d’un dossier différent. Le répertoire comprend un second dossier, workspace-rust, destiné à un cours de Rust pour débutants. Il copie le premier dossier et ne diffère que par deux fichiers : le profil du public et l’idée en une ligne, tandis que tous les contrats d’étape, le fichier de routage et la référence à l’utilisation des outils restent identiques au niveau binaire. En exécutant mcp-workflow-stage5 --workspace workspace-rust, on obtient un cours de Rust issu du même processus. Telle est la conception d’ICM, qui consiste à configurer l’usine plutôt que le produit.
Ces deux espaces de travail ne diffèrent que par audience.md et l’idée en une ligne : le même pipeline, un cours différent.
Il est possible d’élargir cette structure. Une section Verify dans le contrat d’une étape pourrait amener cette dernière à relire un résultat antérieur et à signaler d’éventuelles déviations avant qu’un humain ne s’en occupe, ce que l’article ICM décrit comme une vérification inter-étapes. Un résumé en une ligne des modules précédents pourrait alimenter chaque appel de rédaction, réduisant ainsi les chevauchements entre les modules 1 et 2. De plus, le dossier proposed/ pourrait devenir automatiquement mis à jour.
Pourquoi créer manuellement cette version légère alors que CrewAI et LangGraph existent ? Ce sont de bons outils, et un flux de travail nécessitant des agents concurrents, une branching dynamique ou un déploiement réel justifie leur encombrement. Cependant, de nombreuses tâches sont séquentielles, susceptibles d’être revues et répétées, sans avoir besoin de ces fonctionnalités. Pour celles-ci, la solution la plus simple existe déjà sur chaque machine : un dossier de fichiers. La créer en premier montre également où les outils plus lourds commencent à être utiles.