Skip to content

Generate and Test Code With Pydantic AI

View as Markdown

Pydantic AI’s native Daytona integration gives an agent an isolated workspace for generated code. The harness supplies file and shell tools separately, so the workspace determines where those tools operate. Files remain available after a run ends, and a later run can reattach to the same sandbox.

This example builds an agent that implements a Python moving-average function, writes pytest tests, and executes them in Daytona. The agent corrects failures before returning a validated test report. A follow-up run adds a test in the same workspace, and the application explicitly deletes the sandbox when finished.


The task is a moving average: the mean of every window consecutive values, a common way to smooth noisy data such as sensor readings. For example, moving_average([1, 2, 3, 4], 2) returns [1.5, 2.5, 3.5].

The first run writes moving_average(values, window) in stats.py and tests in test_stats.py. It checks normal windows, empty input, oversized windows, invalid windows, and fractional values. The agent runs pytest, installs it if missing, and fixes failures until the suite passes.

The second run starts a new conversation in the same sandbox by passing its workspace ref. It reads the existing files, adds a test for negative values, and runs the full suite. A finally block deletes the sandbox after either success or failure.

Clone the repository and navigate to the example directory:

Terminal window
git clone https://github.com/daytona/guides.git
cd guides/python/pydantic-ai/coding-agent

Create a virtual environment, then install the dependencies:

Terminal window
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -U "pydantic-ai-harness[daytona,anthropic]" python-dotenv

The daytona extra installs the Daytona SDK. The anthropic extra installs the matching pydantic-ai-slim[anthropic] provider dependencies. python-dotenv loads your local .env file.

Get a Daytona API key and an Anthropic API key. Copy the environment template:

Terminal window
cp .env.example .env

Set the keys in .env:

Terminal window
DAYTONA_API_KEY=your-daytona-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
# Optional:
# MODEL=anthropic:claude-sonnet-5

The default model requires access to claude-sonnet-5. To use another provider, install its Pydantic AI provider extra and set its API key and MODEL.

Your API keys stay on your machine: the agent calls the model from your application, and only the generated code runs in Daytona. Nothing from your machine’s environment, including the keys in .env, reaches the sandbox.

The example combines these components:

ComponentRole
Native Daytona integrationSupplies the persistent sandbox workspace, not tools
Coder capabilitySupplies file and shell tools that use the workspace
Workspace refIdentifies the sandbox for a later run or deletion
Test reportRecords changed files and final pytest results as validated fields
ApplicationOwns sandbox cleanup, including after a failed run

The agent sends prompts to the model, dispatches tool calls, and returns the model’s response with the run’s messages and workspace. Async runs use await agent.run(...). The Daytona SDK requires an asyncio event loop.

Import the native integration from pydantic_ai_harness.daytona_sandbox. DaytonaSandbox supplies the workspace and adds no tools. Its sandbox keeps running after the run ends; Pydantic AI never stops or deletes it.

A workspace ref identifies the sandbox without holding credentials. A later run can reattach with the ref, or recover it from message history. Reattaching starts a stopped sandbox again; a deleted sandbox raises WorkspaceUnavailableError rather than creating an empty replacement.

Coder supplies shell, read_file, write_file, edit_file, list_files, and grep. This example disables sub-agent delegation because the task is small. Files and commands use the Daytona workspace rather than your local filesystem.

Start with the imports and the sandbox working directory:

import asyncio
import os
from contextlib import aclosing
from textwrap import shorten
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from pydantic_ai import Agent
from pydantic_ai.messages import ToolCallPart, ToolReturnPart
from pydantic_ai.run import AgentRunResult
from pydantic_ai_harness.coder import Coder
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox, DaytonaSandboxBackend
WORKING_DIR = "/home/daytona/project"

Define the output model that each run returns:

class TestReport(BaseModel):
"""Files changed and final pytest results from one run."""
files: list[str] = Field(description="Files created or changed in this run")
tests_passed: int = Field(description="Number of tests passed in the final pytest run")
tests_failed: int = Field(description="Number of tests failed in the final pytest run")
pytest_summary: str = Field(description="Final line of the pytest output, e.g. 11 passed in 0.03s")

