sandbox-agents 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ Metadata-Version: 2.4
2
+ Name: sandbox-agents
3
+ Version: 0.1.0
4
+ Summary: Python client for the Sandbox Agents control-plane: agents, sandboxed sessions, streamed runs.
5
+ Keywords: agents,sandbox,llm,control-plane,sse
6
+ Author: Anecdote AI
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: httpx (>=0.27,<1)
16
+ Requires-Dist: pydantic (>=2.7,<3)
17
+ Project-URL: Changelog, https://github.com/Anecdote-AI/Managed-Agents/blob/main/sdk/python/CHANGELOG.md
18
+ Project-URL: Documentation, https://github.com/Anecdote-AI/Managed-Agents/tree/main/sdk/python
19
+ Project-URL: Homepage, https://github.com/Anecdote-AI/Managed-Agents
20
+ Project-URL: Repository, https://github.com/Anecdote-AI/Managed-Agents
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sandbox-agents
24
+
25
+ Python client for the **Sandbox Agents control-plane** — agents that run in
26
+ sandboxed containers, conversations against them, and the event log everything is
27
+ observed through.
28
+
29
+ ```bash
30
+ pip install sandbox-agents
31
+ ```
32
+
33
+ Requires Python 3.11 or newer. Sync and async clients, typed models, resumable
34
+ event streaming, client-tool dispatch and webhook verification are all included;
35
+ `httpx` and `pydantic` are the only dependencies.
36
+
37
+ ## The model in three sentences
38
+
39
+ An **agent** is a reusable definition: instructions, a model, the manifest its
40
+ sandboxes start from. A **session** is one conversation *and* the container it
41
+ owns — the workspace, the ports, the snapshots. A **run** is one turn, and it is
42
+ asynchronous: sending a message returns a run id, and the answer arrives over the
43
+ session's event log.
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ from sandbox_agents import Client
49
+
50
+ client = Client(base_url="http://localhost:8000", api_key="ak_…")
51
+
52
+ agent = client.agents.create(
53
+ slug="renewal-analyst",
54
+ name="Renewal analyst",
55
+ instructions="Inspect the files before answering. Cite the source of every claim.",
56
+ model="gpt-5.4-mini",
57
+ manifest={
58
+ "entries": {
59
+ "brief.md": {"kind": "file", "content": "# Northwind\n- Renewal: 2026-04-15\n"},
60
+ "output": {"kind": "dir"},
61
+ }
62
+ },
63
+ )
64
+
65
+ session = client.sessions.create_and_wait(agent.slug, title="Northwind renewal")
66
+ result = client.sessions.run(session.id, "Write output/report.md listing every blocker.")
67
+
68
+ print(result.text) # the model's answer
69
+ print(client.sessions.read_text(session.id, "output/report.md")) # what it wrote
70
+ client.sessions.stop(session.id)
71
+ ```
72
+
73
+ `create_and_wait` and `run` are the two waiting helpers: the first returns when
74
+ the container is up, the second when the turn is over. Both follow the event log
75
+ rather than holding a request open, so neither is affected by a proxy's idle
76
+ timeout.
77
+
78
+ ## Configuration
79
+
80
+ | Argument | Environment | Default |
81
+ |---|---|---|
82
+ | `base_url` | `SANDBOX_AGENTS_BASE_URL` | `http://localhost:8000` |
83
+ | `api_key` | `SANDBOX_AGENTS_API_KEY` | — |
84
+ | `project` | `SANDBOX_AGENTS_PROJECT` | — |
85
+
86
+ ```python
87
+ client = Client() # entirely from the environment
88
+ client = Client(project="acme") # X-Project, by id or slug
89
+ ```
90
+
91
+ `project` can be left out where the answer is unambiguous — a deployment with one
92
+ project, or an API key bound to one. With several, a request that does not name
93
+ one is refused rather than guessed at.
94
+
95
+ Keep one client for the process. It owns a connection pool, and it remembers
96
+ where it is in each session's log — which is what lets `run` start from *now*
97
+ instead of paging the whole conversation.
98
+
99
+ ## Async
100
+
101
+ The same surface, awaited. Prefer it for anything following more than one
102
+ conversation: a turn is minutes of mostly waiting.
103
+
104
+ ```python
105
+ import asyncio
106
+ from sandbox_agents import AsyncClient
107
+
108
+ async def main() -> None:
109
+ async with AsyncClient() as client:
110
+ session = await client.sessions.create_and_wait("renewal-analyst")
111
+ async for event in client.sessions.stream(session.id):
112
+ if event.type == "agent.text_delta":
113
+ print(event.text, end="", flush=True)
114
+
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ ## Streaming
119
+
120
+ `sessions.stream` yields the log from a cursor and then live events. It resumes on
121
+ its own: a dropped connection reconnects from the last `seq` seen, and because
122
+ that cursor is a database sequence it survives a server restart too.
123
+
124
+ ```python
125
+ for event in client.sessions.stream(session.id, after=cursor):
126
+ if event.type == "agent.message":
127
+ print(event.text)
128
+ elif event.type == "agent.tool_use":
129
+ print(f"→ {event.tool}")
130
+ elif event.type in ("session.status_idle", "session.status_error"):
131
+ break
132
+ ```
133
+
134
+ Token deltas (`agent.text_delta`) are streamed but never stored, so they carry
135
+ `seq == 0` and never move the cursor — `event.is_persistent` is the check.
136
+ `sandbox_agents.events` has every type name as a constant, plus `TERMINAL_TYPES`
137
+ and `EPHEMERAL_TYPES`.
138
+
139
+ To render a transcript first and then follow it, page the log and stream from
140
+ where the page ended:
141
+
142
+ ```python
143
+ history = list(client.sessions.history(session.id))
144
+ for event in client.sessions.stream(session.id, after=history[-1].seq):
145
+ ...
146
+ ```
147
+
148
+ ## Client tools
149
+
150
+ An agent can declare tools it does not implement. When the model calls one, the
151
+ turn stops and waits for the caller to answer — that is how an agent in a
152
+ container reaches the page a user is looking at.
153
+
154
+ ```python
155
+ from sandbox_agents import Tool
156
+
157
+ def open_account(args: dict) -> dict:
158
+ return {"id": args["id"], "status": "active"}
159
+
160
+ result = client.sessions.run(
161
+ session.id,
162
+ "Look up account 42 and summarise it.",
163
+ tools=[
164
+ Tool(
165
+ name="open_account",
166
+ handler=open_account,
167
+ description="Read an account by id",
168
+ parameters={"type": "object", "properties": {"id": {"type": "string"}}},
169
+ )
170
+ ],
171
+ )
172
+ ```
173
+
174
+ A `Tool` is declared for that turn *and* answered by it. For tools already
175
+ declared on the agent, pass handlers by name instead: `tools={"open_account": open_account}`.
176
+
177
+ A handler that raises does not fail the run: the exception is reported to the
178
+ model as the tool's output, because the turn is blocked on this answer and losing
179
+ the conversation over one failed lookup is worse. Handlers may be `async def` on
180
+ the async client.
181
+
182
+ ## Files, shell, ports
183
+
184
+ ```python
185
+ client.sessions.upload_file(session.id, "brief.pdf") # → uploads/brief.pdf
186
+ client.sessions.list_files(session.id, "output")
187
+ client.sessions.download_file(session.id, "output/report.md", "./report.md")
188
+ client.sessions.exec(session.id, "ls -la output").check()
189
+ client.sessions.port(session.id, 8000).url # a preview URL
190
+ ```
191
+
192
+ ## Snapshots
193
+
194
+ ```python
195
+ snapshot = client.sessions.create_snapshot(session.id, label="before-refactor")
196
+ fork = client.sessions.create_and_wait("renewal-analyst", from_snapshot_id=snapshot.id)
197
+ ```
198
+
199
+ ## Webhooks
200
+
201
+ For consumers that are not connected when something happens. Bodies are signed
202
+ with HMAC-SHA256 over `"<timestamp>.<body>"`; verify against the **raw bytes**,
203
+ before anything parses the JSON.
204
+
205
+ ```python
206
+ from fastapi import FastAPI, Request, Response
207
+ from sandbox_agents import webhooks
208
+
209
+ app = FastAPI()
210
+
211
+ @app.post("/hooks/agents")
212
+ async def receive(request: Request) -> Response:
213
+ raw = await request.body()
214
+ if not webhooks.verify(SECRET, raw, request.headers.get(webhooks.SIGNATURE_HEADER)):
215
+ return Response(status_code=401)
216
+ delivery = webhooks.parse(raw)
217
+ print(delivery.type, delivery.event.text if delivery.event else "")
218
+ return Response(status_code=204)
219
+ ```
220
+
221
+ Delivery is at-least-once; `X-Anecdote-Delivery` is stable across retries and is
222
+ what makes a consumer idempotent.
223
+
224
+ ## Errors
225
+
226
+ ```python
227
+ from sandbox_agents import ConflictError, NotFoundError, RunFailedError
228
+
229
+ try:
230
+ agent = client.agents.update(agent.id, version=agent.version, name="New name")
231
+ except ConflictError:
232
+ agent = client.agents.get(agent.id) # somebody edited it first; re-apply
233
+ ```
234
+
235
+ `APIStatusError` and its subclasses (`BadRequestError`, `AuthenticationError`,
236
+ `PermissionDeniedError`, `NotFoundError`, `ConflictError`,
237
+ `UnprocessableEntityError`, `RateLimitError`, `InternalServerError`) carry
238
+ `status_code` and `detail`. `APIConnectionError` and `APITimeoutError` mean no
239
+ answer arrived. `RunFailedError` is a turn that ended in `session.status_error`;
240
+ `TimeoutExpiredError` is a waiting helper giving up on something still running,
241
+ and carries `last_seq` so the wait can be resumed.
242
+
243
+ Safe methods and 429s are retried with jittered backoff (`max_retries=2`).
244
+ A POST is not: one that timed out may already have created the session.
245
+
246
+ ## Partial updates: `None` means *clear*
247
+
248
+ The control-plane distinguishes a field that was not sent from one sent as null,
249
+ so this client does too. Anything you do not pass is left alone; `None` clears.
250
+
251
+ ```python
252
+ client.agents.update(ref, version=7, image=None) # clears the image override
253
+ client.agents.update(ref, version=7, name="x") # touches nothing else
254
+ ```
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ poetry install
260
+ poetry run pytest
261
+ poetry run ruff check . && poetry run mypy src
262
+ ```
263
+
264
+ The full API reference — every resource, with the event catalogue and the
265
+ webhook payloads — is in the control-plane UI under **Developers → Python SDK**,
266
+ alongside the OpenAPI schema at `/docs` on the API itself.
267
+
@@ -0,0 +1,244 @@
1
+ # sandbox-agents
2
+
3
+ Python client for the **Sandbox Agents control-plane** — agents that run in
4
+ sandboxed containers, conversations against them, and the event log everything is
5
+ observed through.
6
+
7
+ ```bash
8
+ pip install sandbox-agents
9
+ ```
10
+
11
+ Requires Python 3.11 or newer. Sync and async clients, typed models, resumable
12
+ event streaming, client-tool dispatch and webhook verification are all included;
13
+ `httpx` and `pydantic` are the only dependencies.
14
+
15
+ ## The model in three sentences
16
+
17
+ An **agent** is a reusable definition: instructions, a model, the manifest its
18
+ sandboxes start from. A **session** is one conversation *and* the container it
19
+ owns — the workspace, the ports, the snapshots. A **run** is one turn, and it is
20
+ asynchronous: sending a message returns a run id, and the answer arrives over the
21
+ session's event log.
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ from sandbox_agents import Client
27
+
28
+ client = Client(base_url="http://localhost:8000", api_key="ak_…")
29
+
30
+ agent = client.agents.create(
31
+ slug="renewal-analyst",
32
+ name="Renewal analyst",
33
+ instructions="Inspect the files before answering. Cite the source of every claim.",
34
+ model="gpt-5.4-mini",
35
+ manifest={
36
+ "entries": {
37
+ "brief.md": {"kind": "file", "content": "# Northwind\n- Renewal: 2026-04-15\n"},
38
+ "output": {"kind": "dir"},
39
+ }
40
+ },
41
+ )
42
+
43
+ session = client.sessions.create_and_wait(agent.slug, title="Northwind renewal")
44
+ result = client.sessions.run(session.id, "Write output/report.md listing every blocker.")
45
+
46
+ print(result.text) # the model's answer
47
+ print(client.sessions.read_text(session.id, "output/report.md")) # what it wrote
48
+ client.sessions.stop(session.id)
49
+ ```
50
+
51
+ `create_and_wait` and `run` are the two waiting helpers: the first returns when
52
+ the container is up, the second when the turn is over. Both follow the event log
53
+ rather than holding a request open, so neither is affected by a proxy's idle
54
+ timeout.
55
+
56
+ ## Configuration
57
+
58
+ | Argument | Environment | Default |
59
+ |---|---|---|
60
+ | `base_url` | `SANDBOX_AGENTS_BASE_URL` | `http://localhost:8000` |
61
+ | `api_key` | `SANDBOX_AGENTS_API_KEY` | — |
62
+ | `project` | `SANDBOX_AGENTS_PROJECT` | — |
63
+
64
+ ```python
65
+ client = Client() # entirely from the environment
66
+ client = Client(project="acme") # X-Project, by id or slug
67
+ ```
68
+
69
+ `project` can be left out where the answer is unambiguous — a deployment with one
70
+ project, or an API key bound to one. With several, a request that does not name
71
+ one is refused rather than guessed at.
72
+
73
+ Keep one client for the process. It owns a connection pool, and it remembers
74
+ where it is in each session's log — which is what lets `run` start from *now*
75
+ instead of paging the whole conversation.
76
+
77
+ ## Async
78
+
79
+ The same surface, awaited. Prefer it for anything following more than one
80
+ conversation: a turn is minutes of mostly waiting.
81
+
82
+ ```python
83
+ import asyncio
84
+ from sandbox_agents import AsyncClient
85
+
86
+ async def main() -> None:
87
+ async with AsyncClient() as client:
88
+ session = await client.sessions.create_and_wait("renewal-analyst")
89
+ async for event in client.sessions.stream(session.id):
90
+ if event.type == "agent.text_delta":
91
+ print(event.text, end="", flush=True)
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ ## Streaming
97
+
98
+ `sessions.stream` yields the log from a cursor and then live events. It resumes on
99
+ its own: a dropped connection reconnects from the last `seq` seen, and because
100
+ that cursor is a database sequence it survives a server restart too.
101
+
102
+ ```python
103
+ for event in client.sessions.stream(session.id, after=cursor):
104
+ if event.type == "agent.message":
105
+ print(event.text)
106
+ elif event.type == "agent.tool_use":
107
+ print(f"→ {event.tool}")
108
+ elif event.type in ("session.status_idle", "session.status_error"):
109
+ break
110
+ ```
111
+
112
+ Token deltas (`agent.text_delta`) are streamed but never stored, so they carry
113
+ `seq == 0` and never move the cursor — `event.is_persistent` is the check.
114
+ `sandbox_agents.events` has every type name as a constant, plus `TERMINAL_TYPES`
115
+ and `EPHEMERAL_TYPES`.
116
+
117
+ To render a transcript first and then follow it, page the log and stream from
118
+ where the page ended:
119
+
120
+ ```python
121
+ history = list(client.sessions.history(session.id))
122
+ for event in client.sessions.stream(session.id, after=history[-1].seq):
123
+ ...
124
+ ```
125
+
126
+ ## Client tools
127
+
128
+ An agent can declare tools it does not implement. When the model calls one, the
129
+ turn stops and waits for the caller to answer — that is how an agent in a
130
+ container reaches the page a user is looking at.
131
+
132
+ ```python
133
+ from sandbox_agents import Tool
134
+
135
+ def open_account(args: dict) -> dict:
136
+ return {"id": args["id"], "status": "active"}
137
+
138
+ result = client.sessions.run(
139
+ session.id,
140
+ "Look up account 42 and summarise it.",
141
+ tools=[
142
+ Tool(
143
+ name="open_account",
144
+ handler=open_account,
145
+ description="Read an account by id",
146
+ parameters={"type": "object", "properties": {"id": {"type": "string"}}},
147
+ )
148
+ ],
149
+ )
150
+ ```
151
+
152
+ A `Tool` is declared for that turn *and* answered by it. For tools already
153
+ declared on the agent, pass handlers by name instead: `tools={"open_account": open_account}`.
154
+
155
+ A handler that raises does not fail the run: the exception is reported to the
156
+ model as the tool's output, because the turn is blocked on this answer and losing
157
+ the conversation over one failed lookup is worse. Handlers may be `async def` on
158
+ the async client.
159
+
160
+ ## Files, shell, ports
161
+
162
+ ```python
163
+ client.sessions.upload_file(session.id, "brief.pdf") # → uploads/brief.pdf
164
+ client.sessions.list_files(session.id, "output")
165
+ client.sessions.download_file(session.id, "output/report.md", "./report.md")
166
+ client.sessions.exec(session.id, "ls -la output").check()
167
+ client.sessions.port(session.id, 8000).url # a preview URL
168
+ ```
169
+
170
+ ## Snapshots
171
+
172
+ ```python
173
+ snapshot = client.sessions.create_snapshot(session.id, label="before-refactor")
174
+ fork = client.sessions.create_and_wait("renewal-analyst", from_snapshot_id=snapshot.id)
175
+ ```
176
+
177
+ ## Webhooks
178
+
179
+ For consumers that are not connected when something happens. Bodies are signed
180
+ with HMAC-SHA256 over `"<timestamp>.<body>"`; verify against the **raw bytes**,
181
+ before anything parses the JSON.
182
+
183
+ ```python
184
+ from fastapi import FastAPI, Request, Response
185
+ from sandbox_agents import webhooks
186
+
187
+ app = FastAPI()
188
+
189
+ @app.post("/hooks/agents")
190
+ async def receive(request: Request) -> Response:
191
+ raw = await request.body()
192
+ if not webhooks.verify(SECRET, raw, request.headers.get(webhooks.SIGNATURE_HEADER)):
193
+ return Response(status_code=401)
194
+ delivery = webhooks.parse(raw)
195
+ print(delivery.type, delivery.event.text if delivery.event else "")
196
+ return Response(status_code=204)
197
+ ```
198
+
199
+ Delivery is at-least-once; `X-Anecdote-Delivery` is stable across retries and is
200
+ what makes a consumer idempotent.
201
+
202
+ ## Errors
203
+
204
+ ```python
205
+ from sandbox_agents import ConflictError, NotFoundError, RunFailedError
206
+
207
+ try:
208
+ agent = client.agents.update(agent.id, version=agent.version, name="New name")
209
+ except ConflictError:
210
+ agent = client.agents.get(agent.id) # somebody edited it first; re-apply
211
+ ```
212
+
213
+ `APIStatusError` and its subclasses (`BadRequestError`, `AuthenticationError`,
214
+ `PermissionDeniedError`, `NotFoundError`, `ConflictError`,
215
+ `UnprocessableEntityError`, `RateLimitError`, `InternalServerError`) carry
216
+ `status_code` and `detail`. `APIConnectionError` and `APITimeoutError` mean no
217
+ answer arrived. `RunFailedError` is a turn that ended in `session.status_error`;
218
+ `TimeoutExpiredError` is a waiting helper giving up on something still running,
219
+ and carries `last_seq` so the wait can be resumed.
220
+
221
+ Safe methods and 429s are retried with jittered backoff (`max_retries=2`).
222
+ A POST is not: one that timed out may already have created the session.
223
+
224
+ ## Partial updates: `None` means *clear*
225
+
226
+ The control-plane distinguishes a field that was not sent from one sent as null,
227
+ so this client does too. Anything you do not pass is left alone; `None` clears.
228
+
229
+ ```python
230
+ client.agents.update(ref, version=7, image=None) # clears the image override
231
+ client.agents.update(ref, version=7, name="x") # touches nothing else
232
+ ```
233
+
234
+ ## Development
235
+
236
+ ```bash
237
+ poetry install
238
+ poetry run pytest
239
+ poetry run ruff check . && poetry run mypy src
240
+ ```
241
+
242
+ The full API reference — every resource, with the event catalogue and the
243
+ webhook payloads — is in the control-plane UI under **Developers → Python SDK**,
244
+ alongside the OpenAPI schema at `/docs` on the API itself.
@@ -0,0 +1,72 @@
1
+ # Packaged with Poetry, published to PyPI as `sandbox-agents`.
2
+ #
3
+ # poetry install
4
+ # poetry run pytest
5
+ # poetry build && poetry publish
6
+ #
7
+ # PEP 621 metadata rather than the legacy `[tool.poetry]` table: poetry-core 2.x
8
+ # reads it natively, and every other tool in a consumer's toolchain (pip, uv,
9
+ # build) reads the same fields without a Poetry-specific parser.
10
+ [project]
11
+ name = "sandbox-agents"
12
+ version = "0.1.0"
13
+ description = "Python client for the Sandbox Agents control-plane: agents, sandboxed sessions, streamed runs."
14
+ readme = "README.md"
15
+ # 3.11 is the floor the client is written against: `X | Y` unions in runtime
16
+ # positions, `asyncio.TaskGroup`-era semantics, and `Self`. Nothing here needs
17
+ # 3.12, so the SDK deliberately supports one version further back than the
18
+ # server it talks to.
19
+ requires-python = ">=3.11,<4.0"
20
+ authors = [{ name = "Anecdote AI" }]
21
+ keywords = ["agents", "sandbox", "llm", "control-plane", "sse"]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = [
32
+ "httpx>=0.27,<1",
33
+ "pydantic>=2.7,<3",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Anecdote-AI/Managed-Agents"
38
+ Repository = "https://github.com/Anecdote-AI/Managed-Agents"
39
+ Documentation = "https://github.com/Anecdote-AI/Managed-Agents/tree/main/sdk/python"
40
+ Changelog = "https://github.com/Anecdote-AI/Managed-Agents/blob/main/sdk/python/CHANGELOG.md"
41
+
42
+ [tool.poetry]
43
+ # `src/` layout: the tests import the installed package rather than the working
44
+ # directory, so a missing `py.typed` or a module left out of the wheel fails the
45
+ # suite instead of shipping.
46
+ packages = [{ include = "sandbox_agents", from = "src" }]
47
+
48
+ [tool.poetry.group.dev.dependencies]
49
+ pytest = "^8.3.0"
50
+ pytest-asyncio = "^0.24.0"
51
+ mypy = "^1.13.0"
52
+ ruff = "^0.8.0"
53
+
54
+ [build-system]
55
+ requires = ["poetry-core>=2.0.0"]
56
+ build-backend = "poetry.core.masonry.api"
57
+
58
+ [tool.pytest.ini_options]
59
+ asyncio_mode = "auto"
60
+ testpaths = ["tests"]
61
+
62
+ [tool.ruff]
63
+ line-length = 100
64
+ target-version = "py311"
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "F", "I", "UP", "B", "SIM"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.11"
71
+ strict = true
72
+ warn_unused_ignores = false