Ingeniería de harness: el entorno en tiempo de ejecución que convierte a un LLM en un agente
Lo que realmente hace que funcione un agente de programación: el bucle, el envío de herramientas que resiste las llamadas defectuosas, y las permisos de permitir/pedir/negar, mostrado con unas 600 líneas de Python puro.
Fuentes: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)
Cuando un agente de codificación falla, por lo general el modelo es el culpable. Con mayor frecuencia, el problema radica en el entorno de ejecución, el programa que lo rodea.
Cualquiera que haya utilizado una API de completación de chat conoce la parte sencilla: enviar mensajes, recibir una respuesta y, quizás, añadir llamadas a funciones. La parte difícil es el paso desde eso hasta un agente capaz de operar dentro de un repositorio, editar archivos, ejecutar pruebas y recuperarse después de dañar algo. El entorno de ejecución cubre esa distancia. Se trata del entorno en tiempo de ejecución alrededor del modelo: herramientas, permisos, aislamiento, manejo de contexto, gestión de memoria y recuperación de errores. Todo lo que hace un modelo es procesar texto de entrada y salida. El entorno de ejecución es el programa que repite el ciclo, ejecuta las herramientas, mantiene todo seguro y se comunica con usted en una terminal. Si el modelo es el motor, el entorno de ejecución es el coche.
Por sí solo, un modelo de lenguaje no puede abrir tus archivos, ejecutar pytest, editar código, recordar la conversación del martes pasado ni impedirse a sí mismo escribir rm -rf /. Lo único que puede hacer es emitir el siguiente mensaje. Todo lo demás requiere trabajo adicional:
- Bucle: llamar al modelo una y otra vez hasta que deje de solicitar herramientas.
- Herramientas: los medios para actuar (símbolo de comando, archivos, ediciones).
- Permisos: pedir autorización antes de realizar acciones riesgosas y bloquear las más peligrosas.
- Sandbox: hacer que las acciones peligrosas fallen a nivel del sistema operativo.
- Contexto: mantener la conversación lo suficientemente breve como para enviarla.
- Memoria: conocimientos, una lista de tareas y un archivo de sesión que se pueda reanudar.
- Subagentes: explorar un código sin saturar la conversación principal.
Este texto aborda los tres primeros elementos, que son los que convierten a un chatbot en un agente. El resto, como el aislamiento en entorno seguro, la gestión de contexto, la memoria y los subagentes, constituyen capas adicionales sobre esa misma estructura básica.
Chatbot versus agente
Un asistente de chat realiza un solo ciclo: se ingresa una pregunta y se obtiene una respuesta. Herramientas como Claude Code o el modo agente de Cursor funcionan de manera diferente. Si se dice “refactoriza este módulo”, estas herramientas leen archivos, ejecutan pruebas, editan código, vuelven a ejecutar las pruebas y corrigen cualquier error que hayan causado. La razón no es un modelo más inteligente, sino un programa que rodea al modelo y sabe cómo repetir procesos, utilizar herramientas y seguir funcionando cuando algún paso falla.
La gente ha comenzado a denominar esto ingeniería de soportes, un término bastante grandilocuente para una idea sencilla: Claude Code, Cursor y Codex son mejores soportes tanto como modelos más avanzados.
Un ejemplo sirve de más que las definiciones. Escribes: “Arregla la prueba fallida en test_parser.py”. Esto es lo que sigue:
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."
Seis turnos y seis decisiones, y nadie hizo clic en nada después de la primera solicitud. Eso demuestra que el arnés está cumpliendo su función.
Vocabulario
Algunos términos aparecen repetidamente a lo largo del texto.
- LLM: una función que transforma una lista de mensajes en otro mensaje. No tiene memoria de las llamadas API anteriores. Si se desea que parezca recordar, se debe enviar nuevamente el historial.
- Token: una unidad de texto que el modelo cuenta; equivale a aproximadamente tres cuartos de una palabra en inglés. “Hello world” tiene unos dos tokens, mientras que un archivo de Python de 100 líneas genera cerca de 500 tokens.
- Ventana de contexto: la cantidad de tokens que el modelo puede procesar al mismo tiempo. Valores como 128K para modelos de clase GPT-4 y unos 200K para Claude parecen enormes, hasta que un agente pega el resultado de
grep -ren el chat 40 veces. - Llamada a la herramienta: el modelo no ejecuta tu código. Escribe una solicitud como
{"name": "bash", "arguments": {"command": "ls"}}, y luego tu Python ejecutalsy devuelve el resultado. - Sin estado: cada
call_llm(messages)funciona de forma independiente, y la listamessagesactúa como memoria.
No se necesita conocimiento previo en aprendizaje automático. Basta con conocimientos básicos de Python (funciones, bucles, diccionarios, try/except), algo de experiencia con una API al estilo de OpenAI y los comandos de shell más comunes (ls, grep, cat).
La implementación de referencia
El agente de ejemplo consta de unas 600 líneas de Python sin ningún framework, y utiliza cuatro bibliotecas: openai, rich, prompt-toolkit y pyyaml. Los fragmentos que se muestran a continuación son el código real, no pseudocódigo, y al final deberías reconocer las mismas partes dentro de Claude Code o Cursor. Para seguir el proceso, clona una copia del proyecto e instálalo en modo editable:
git clone https://github.com/shrinidhi-mahishi/coding-harness.git
cd coding-harness
pip install -e .
Cualquier punto de extremo compatible con OpenAI funciona: OpenRouter, un servidor local, OpenAI mismo. Coloque la configuración en ~/.agents/env:
BASE_URL=https://openrouter.ai/api/v1
API_KEY=sk-or-...
MODEL=deepseek/deepseek-v4-flash
Las variables de entorno reales anulan ese archivo. La orden coding-harness, declarada en pyproject.toml como punto de entrada a coding_harness.agent:main, inicia una sesión de terminal. Usted escribe y el bucle interno se ejecuta hasta que el agente no tenga más llamadas a herramientas que realizar.
Siete conceptos componen todo el diseño: el bucle del agente (el latido del sistema), las herramientas (las manos), los permisos (quién puede hacer qué), una zona aislada (seguridad garantizada por el kernel), la ingeniería de contexto (una ventana limpia), las habilidades, las tareas pendientes y las sesiones (la memoria), y los subagentes (uso del contexto en espacios desechables). Solo se tratan los tres primeros aquí.
Los archivos importantes:
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
No existe base de datos, servidor web ni biblioteca de orquestación. El “framework” está compuesto por dos bucles while True y un diccionario con funciones de herramientas. Para una primera lectura, revise config.py, llm.py, agent.py, tools.py y permissions.py en ese orden.
1. El bucle del agente
Claude Code, Cursor y Codex comparten un mismo bucle:
- Enviar la conversación al modelo.
- Si el modelo solicita una herramienta, ejecutarla.
- Añadir el resultado a la conversación y volver al paso 1.
- Al no quedar llamadas a herramientas, el trabajo está completo y se muestra la respuesta.
Un chatbot realiza un único viaje de ida y vuelta, mientras que un agente continúa hasta que la tarea se finaliza. Esa es toda la diferencia, y la mayor parte del trabajo de ingeniería restante tiene como objetivo evitar que el bucle se bloquee.
Un punto que confunde a los recién llegados es que los modelos no tienen estado. No recuerdan la turno anterior, por lo que cada llamada a la API debe incluir toda la conversación. Los mensajes se ven así:
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 ..."},
]
Existen cuatro roles. El rol del sistema alberga las instrucciones permanentes, el usuario eres tú, el asistente es el modelo, y la herramienta contiene la salida que devuelve. El modelo recibe la lista y genera la siguiente entrada, y esa lista es su única “memoria”. No existe un segundo almacenamiento oculto, por lo que si la lista está incorrecta, el agente también lo está.
Hay dos bucles involucrados. El interior abarca una sola intervención del usuario y tantas intervenciones del modelo como sean necesarias. El exterior simplemente espera la entrada.
Este es el bucle interior en su forma simplificada. El archivo real también elimina las salidas antiguas de las herramientas, agrega un recordatorio nuevo al final de cada solicitud, elimina los archivos temporales y puede comprimir el historial.
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,
})
El código se encuentra en agent.py. call_llm(messages) envía todo el historial y el modelo devuelve texto, llamadas a herramientas o ambos. model_dump() simplemente convierte el objeto de la API en un diccionario que se puede almacenar. Si no hay llamadas a herramientas, el bucle finaliza. De lo contrario, las ejecuta, las muestra, agrega los resultados y vuelve a repetir el proceso, de modo que el modelo vea qué ocurrió y decida si continuar.
El bucle exterior es aún más pequeño:
while True:
user_input = ui.ask()
if not user_input:
break
messages.append({"role": "user", "content": user_input})
# ... inner agent loop runs here ...
El texto se agrega a la lista, el bucle interno se ejecuta hasta que el agente termina, y luego el programa vuelve a esperar. No hay máquina de estados, solo dos instrucciones anidadas while True, con la misma estructura que Claude Code y Cursor. El modelo habla o solicita herramientas, los resultados de las herramientas se reintegran en messages, y el ciclo se repite.
2. Herramientas: manos para el modelo
Sin herramientas, el bucle es simplemente un chatbot con pasos adicionales. Las herramientas son lo que permite al modelo realizar tareas: leer archivos, ejecutar comandos, escribir código, buscar en un repositorio.
Imagínese pedirle a una persona brillante que arregle un error mientras está en una habitación cerrada sin computadora. Puede pensar pero no puede tocar nada. Las herramientas son la puerta.
Muchos tutoriales pasan por alto un hecho importante: las herramientas nunca son ejecutadas por el modelo en sí. Lo que produce es una solicitud del tipo “ejecuta este comando”, y es el Python circundante el que realiza el trabajo y envía el resultado. El modelo no recibe una terminal, solo un formulario.
Una herramienta consta de cuatro elementos: un nombre (bash, read_file), una descripción que indica al modelo cuándo usarla, un JSON Schema para los argumentos con campos y tipos obligatorios, y una función en Python que realiza el trabajo.
bash es tanto la herramienta más potente como la que genera más preocupaciones. Aquí está su código de 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)"
Lo que ve el modelo es el esquema:
{
"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"],
},
},
}
Cuando el modelo desea un listado de directorios, emite {"name": "bash", "arguments": {"command": "ls -la src/"}}. El código lo ejecuta, devuelve la salida y el modelo elige su siguiente paso.
El agente cuenta con siete herramientas, cada una compuesta por una función y un esquema:
bashejecuta una orden de shell a través de la sandbox.read_filelee un archivo.write_filecrea o sobrescribe uno.str_replacerealiza una edición precisa al reemplazar una cadena por otra.read_skillcarga un manual de operacionesSKILL.md.write_todosreemplaza toda la lista de tareas pendientes.taskinicia un subagente y devuelve únicamente su respuesta final.
La función bash es un envoltorio simple: ejecuta sandbox.run con un tiempo de espera de 60 segundos y devuelve stdout y stderr, o un mensaje de tiempo agotado que sugiere realizar la búsqueda dentro del directorio del proyecto, y no en /.
Trate las llamadas a herramientas como entrada no fiable
Muchos artículos del tipo “construya un agente en 20 líneas” omiten esta parte. Una llamada a herramienta es texto escrito por el modelo, por lo que el modelo de amenazas es similar al de un formulario web. El nombre podría no existir, los argumentos podrían no ser JSON válido y los tipos podrían estar incorrectos. Cualquiera de estos problemas puede terminar la sesión si el código no está preparado.
Los modelos generan ilusiones: inventan nombres de funciones y emiten JSON defectuoso. Una caída fuerza al usuario a empezar de nuevo. Si detecta el problema y devuelve un error legible, el modelo suele intentarlo de nuevo correctamente, y sorprendentemente lo hace bien, siempre y cuando le proporcione el error y no un rastro de pila.
execute(), un despachador defensivo que también se encuentra en tools.py, funciona siguiendo este orden:
- Analiza el JSON; si está dañado, devuelve un error indicando que los argumentos no son JSON válido.
- Busca el nombre de la herramienta; si no es conocido, muestra la lista de herramientas reales.
- Consulte
permissions.check()para obtener un veredicto de denegación, solicitud o autorización. - Llama a la función de Python.
- Captura
TypeErrory cualquier otra excepción, y devuelve una cadena “Error: ...” sin incluir nunca el rastro de errores.
En resumen, dirige la llamada a una herramienta a una función sin que la sesión se cierre nunca.
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}"
Supongamos que el modelo inventa una herramienta llamada search_code. El JSON está bien, pero el nombre no figura en TOOLS, por lo que el distribuidor responde que no existe tal herramienta y enumera las disponibles: bash, read_file, write_file, str_replace, read_skill, write_todos y task. En el siguiente turno, el modelo utiliza bash con grep -r "auth" .. Nada falló, y ese es el objetivo: una llamada a una herramienta defectuosa nunca debe interrumpir la sesión. Los errores son simplemente más entrada.
3. Permisos: quién puede hacer qué
Con bash disponible, el agente resulta útil, pero también es así como se puede terminar ejecutando rm -rf / o enviando datos confidenciales a algún lugar. Si puede ejecutar cualquier cosa, puede borrar archivos, enviar basura o comunicarse con el servidor central. Si se confirma cada comando, se pasará la tarde aprobando ls. Se necesita un camino intermedio.
La respuesta son tres niveles, como un semáforo:
| Nivel | Significado | Ejemplos |
|---|---|---|
| allow | se ejecuta en silencio | ls, grep, cat, git status, pytest |
| ask | hace una pausa a la espera de tu confirmación | mkdir, python script.py, npm install |
| deny | se bloquea incluso si estás de acuerdo | rm, sudo, curl, wget, git push |
Las reglas son patrones globales donde * corresponde a cualquier elemento, y gana la última regla que coincida. 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",
}
La entrada * establece como valor predeterminado pedir confirmación. Los comandos de solo lectura se permiten según corresponda, luego los peligrosos se bloquean por completo, y todo lo demás, como python script.py o npm install, sigue requiriendo confirmación.
El modelo intentará trucos astutos. Un comando como grep foo | rm -rf / comienza de forma inofensiva pero termina de manera catastrófica. Por eso el verificador divide el comando en partes usando |, ; y &&, respetando las comillas, y aplica la decisión más estricta basada en cada parte:
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 la práctica, grep foo | rm -rf / se rechaza porque rm también se rechaza. ls && mkdir test genera una solicitud, ya que mkdir no está en la lista de permisos permitidos. cat file.py | grep TODO está permitido porque ambas partes son de solo lectura.
Los permisos y el aislamiento en entorno seguro cumplen funciones diferentes. Los permisos se refieren a la experiencia del usuario: ¿vale la pena interrumpirlo por esto? No constituyen una medida de seguridad, ya que un comando astuto puede parecerse a ls pero ser algo distinto. El aislamiento en entorno seguro sí es una medida de seguridad, ya que determina si la acción está permitida físicamente. Un agente real necesita ambos.
Donde te deja esto
Un bucle, herramientas defensivas y permisos ya suman un agente de programación funcional. Puede leer un repositorio, ejecutar pruebas, editar archivos, preguntar antes de hacer algo imprudente y recuperarse de una llamada a una herramienta mal formada.
Todavía no es seguro, porque un modelo decidido aún puede causar daños. Tampoco es eficiente, ya que la ventana de contexto se llenará. Además, no tiene memoria, por lo que cada sesión comienza desde cero. Una caja de arena a nivel de sistema operativo, que hace que las acciones destructivas fallen en el kernel, sumada a una gestión de contexto en capas que permite que las sesiones largas permanezcan estables, son los siguientes pasos lógicos. Estas son las mismas primitivas que utilizan los agentes de producción, sin el misterio que las rodea.