Passed to the agent as output_type=TestReport, the class becomes a JSON schema that the model fills through an output tool. Pydantic validates the final answer, so result.output is a TestReport. If validation fails, Pydantic AI sends the validation errors back to the model to retry. By default it retries once; pass retries={"output": N} to Agent to change that. Running out of retries raises UnexpectedModelBehavior.

Validation checks the report’s structure, not the truth of its test results. Compare the reported counts and summary with the shell trace. The application uses the validated failure counts to decide whether to continue.

Inside main(), load the environment and compose the tools with the workspace capability:

load_dotenv()
model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
sandbox = DaytonaSandbox(working_dir=WORKING_DIR)
agent = Agent(model, output_type=TestReport, capabilities=[Coder(sub_agents=False), sandbox])

The native backend creates the project directory on a new sandbox. The example leaves snapshot selection at Daytona’s default. Dependencies installed on your machine are not installed in that sandbox.

The prompt defines the function’s behavior and requires execution before the final response:

TASK = """Implement moving_average(values, window) in stats.py with type hints.
Return the arithmetic mean of every consecutive full window as a list of floats.
For a valid window, an empty list or window > len(values) returns [].
window < 1 raises ValueError, including when values is empty.
Write test_stats.py using pytest: cover normal windows, window=1, window=len(values),
empty input, oversized windows, zero and negative windows, and fractional values.
Run python -m pytest -q. If pytest is missing, install it in the sandbox.
Fix any failures and re-run until green.
Report the files created or changed in this run and the final pytest results."""
FOLLOWUP = """The existing stats.py and test_stats.py are in this workspace.
Read them. Add one more pytest test for moving_average([-4, -2, 0, 2], 2),
expecting [-3.0, -1.0, 1.0]. Do not replace the existing tests.
Run the full suite with python -m pytest -q, fix failures, and re-run until green.
Report the files created or changed in this run and the final pytest results."""

The invalid-window rule takes precedence over the empty-input rule. With a positive window, an empty list returns []; with a window below 1, it raises ValueError even when the list is empty.

First, define a trace helper that prints sandbox tool calls and results from each run. It excludes the default final_result output tool so the structured report appears only once, separately:

def print_trace(result: AgentRunResult[TestReport]) -> None:
"""Print bounded tool arguments and results from this run only."""
for message in result.new_messages():
for part in message.parts:
if isinstance(part, (ToolCallPart, ToolReturnPart)) and part.tool_name == "final_result":
continue
if isinstance(part, ToolCallPart):
arguments = shorten(part.args_as_json_str(), width=180)
print(f" -> {part.tool_name} {arguments}")
elif isinstance(part, ToolReturnPart):
output = shorten(str(part.content), width=500)
print(f" <- {part.tool_name} {output}")

Keep a caller-owned backend reachable until cleanup. It creates the sandbox on first use during the run. A failed model run returns no result, but the backend still holds the ref for any sandbox it created:

async def main() -> None:
"""Run the agent twice in one Daytona sandbox, then delete the sandbox."""
load_dotenv()
model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
sandbox = DaytonaSandbox(working_dir=WORKING_DIR)
agent = Agent(model, output_type=TestReport, capabilities=[Coder(sub_agents=False), sandbox])
# Keep the backend available for cleanup even if the first run fails.
async with aclosing(DaytonaSandboxBackend(working_dir=WORKING_DIR)) as backend:
try:
first = await agent.run(TASK, workspace=backend)
ref = first.workspace.ref
assert ref is not None
print(f"Sandbox: {ref.id}", flush=True)
print("\nRun 1 tool trace:")
print_trace(first)
print(first.output.model_dump_json(indent=2))
if first.output.tests_failed:
raise RuntimeError(f"Run 1 reported {first.output.tests_failed} failed tests")
print(f"\nReattaching: {ref.id}", flush=True)
second = await agent.run(FOLLOWUP, workspace=ref)
print("\nRun 2 tool trace:")
print_trace(second)
print(second.output.model_dump_json(indent=2))
if second.output.tests_failed:
raise RuntimeError(f"Run 2 reported {second.output.tests_failed} failed tests")
print(f"Run 2 workspace: {second.workspace.ref}")
finally:
ref = backend.ref
if ref is not None:
await sandbox.destroy(ref)
print(f"Deleted sandbox: {ref.id}", flush=True)
if __name__ == "__main__":
asyncio.run(main())

