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.
- sandbox_agents-0.1.0/PKG-INFO +267 -0
- sandbox_agents-0.1.0/README.md +244 -0
- sandbox_agents-0.1.0/pyproject.toml +72 -0
- sandbox_agents-0.1.0/src/sandbox_agents/__init__.py +166 -0
- sandbox_agents-0.1.0/src/sandbox_agents/_calls.py +711 -0
- sandbox_agents-0.1.0/src/sandbox_agents/_sse.py +114 -0
- sandbox_agents-0.1.0/src/sandbox_agents/_transport.py +380 -0
- sandbox_agents-0.1.0/src/sandbox_agents/_types.py +95 -0
- sandbox_agents-0.1.0/src/sandbox_agents/_waiting.py +149 -0
- sandbox_agents-0.1.0/src/sandbox_agents/async_client.py +120 -0
- sandbox_agents-0.1.0/src/sandbox_agents/async_resources.py +765 -0
- sandbox_agents-0.1.0/src/sandbox_agents/client.py +158 -0
- sandbox_agents-0.1.0/src/sandbox_agents/errors.py +227 -0
- sandbox_agents-0.1.0/src/sandbox_agents/events.py +91 -0
- sandbox_agents-0.1.0/src/sandbox_agents/models.py +762 -0
- sandbox_agents-0.1.0/src/sandbox_agents/py.typed +0 -0
- sandbox_agents-0.1.0/src/sandbox_agents/resources.py +927 -0
- sandbox_agents-0.1.0/src/sandbox_agents/tools.py +93 -0
- sandbox_agents-0.1.0/src/sandbox_agents/webhooks.py +115 -0
|
@@ -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
|