PandaStack

Secure code execution for AutoGen agents

Give an AutoGen or AG2 agent an autogen code execution sandbox in Python — a custom tool that runs the model's code inside an isolated Firecracker microVM.

An AutoGen agent that proposes Python to answer a question has to run it somewhere. Running it on your host is a liability — a hallucinated subprocess.run or open("/etc/...") is now your problem. This recipe gives the agent a code-execution function backed by a PandaStack code interpreter microVM: the model proposes code, an executor runs it inside a netns-isolated Firecracker VM, and the result — including charts as objects, not stdout — flows back into the conversation.

Which "autogen" do you mean?

The autogen name now points at three different codebases. Pick one and never mix imports — they have different import roots and incompatible APIs.

ProjectPyPI installImport rootNotes
AG2 (the AutoGen fork)pip install "ag2[openai]"autogenActive line (v0.13.4, Jun 2026). ConversableAgent + register_function. This recipe's primary path.
autogen-agentchatpip install "autogen-agentchat" "autogen-ext[openai]"autogen_agentchatMicrosoft's async actor-model rewrite (v0.7.5, Sep 2025). AssistantAgent(tools=[...]). Shown as an alternative below.
Microsoft Agent Frameworkpip install agent-frameworkagent_frameworkMicrosoft's stated successor to autogen-agentchat. Different API again — out of scope here.

Two footguns to avoid:

  • pip install autogen resolves to AG2's alias, not Microsoft's autogen-agentchat. If you install autogen and then import autogen_agentchat, the import fails — they are different packages.
  • pip install pyautogen is the legacy 0.2 dist name. Don't use it for new code.

We use AG2 as the primary path: it is the actively-developed line, it ships first-class code-execution tooling, and its single-package install is simplest for a sandbox recipe.

Install

The PandaStack SDK reads PANDASTACK_API_KEY from the environment — never hardcode it in your tool.

export PANDASTACK_API_KEY=pds_...           # your PandaStack key
export OPENAI_API_KEY=sk-...                # AG2's model client
pip install pandastack "ag2[openai]"

The caller / executor split

AG2's code-execution model is built on two agents: a caller (the LLM agent that decides to invoke the tool) and an executor (the agent that actually runs the function body). This maps cleanly onto PandaStack — make the executor's function call into a sandbox:

RoleAgentWhat it does
callerConversableAgent (with LLMConfig)Proposes the tool call, reads the result, decides next step
executorConversableAgent (human_input_mode="NEVER")Runs run_python against the PandaStack sandbox

The function body is plain Python — its type hints and description become the tool schema the model sees.

The sandbox tool

Create a long-lived code context once — a Jupyter-style kernel inside the microVM — and reuse it across every tool call so imports and variables persist across the conversation.

import os

from autogen import ConversableAgent, LLMConfig, register_function
from pandastack import Sandbox, CodeContext

# One persistent kernel inside a Firecracker microVM, shared across tool calls.
_ctx: CodeContext | None = None


def _kernel() -> CodeContext:
    global _ctx
    if _ctx is None:
        sandbox = Sandbox.create(
            template="code-interpreter",
            ttl_seconds=3600,
            metadata={"tool": "autogen-code-exec"},
        )
        _ctx = sandbox.create_code_context(language="python")
    return _ctx


def run_python(code: str) -> str:
    """Run Python code in a secure, isolated sandbox and return its output.

    State persists across calls: variables and imports from earlier
    executions are still available. Use this to compute, analyze data,
    or verify a result by running code.
    """
    ex = _kernel().run_code(code)

    # Hand the model everything it needs to self-correct.
    if ex.error:
        return f"ERROR:\n{ex.error}\n\nstdout:\n{ex.stdout}".strip()

    parts = []
    if ex.stdout:
        parts.append(ex.stdout.rstrip())
    if ex.stderr:
        parts.append(f"[stderr]\n{ex.stderr.rstrip()}")
    if ex.text and ex.text not in (ex.stdout or ""):
        parts.append(f"[result] {ex.text}")
    if ex.png:
        # A chart came back as a base64 PNG object — not stdout to parse.
        parts.append("[chart] PNG produced (base64 available on ex.png)")

    return "\n\n".join(parts) or "(no output)"

The tool returns a string — that string is exactly what the caller agent sees on its next turn. Returning stderr and the full traceback (not just stdout) is the move that makes self-correction work: the model reads its own failure and fixes the code on the next call.

Why a code context, not exec

create_code_context() is a persistent kernel, not a one-shot. Imports and variables survive across run_code calls, so the agent builds up state over a multi-turn conversation instead of re-importing pandas every time. And on the code-interpreter template — which bakes IPython, matplotlib, plotly, kaleido, and pandas — rich outputs are captured automatically: a plt.show() or a bare DataFrame as the last expression comes back as ex.png / ex.results[0].html objects. No savefig, no save-to-disk, no second download.