The initial backend’s settings apply to the first run. On reattachment, the capability supplies a new backend with the same working directory. Capability settings do not override a caller-supplied backend.

The follow-up uses workspace=ref without message history: it reuses files, not conversation context. To preserve both, pass message_history=first.all_messages() instead; the native integration finds the workspace ref in those messages. Without either a ref or history, a run starts a new sandbox.

Each run prints its report as JSON, and the script checks its failure count. If run 1 reports failed tests, raise RuntimeError(...) ends main() before the follow-up run; if run 2 does, the script exits with the error after it. Either way, the finally block still deletes the sandbox.

Native Daytona sandboxes persist after runs and keep their disk until deleted. Closing a backend’s client does not delete its sandbox. The example therefore deletes the sandbox explicitly in finally, then releases the caller-owned client’s resources through aclosing.

Download any files you want to keep before cleanup.

The example deletes its sandbox before it exits. If your application keeps a sandbox instead, for example to continue work in a later session, store its ID (the script prints it after the first run). A later process can rebuild the ref from that ID, pass it as workspace=ref to reattach, or delete the sandbox:

from pydantic_ai.workspaces import WorkspaceRef
from pydantic_ai_harness.daytona_sandbox import DaytonaSandbox
ref = WorkspaceRef(provider="daytona", id="YOUR_SANDBOX_ID")
await DaytonaSandbox().destroy(ref)

Deleting by ref does not start a stopped sandbox first. The ref holds no credentials, so the process that deletes or reattaches still needs DAYTONA_API_KEY.

Run the complete script from the example directory:

Terminal window
python main.py

These excerpts come from a real Daytona sandbox and anthropic:claude-sonnet-5. Long file payloads and some tool results are omitted. Model responses and test counts can vary.

Sandbox: fee5575f-8885-4ca8-a32c-02e7724bc894
Run 1 tool trace:
-> list_files {"path":"."}
<- list_files No files found.
... write_file calls created stats.py and test_stats.py ...
-> shell {"command":"cd /home/daytona/project && python -m pytest -q 2>&1 | tail -30"}
<- shell /usr/local/bin/python: No module named pytest ...
-> shell {"command":"cd /home/daytona/project && pip install pytest -q 2>&1 | tail -20"}
... installation output omitted ...
-> shell {"command":"cd /home/daytona/project && python -m pytest -q 2>&1 | tail -30"}
<- shell .......... [100%] 10 passed in 0.03s ...
... workspace listing and cache cleanup omitted ...
{
"files": [
"stats.py",
"test_stats.py"
],
"tests_passed": 10,
"tests_failed": 0,
"pytest_summary": "10 passed in 0.03s"
}
Reattaching: fee5575f-8885-4ca8-a32c-02e7724bc894
Run 2 tool trace:
-> read_file {"path":"stats.py"}
-> read_file {"path":"test_stats.py"}
... edit_file added test_negative_values ...
-> shell {"command":"cd /home/daytona/project && python -m pytest -q"}
<- shell ........... [100%] 11 passed in 0.02s ...
{
"files": [
"test_stats.py"
],
"tests_passed": 11,
"tests_failed": 0,
"pytest_summary": "11 passed in 0.02s"
}
Run 2 workspace: WorkspaceRef(provider='daytona', id='fee5575f-8885-4ca8-a32c-02e7724bc894')
Deleted sandbox: fee5575f-8885-4ca8-a32c-02e7724bc894

