Metadata-Version: 2.5
Name: pydantic-ai-exedev
Version: 0.1.2
Summary: Run Pydantic AI agent workspaces on exe.dev VMs
Project-URL: Documentation, https://github.com/daikeren/pydantic-ai-exedev#readme
Project-URL: Repository, https://github.com/daikeren/pydantic-ai-exedev
Project-URL: Issues, https://github.com/daikeren/pydantic-ai-exedev/issues
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: exedev<0.5,>=0.4.0
Requires-Dist: pydantic-ai-slim>=2.68.0
Description-Content-Type: text/markdown

# pydantic-ai-exedev

Run a [Pydantic AI](https://pydantic.dev/docs/ai/) agent's commands and file operations on an
[exe.dev](https://exe.dev) VM. `ExeDevSandbox` supplies the agent's
[workspace](https://pydantic.dev/docs/ai/core-concepts/workspace/); compose it with
[`FileSystem`](https://pydantic.dev/docs/ai/harness/filesystem/),
[`Shell`](https://pydantic.dev/docs/ai/harness/shell/), or other capabilities that use the workspace.
Your application can also provision and manage VMs outside an agent run.

This is a community package, independent of Pydantic AI. While it is on 0.x releases, its API may change
between minor versions.

## Install

Requires Python 3.11 or later.

```bash
uv add pydantic-ai-exedev
```

Create an exe.dev API token from a computer whose SSH key is registered with your exe.dev account.
VM creation requires an active exe.dev plan. This token can manage VMs tagged `agents`, with permissions
for commands, creation, copying, deletion, pause, resume, and comments:

```bash
ssh exe.dev ssh-key generate-api-key --tag=agents --exp=30d \
  --cmds=new,ls,ssh,rm,cp,pause,resume,comment
```

Set the returned token in the environment where your Python application runs:

```bash
export EXE_TOKEN='exe1.…'
```

See [exe.dev token permissions](https://exe.dev/docs/https-api#granular-permissions) and
[scope tags](#scope-tags) for the difference between command permissions and VM access.

## Quick start

The examples use OpenAI with FileSystem and Shell. Install the harness and OpenAI model support, then
configure the model's API key:

```bash
uv add pydantic-ai-harness "pydantic-ai-slim[openai]"
export OPENAI_API_KEY='your-openai-api-key'
```

For a different [model provider](https://pydantic.dev/docs/ai/models/overview/), choose its extra and
credentials and update the model in `Agent(...)`. `pydantic-ai-exedev` itself does not require an
OpenAI API key.

```python
from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandbox, VMSpec
from pydantic_ai_harness import FileSystem, Shell

sandbox = ExeDevSandbox(spec=VMSpec(working_dir='app'), scope_tag='agents')
agent = Agent('openai:gpt-6.1-sol', capabilities=[sandbox, FileSystem(), Shell()])

result = agent.run_sync('Write a Python script that prints the first ten square numbers, then run it.')
print(result.output)
```

The first workspace operation creates a VM and its `app` directory. Commands and relative file paths
start there. With no `image`, exe.dev uses its default `exeuntu` image; a custom image must supply the
shell and programs your tools need.

The VM persists after the run. Keep its reference so you can use it again or [delete it](#clean-up).
A run that never uses the workspace does not create a VM, and its workspace ref remains `None`.

## Continue in the same VM

Pass the previous run's messages to continue the conversation with the same files and installed packages:

```python
followup = agent.run_sync(
    'Add a test for the script and run it.',
    message_history=result.all_messages(),
)
print(followup.output)
```

Pydantic AI stores the workspace reference in the messages and uses it to attach on the next run.
Without a reference or message history, a run creates a new VM when it first uses the workspace.
Pass `workspace='new'` to start fresh even when the history contains an existing reference.

### Reattach without the conversation

Save `result.workspace.ref` and pass it as `workspace=` when a new conversation should use the same VM:

```python
ref = result.workspace.ref
assert ref is not None

agent.run_sync('List the files in this project and explain what they do.', workspace=ref)
```

A `WorkspaceRef` contains the provider, `'exedev'`, and the VM name as its `id`. It identifies the VM;
it does not include previous prompts or tool results. A missing VM raises `VMNotFoundError`, a
`WorkspaceUnavailableError`, instead of silently creating a replacement.

## Prepare a VM before the run

Use `ExeDevVMs` when your application creates the VM and decides when to delete it. Pass a backend to
the run to use that VM directly:

```python
import asyncio

from pydantic_ai import Agent
from pydantic_ai_exedev import ExeDevSandboxBackend, ExeDevVMs, VMSpec
from pydantic_ai_harness import FileSystem, Shell


async def main() -> None:
    spec = VMSpec(working_dir='app')
    async with ExeDevVMs(scope_tag='agents') as vms:
        state = await vms.provision(spec)

    backend = ExeDevSandboxBackend(ref=state.ref, spec=spec, scope_tag='agents')
    try:
        agent = Agent('openai:gpt-6.1-sol', capabilities=[FileSystem(), Shell()])
        result = await agent.run('Write a README for an empty Python project.', workspace=backend)
        print(result.output)
        print(state.ref)
    finally:
        await backend.aclose()


asyncio.run(main())
```

`provision()` creates the VM, waits until it can run commands, and creates `working_dir`.
Closing the manager or backend closes its HTTP client; the VM remains available.

A backend constructed with `ref=` attaches on first use. It does not create a replacement or apply the
spec's provisioning settings. Its `working_dir` must already exist. Without `ref`, a backend provisions
from `spec` on first use, just like the capability.

## Clean up

Ending a run does not delete its VM. When you no longer need it, delete it explicitly. In async code:

```python
await sandbox.destroy(ref)
```

Or manage a VM by reference or name, independently of the agent:

```python
from pydantic_ai_exedev import ExeDevVMs

async with ExeDevVMs(scope_tag='agents') as vms:
    state = await vms.inspect(ref)
    await vms.pause(ref)
    await vms.resume(ref)
    deleted = await vms.destroy(ref)
```

`destroy()` deletes the VM and its disk. It returns `False` if the VM no longer exists.
`pause()` saves memory to disk and releases CPU and memory; `resume()` waits until commands can run.
`inspect()`, `lookup()`, `get_vm()`, `pause()`, and `destroy()` do not resume paused VMs.
A workspace command on a paused VM resumes it after exe.dev refuses the command before execution.

Use `await vms.get_vm(ref)` for the SDK's `exedev.AsyncVM` and operations this package does not wrap.
`sandbox.vms()` creates a manager with the capability's client and scope tag; close it with `async with`
or `aclose()`.

## Configuration

`VMSpec` configures a new VM or a copy of an existing VM:

```python
spec = VMSpec(
    image='ubuntu:24.04',
    cpu=4,
    memory='8G',
    disk='50G',
    working_dir='app',
    comment='nightly build',
)
```

| Setting | Behavior |
|---|---|
| `name` | Optional VM name. Omit it to let exe.dev choose. A fixed name must be available when provisioning. |
| `image` | Container image for a new VM. Omit it to use exe.dev's default image. |
| `base` | Name of an existing VM to copy instead of creating from an image. Cannot be combined with `image`. |
| `cpu`, `memory`, `disk` | Resources for a new VM or copy. Omitted values use exe.dev's defaults. |
| `egress` | `'all'` for internet access or `'integrations'` for exe.dev integrations only. A copy inherits its base's policy when omitted. |
| `standalone` | Reserved capacity, requiring a plan that supports it. Defaults to `False`. |
| `integrations` | Integration names to attach to a new VM. Cannot be used with `base`. |
| `tags` | Tags applied at creation, or after copying. Subject to the token's tag restrictions. |
| `comment` | Up to 200 bytes. Set at creation, or after copying. |
| `copy_base_tags` | Whether a copy inherits its base's tags. Defaults to inheritance; valid only with `base`. |
| `working_dir` | Directory created during provisioning. Relative paths start from the VM user's home. |

A spec validates local constraints without I/O. Unsupported combinations raise `ValueError` before a
request is sent. exe.dev still validates image availability, resources, integrations, and permissions.
A copy keeps its base's disk and image. Its tags and comment are applied after creation because the copy
API does not accept them; the token needs `tag` and `comment` respectively when those updates are needed.

### Scope tags

`scope_tag` is available on `ExeDevSandbox`, `ExeDevSandboxBackend`, and `ExeDevVMs`. The package adds it
to new VMs and checks it before accessing a named VM, including a copy's base. A VM without the tag
raises `VMOutOfScopeError`.

This check prevents accidental access through an incorrect reference. exe.dev enforces authorization
through the token. Use a token scoped to the same tag, as in the installation example.

[Tag-scoped tokens](https://exe.dev/deploy/python-sandboxes#get-a-token) cannot change VM tags, and
exe.dev rejects additional tags outside their scope. With the example token, omit `tags` or use only
`tags=['agents']`. Adding `tag` to the command permissions does not remove that restriction. Workflows
that assign other tags need credentials authorized to do so.

A copy must inherit the scope tag from its base. `copy_base_tags=False` with `scope_tag` is rejected.

### Environment and commands

Pass `env=` to the capability or backend for variables that every command needs. Each call's `env`,
including `Shell(env=...)`, overrides those values. The VM's login environment supplies the defaults;
host environment variables are not automatically forwarded.

Commands start in `working_dir` under the VM user's login shell. At the workspace API, pass an argv
list for literal arguments, or a string with `shell=True` for shell syntax. Shell tools handle this
for you. A working directory does not restrict commands to that directory.

File operations use Pydantic AI's shell fallback, transferring data in 64 KiB chunks. Large files take
multiple requests; for directory transfers, consider an archive through Shell.

### Background commands and limits

Shell's `start_command`, `check_command`, and `stop_command` tools, and its opt-in persistent `shell`
tool, store job state and output under `.pydantic-ai-harness/` in the working directory. With `setsid`
available, jobs detach into their own sessions and can survive runs and backend reattachments.
Custom images need the programs required by [Shell](https://pydantic.dev/docs/ai/harness/shell/),
including `setsid` for process-group isolation and `mv` and `base64` for job files.

A plain `server &` can keep a command waiting by inheriting its output streams. Redirect them, as in
`server > server.log 2>&1 &`, or use Shell's background tools.

On cancellation, timeout, or an output-limit error, the backend sends a separate request to kill the
command's process group before propagating the interruption. Closing the SDK's HTTP stream alone can
leave commands running. Jobs in detached sessions survive. The VM needs `setsid` so the SDK can isolate
each command; without an isolated group, the backend refuses to run user code with exit 126.

Stopping and control-file cleanup each have a 10-second bound and are shielded from caller cancellation.
If the VM is unreachable or paused, stopping is best effort: retain its ref to inspect or destroy it.
Each completed command uses an additional request to remove its control files. A cancellation marker
can remain under `/tmp` if a cancelled request never starts; it prevents that request from running later.

A command's combined stdout and stderr are limited to 8 MiB; exceeding it raises `WorkspaceOutputLimitError`.
Shell's `max_output_chars` separately limits the text returned to the model, retaining the tail.
Read a growing log with a shell command such as `tail` or `wc`, or through Shell's command tools.
Shell-derived `read_text()` and `read_bytes()` check file size across requests and can reject a file
that changes during the read.

## Provisioning and recovery

### Unknown creation outcome

A network failure, unreadable response, or server error can occur after exe.dev accepted a create or
copy request. `ProvisionOutcomeUnknownError` means a VM may exist. The package does not retry the
creation; a backend that encountered this error raises it again on later operations.

For recoverable provisioning, choose a stable, unique name and explicit tags before creating the VM.
This spec works with the installation example's scoped token:

```python
spec = VMSpec(name='build-req-7f3a', tags=['agents'], working_dir='app')
```

After `ProvisionOutcomeUnknownError`, call `await vms.lookup(spec)` with the same spec. `lookup()`
requires non-empty `spec.tags`, even when `scope_tag` is set. It never creates, changes, or resumes a VM.
With credentials that allow additional tags, a tag unique to the request can also identify the VM.

| Lookup status | Meaning |
|---|---|
| `'found'` | One visible VM matches the name, if supplied, and all spec and scope tags. Its state is in `vm`. |
| `'not_found'` | No matching VM is visible now. This does not prove creation failed; check again before retrying. |
| `'conflict'` | The name exists but some required tags are missing. Inspect it before attaching. |
| `'ambiguous'` | Without a name, several VMs match. Their states are in `candidates`. |

A fixed name prevents another creation with that name while it is taken. Names and tags help you find
a VM; they do not guarantee exactly-once creation. A create refusal such as a name conflict propagates
as `exedev.APIError`; authentication and permission failures are translated to `ExeDevAccessError`.

### Initialization failure and cancellation

After creation, provisioning waits for command readiness, applies a copy's tags and comment, and
creates `working_dir`. A failure raises `VMInitializationError` with the VM's `ref`. The VM is kept.
Retry initialization with `await vms.initialize(error.ref, spec)`, or delete it explicitly.
Initialization can be repeated; it does not change resources, image, or egress. A backend that created
the VM records its ref before initialization and retries initialization on its next operation.

Cancellation depends on the stage. While the SDK is creating or waiting for the VM, it attempts to
delete the VM before propagating cancellation. Cleanup can fail or be interrupted. Once the SDK has
returned the VM, cancellation during tag updates, comment updates, or directory creation leaves it
in place. Retain the spec so you can find it with `lookup()` and decide whether to initialize or delete it.

## Clients and errors

Pass `client=exedev.AsyncClient(...)` to configure SDK retries or transport settings. A supplied client
belongs to the caller and is never closed by this package. Otherwise, clients are created lazily from
`EXE_TOKEN` and closed as follows:

| Entry point | Client lifetime |
|---|---|
| `ExeDevSandbox` | The backend created for a run closes its client when that run ends, including on failure or cancellation. A workspace passed in from another run remains owned by that run. |
| `ExeDevVMs` | Close with `aclose()` or `async with`. |
| `ExeDevSandboxBackend` | Close with `aclose()`, which waits for in-progress provisioning to record its ref first. |

`aclose()` does not pause or delete the VM. Reusing a closed manager or backend creates a new client.
This also applies to operations through `result.workspace` after a run; close that client's backend with
`await result.workspace.backend.aclose()` when finished.

Once client cleanup starts, it finishes before propagating asyncio or AnyIO cancellation. Repeated
cancellation does not leave cleanup in the background. There is no cleanup timeout. A failed close
retains the client so `aclose()` can retry. If cancellation occurs during a failed close, the caller
receives `CancelledError` with the close error as its cause; otherwise, the close error propagates.

The package adds no general retries. It retries a command only when exe.dev refused it before execution
because the VM was paused, after resuming the VM. Commands with unknown outcomes are not repeated.

| Error | Meaning |
|---|---|
| `VMNotFoundError` | VM missing, including deletion during a command. |
| `VMOutOfScopeError` | VM lacks the configured scope tag. |
| `ExeDevAccessError` | Missing credentials, rejected token, or denied command. `status` is `None`, 401, or 403. |
| `WorkspaceTimeoutError` | Command deadline exceeded. Partial output is available on the error. |
| `WorkspaceOutputLimitError` | Combined command output exceeded 8 MiB. Partial output is available on the error. |
| `CommandOutcomeUnknownError` | The command's exit status or response was lost and the VM is not known to be gone. Check its effects before repeating it. `stdout` and `stderr` contain any captured output. |
| `ProvisionOutcomeUnknownError` | Creation may have succeeded. Use `lookup()` to investigate. |
| `VMInitializationError` | VM exists but initialization failed. The error carries its `ref`. |

These errors inherit from `WorkspaceError`; Pydantic AI's durable workspace calls record them rather
than retrying them as transient failures. The first three also inherit from `WorkspaceUnavailableError`.
Other SDK errors propagate unchanged, allowing the durable engine's configured retry policy to apply.

## Verification

Local tests use a fake of the exedev 0.4.0 SDK and execute commands as real local processes. They cover
Pydantic AI's `WorkspaceBackendSuite`, FileSystem and Shell across runs and backends, provisioning,
lookup, scope checks, lifecycle operations, and client cleanup.

Real-provider behavior remains unverified until live tests are run. In particular, the fake cannot
establish server-side token restrictions, pause behavior, command termination, background-job survival,
or the outcome of a create that times out at the gateway. The live suite covers several of these paths;
it does not establish every failure mode or permission combination.

## Development

```bash
uv sync
uv run pytest -m "not live"
uv run ruff check .
uv run ruff format --check .
uv run ty check
```

Live tests create billed VMs and need an `EXE_TOKEN` with creation, commands, copying, lifecycle,
comment, and tag permissions. They assign request-specific tags, so the installation example's
tag-scoped token is not sufficient. Use a separate test credential with the required access.
The suite's cleanup deletes **all** VMs carrying `pydantic-ai-exedev-test`, including pre-existing ones;
reserve that tag for these tests.

```bash
EXE_TOKEN=... uv run pytest -m live
```

## Releasing

[The release workflow](.github/workflows/release.yml) publishes to PyPI using Trusted Publishing.
PRs and pushes to `main` run CI. Publishing a GitHub Release runs CI again, builds and checks the
distributions, then uploads them. Pushing a tag alone does not publish the package.

One-time setup:

- Add a PyPI pending publisher for project `pydantic-ai-exedev`, owner `daikeren`, repository
  `pydantic-ai-exedev`, workflow `release.yml`, and environment `pypi`.
- Create the repository's `pypi` environment under Settings > Environments.

To release:

1. Update `project.version` in `pyproject.toml`, run `uv lock`, and commit the release changes.
2. Push the commit and wait for CI to pass.
3. Create a GitHub Release with a matching version tag, such as `v0.1.0`, pointing to that commit.
4. Check the release notes and publish the release.
5. Wait for the **Publish to PyPI** workflow to succeed, then verify the published package:

   ```bash
   uv run --isolated --no-project --with pydantic-ai-exedev==0.1.0 python -I -c 'import pydantic_ai_exedev'
   ```

Use the released version in the verification command. Each PyPI version is immutable; corrections need
a new version.

## License

[MIT](LICENSE).