Execution fieldWhat the agent gets
stdout / stderrcaptured streams (feed both back so it can self-correct)
errorformatted traceback if the cell raised
textconvenience: the last text repr (a bare expression result)
pngconvenience: first chart as a base64 PNG object
resultsfull list of rich outputs (.html, .json, .svg, .markdown, …)

Wire it into AG2

Register run_python with the two-agent split, then start the chat. The executor runs the code; the caller reads the result back.

llm_config = LLMConfig(api_type="openai", model="gpt-4o")

with llm_config:
    analyst = ConversableAgent(
        name="analyst",
        system_message=(
            "You answer quantitative questions by writing and running Python "
            "with the run_python tool. Never guess at numbers — compute them. "
            "Read any error traceback the tool returns and fix your code before "
            "answering. Reply with TERMINATE when the question is fully answered."
        ),
    )

# The executor actually runs the tool; it never calls the model.
code_runner = ConversableAgent(name="code_runner", human_input_mode="NEVER")

register_function(
    run_python,
    caller=analyst,        # the LLM agent that may CALL the tool
    executor=code_runner,  # the agent that actually RUNS it
    name="run_python",
    description="Run Python code in a secure PandaStack sandbox and return its output.",
)

try:
    code_runner.initiate_chat(
        analyst,
        message=(
            "Generate 1,000 samples from a normal distribution (mean 50, std 8) "
            "with numpy. Report the sample mean and the 95th percentile, then "
            "plot a histogram of the samples with matplotlib (call plt.show())."
        ),
        max_turns=8,
    )
finally:
    if _ctx is not None:
        _ctx.close()   # tear down the kernel + sandbox

The analyst proposes a numpy snippet, code_runner executes it in the sandbox and feeds the printed values back, and — because the task asks for a plot — the final call produces a chart that comes back as ex.png. The tool reports [chart] PNG produced, and the base64 image rides on the Execution, ready to render in your UI. If the agent writes broken code, the ERROR: prefix and traceback go straight back into its context and it retries — code execution and self-correction, on an isolated Firecracker VM.

Decorator style

If you prefer decorators to register_function, the same wiring is two stacked decorators on the function. The inner register_for_llm description and the function's signature become the tool schema; register_for_execution binds it to the executor:

@code_runner.register_for_execution()
@analyst.register_for_llm(description="Run Python in a secure PandaStack sandbox.")
def run_python(code: str) -> str:
    ex = _kernel().run_code(code)
    if ex.error:
        return f"ERROR:\n{ex.error}"
    return ex.stdout or ex.text or "(no output)"

AG2 also ships a built-in PythonCodeExecutionTool under autogen.tools.experimental. It runs code in a local WorkingDirectory / SystemPythonEnvironment on your host — fine for trusted code, but it does not give you VM isolation. The custom tool above is the path when the code is model-generated and you want it contained.

Alternative: autogen-agentchat

If you are on Microsoft's autogen-agentchat line instead, the wiring is different but the PandaStack tool body is identical. There is no caller/executor split and no register_function — tools are plain functions passed to AssistantAgent(tools=[...]), and the whole API is async. Forgetting asyncio.run / await (and await model_client.close()) is the most common error.

import asyncio

from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
# reuse the same _kernel() helper and run_python body from above


async def main():
    model_client = OpenAIChatCompletionClient(model="gpt-4o")
    agent = AssistantAgent(
        name="analyst",
        model_client=model_client,
        tools=[run_python],   # type hints + docstring become the tool schema
        system_message="Use the run_python tool to execute code in the sandbox.",
    )

    result = await agent.run(task="Compute the mean of [3, 1, 4, 1, 5, 9, 2, 6].")
    print(result)

    await model_client.close()
    if _ctx is not None:
        _ctx.close()


asyncio.run(main())

Do not mix the two paths' symbols: ConversableAgent / LLMConfig / register_function are AG2-only, and AssistantAgent(model_client=..., tools=[...]) / OpenAIChatCompletionClient are autogen-agentchat-only.

Notes

  • Isolation. Each sandbox is its own Firecracker microVM with netns-isolated egress — a model that writes rm -rf / or tries to read host files is contained to the VM, not your machine.
  • One kernel per conversation. The tool shares a single CodeContext so state persists across turns. For concurrent agents, create one kernel (one Sandbox) per conversation to avoid sharing state.
  • Always tear down. Close the context in a finally so the kernel and sandbox don't linger. Idle kernels are reaped on TTL regardless, but explicit cleanup is cheaper.
  • TypeScript. AutoGen and AG2 are Python-only. The underlying PandaStack call maps one-to-one if you build the same tool in a TS agent framework: const ex = await ctx.runCode(code) returns { stdout, stderr, error?, text?, png? }.

Next steps

On this page