Understanding the Agent’s Execution Flow

Section titled “Understanding the Agent’s Execution Flow”
  1. Inspect the workspace: The first run found no project files.
  2. Write the implementation and tests: The agent created stats.py and ten tests in test_stats.py.
  3. Correct a missing dependency: The first pytest invocation reported No module named pytest. The agent installed pytest inside Daytona and reran the suite: 10 passed in 0.03s. No implementation fix was needed in this run.
  4. Reattach by ref: The second run used the same sandbox ID, read both files, and added test_negative_values without replacing the original tests.
  5. Run the complete suite: The second run returned 11 passed in 0.02s. Both typed reports matched the final pytest results.
  6. Delete the sandbox: The application’s finally block deleted it.

Daytona also maintains pydantic-ai-daytona (source), a Pydantic AI capability that works without the harness. The two approaches differ as follows:

FeatureNative integrationpydantic-ai-daytona
ToolsNone of its own; Coder and other harness capabilities supply themIts own run_command, read_file, write_file, and list_directory
Dependenciespydantic-ai-harness; Python 3.11+pydantic-ai-slim and the Daytona SDK; Python 3.10+
Sandbox lifetimePersists after a run until you delete itCreated per run and deleted when the run ends (default)
Reuse across runsReattach by ref or message historyShare one DaytonaSandboxSession, or attach with sandbox_id=
Sandbox creation optionssnapshot, allow_internet_access, auto_stop_intervalAlso a container image, network allow lists by CIDR or domain, ephemeral, auto_delete_interval, labels, and os_user

Use the native integration to run harness capabilities such as Coder in Daytona and to keep workspaces between runs. Use the package for a self-contained capability that cleans up after each run and offers more control over how the sandbox is created.

Both packages export a class named DaytonaSandbox. The import path decides which one you get. Use one or the other, not both.

Install it in the same virtual environment as the harness; the Anthropic provider is already installed above:

Terminal window
pip install -U "pydantic-ai-daytona>=0.2.1"

With the same API keys loaded, inside an async function:

import os
from pydantic_ai import Agent
from pydantic_ai_daytona import DaytonaSandbox
model = os.getenv("MODEL", "anthropic:claude-sonnet-5")
agent = Agent(model, capabilities=[DaytonaSandbox()])
result = await agent.run("Run python -c 'print(2 + 2)' in the sandbox and report the result.")
print(result.output)

The agent runs the command with the package’s run_command tool and replies with something like:

The command `python -c 'print(2 + 2)'` output: **4**

DaytonaSandboxSession owns one sandbox for as long as its async with block runs. Pass it to the capability to share that sandbox across runs; the capability uses the session but never closes it. With model from the previous snippet:

from pydantic_ai import Agent
from pydantic_ai_daytona import DaytonaSandbox, DaytonaSandboxSession
async with DaytonaSandboxSession() as session:
agent = Agent(model, capabilities=[DaytonaSandbox(session=session)])
await agent.run("Create hello.py that prints 'hello from Daytona'.")
result = await agent.run("Run hello.py and report its output.")
print(result.output)

The second run finds the hello.py that the first run created and reports hello from Daytona. The sandbox is deleted when the block exits. A sandbox you attach with sandbox_id= instead is never deleted by the package.

See the complete runnable example for main.py, the environment template, bounded tool traces, and cleanup that retains access to the sandbox ref after a failed run.


Key advantages of this approach:

  • Isolated execution: Generated code and commands run in a Daytona sandbox, never on the machine running the agent, and your API keys stay on that machine
  • Persistent workspaces: A later run reattaches by ref and continues with the same files instead of starting over
  • Typed results: Each run returns a validated TestReport that your code can act on, instead of free text to parse
  • Self-verifying agent: The agent runs pytest in the sandbox and fixes failures, including missing dependencies, before it reports
  • Any harness capability: DaytonaSandbox supplies the workspace, so Coder and other harness capabilities run their tools in Daytona
  • Explicit cleanup: The application decides when the sandbox is deleted, including after a failed run