Ingénierie des harnais : l’environnement de exécution qui transforme un LLM en agent
Ce qui fait réellement fonctionner un agent de codage : la boucle, l’envoi d’outils capable de résister aux appels défectueux, ainsi que les fonctionnalités d’autorisation (autoriser, demander, refuser), présentés à l’aide d’environ 600 lignes de Python pur.
Sources: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)
Lorsqu’un agent de codage fonctionne mal, c’est généralement le modèle qui en porte la responsabilité. Le plus souvent, la faute réside dans le framework, le programme qui l’entoure.
Tout ceux qui ont utilisé une API de complétion de chat connaissent la partie facile : envoyer des messages, recevoir une réponse, éventuellement ajouter des appels de fonction. La partie difficile, c’est le passage de cela à un agent capable de se trouver à l’intérieur d’un répertoire, d’éditer des fichiers, d’exécuter des tests et de se remettre en marche après avoir endommagé quelque chose. Le framework comble cette lacune. Il s’agit du environnement de exécution entourant le modèle : outils, permissions, isolation, gestion du contexte, gestion de la mémoire et récupération des erreurs. Tout ce que fait le modèle, c’est traiter du texte en entrée et en sortie. Le framework est le programme qui répète ce cycle, exécute les outils, assure la sécurité et communique avec vous via une terminal. Si le modèle est le moteur, le framework est la voiture.
Un modèle de langage seul ne peut pas ouvrir vos fichiers, exécuter pytest, modifier du code, se souvenir de la conversation de mardi dernier, ni s’empêcher de taper rm -rf /. Tout ce qu’il peut faire, c’est générer le message suivant. Tout le reste nécessite une gestion supplémentaire :
- Boucle : appeler le modèle encore et encore jusqu’à ce qu’il cesse de demander des outils.
- Outils : les moyens concrets (shell, fichiers, modifications).
- Permissions : demander l’autorisation avant d’effectuer des actions risquées et bloquer les plus dangereuses.
- Sandbox : faire échouer les actions dangereuses au niveau du système d’exploitation.
- Contexte : limiter la longueur de la conversation afin de pouvoir la transmettre.
- Mémoire : connaissances, liste de tâches, fichier de session pouvant être repris.
- Sous-agents : explorer un codebase sans encombrer la conversation principale.
Cet article traite des trois premiers éléments, qui sont ceux qui transforment un chatbot en agent. Le reste, à savoir le sandboxing, la gestion du contexte, la mémoire et les sous-agents, constitue des couches supplémentaires sur ce même cadre de base.
Chatbot versus agent
Un assistant de chat effectue un seul aller-retour : une question entrante, une réponse sortante. Des outils comme Claude Code ou le mode agent de Cursor fonctionnent différemment. En disant « refactoriser ce module », ils lisent les fichiers, exécutent des tests, modifient le code, relancent les tests et corrigent tout ce qu’ils ont endommagé. Ce n’est pas un modèle plus intelligent qui en est la raison. Un programme entourant ce modèle sait comment itérer, appeler des outils et persister en cas d’échec d’une étape.
Les gens ont commencé à qualifier cela d’ingénierie de harnais, un terme plutôt grandiose pour une idée simple : Claude Code, Cursor et Codex sont de meilleurs harnais autant qu’ils sont de meilleurs modèles.
Un exemple est plus utile que des définitions. Vous tapez : « Corrigez le test qui échoue dans test_parser.py. » Voici ce qui suit :
Turn 1: LLM reads the test file → bash("cat test_parser.py")
Turn 2: LLM runs the test to see error → bash("pytest test_parser.py")
Turn 3: LLM reads the source file → read_file("parser.py")
Turn 4: LLM edits the bug → str_replace("parser.py", old, new)
Turn 5: LLM re-runs the test → bash("pytest test_parser.py")
Turn 6: Tests pass → LLM says "Done! Fixed the off-by-one error in line 42."
Six tours et six décisions, et personne n’a cliqué sur rien après la première demande. C’est là que le harnais fait son travail.
Vocabulaire
Un petit nombre de termes reviennent fréquemment.
- LLM : une fonction qui transforme une liste de messages en un autre message. Il n’a pas de mémoire des appels API précédents. Si vous voulez qu’il donne l’impression de se souvenir, vous devez envoyer à nouveau l’historique.
- Token : une unité de texte que le modèle compte, équivalant à environ trois quarts d’un mot en anglais. « Hello world » compte environ deux tokens, tandis qu’un fichier Python de 100 lignes en compte près de 500.
- Window de contexte : le nombre de tokens que le modèle peut prendre en compte en même temps. Des valeurs d’environ 128 K pour les modèles de type GPT-4 et de 200 K pour Claude semblent énormes, jusqu’à ce qu’un agent colle 40 fois le résultat de
grep -rdans la conversation. - Appel d’outil : le modèle ne exécute pas votre code. Il génère une requête telle que
{"name": "bash", "arguments": {"command": "ls"}}, puis votre Python exécutelset renvoie le résultat. - Sans état : chaque
call_llm(messages)fonctionne de manière indépendante, et la listemessagessert de mémoire.
Aucune connaissance préalable en apprentissage automatique n’est nécessaire. Un Python de base (fonctions, boucles, dictionnaires, try/except), une petite expérience avec une API de type OpenAI ainsi que les commandes de shell les plus courantes (ls, grep, cat) suffisent.
L’implémentation de référence
L’agent d’exemple compte environ 600 lignes de code Python sans framework, utilisant quatre bibliothèques : openai, rich, prompt-toolkit et pyyaml. Les extraits ci-dessous sont du code réel, pas du pseudocode, et à la fin vous devriez reconnaître les mêmes éléments à l’intérieur de Claude Code ou de Cursor. Pour suivre, clonez une copie du projet et installez-le en mode éditable :
git clone https://github.com/shrinidhi-mahishi/coding-harness.git
cd coding-harness
pip install -e .
Tout point de terminaison compatible avec OpenAI fonctionne : OpenRouter, un serveur local, OpenAI lui-même. Placez les paramètres dans ~/.agents/env :
BASE_URL=https://openrouter.ai/api/v1
API_KEY=sk-or-...
MODEL=deepseek/deepseek-v4-flash
Les variables d’environnement authentiques prennent le pas sur ce fichier. La commande coding-harness, déclarée dans pyproject.toml comme point d’entrée vers coding_harness.agent:main, lance une session de terminal. Vous tapez des commandes, et la boucle interne s’exécute jusqu’à ce que l’agent n’ait plus de requêtes d’outil à effectuer.
Sept éléments composent l’ensemble du design : la boucle de l’agent (le cœur du système), les outils (les mains), les permissions (qui peut faire quoi), un environnement isolé (sécurité assurée par le noyau), l’ingénierie de contexte (une fenêtre propre), les compétences, les tâches à effectuer et les sessions (la mémoire), ainsi que les sous-agents (utilisation du contexte dans un espace jetable). Seuls les trois premiers sont abordés ici.
Les fichiers importants :
coding_harness/agent.py # two nested loops
coding_harness/llm.py # one API call + the system prompt
coding_harness/tools.py # tool functions + defensive execute()
coding_harness/permissions.py # allow / ask / deny
coding_harness/config.py # BASE_URL, API_KEY, MODEL
Il n’y a ni base de données, ni serveur web, ni bibliothèque d’orchestration. Le « framework » se compose de deux boucles while True et d’un dictionnaire contenant des fonctions d’outils. Pour une première lecture, consultez config.py, llm.py, agent.py, tools.py et permissions.py dans cet ordre.
1. La boucle de l’agent
Claude Code, Cursor et Codex partagent toutes une même boucle :
- Envoyer la conversation au modèle.
- Si le modèle demande un outil, l’exécuter.
- Ajouter le résultat à la conversation et revenir à l’étape 1.
- Lorsqu’il n’y a plus d’appels d’outils, le travail est terminé et la réponse est affichée.
Un chatbot effectue un seul aller-retour, tandis qu’un agent continue jusqu’à ce que la tâche soit achevée. Telle est toute la différence ; la majeure partie du travail d’ingénierie restant vise à empêcher la boucle de déraper.
Un point déroute les nouveaux venus : les modèles sont sans état. Ils ne se souviennent pas du tour précédent, donc chaque appel API doit contenir l’intégralité de la conversation. Les messages ont l’aspect suivant :
messages = [
{"role": "system", "content": "You are a coding agent..."},
{"role": "user", "content": "Fix the failing test"},
{"role": "assistant", "content": "I'll start by reading the test file."},
{"role": "tool", "content": "... contents of test file ..."},
]
Il existe quatre rôles. Le rôle du système contient les instructions permanentes, l’utilisateur, c’est vous, l’assistant correspond au modèle, et l’outil représente la sortie renvoyée par celui-ci. Le modèle prend en charge la liste et génère l’entrée suivante, et cette liste constitue sa seule « mémoire ». Aucun autre stockage caché n’existe, donc si la liste est incorrecte, l’agent l’est également.
Deux boucles sont impliquées. La boucle intérieure couvre une seule interaction de l’utilisateur ainsi que autant d’interactions du modèle que nécessaire. La boucle extérieure se contente d’attendre des entrées.
Ceci est la boucle intérieure sous forme simplifiée. Le fichier réel supprime également les anciennes sorties d’outil, ajoute un rappel frais à la fin de chaque demande, élimine les fichiers temporaires et peut compresser l’historique.
while True:
message, usage = call_llm(messages)
messages.append(message.model_dump(exclude_none=True))
if message.content:
ui.agent(message.content)
# No tool calls? The agent is done thinking.
if not message.tool_calls:
break
# Execute each tool and feed the result back
for tool_call in message.tool_calls:
args, result = execute(tool_call)
ui.tool(tool_call.function.name, args, result)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
Le code se trouve dans agent.py. call_llm(messages) envoie l’historique complet et le modèle renvoie du texte, des appels d’outils ou les deux. model_dump() se contente de convertir l’objet API en un dictionnaire pouvant être stocké. En l’absence d’appels d’outils, la boucle s’arrête. Sinon, elle les exécute, les affiche, ajoute les résultats et recommence, de sorte que le modèle voit ce qui s’est passé et décide s’il doit continuer.
La boucle externe est encore plus courte :
while True:
user_input = ui.ask()
if not user_input:
break
messages.append({"role": "user", "content": user_input})
# ... inner agent loop runs here ...
Votre texte est ajouté à la liste, la boucle interne s’exécute jusqu’à ce que l’agent ait terminé, puis le programme attend à nouveau. Il n’y a pas de machine à états, seulement deux instructions while True imbriquées, identiques à celles utilisées par Claude Code et Cursor. Le modèle parle ou demande des outils, les résultats des outils sont renvoyés dans les messages, et le cycle se répète.
2. Outils : les mains du modèle
Sans outils, la boucle n’est qu’un chatbot avec des étapes supplémentaires. Les outils permettent au modèle d’accomplir des tâches : lire des fichiers, exécuter des commandes, écrire du code, rechercher dans un dépôt.
Imaginez demander à une personne brillante de corriger une erreur alors qu’elle se trouve dans une pièce verrouillée sans ordinateur. Elle peut réfléchir mais ne peut toucher à rien. Les outils constituent la porte d’accès.
De nombreux tutoriels omettent un fait essentiel : les outils ne sont jamais exécutés par le modèle lui-même. Ce que le modèle produit, c’est une demande du type « exécutez cette commande », et c’est le Python environnant qui effectue le travail et renvoie le résultat. Le modèle ne dispose pas d’un interpréteur de commandes, seulement d’un formulaire.
Un outil se compose de quatre éléments : un nom (bash, read_file), une description indiquant au modèle quand l’utiliser, un JSON Schema décrivant les arguments avec leurs champs et types obligatoires, ainsi qu’une fonction Python qui effectue le travail.
bash est à la fois l’outil le plus puissant et le plus préoccupant. Voici son exemple dans tools.py :
def bash(command: str) -> str:
"""Run a shell command and return its combined stdout and stderr."""
try:
result = sandbox.run(command)
except subprocess.TimeoutExpired as expired:
return (
f"Timed out after {expired.timeout}s and was killed. "
"Narrow it down - search inside the working directory rather than /."
)
return (result.stdout + result.stderr) or "(no output)"
Ce que le modèle voit, c’est le schéma :
{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command and return its combined stdout and stderr.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to run",
}
},
"required": ["command"],
},
},
}
Lorsque le modèle souhaite une liste des fichiers d’un répertoire, il émet {"name": "bash", "arguments": {"command": "ls -la src/"}}. Le code l’exécute, renvoie le résultat, et le modèle choisit sa prochaine action.
L’agent dispose de sept outils, chacun étant une fonction accompagnée d’un schéma :
bashexécute une commande de shell à l’intérieur du sandbox.read_filelit un fichier.write_filecrée ou écrase un fichier.str_replaceeffectue une modification ciblée en remplaçant une chaîne de caractères par une autre.read_skillcharge un manuel de procéduresSKILL.md.write_todosremplace toute la liste des tâches à faire.tasklance un sous-agent et ne renvoie que sa réponse finale.
La fonction bash n’est qu’un enveloppe légère : elle exécute sandbox.run avec un délai d’expiration de 60 secondes et renvoie stdout et stderr, ou un message d’expiration indiquant de rechercher dans le répertoire du projet, et non dans /.
Considérez les appels aux outils comme des entrées non fiables
De nombreux articles du type « créez un agent en 20 lignes » omettent cette étape. Un appel à un outil est du texte écrit par le modèle, donc le modèle de menace correspond à celui d’un formulaire web. Le nom peut ne pas exister, les arguments peuvent ne pas être du JSON valide et les types peuvent être incorrects. Tout cela peut mettre fin à la session si le code n’est pas préparé.
Les modèles inventent des choses. Ils inventent des noms de fonctions et génèrent du JSON défectueux. Une panne force l’utilisateur à recommencer. Si vous détectez le problème et renvoyez une erreur lisible, le modèle tente souvent à nouveau correctement, et il est étonnamment doué pour cela, à condition de lui fournir l’erreur et non un traceur d’exception.
execute(), un dispatcheur de sécurité également présent dans tools.py, fonctionne selon cet ordre :
- Parser le JSON ; s’il est corrompu, retourner une erreur indiquant que les arguments n’étaient pas un JSON valide.
- Rechercher le nom de l’outil ; s’il est inconnu, afficher la liste des outils réels.
- Consulter
permissions.check()pour obtenir une décision de refus, de demande ou d’autorisation. - Appeler la fonction Python correspondante.
- Attraper les exceptions
TypeErroret toutes autres, puis retourner une chaîne du type « Error: ... » sans jamais afficher les détails de l’erreur.
En bref, il dirige l’appel à un outil vers une fonction sans jamais faire planter la session.
def execute(tool_call):
name = tool_call.function.name
# The model might send broken JSON
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as broken:
return {}, f"Error: arguments were not valid JSON ({broken})."
# The model might hallucinate a tool name
if name not in TOOLS:
return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
# Run through the permission layer, then call the function
try:
action, reason = check(name, args)
if action == "deny":
return args, f"Blocked by policy: {reason}"
if action == "ask" and not ui.approve(reason):
return args, "The user denied this tool call."
return args, TOOLS[name](**args)
except TypeError as mismatch:
return args, f"Error: wrong arguments for {name} ({mismatch})."
except Exception as failure:
return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
Supposons que le modèle invente un outil appelé search_code. Le JSON est correct, mais ce nom n’apparaît pas dans TOOLS ; par conséquent, le dispatcheur indique qu’aucun tel outil n’existe et énumère ceux qui sont disponibles : bash, read_file, write_file, str_replace, read_skill, write_todos et task. Lors du tour suivant, le modèle utilise bash avec grep -r “auth” .. Rien ne plante, ce qui est justement l’objectif : une mauvaise invocation d’outil ne doit jamais faire planter la session. Les erreurs ne sont que de nouvelles entrées.
3. Permissions : qui peut faire quoi
Avec bash à disposition, l’agent devient utile, mais c’est aussi ainsi que l’on peut exécuter des commandes dangereuses comme rm -rf / ou envoyer des informations secrètes quelque part. S’il peut exécuter n’importe quelle commande, il peut supprimer des fichiers, envoyer du contenu inutile ou communiquer avec un serveur externe. Si vous devez valider chaque commande, vous passerez l’après-midi à approuver des opérations simples comme ls. Un compromis est nécessaire.
La réponse est de trois niveaux, comme un feu de signalisation :
| Niveau | Signification | Exemples |
|---|---|---|
| allow | fonctionne en silence | ls, grep, cat, git status, pytest |
| ask | fait une pause en attendant vous | mkdir, python script.py, npm install |
| deny | bloqué même si vous êtes d’accord | rm, sudo, curl, wget, git push |
Les règles sont des motifs globaux où * correspond à n’importe quoi, et la dernière règle correspondante l’emporte. Extrait de permissions.py :
BASH_RULES = {
"*": "ask", # default: ask for everything
# read-only: let them through
"ls*": "allow",
"cat *": "allow",
"grep *": "allow",
"git status*": "allow",
"git diff*": "allow",
"pytest*": "allow",
# risky: never, even if the user says yes
"rm *": "deny",
"sudo *": "deny",
"curl *": "deny",
"git push*": "deny",
}
L’entrée * définit la politique par défaut comme « demander ». Les commandes en lecture seule sont autorisées, celles qui sont dangereuses sont bloquées directement, et tout le reste, comme python script.py ou npm install, reste soumis à une demande préalable.
Le modèle essaiera des astuces rusées. Une commande telle que grep foo | rm -rf / commence de manière inoffensive mais se termine de façon catastrophique. C’est pourquoi le vérificateur divise la commande en fonction des séparateurs |, ; et &&, en respectant les guillemets, et applique la décision la plus stricte issue de chacune de ces parties :
def decide(command):
"""Rate every part of a compound command; the strictest verdict wins."""
verdicts = []
for part in split_command(command):
action = "ask"
for pattern, rule in BASH_RULES.items():
if fnmatch(part, pattern):
action = rule
verdicts.append(action)
for strictest in ("deny", "ask"):
if strictest in verdicts:
return strictest
return "allow"
En pratique, grep foo | rm -rf / est refusé car rm l’est également. ls && mkdir test est demandé, étant donné que mkdir n’est pas sur la liste des commandes autorisées. cat file.py | grep TODO est autorisé car les deux parties de la commande sont en lecture seule.
Les permissions et le sandboxing ont des fonctions différentes. Les permissions concernent l’expérience utilisateur : vaut-il la peine de vous interrompre pour cela ? Elles ne constituent pas une mesure de sécurité, car une commande astucieuse peut ressembler à ls tout en étant en réalité autre chose. Un sandbox, lui, constitue une mesure de sécurité, car il détermine si l’action est physiquement possible. Un véritable agent a besoin des deux.
Où cela vous mène
Une boucle, des outils défensifs et des permissions suffisent déjà à créer un agent de codage fonctionnel. Il peut lire un dépôt, exécuter des tests, modifier des fichiers, demander l’autorisation avant d’effectuer une action risquée et se remettre d’une appel d’outil défectueux.
Ce n’est pas encore sûr, car un modèle déterminé peut encore causer des dommages. Ce n’est pas non plus efficace, car la fenêtre de contexte se remplit rapidement. De plus, il n’a pas de mémoire, ce qui signifie que chaque session commence à zéro. Une sandbox au niveau du système d’exploitation, qui fait échouer les actions destructrices dans le noyau, ainsi qu’une gestion en couches du contexte pour permettre aux sessions longues de fonctionner correctement, constituent les prochaines étapes logiques. Ce sont les mêmes primitives utilisées par les agents de production, sans le côté mystérieux.