Skip to content

Commit 6c72537

Browse files
authored
feat(workspaces)!: replace the backend protocol with pydantic ai workspaces (#112)
Pydantic AI 2.52 gives every tool one environment, ctx.workspace, with its own file and command contract. Keeping BackendProtocol beside it meant every tool, adapter and sandbox existed twice. The sandboxes are now workspaces and the console tools work in whichever one a run has. Adds DockerWorkspace, SandboxdWorkspace, KubernetesWorkspace, DaytonaWorkspace and StateWorkspace. Every command-capable workspace stops a timed out or cancelled command with its whole process group. sandboxd serves commands only through /run, and answers 410 rather than replacing a sandbox whose files are gone. Read-only workspaces hide the mutating tools. Permission rules bind however a path is spelled, including through a symlink. Fixes grep on BSD and on a single file with GNU grep, and glob/grep reporting transport failures as no matches. Verified: 1313 tests at 100% coverage on Python 3.10 to 3.13; ruff, mypy, pyright; DockerWorkspace and SandboxdWorkspace pass Pydantic AI's WorkspaceBackendSuite on a real daemon. The Kubernetes and Daytona suites exist but have not run against a live cluster or account. BREAKING CHANGE: BackendProtocol, SandboxProtocol, the async adapters, LocalBackend, CompositeBackend, BaseSandbox, RemoteSandbox, DaytonaSandbox, ConsoleDeps, the background shell tools, ConsoleCapability(backend=, include_background=), the sandboxd file and exec routes, DockerSandbox file operations and the Kubernetes HTTP mode are removed. StateBackend is a document store with directories. The console and workspaces extras need pydantic-ai-slim>=2.52.0. See CHANGELOG.md for what replaces each name. Closes #113 Closes #114
1 parent 8ac7b67 commit 6c72537

137 files changed

Lines changed: 8879 additions & 18160 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ jobs:
9797
file: coverage.lcov
9898

9999
pydantic-ai-range:
100-
# The `console` extra declares `pydantic-ai-slim>=1.74.0`, and every job
100+
# The `console` extra declares `pydantic-ai-slim>=2.52.0`, and every job
101101
# above installs whatever `uv.lock` pins — so the *declared* range was never
102-
# exercised at either end. A floor is satisfiable by any 2.x release, which
103-
# meant an application on 2.x installed this library cleanly and found out at
104-
# runtime whether `prepare_tools` and `before_tool_execute` still behaved.
102+
# exercised at either end. A floor is satisfiable by every later release, so
103+
# an application on a newer one installs this library cleanly and finds out at
104+
# runtime whether `prepare_tools` and `before_tool_execute` still behave.
105105
#
106106
# That failure is quiet in the direction that matters: a hook whose signature
107107
# no longer matches stops hiding the tools a ruleset denied, and the
@@ -111,7 +111,7 @@ jobs:
111111
strategy:
112112
fail-fast: false
113113
matrix:
114-
pydantic-ai: ["1.74.0", "latest"]
114+
pydantic-ai: ["2.52.0", "latest"]
115115
steps:
116116
- uses: actions/checkout@v7
117117

‎.github/workflows/docs.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
- name: Install dependencies
2424
run: |
2525
pip install mkdocs-material mkdocstrings[python] mkdocs-autorefs
26-
pip install -e ".[console,docker]"
26+
pip install -e ".[console,docker,workspaces]"
2727
2828
- name: Build and deploy
2929
run: mkdocs gh-deploy --force

‎CHANGELOG.md‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,133 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
**⚠️ Breaking: the library is now built on Pydantic AI workspaces.** Pydantic AI
11+
2.52 gave every tool one environment to work in, `ctx.workspace`, with its own
12+
file and command contract. This library had a second one — `BackendProtocol`
13+
and its backends — so every tool, adapter and sandbox existed twice. That
14+
abstraction is gone: the sandboxes are now workspaces, and the console tools
15+
work in whichever workspace a run has, including the harness's E2B, Modal and
16+
Sprites. `pydantic-ai-slim>=2.52.0` is the floor for the `console` and
17+
`workspaces` extras. See the "Workspaces" page for the model, and below for
18+
what replaces each removed name.
19+
20+
### Added
21+
22+
- **Five workspace capabilities**, in `pydantic_ai_backends.workspaces` and the
23+
package root: `DockerWorkspace` (a container on this host), `SandboxdWorkspace`
24+
(a `sandboxd` session, so the agent's process holds no Docker socket),
25+
`KubernetesWorkspace` (a pod, through `pods/exec`), `DaytonaWorkspace` (a
26+
Daytona sandbox) and `StateWorkspace` (a `StateBackend` document, files only).
27+
Each is created on first use, attached again by its ref on a later run, never
28+
deleted for you (`await capability.destroy(ref)`), and fails with
29+
`WorkspaceUnavailableError` when its environment is gone rather than
30+
continuing in an empty one. `DockerWorkspace` and `SandboxdWorkspace` pass
31+
Pydantic AI's `WorkspaceBackendSuite` against a real Docker daemon;
32+
`StateWorkspace` passes its filesystem rules in CI. The Kubernetes and
33+
Daytona suites run behind `-m kubernetes` / `-m daytona` and are not yet
34+
verified against a live cluster or account. A ref is input — it arrives with
35+
the message history — so `DockerWorkspace` attaches to and destroys only
36+
containers named the way it names its own, never another container on the
37+
host.
38+
- **Commands that stop with everything they started.** Every command-capable
39+
workspace runs commands through a small wrapper that records the command's
40+
process group, so a timeout or a cancelled run stops the command and its
41+
children with a second command. stdout and stderr come back apart,
42+
`WorkspaceTimeoutError` carries partial output, and output past 10 MiB raises
43+
`WorkspaceOutputLimitError`.
44+
- **`sandboxd` runs commands for a workspace: `POST /sessions/{id}/run`** takes
45+
an argv, `env` and a `run_id`, keeps stdout and stderr apart and answers `410`
46+
for a vanished sandbox, including one that died between commands and whose
47+
files died with it (no `workspace_root`, no persisted container) rather than
48+
replacing it with an empty one. A persisted container is started again but
49+
never recreated, so one that was removed is a `410` too; `POST /sessions/{id}/runs/{run_id}/stop` stops a
50+
command and its process group. A session open request with `attach` attaches
51+
without ever creating, answering `404` when there is nothing left to attach to.
52+
- **`CommandRunner`, `CommandOutcome` and `SandboxUnavailableError`**: what a
53+
sandbox implements to back a container workspace or a `sandboxd` session.
54+
`DockerSandbox` and `KubernetesPodSandbox` implement it.
55+
- **A read-only workspace hides the mutating tools.** On `ReadOnlyWorkspace` or
56+
`LocalWorkspace(read_only=True)`, `ConsoleCapability` offers no `write_file`,
57+
`edit_file` or `execute`, whatever the ruleset allows.
58+
- **Permission rules bind however a path is spelled.** The console tools check
59+
a path as the model wrote it and as the workspace resolves it against its
60+
working directory, so a deny on `/workspace/private/**` also refuses
61+
`private/notes.txt`, and command arguments resolve against the same directory.
62+
Reads, writes, edits and `grep` matches are also checked where the
63+
workspace's `realpath` says the path leads, so a symlink cannot stand in for a
64+
denied file.
65+
`LocalBackend` resolved paths and links against its root; this keeps that
66+
protection on `LocalWorkspace` and gives it to every other workspace.
67+
- **An unavailable workspace is reported, not read as empty.** With no
68+
workspace attached, or its environment gone, `ls`, `glob`, `grep` and an image
69+
read answer with that error instead of an empty directory or a missing file.
70+
71+
### Removed
72+
73+
- **`BackendProtocol`, `SandboxProtocol` and their async variants**, with
74+
`adapter.py` (`ensure_async` and the sync/async adapters). Tools reach the
75+
environment through `ctx.workspace`.
76+
- **`LocalBackend`**: use Pydantic AI's `LocalWorkspace`, which takes
77+
`read_only=` for a directory the agent may only read. It does not confine
78+
paths: an absolute path reaches anything the process can, where
79+
`LocalBackend(allowed_directories=...)` refused everything outside its
80+
directories. Deny what must stay out of reach with a permission ruleset, or
81+
use a container workspace for a real boundary.
82+
- **`CompositeBackend` and `PrefixRouter`**: a run has one workspace. Compose
83+
policies around it with Pydantic AI's `WrapperWorkspace`.
84+
- **`BaseSandbox` and `AsyncBaseSandbox`**: file operations are derived by
85+
Pydantic AI's `Workspace` from the shell; a new sandbox implements
86+
`CommandRunner`.
87+
- **`RemoteSandbox`**: use `SandboxdWorkspace`. `WorkspaceArchive` and
88+
`WorkspaceArchiveError` moved to `pydantic_ai_backends.remote.archive` and stay
89+
importable from the package root.
90+
- **`DaytonaSandbox`**: use `DaytonaWorkspace`. The `daytona` extra now installs
91+
the `daytona` package; the old `daytona-sdk` installed `daytona_sdk`, which the
92+
code never imported, so the previous class could not be loaded with its own
93+
extra.
94+
- **`sandboxd` file and exec routes** — `/exec`, `/read`, `/write`, `/edit`,
95+
`/ls`, `/glob`, `/grep` and `/exists` under `/sessions/{id}` — and their wire
96+
models. `/run` is the one way in; the archive routes under `/workspaces` are
97+
unchanged.
98+
- **The background shell tools** — `run_in_background`, `read_output`,
99+
`kill_shell`, `list_shells` — and `ConsoleCapability(include_background=...)`.
100+
Pydantic AI's workspace contract has no background processes; a command that
101+
must outlive its call can be started through `execute` with its output
102+
redirected — `nohup server > server.log 2>&1 &` returns at once.
103+
- **`ConsoleCapability(backend=...)` and `ConsoleDeps`**: the tools work in the
104+
run's workspace, supplied by a workspace capability or `agent.run(workspace=...)`.
105+
- **`KubernetesPodSandbox`'s HTTP mode**: it reaches the pod only through
106+
`pods/exec`, and the default pod runs `sleep infinity`.
107+
- **`DockerSandbox` file operations** (`read`, `write`, `edit`, `ls_info`,
108+
`glob_info`, `grep_raw`, `execute`) and `max_read_bytes`: reach files through
109+
a workspace, run commands with `run_command`.
110+
- **The live file browser in the `sandboxd` dashboard.** The terminal runs
111+
through `/run`; files are browsed in the stored workspace.
112+
113+
### Changed
114+
115+
- **`StateBackend` is a document store, not a backend.** It keeps `files` and
116+
the `directories` created — both JSON — and follows a filesystem's rules,
117+
raising `FileNotFoundError`, `IsADirectoryError` and `NotADirectoryError`.
118+
`StateWorkspace` serves documents from a store the application owns. A
119+
directory a write or `make_dir` created stays when the last thing in it is
120+
removed. A document written by an earlier version loads unchanged.
121+
122+
### Fixed
123+
124+
- **`grep` found nothing on macOS.** BSD `grep` matches `--exclude` against the
125+
whole path (`./f.txt`), so excluding hidden files with `.*` excluded every
126+
file. Hidden directories are excluded by the shell and hidden files filtered
127+
afterwards.
128+
- **`glob` and `grep` reported "no matches" when the sandbox was unreachable.**
129+
Transport failures now surface as errors.
130+
- **File-read tracking was lost between tool calls** when the tools ran in a
131+
workspace. What a toolset has read is now kept per workspace, and released
132+
with it, so a long-lived agent does not keep every workspace it worked in.
133+
- **`grep` on a single file found nothing with GNU grep**, which leaves the file
134+
name out for one file; the line then did not parse as a match. `-H` makes it
135+
print the name.
136+
10137
## [0.2.29] - 2026-08-22
11138

12139
### Fixed
@@ -503,7 +630,7 @@ before upgrading is deferred.
503630

504631
The guard deliberately lets pydantic-ai's control-flow exceptions past — `ModelRetry`, `ApprovalRequired`, `CallDeferred` and the `Skip*` family. Those are not failures, they steer the run, and catching `ModelRetry` in particular would turn a retry into a dead end the model cannot recover from. `UserError` passes through too, because reporting a misuse of the library to the model as a failed file operation hides the bug; it subclasses `RuntimeError`, so the narrower handler this replaced was already swallowing it.
505632
- **The shell derivation every sandbox depends on is measured.** `BaseSandbox` carried a blanket `# pragma: no cover`, so the command construction and output parsing behind Docker, Daytona, Kubernetes and any third-party sandbox contributed nothing to the 100% gate. Moving it into one module made it directly testable, and it is now covered — including the quoting of a hostile path, `ls` rows with spaces in the name, a `grep` line containing colons, and the failure branch of every operation.
506-
- **The failure contract is written down.** What a backend must return when an operation fails was real, load-bearing and documented nowhere, so implementors were deducing it from our source. `protocol.py` now states it per method, including the asymmetry that matters most: `read` may return an `Error: ` string but `read_bytes` must return `b""`, because its caller cannot tell an error message from real file content — a probe staging a screenshot would treat `b"Error: not found"` as the image.
633+
- **The failure contract is written down.** What a backend must return when an operation fails was real, load-bearing and documented nowhere, so implementers were deducing it from our source. `protocol.py` now states it per method, including the asymmetry that matters most: `read` may return an `Error: ` string but `read_bytes` must return `b""`, because its caller cannot tell an error message from real file content — a probe staging a screenshot would treat `b"Error: not found"` as the image.
507634

508635
- **`SandboxdConfig(container_ttl=...)`** removes a persisted sandbox container that has been stopped for that long, leaving its workspace untouched. It separates the two things a session accumulates: what it *installed* is rebuildable, what it *wrote* is not — so a deployment can reclaim the first on a schedule while keeping an agent's files for ever, which is what its user expects. `workspace_ttl` remains the opposite knob and stays `None` by default. The sweep finds containers by their name prefix rather than from a record of its own, because after a restart Docker is the only source of what is still lying around.
509636
- **`SandboxdConfig(evict_idle_after=...)`** turns the session ceiling from a hard cap on how many sessions may exist into a working-set size. At the ceiling the least recently used session idle for at least that long is closed to make room, instead of the incoming request being refused — which was the wrong answer when the pool was full of sandboxes nobody was using. With `workspace_root` set the evicted session loses nothing but its container: its next request re-attaches and finds its files, for the price of a container start. A session idle for less than the threshold is never a candidate, because killing an agent's work to serve somebody else's first request is worse than making them wait, and a pool of genuinely busy sessions still answers `429`. Requires `workspace_root`, and the config refuses without it rather than silently discarding an evicted session's files.

‎CLAUDE.md‎

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ Guidance for Claude Code when working on this repository.
44

55
## What This Project Is
66

7-
**pydantic-ai-backend** provides file storage and sandbox backends for AI agents. It's designed to work with pydantic-ai and pydantic-deep.
7+
**pydantic-ai-backend** supplies Pydantic AI workspaces (Docker, sandboxd, Kubernetes,
8+
Daytona, a JSON state document) and the console tools that work in any workspace. It's
9+
designed to work with pydantic-ai and pydantic-deep.
810

9-
Key pattern: **Protocol-based backends** - all backends implement `BackendProtocol` for consistent file operations.
11+
Key pattern: **Pydantic AI workspaces** — tools reach the environment through
12+
`ctx.workspace`; a workspace capability supplies it. This library has no file-operation
13+
protocol of its own.
1014

1115
## Commands
1216

@@ -25,29 +29,24 @@ uv run mypy src/pydantic_ai_backends # MyPy check
2529
```
2630
src/pydantic_ai_backends/
2731
├── __init__.py # Public API, lazily loaded
28-
├── types.py # FileData, FileInfo, WriteResult, EditResult, RuntimeConfig
29-
├── protocol.py # BackendProtocol, SandboxProtocol (+ async variants)
30-
├── adapter.py # Sync -> async adapters, ensure_async()
32+
├── types.py # FileData, FileInfo, CommandOutcome, RuntimeConfig, results
33+
├── protocol.py # CommandRunner, SandboxUnavailableError
3134
├── capability.py # ConsoleCapability for pydantic-ai
3235
├── hashline.py # Content-hash line editing
3336
├── _editing.py # Shared `edit` replacement rules
3437
├── _limits.py # Output and read ceilings
3538
├── _optional.py # Optional-extra imports with install hints
36-
├── _paths.py # Virtual path normalisation and validation
3739
├── _text.py # Encoding detection, decoding, PDF extraction
38-
├── backends/
39-
│ ├── base.py # BaseSandbox (shell-based defaults)
40-
│ ├── state.py # StateBackend (in-memory)
41-
│ ├── local.py # LocalBackend (real filesystem + shell)
42-
│ ├── composite.py # PrefixRouter, CompositeBackend, AsyncCompositeBackend
43-
│ ├── daytona.py # DaytonaSandbox
44-
│ ├── kubernetes.py # KubernetesPodSandbox
45-
│ ├── _background.py # Long-lived process registry
46-
│ ├── _guard.py # Synchronous permission enforcement
47-
│ └── docker/ # sandbox.py, session.py, runtimes.py, _client/_image/_stats
40+
├── backends/ # The sandboxes the workspaces run on
41+
│ ├── _runner.py # The pid-file wrapper and stopper every runner shares
42+
│ ├── state.py # StateBackend (a filesystem as a JSON document)
43+
│ ├── kubernetes.py # KubernetesPodSandbox (pods/exec)
44+
│ └── docker/ # sandbox.py, session.py, runtimes.py, _exec/_client/_image/_stats
45+
├── workspaces/ # Docker/Sandboxd/Kubernetes/Daytona/StateWorkspace (+ *Backend)
4846
├── permissions/ # types.py, checker.py, presets.py
49-
├── toolsets/ # console.py, descriptions.py, _content/_tracking/_ruleset
50-
└── remote/ # client.py (RemoteSandbox), server.py (sandboxd), wire.py
47+
├── toolsets/ # console.py, descriptions.py, _workspace (ops over ctx.workspace),
48+
│ # _guard, _shell (glob/grep), _content/_tracking/_ruleset/_failures
49+
└── remote/ # server.py (sandboxd), wire.py, archive.py, env.py, ui/
5150
```
5251

5352
Modules with a leading underscore are internal: no compatibility promise, and the
@@ -56,23 +55,24 @@ names inside them are public so call sites read cleanly.
5655
## Core Pattern
5756

5857
```python
59-
class BackendProtocol(Protocol):
60-
def ls_info(self, path: str) -> list[FileInfo]: ...
61-
def read(self, path: str, offset: int = 0, limit: int = 2000) -> str: ...
62-
def write(self, path: str, content: str | bytes) -> WriteResult: ...
63-
def edit(self, path: str, old: str, new: str, replace_all: bool = False) -> EditResult: ...
64-
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: ...
65-
def grep_raw(
66-
self, pattern: str, path: str | None = None, glob: str | None = None
67-
) -> list[GrepMatch] | str: ...
68-
69-
70-
class SandboxProtocol(BackendProtocol, Protocol):
71-
def execute(self, command: str, timeout: int | None = None) -> ExecuteResponse: ...
72-
@property
73-
def id(self) -> str: ...
58+
class CommandRunner(Protocol):
59+
async def run_command(
60+
self,
61+
argv: Sequence[str],
62+
*,
63+
run_id: str,
64+
env: Mapping[str, str] | None = None,
65+
timeout: float | None = None,
66+
output_limit: int | None = None,
67+
) -> CommandOutcome: ...
68+
async def stop_command(self, run_id: str) -> None: ...
7469
```
7570

71+
A sandbox that implements `CommandRunner` can back a container workspace and a sandboxd
72+
session. File operations are derived by Pydantic AI's `Workspace` through the shell;
73+
only `StateWorkspaceBackend` implements `SupportsFilesystem` natively. Every workspace
74+
here must pass `pydantic_ai.workspaces.conformance.WorkspaceBackendSuite`.
75+
7676
## Requirements
7777

7878
- **100% test coverage** - every PR must maintain this
@@ -83,7 +83,10 @@ class SandboxProtocol(BackendProtocol, Protocol):
8383

8484
```bash
8585
# Run specific test
86-
uv run pytest tests/test_backends.py::TestStateBackend -v
86+
uv run pytest tests/test_state.py -v
87+
88+
# Against a real Docker daemon
89+
uv run pytest -m docker
8790

8891
# Debug mode
8992
uv run pytest -v -s

0 commit comments

Comments
 (0)