El sandbox de un agent loop: del texto del modelo a una observación fiable
El sandbox de un agent loop convierte el texto del modelo en una observación fiable: extraer el código de la prosa, validar que parsea, ejecutar con timeout y aislar el proceso.
El sandbox es la parte del loop write-test-fix que toma el código que escribió el modelo y lo ejecuta. Pero describirlo como “ejecutar” se queda corto: su trabajo real es convertir la salida de texto del modelo en una observación fiable, una en la que el loop pueda basar la siguiente vuelta. En el post anterior eso era una función runTests de tres líneas —escribir el código a un archivo, correr los tests, capturar la salida— y funcionó porque el ejemplo era de juguete. En cuanto el código viene de un modelo de verdad, entre esas tres líneas se cuelan cuatro fases que el runner ingenuo se salta, y cada una tiene su forma de romperse.
Resumen para perezosos
- El sandbox es el paso
ejecutardel loop, pero su trabajo es convertir el texto del modelo en una observación fiable. Pasa por cuatro fases: entrada, preparación, ejecución y aislamiento. - Extrae el código de la prosa, comprueba que parsea, ejecútalo con un timeout y en un proceso aparte con entorno limpio. Ojo: el timeout solo corta el código que tarda —no el que agota memoria o procesos— y un subproceso no es una frontera de seguridad.
- Lo que más mueve la aguja es clasificar: que el sandbox devuelva un estado (no un booleano) y separe stdout de stderr, para que cada fallo vuelva al modelo como un mensaje distinto.
En este artículo:
- El plano — Las cuatro fases del sandbox
- Las fases — Entrada: extraer el código · Preparación: ¿parsea? · Ejecución: timeout · Aislamiento
- El pago — Clasificar, no solo ejecutar
Las cuatro fases del sandbox
Para que el runTests de tres líneas del post anterior funcione tienen que cumplirse cuatro supuestos: que la respuesta del modelo sea código, que ese código parsee, que termine, y que sea seguro de ejecutar. En el ejemplo de juguete los cuatro se cumplen porque tú escribiste el caso para que se porte bien. En producción ninguno se cumple solo, y cada supuesto roto es una fase del sandbox: un filtro que la salida del modelo tiene que pasar antes de seguir.
Este es el plano del resto del post. Conviene tenerlo entero antes de entrar en cada pieza:
| Fase | Qué recibe | Qué puede romperse | La defensa |
|---|---|---|---|
| Entrada | El texto del modelo | Viene envuelto en prosa o un fence | Extraer el código |
| Preparación | El código extraído | No parsea | Chequeo de sintaxis |
| Ejecución | El código válido | No termina o se desboca | Timeout (con sus límites) |
| Aislamiento | El proceso en marcha | Toca tu entorno | Proceso aparte, entorno limpio |
Visto así, el sandbox no es un bloque único sino una tubería de cuatro filtros, y la salida del modelo cruza uno tras otro:
texto del modelo
│
[ Entrada ] extraer el código → ¿prosa / fence?
│
[ Preparación ] ¿parsea? → syntax_error
│
[ Ejecución ] correr con timeout → timeout · test_failed · pass
│
[ Aislamiento ] proceso aparte, env limpio
│
▼
observación clasificada que vuelve al loop
La idea que une las cuatro fases, y a la que vuelvo al final: en cada una, un desenlace distinto tiene que poder volver al modelo como una observación distinta. Eso es lo que separa un runner que solo ejecuta de uno que clasifica.
Fase 1: extraer el código de la prosa
El system prompt del loop pedía “solo el código, sin fences”. El modelo lo ignora más a menudo de lo que te gustaría: envuelve la respuesta en un bloque de markdown, o antepone una frase como “Aquí tienes la función corregida”. Escrito tal cual a un archivo, eso ya no es código válido, y lo peor es cómo se manifiesta: un error de sintaxis en una línea que el modelo nunca escribió como código. Pasé un rato culpando al modelo por un bug de lógica que en realidad era un fence sobrante.
La defensa es corta. Si hay un bloque cercado, el código es lo de dentro del primero; si no, se asume que toda la respuesta es código y se limpian los bordes:
// extract-code.ts — saca el código del texto que devuelve el modelo.
// Aunque le pidas "solo código", el modelo a veces envuelve la respuesta en un
// bloque de markdown o antepone una frase. Esto se queda solo con el código.
export function extractCode(reply: string): string {
// Si hay un bloque cercado, el código es lo de dentro del primer fence.
const fenced = reply.match(/```(?:[a-z]+)?\s*\n([\s\S]*?)\n\s*```/i);
if (fenced) return fenced[1].trim();
// Si no hay fence, asumimos que toda la respuesta es código y limpiamos los bordes.
return reply.trim();
}# extract_code.py — saca el código del texto que devuelve el modelo.
import re
# Aunque le pidas "solo código", el modelo a veces envuelve la respuesta en un
# bloque de markdown o antepone una frase. Esto se queda solo con el código.
def extract_code(reply: str) -> str:
# Si hay un bloque cercado, el código es lo de dentro del primer fence.
fenced = re.search(r"```(?:[a-z]+)?\s*\n([\s\S]*?)\n\s*```", reply, re.I)
if fenced:
return fenced.group(1).strip()
# Si no hay fence, asumimos que toda la respuesta es código y limpiamos los bordes.
return reply.strip()<?php
// extract_code.php — saca el código del texto que devuelve el modelo.
// Aunque le pidas "solo código", el modelo a veces envuelve la respuesta en un
// bloque de markdown o antepone una frase. Esto se queda solo con el código.
function extract_code(string $reply): string {
// Si hay un bloque cercado, el código es lo de dentro del primer fence.
if (preg_match('/```(?:[a-z]+)?\s*\n([\s\S]*?)\n\s*```/i', $reply, $m)) {
return trim($m[1]);
}
// Si no hay fence, asumimos que toda la respuesta es código y limpiamos los bordes.
return trim($reply);
}Es el mismo parseo tolerante que usé con el JSON en el routing de modelos: no pelees con el formato del modelo, límpialo. Para una sola función basta el primer fence; si el modelo devuelve varios bloques —código más un test, o dos archivos—, esta heurística se queda corta y conviene pedir un formato más explícito en el prompt.
Fase 2: comprobar que el código parsea
Con el código ya extraído, el modelo todavía escribe errores de sintaxis: un paréntesis sin cerrar, un return fuera de una función. El runner ingenuo lo ejecuta, el intérprete revienta, y el error cae en el mismo catch que un test fallido. No deberían ir juntos: un error de sintaxis te da línea y columna, un test fallido te da un valor equivocado. Es feedback distinto, y si lo mezclas el modelo recibe una señal más confusa de la que podría tener.
El arreglo es comprobar que el código parsea antes de ejecutarlo, usando el parser del propio lenguaje sin correr el programa:
// check-syntax.ts — ¿el código siquiera parsea? Sin esto, un error de sintaxis
// se confunde con un test que falla.
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
export function syntaxError(code: string): string | null {
writeFileSync("analyze.ts", code);
try {
// node --check parsea el archivo sin ejecutarlo.
execSync("node --check analyze.ts", { encoding: "utf8", stdio: "pipe" });
return null; // parsea bien
} catch (err: any) {
return (err.stderr ?? err.stdout ?? "").toString(); // el mensaje del parser
}
}# check_syntax.py — ¿el código siquiera parsea? Sin esto, un error de sintaxis
# se confunde con un test que falla.
def syntax_error(code: str) -> str | None:
try:
# compile() parsea el código sin ejecutarlo.
compile(code, "analyze.py", "exec")
return None # parsea bien
except SyntaxError as err:
return f"{err.msg} (línea {err.lineno})" # el mensaje del parser<?php
// check_syntax.php — ¿el código siquiera parsea? Sin esto, un error de sintaxis
// se confunde con un test que falla.
function syntax_error(string $code): ?string {
file_put_contents("analyze.php", $code);
$output = [];
$exitCode = 0;
// php -l hace un "lint": parsea el archivo sin ejecutarlo.
exec("php -l analyze.php 2>&1", $output, $exitCode);
return $exitCode === 0 ? null : implode("\n", $output); // el mensaje del parser
}Cada lenguaje trae su forma de parsear sin ejecutar: node --check en Node, compile() en Python, php -l en PHP. El flag exacto depende de tu toolchain —si compilas TypeScript, el chequeo lo hace tsc --noEmit—, pero la idea es la misma: pedirle al parser un sí o un no antes de gastar una ejecución. Con eso, un error de sintaxis se vuelve un desenlace propio en vez de colarse como si fuera un test en rojo.
Fase 3: timeout, y lo que el timeout no cubre
El modelo escribe un while cuya condición nunca se vuelve falsa, o un bucle que se olvida de avanzar el índice. El runner ingenuo se queda esperando a que el proceso termine, y como nunca termina, tu loop entero se congela: sin error, sin test en rojo, solo un agente que dejó de avanzar. El arreglo es ponerle un tope de tiempo y matar el proceso si lo supera:
// run-with-timeout.ts — ejecuta los tests, pero nunca cuelga el loop.
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";
const TIMEOUT_MS = 5000;
export function runTests(code: string): { passed: boolean; timedOut: boolean; output: string } {
writeFileSync("analyze.ts", code);
try {
const output = execSync("node analyze.test.ts", {
encoding: "utf8",
timeout: TIMEOUT_MS, // si el código no termina, lo matamos
});
return { passed: true, timedOut: false, output };
} catch (err: any) {
// execSync marca el timeout matando el proceso: err.killed o señal SIGTERM.
const timedOut = err.killed === true || err.signal === "SIGTERM";
return { passed: false, timedOut, output: (err.stdout ?? "") + (err.stderr ?? "") };
}
}# run_with_timeout.py — ejecuta los tests, pero nunca cuelga el loop.
import subprocess
TIMEOUT_S = 5
def run_tests(code: str) -> dict:
with open("analyze.py", "w") as f:
f.write(code)
try:
proc = subprocess.run(
["python", "analyze_test.py"],
capture_output=True, text=True,
timeout=TIMEOUT_S, # si el código no termina, se lanza TimeoutExpired
)
return {"passed": proc.returncode == 0, "timed_out": False,
"output": proc.stdout + proc.stderr}
except subprocess.TimeoutExpired as err:
out = (err.stdout or "") + (err.stderr or "")
return {"passed": False, "timed_out": True, "output": out}<?php
// run_with_timeout.php — ejecuta los tests, pero nunca cuelga el loop.
const TIMEOUT_S = 5;
function run_tests(string $code): array {
file_put_contents("analyze.php", $code);
$output = [];
$exitCode = 0;
// El comando `timeout` mata el proceso si pasa de TIMEOUT_S segundos.
// Sale con código 124 cuando expira.
exec("timeout " . TIMEOUT_S . " php analyze_test.php 2>&1", $output, $exitCode);
$timedOut = $exitCode === 124;
return [
"passed" => $exitCode === 0,
"timed_out" => $timedOut,
"output" => implode("\n", $output),
];
}Cinco segundos es un punto de partida razonable para una función pequeña; ajústalo a tu caso más lento legítimo, con margen.
Pero cuidado con leer el timeout como una protección general, porque solo cubre una clase de fallo: el código que tarda demasiado, no el que consume demasiado. Un bucle que en vez de no terminar va reservando memoria revienta por falta de memoria (OOM), no por tiempo. Un proceso que lanza procesos sin parar —un fork bomb— o que abre miles de sockets agota la máquina sin pasarse del reloj. El timeout no ve nada de eso.
Defenderse de esa otra clase —límites de memoria, CPU, número de procesos, descriptores de archivo— ya no es trabajo del runner, sino de la capa de aislamiento del sistema operativo, y ese es un tema aparte que da para su propia serie. Lo que importa aquí, para el loop, es no salir con la idea de que un timeout te protege de todo: protege de la categoría más común, y nada más.
Fase 4: aislar el proceso (que no es seguridad)
Empiezo por lo que más se malinterpreta: lo que viene en esta fase mejora mucho la robustez, pero no es una frontera de seguridad. Un subproceso comparte kernel, red y disco con el resto de la máquina; aísla un crash y el estado de memoria, no a un atacante. Conviene tenerlo claro antes de escribir una línea, para no salir creyendo que un directorio temporal es un sandbox de verdad.
Dicho eso, hay un mínimo que sí vale la pena y cuesta poco: que el código del modelo no corra con tu mismo acceso. Dos medidas bastan para cerrar los agujeros más obvios. Un directorio temporal propio, para que el código no tenga el resto del proyecto a mano para tocarlo. Y un entorno limpio, para que no vea tus variables de entorno, donde probablemente están tus claves de API. En código son dos opciones al lanzar el proceso —cwd y env—, y las muestro ya integradas en el runner de la siguiente sección para no repetirlas.
La regla con la que me quedé: el nivel de aislamiento tiene que subir con la desconfianza hacia quien produjo el código y el prompt. Para un agente que escribe funciones contra tus propios tests, un proceso aparte con entorno limpio es proporcionado. Para una plataforma donde cualquiera manda un prompt que termina en código ejecutándose en tu infraestructura, eso no basta y necesitas aislamiento a nivel de sistema operativo —pero eso ya es construir un sandbox de producción, un problema distinto del que resuelve el loop—. Empezar por el subproceso está bien; quedarse ahí cuando el código deja de ser de fiar, no.
El runner completo: clasificar, no solo ejecutar
Aquí está el pago de todo lo anterior, y no es la robustez. Es que el loop write-test-fix avanza realimentando el resultado al modelo, y un modelo corrige mucho mejor cuando ese resultado le dice qué tipo de problema tiene. Por eso la pieza que de verdad importa no es ninguna de las defensas por separado, sino juntarlas en un runner que devuelve un estado —no un booleano— y que guarda stdout y stderr por separado:
// sandbox.ts — extrae, valida, ejecuta aislado con timeout y clasifica el desenlace.
import { execSync } from "node:child_process";
import { writeFileSync, copyFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractCode } from "./extract-code.js";
import { syntaxError } from "./check-syntax.js";
type Result = {
status: "pass" | "syntax_error" | "timeout" | "test_failed";
stdout: string; // la observación: lo que el programa produjo
stderr: string; // el error: dónde fallaron los tests o reventó el intérprete
};
const TIMEOUT_MS = 5000;
export function runInSandbox(reply: string): Result {
const code = extractCode(reply); // fase 1: sacar el código de la prosa
const synErr = syntaxError(code); // fase 2: ¿parsea?
if (synErr) return { status: "syntax_error", stdout: "", stderr: synErr };
const dir = mkdtempSync(join(tmpdir(), "run-")); // fase 4: directorio propio
writeFileSync(join(dir, "analyze.ts"), code);
copyFileSync("analyze.test.ts", join(dir, "analyze.test.ts"));
try {
const stdout = execSync("node analyze.test.ts", {
cwd: dir,
env: { PATH: process.env.PATH }, // fase 4: entorno limpio, sin secretos
encoding: "utf8",
timeout: TIMEOUT_MS, // fase 3: cortar bucles infinitos
stdio: ["ignore", "pipe", "pipe"], // stdout y stderr por separado
});
return { status: "pass", stdout, stderr: "" };
} catch (err: any) {
if (err.killed || err.signal === "SIGTERM") {
return { status: "timeout", stdout: err.stdout ?? "", stderr: "" };
}
return { status: "test_failed", stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
}
}# sandbox.py — extrae, valida, ejecuta aislado con timeout y clasifica el desenlace.
import os
import shutil
import subprocess
import tempfile
from extract_code import extract_code
from check_syntax import syntax_error
TIMEOUT_S = 5
def run_in_sandbox(reply: str) -> dict:
code = extract_code(reply) # fase 1: sacar el código de la prosa
syn_err = syntax_error(code) # fase 2: ¿parsea?
if syn_err:
return {"status": "syntax_error", "stdout": "", "stderr": syn_err}
work = tempfile.mkdtemp(prefix="run-") # fase 4: directorio propio
with open(os.path.join(work, "analyze.py"), "w") as f:
f.write(code)
shutil.copy("analyze_test.py", os.path.join(work, "analyze_test.py"))
try:
proc = subprocess.run(
["python", "analyze_test.py"],
cwd=work,
env={"PATH": os.environ["PATH"]}, # fase 4: entorno limpio, sin secretos
capture_output=True, text=True, # stdout y stderr por separado
timeout=TIMEOUT_S, # fase 3: cortar bucles infinitos
)
status = "pass" if proc.returncode == 0 else "test_failed"
return {"status": status, "stdout": proc.stdout, "stderr": proc.stderr}
except subprocess.TimeoutExpired as err:
return {"status": "timeout", "stdout": err.stdout or "", "stderr": ""}<?php
// sandbox.php — extrae, valida, ejecuta aislado con timeout y clasifica el desenlace.
require "extract_code.php";
require "check_syntax.php";
const TIMEOUT_S = 5;
function run_in_sandbox(string $reply): array {
$code = extract_code($reply); // fase 1: sacar el código de la prosa
$synErr = syntax_error($code); // fase 2: ¿parsea?
if ($synErr !== null) {
return ["status" => "syntax_error", "stdout" => "", "stderr" => $synErr];
}
$work = sys_get_temp_dir() . "/run-" . bin2hex(random_bytes(4)); // fase 4: directorio propio
mkdir($work);
file_put_contents("$work/analyze.php", $code);
copy("analyze_test.php", "$work/analyze_test.php");
// stdout (1) y stderr (2) en tuberías separadas; cwd y entorno limpio (fase 4).
$spec = [1 => ["pipe", "w"], 2 => ["pipe", "w"]];
$proc = proc_open(
"timeout " . TIMEOUT_S . " php analyze_test.php", // fase 3: cortar bucles infinitos
$spec, $pipes, $work, ["PATH" => getenv("PATH")]
);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exit = proc_close($proc);
if ($exit === 124) return ["status" => "timeout", "stdout" => $stdout, "stderr" => ""];
$status = $exit === 0 ? "pass" : "test_failed";
return ["status" => $status, "stdout" => $stdout, "stderr" => $stderr];
}Fíjate en que el runner guarda stdout y stderr por separado, no concatenados. No es cosmético: en un agente de código son dos señales distintas. stdout es lo que el programa produjo —la observación, lo que de verdad pasó al ejecutar—; stderr es el canal de error —dónde el runner de tests escribió los FAIL, dónde el intérprete dejó el rastro de un crash—. Mantenerlos separados te deja decidir qué le mandas al modelo según el desenlace, en lugar de pasarle un volcado mezclado del que tiene que adivinar qué es qué.
Con eso, el sandbox ya no responde “¿pasó o no?”, sino que mapea cada desenlace a una acción del loop:
| Estado | Qué pasó | Qué vuelve al modelo |
|---|---|---|
syntax_error | El código ni parsea | El mensaje del parser, con línea |
timeout | No terminó a tiempo | ”Excedió el tope; revisa el bucle” |
test_failed | Compiló pero falló un test | stderr: qué esperaba y qué recibió |
pass | Todo en verde | Nada: el loop se detiene |
Esa tabla es una función. Cada estado se traduce en un mensaje distinto de vuelta al modelo, y stderr es justo lo que llevas como detalle del error:
// feedback.ts — convierte el resultado del sandbox en el mensaje que vuelve al modelo.
export function feedbackFor(result: Result): string {
switch (result.status) {
case "syntax_error":
return `Tu código no compila. El parser dijo:\n\n${result.stderr}\n\nDevuelve la función corregida, solo el código.`;
case "timeout":
return `Tu código no terminó a tiempo y se detuvo. Probablemente hay un bucle que no avanza. Revísalo.`;
case "test_failed":
return `Los tests fallaron:\n\n${result.stderr || result.stdout}\n\nCorrige la función.`;
case "pass":
return ""; // no hay nada que corregir
}
}# feedback.py — convierte el resultado del sandbox en el mensaje que vuelve al modelo.
def feedback_for(result: dict) -> str:
status = result["status"]
if status == "syntax_error":
return f"Tu código no compila. El parser dijo:\n\n{result['stderr']}\n\nDevuelve la función corregida, solo el código."
if status == "timeout":
return "Tu código no terminó a tiempo y se detuvo. Probablemente hay un bucle que no avanza. Revísalo."
if status == "test_failed":
return f"Los tests fallaron:\n\n{result['stderr'] or result['stdout']}\n\nCorrige la función."
return "" # pass: no hay nada que corregir<?php
// feedback.php — convierte el resultado del sandbox en el mensaje que vuelve al modelo.
function feedback_for(array $result): string {
return match ($result["status"]) {
"syntax_error" => "Tu código no compila. El parser dijo:\n\n{$result['stderr']}\n\nDevuelve la función corregida, solo el código.",
"timeout" => "Tu código no terminó a tiempo y se detuvo. Probablemente hay un bucle que no avanza. Revísalo.",
"test_failed" => "Los tests fallaron:\n\n" . ($result["stderr"] ?: $result["stdout"]) . "\n\nCorrige la función.",
"pass" => "", // no hay nada que corregir
};
}El día que separé “no compila” de “falló un test”, y
stdoutdestderr, el loop empezó a converger en menos vueltas. Con un único volcado de error mezclado, el modelo trataba un fence de markdown sobrante como si fuera un bug de lógica y reescribía la función entera. Clasificar el fallo no cambió el modelo: cambió lo que el modelo veía en cada vuelta.
Esta es la idea que sostiene el post. El sandbox no es el músculo que ejecuta, es el filtro que convierte una salida de texto cualquiera en una observación que el loop puede creer. Las cuatro fases —extraer, validar, ejecutar acotado, aislar— existen para una sola cosa: que lo que vuelve al modelo sea una señal limpia y clasificada, no un volcado ambiguo.
Preguntas frecuentes
¿Un proceso aparte con timeout ya es un sandbox seguro?
No. Aísla los fallos —un crash o un bucle infinito no se llevan por delante tu loop— y, con un entorno limpio, evita que el código vea tus secretos. Pero comparte el kernel, la red y el sistema de archivos de la máquina, así que no es una frontera frente a código malicioso. Para código no confiable de verdad necesitas aislamiento a nivel de sistema operativo, y eso ya es construir un sandbox de producción: un problema distinto del que resuelve el loop.
¿El timeout me protege de cualquier código que se desboca?
Solo de una clase: el que tarda demasiado. No del que consume demasiado. Un bucle que reserva memoria sin parar revienta por OOM antes de agotar el reloj; un fork bomb o miles de sockets abiertos agotan la máquina sin pasarse de tiempo. Defenderte de eso son límites de recursos —memoria, CPU, procesos— que viven en la capa de aislamiento del sistema operativo, no en el runner. El timeout cubre el caso más común, no todos.
¿Por qué separar stdout de stderr en vez de juntarlos?
Porque son dos señales distintas. stdout es lo que el programa produjo: la observación de qué pasó al ejecutar. stderr es el canal de error: dónde el runner de tests escribió los fallos o el intérprete dejó el rastro de un crash. Si los concatenas, el modelo recibe un volcado del que tiene que adivinar qué parte es la observación y qué parte es el error. Separados, le mandas exactamente lo que toca según el desenlace.
¿Cómo distingo un error de sintaxis de un test que falla?
Comprobando que el código parsea antes de ejecutarlo, con el parser del propio lenguaje: node --check, compile() en Python, php -l en PHP. Si el chequeo falla, es un error de sintaxis y devuelves ese estado. Si pasa, ejecutas los tests, y un código de salida distinto de cero ya es un test fallido de verdad. La clave es separarlo en dos pasos: primero “¿compila?”, después “¿pasa?”.
¿Por qué el modelo devuelve fences si le pido en el prompt que no lo haga?
Porque está fuertemente sesgado por entrenamiento a presentar el código como un bloque de markdown, y una instrucción en el system prompt no siempre gana a ese sesgo. Puedes reducir la frecuencia con structured outputs o un prompt más insistente, pero no la eliminas. Sale más barato asumir que a veces vendrá envuelto y extraer el código siempre, que confiar en que el modelo obedezca el 100% de las veces.
Conclusión
El sandbox es el paso ejecutar del agent loop, pero su trabajo no es ejecutar: es convertir el texto del modelo en una observación fiable. En el ejemplo de juguete cabía en tres líneas porque el código se portaba bien; con código real hay que pasarlo por cuatro fases —extraer de la prosa, comprobar que parsea, ejecutar con un timeout y aislar el proceso— y, sobre todo, clasificar el desenlace para que cada fallo vuelva al modelo como un mensaje distinto.
Dos límites que conviene no olvidar: el timeout corta el código que tarda, no el que agota memoria o procesos; y un subproceso con entorno limpio aísla un crash, no a un atacante. Si necesitas correr código realmente no confiable, eso es aislamiento a nivel de sistema operativo, y es otro problema —de hecho, otra serie—.
El sandbox no hace al agente más inteligente; hace que el loop sobreviva al mundo real. Esa es la única razón por la que merece tanto cuidado: sin él, el ciclo de decidir, actuar y observar del primer post se rompe en la primera respuesta que no venga limpia. Si vas a construirlo, empieza por extraer el código y separar el error de sintaxis del test fallido —es lo que más rápido hace converger el loop—, añade el timeout antes de que un bucle te cuelgue el proceso, y sube el aislamiento en cuanto el código deje de ser tuyo.