dothedev-sandbox 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,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+
8
+ # Node
9
+ node_modules/
10
+ .next/
11
+ out/
12
+ *.tsbuildinfo
13
+ next-env.d.ts
14
+
15
+ # Env and local files
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shahraiz ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: dothedev-sandbox
3
+ Version: 0.1.0
4
+ Summary: Deep agents sandbox backend for DoTheDev Agent Platform sandboxes
5
+ Author: shahraiz ali
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agents,deepagents,langchain,langgraph,sandbox
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: deepagents>=0.7.28
18
+ Requires-Dist: httpx>=0.28.1
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dothedev-sandbox
22
+
23
+ A [deep agents](https://docs.langchain.com/oss/python/deepagents/sandboxes) sandbox backend for agents
24
+ deployed on the DoTheDev Agent Platform. The platform runs each sandbox in a locked-down container (no
25
+ secrets, no network unless the template allows it) and gives deployed agents `DOTHEDEV_SANDBOX_URL` and
26
+ `DOTHEDEV_SANDBOX_TOKEN`. The token stays in the agent server; only commands and files reach the sandbox.
27
+
28
+ ```bash
29
+ pip install dothedev-sandbox
30
+ ```
31
+
32
+ ```python
33
+ from deepagents import create_deep_agent
34
+ from dothedev_sandbox import ThreadSandbox
35
+
36
+ # One sandbox per conversation, kept across turns
37
+ agent = create_deep_agent(
38
+ model="openai:gpt-4o-mini",
39
+ backend=ThreadSandbox(),
40
+ )
41
+ ```
42
+
43
+ `ThreadSandbox` sends each command and file call to the sandbox of the run's `configurable.thread_id`,
44
+ created on first use. Pass `template=` to pick one of the templates the platform offers.
45
+
46
+ A sandbox shared by every thread instead:
47
+
48
+ ```python
49
+ from dothedev_sandbox import SandboxClient
50
+
51
+ shared = SandboxClient().get_or_create("shared:research")
52
+ agent = create_deep_agent(model=..., backend=shared)
53
+ ```
54
+
55
+ Locally, point it at a platform API and a deployment's token:
56
+
57
+ ```bash
58
+ export DOTHEDEV_SANDBOX_URL=http://localhost:8100
59
+ export DOTHEDEV_SANDBOX_TOKEN=sbx_...
60
+ ```
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,44 @@
1
+ # dothedev-sandbox
2
+
3
+ A [deep agents](https://docs.langchain.com/oss/python/deepagents/sandboxes) sandbox backend for agents
4
+ deployed on the DoTheDev Agent Platform. The platform runs each sandbox in a locked-down container (no
5
+ secrets, no network unless the template allows it) and gives deployed agents `DOTHEDEV_SANDBOX_URL` and
6
+ `DOTHEDEV_SANDBOX_TOKEN`. The token stays in the agent server; only commands and files reach the sandbox.
7
+
8
+ ```bash
9
+ pip install dothedev-sandbox
10
+ ```
11
+
12
+ ```python
13
+ from deepagents import create_deep_agent
14
+ from dothedev_sandbox import ThreadSandbox
15
+
16
+ # One sandbox per conversation, kept across turns
17
+ agent = create_deep_agent(
18
+ model="openai:gpt-4o-mini",
19
+ backend=ThreadSandbox(),
20
+ )
21
+ ```
22
+
23
+ `ThreadSandbox` sends each command and file call to the sandbox of the run's `configurable.thread_id`,
24
+ created on first use. Pass `template=` to pick one of the templates the platform offers.
25
+
26
+ A sandbox shared by every thread instead:
27
+
28
+ ```python
29
+ from dothedev_sandbox import SandboxClient
30
+
31
+ shared = SandboxClient().get_or_create("shared:research")
32
+ agent = create_deep_agent(model=..., backend=shared)
33
+ ```
34
+
35
+ Locally, point it at a platform API and a deployment's token:
36
+
37
+ ```bash
38
+ export DOTHEDEV_SANDBOX_URL=http://localhost:8100
39
+ export DOTHEDEV_SANDBOX_TOKEN=sbx_...
40
+ ```
41
+
42
+ ## License
43
+
44
+ MIT
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "dothedev-sandbox"
3
+ version = "0.1.0"
4
+ description = "Deep agents sandbox backend for DoTheDev Agent Platform sandboxes"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "shahraiz ali" }]
9
+ keywords = ["deepagents", "langgraph", "langchain", "sandbox", "agents"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Typing :: Typed",
18
+ ]
19
+ requires-python = ">=3.11"
20
+ dependencies = [
21
+ "deepagents>=0.7.28",
22
+ "httpx>=0.28.1",
23
+ ]
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "basedpyright>=1.40.1",
28
+ "black>=26.5.1",
29
+ "pytest>=9.1.1",
30
+ "ruff>=0.16.10",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/dothedev_sandbox"]
39
+
40
+ # Only these go into the published source archive
41
+ [tool.hatch.build.targets.sdist]
42
+ only-include = ["src/dothedev_sandbox", "tests", "README.md", "LICENSE", "pyproject.toml"]
43
+
44
+ [tool.ruff]
45
+ line-length = 120
46
+ target-version = "py311"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "W", "I", "B"]
50
+ ignore = ["E501"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.basedpyright]
56
+ include = ["src", "tests"]
57
+ pythonVersion = "3.11"
58
+ venvPath = "."
59
+ venv = ".venv"
60
+ typeCheckingMode = "standard"
61
+
62
+ [tool.black]
63
+ line-length = 120
64
+ target-version = ["py311"]
@@ -0,0 +1,5 @@
1
+ """Deep agents backend for DoTheDev Agent Platform sandboxes (docs/sandboxes.md in the platform repo)."""
2
+
3
+ from dothedev_sandbox.client import DoTheDevSandbox, SandboxClient, SandboxError, ThreadSandbox, sandbox_for_thread
4
+
5
+ __all__ = ["DoTheDevSandbox", "SandboxClient", "SandboxError", "ThreadSandbox", "sandbox_for_thread"]
@@ -0,0 +1,455 @@
1
+ """A deep agents sandbox backend that runs on the DoTheDev Agent Platform.
2
+
3
+ The platform gives each deployed revision `DOTHEDEV_SANDBOX_URL` and `DOTHEDEV_SANDBOX_TOKEN`. The token
4
+ stays in the agent server: commands and files go to the platform, which runs them in a locked-down
5
+ container with no secrets and, by default, no network.
6
+
7
+ from deepagents import create_deep_agent
8
+ from dothedev_sandbox import ThreadSandbox
9
+
10
+ agent = create_deep_agent(model=..., backend=ThreadSandbox())
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import base64
17
+ import os
18
+ import threading
19
+ from collections import OrderedDict
20
+ from typing import Any
21
+
22
+ import httpx
23
+ from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse
24
+ from deepagents.backends.sandbox import BaseSandbox
25
+
26
+ # Files per upload or download request; the platform takes at most 100
27
+ BATCH = 100
28
+ # Added to a command's own time limit for the HTTP request carrying it
29
+ SLACK_SECONDS = 30
30
+ # Sandboxes `sandbox_for_thread` remembers per process
31
+ CACHED_THREADS = 256
32
+
33
+
34
+ class SandboxError(RuntimeError):
35
+ """The platform refused or failed a sandbox call.
36
+
37
+ Attributes:
38
+ status_code: The HTTP status the platform answered with.
39
+ """
40
+
41
+ def __init__(self, status_code: int, message: str) -> None:
42
+ super().__init__(f"Sandbox API {status_code}: {message}")
43
+ self.status_code = status_code
44
+
45
+
46
+ def _detail(response: httpx.Response) -> str:
47
+ try:
48
+ detail = response.json().get("detail")
49
+ except ValueError:
50
+ detail = None
51
+ return str(detail or response.text or response.reason_phrase)
52
+
53
+
54
+ def _checked(response: httpx.Response) -> dict[str, Any]:
55
+ if response.status_code >= 400:
56
+ raise SandboxError(response.status_code, _detail(response))
57
+ return response.json() if response.content else {}
58
+
59
+
60
+ class SandboxClient:
61
+ """Talks to the platform's sandbox API as one deployment.
62
+
63
+ Args:
64
+ url: The platform API's base URL. Defaults to `DOTHEDEV_SANDBOX_URL`.
65
+ token: The deployment's sandbox token. Defaults to `DOTHEDEV_SANDBOX_TOKEN`.
66
+ default_timeout: Seconds a command may run when the caller gives no limit; None leaves it to the
67
+ platform's default.
68
+ transport: An httpx transport for the synchronous client (tests use a mock one).
69
+ async_transport: An httpx transport for the asynchronous client.
70
+
71
+ Raises:
72
+ ValueError: If no URL or token is given or set in the environment.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ url: str | None = None,
78
+ token: str | None = None,
79
+ *,
80
+ default_timeout: int | None = None,
81
+ transport: httpx.BaseTransport | None = None,
82
+ async_transport: httpx.AsyncBaseTransport | None = None,
83
+ ) -> None:
84
+ url = url or os.environ.get("DOTHEDEV_SANDBOX_URL")
85
+ token = token or os.environ.get("DOTHEDEV_SANDBOX_TOKEN")
86
+ if not url or not token:
87
+ raise ValueError(
88
+ "Set DOTHEDEV_SANDBOX_URL and DOTHEDEV_SANDBOX_TOKEN (the platform sets both on deployed agents)"
89
+ )
90
+ self.base_url = f"{url.rstrip('/')}/sandbox/v1"
91
+ self.default_timeout = default_timeout
92
+ self._headers = {"authorization": f"Bearer {token}"}
93
+ self._transport = transport
94
+ self._async_transport = async_transport
95
+ self._http = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=60, transport=transport)
96
+ self._async_http: httpx.AsyncClient | None = None
97
+
98
+ def _async(self) -> httpx.AsyncClient:
99
+ """The async client, made on first use; it belongs to the event loop that first used it."""
100
+ if self._async_http is None:
101
+ self._async_http = httpx.AsyncClient(
102
+ base_url=self.base_url, headers=self._headers, timeout=60, transport=self._async_transport
103
+ )
104
+ return self._async_http
105
+
106
+ def request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
107
+ """Send one request and return its JSON body.
108
+
109
+ Args:
110
+ method: The HTTP method.
111
+ path: The path under `/sandbox/v1`.
112
+ timeout: Seconds to wait for the answer; the client's default when None.
113
+ **kwargs: Passed on to httpx (`json`, `params`).
114
+
115
+ Returns:
116
+ The decoded JSON body; an empty dict for an empty one.
117
+
118
+ Raises:
119
+ SandboxError: If the platform answers with an error status.
120
+ """
121
+ options: dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
122
+ return _checked(self._http.request(method, path, **options, **kwargs))
123
+
124
+ async def arequest(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
125
+ """Async version of `request`.
126
+
127
+ Args:
128
+ method: The HTTP method.
129
+ path: The path under `/sandbox/v1`.
130
+ timeout: Seconds to wait for the answer; the client's default when None.
131
+ **kwargs: Passed on to httpx (`json`, `params`).
132
+
133
+ Returns:
134
+ The decoded JSON body; an empty dict for an empty one.
135
+
136
+ Raises:
137
+ SandboxError: If the platform answers with an error status.
138
+ """
139
+ options: dict[str, Any] = {"timeout": timeout} if timeout is not None else {}
140
+ return _checked(await self._async().request(method, path, **options, **kwargs))
141
+
142
+ def get_or_create(self, key: str, *, template: str | None = None, thread_id: str | None = None) -> DoTheDevSandbox:
143
+ """The deployment's sandbox with this key, created if new and running when this returns.
144
+
145
+ Args:
146
+ key: The agent's name for it, unique per deployment: `thread:<thread id>` for one per
147
+ conversation, anything else for a shared one.
148
+ template: A template the platform offers; its default when None.
149
+ thread_id: The thread it belongs to, so the console shows it on that thread.
150
+
151
+ Returns:
152
+ A deep agents backend for the sandbox.
153
+
154
+ Raises:
155
+ SandboxError: If the platform refuses (unknown template, org limit reached) or can't start it.
156
+ """
157
+ body = {"key": key, "template": template, "thread_id": thread_id}
158
+ info = self.request("POST", "/sandboxes", json=body)
159
+ return DoTheDevSandbox(self, info, template=template)
160
+
161
+ def close(self) -> None:
162
+ """Close the synchronous client's connections."""
163
+ self._http.close()
164
+
165
+ async def aclose(self) -> None:
166
+ """Close both clients' connections."""
167
+ self._http.close()
168
+ if self._async_http is not None:
169
+ await self._async_http.aclose()
170
+ self._async_http = None
171
+
172
+
173
+ class DoTheDevSandbox(BaseSandbox):
174
+ """One platform sandbox as a deep agents backend.
175
+
176
+ Files, `ls`, `grep`, `glob` and edits come from `BaseSandbox`, built on `execute`. A sandbox the platform
177
+ removed (after its retention period, or by a member) is got again under the same key, empty, and the
178
+ call retried once.
179
+
180
+ Args:
181
+ client: The client to call the platform with.
182
+ info: The sandbox as the platform returned it.
183
+ template: The template it was asked for, used when it has to be got again.
184
+ """
185
+
186
+ def __init__(self, client: SandboxClient, info: dict[str, Any], *, template: str | None = None) -> None:
187
+ self._client = client
188
+ self._info = info
189
+ self._template = template
190
+
191
+ @property
192
+ def id(self) -> str:
193
+ """The platform's id for the sandbox."""
194
+ return str(self._info["id"])
195
+
196
+ @property
197
+ def key(self) -> str:
198
+ """The key the sandbox was got by."""
199
+ return str(self._info["key"])
200
+
201
+ def _renew(self) -> None:
202
+ body = {"key": self.key, "template": self._template, "thread_id": self._info.get("thread_id")}
203
+ self._info = self._client.request("POST", "/sandboxes", json=body)
204
+
205
+ async def _arenew(self) -> None:
206
+ body = {"key": self.key, "template": self._template, "thread_id": self._info.get("thread_id")}
207
+ self._info = await self._client.arequest("POST", "/sandboxes", json=body)
208
+
209
+ def _call(self, method: str, suffix: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
210
+ try:
211
+ return self._client.request(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
212
+ except SandboxError as error:
213
+ if error.status_code != 404:
214
+ raise
215
+ self._renew()
216
+ return self._client.request(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
217
+
218
+ async def _acall(self, method: str, suffix: str, *, timeout: float | None = None, **kwargs: Any) -> dict[str, Any]:
219
+ try:
220
+ return await self._client.arequest(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
221
+ except SandboxError as error:
222
+ if error.status_code != 404:
223
+ raise
224
+ await self._arenew()
225
+ return await self._client.arequest(method, f"/sandboxes/{self.id}{suffix}", timeout=timeout, **kwargs)
226
+
227
+ def _exec_request(self, command: str, timeout: int | None) -> tuple[dict[str, Any], float | None]:
228
+ limit = timeout if timeout is not None else self._client.default_timeout
229
+ body: dict[str, Any] = {"command": command}
230
+ if limit:
231
+ body["timeout"] = limit
232
+ # Without a limit the platform's default applies (at most 30 minutes): wait as long as it may
233
+ wait = (limit or 1800) + SLACK_SECONDS
234
+ return body, wait
235
+
236
+ @staticmethod
237
+ def _response(result: dict[str, Any]) -> ExecuteResponse:
238
+ output = str(result.get("output", ""))
239
+ # Deep agents notes truncation itself, from `truncated`; it has no field for a timeout
240
+ if result.get("timed_out"):
241
+ output += "\n[Command timed out and was stopped]"
242
+ return ExecuteResponse(
243
+ output=output, exit_code=result.get("exit_code"), truncated=bool(result.get("truncated"))
244
+ )
245
+
246
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
247
+ """Run a shell command in the sandbox's workspace (`/app`).
248
+
249
+ Args:
250
+ command: The shell command, run with `sh -c`.
251
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
252
+
253
+ Returns:
254
+ Combined stdout and stderr, the exit code, and whether output was cut.
255
+ """
256
+ body, wait = self._exec_request(command, timeout)
257
+ return self._response(self._call("POST", "/exec", json=body, timeout=wait))
258
+
259
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
260
+ """Async version of `execute`, on the client's async connection pool.
261
+
262
+ Args:
263
+ command: The shell command, run with `sh -c`.
264
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
265
+
266
+ Returns:
267
+ Combined stdout and stderr, the exit code, and whether output was cut.
268
+ """
269
+ body, wait = self._exec_request(command, timeout)
270
+ return self._response(await self._acall("POST", "/exec", json=body, timeout=wait))
271
+
272
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
273
+ """Write files into the sandbox, creating parent directories.
274
+
275
+ Args:
276
+ files: (absolute path, content) pairs.
277
+
278
+ Returns:
279
+ One response per file, in order; `error` is set for the ones that failed.
280
+ """
281
+ responses: list[FileUploadResponse] = []
282
+ for start in range(0, len(files), BATCH):
283
+ batch = files[start : start + BATCH]
284
+ body = {"files": [{"path": path, "content": base64.b64encode(content).decode()} for path, content in batch]}
285
+ try:
286
+ results = self._call("POST", "/files/upload", json=body)["results"]
287
+ except SandboxError as error:
288
+ responses += [FileUploadResponse(path=path, error=str(error)) for path, _ in batch]
289
+ continue
290
+ responses += [FileUploadResponse(path=item["path"], error=item["error"]) for item in results]
291
+ return responses
292
+
293
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
294
+ """Read files from the sandbox.
295
+
296
+ Args:
297
+ paths: Absolute paths.
298
+
299
+ Returns:
300
+ One response per path, in order, with `content` or `error`.
301
+ """
302
+ responses: list[FileDownloadResponse] = []
303
+ for start in range(0, len(paths), BATCH):
304
+ batch = paths[start : start + BATCH]
305
+ try:
306
+ results = self._call("POST", "/files/download", json={"paths": batch})["results"]
307
+ except SandboxError as error:
308
+ responses += [FileDownloadResponse(path=path, content=None, error=str(error)) for path in batch]
309
+ continue
310
+ responses += [
311
+ FileDownloadResponse(
312
+ path=item["path"],
313
+ content=base64.b64decode(item["content"]) if item["content"] is not None else None,
314
+ error=item["error"],
315
+ )
316
+ for item in results
317
+ ]
318
+ return responses
319
+
320
+ def destroy(self) -> None:
321
+ """Remove the sandbox and its files. The next call with its key gets a new, empty one."""
322
+ try:
323
+ self._client.request("DELETE", f"/sandboxes/{self.id}")
324
+ except SandboxError as error:
325
+ if error.status_code != 404:
326
+ raise
327
+
328
+
329
+ _lock = threading.Lock()
330
+ _client: SandboxClient | None = None
331
+ _threads: OrderedDict[tuple[str, str | None], DoTheDevSandbox] = OrderedDict()
332
+
333
+
334
+ def _shared_client() -> SandboxClient:
335
+ global _client
336
+ with _lock:
337
+ if _client is None:
338
+ _client = SandboxClient()
339
+ return _client
340
+
341
+
342
+ def sandbox_for_thread(thread_id: str | None = None, *, template: str | None = None) -> DoTheDevSandbox:
343
+ """The current thread's sandbox: one per conversation, kept across turns.
344
+
345
+ `ThreadSandbox` calls this for each command; call it directly for a sandbox outside the agent loop.
346
+ The platform resumes a stopped sandbox on its next command, so a remembered one stays usable.
347
+
348
+ Args:
349
+ thread_id: The thread; read from the run's config (`configurable.thread_id`) when None.
350
+ template: A template the platform offers; its default when None.
351
+
352
+ Returns:
353
+ The thread's sandbox backend.
354
+
355
+ Raises:
356
+ ValueError: If there is no thread id (the agent must run on a thread).
357
+ SandboxError: If the platform refuses or can't start the sandbox.
358
+ """
359
+ if thread_id is None:
360
+ from langgraph.config import get_config
361
+
362
+ thread_id = get_config().get("configurable", {}).get("thread_id")
363
+ if not thread_id:
364
+ raise ValueError("No thread_id: run the agent on a thread to give it a sandbox")
365
+ cache_key = (str(thread_id), template)
366
+ with _lock:
367
+ cached = _threads.get(cache_key)
368
+ if cached is not None:
369
+ _threads.move_to_end(cache_key)
370
+ return cached
371
+ sandbox = _shared_client().get_or_create(f"thread:{thread_id}", template=template, thread_id=str(thread_id))
372
+ with _lock:
373
+ _threads[cache_key] = sandbox
374
+ while len(_threads) > CACHED_THREADS:
375
+ _threads.popitem(last=False)
376
+ return sandbox
377
+
378
+
379
+ class ThreadSandbox(BaseSandbox):
380
+ """A deep agents backend that runs each call in the current thread's sandbox.
381
+
382
+ Deep agents 0.7 takes a backend instance, not a factory, so the agent is built once with this and every
383
+ command and file call is sent to `sandbox_for_thread()` for the run's `configurable.thread_id`: one
384
+ sandbox per conversation, got on its first call.
385
+
386
+ agent = create_deep_agent(model=..., backend=ThreadSandbox())
387
+
388
+ Args:
389
+ template: A template the platform offers; its default when None.
390
+ """
391
+
392
+ def __init__(self, *, template: str | None = None) -> None:
393
+ self._template = template
394
+
395
+ def _sandbox(self) -> DoTheDevSandbox:
396
+ return sandbox_for_thread(template=self._template)
397
+
398
+ @property
399
+ def id(self) -> str:
400
+ """The platform's id for the current thread's sandbox."""
401
+ return self._sandbox().id
402
+
403
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
404
+ """Run a shell command in the current thread's sandbox.
405
+
406
+ Args:
407
+ command: The shell command, run with `sh -c`.
408
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
409
+
410
+ Returns:
411
+ Combined stdout and stderr, the exit code, and whether output was cut.
412
+
413
+ Raises:
414
+ RuntimeError: If called outside a run, so there is no thread.
415
+ """
416
+ return self._sandbox().execute(command, timeout=timeout)
417
+
418
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
419
+ """Async version of `execute`; the sandbox is got off the event loop.
420
+
421
+ Args:
422
+ command: The shell command, run with `sh -c`.
423
+ timeout: Seconds before the command is stopped; the client's or platform's default when None.
424
+
425
+ Returns:
426
+ Combined stdout and stderr, the exit code, and whether output was cut.
427
+
428
+ Raises:
429
+ RuntimeError: If called outside a run, so there is no thread.
430
+ """
431
+ # to_thread copies the run's context, so the thread id is still there
432
+ sandbox = await asyncio.to_thread(self._sandbox)
433
+ return await sandbox.aexecute(command, timeout=timeout)
434
+
435
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
436
+ """Write files into the current thread's sandbox, creating parent directories.
437
+
438
+ Args:
439
+ files: (absolute path, content) pairs.
440
+
441
+ Returns:
442
+ One response per file, in order; `error` is set for the ones that failed.
443
+ """
444
+ return self._sandbox().upload_files(files)
445
+
446
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
447
+ """Read files from the current thread's sandbox.
448
+
449
+ Args:
450
+ paths: Absolute paths.
451
+
452
+ Returns:
453
+ One response per path, in order, with `content` or `error`.
454
+ """
455
+ return self._sandbox().download_files(paths)
File without changes
@@ -0,0 +1,213 @@
1
+ """The backend against a fake sandbox API with the platform's request and response shapes."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import pytest
11
+ from deepagents import create_deep_agent
12
+ from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
13
+ from langchain_core.messages import AIMessage
14
+ from langgraph.checkpoint.memory import InMemorySaver
15
+
16
+ import dothedev_sandbox.client as module
17
+ from dothedev_sandbox import SandboxClient, SandboxError, ThreadSandbox, sandbox_for_thread
18
+
19
+
20
+ @dataclass
21
+ class FakeApi:
22
+ token: str = "sbx_test"
23
+ sandboxes: dict[str, dict[str, Any]] = field(default_factory=dict)
24
+ files: dict[str, bytes] = field(default_factory=dict)
25
+ requests: list[httpx.Request] = field(default_factory=list)
26
+
27
+ def handle(self, request: httpx.Request) -> httpx.Response:
28
+ self.requests.append(request)
29
+ if request.headers.get("authorization") != f"Bearer {self.token}":
30
+ return httpx.Response(401, json={"detail": "Send the token"})
31
+ path = request.url.path.removeprefix("/sandbox/v1")
32
+ body = json.loads(request.content) if request.content else {}
33
+ if path == "/sandboxes" and request.method == "POST":
34
+ if body["template"] == "missing":
35
+ return httpx.Response(422, json={"detail": "Unknown sandbox template"})
36
+ existing = next((item for item in self.sandboxes.values() if item["key"] == body["key"]), None)
37
+ if existing is None:
38
+ existing = {"id": f"sb-{len(self.sandboxes) + 1}", "key": body["key"], "thread_id": body["thread_id"]}
39
+ self.sandboxes[existing["id"]] = existing
40
+ return httpx.Response(200, json=existing)
41
+ parts = path.split("/")
42
+ if parts[2] not in self.sandboxes:
43
+ return httpx.Response(404, json={"detail": "Sandbox not found"})
44
+ action = "/".join(parts[3:])
45
+ if action == "exec":
46
+ command = body["command"]
47
+ if command == "slow":
48
+ return httpx.Response(200, json={"output": "", "exit_code": 124, "truncated": False, "timed_out": True})
49
+ if command == "loud":
50
+ return httpx.Response(
51
+ 200, json={"output": "y\ny", "exit_code": 0, "truncated": True, "timed_out": False}
52
+ )
53
+ return httpx.Response(
54
+ 200, json={"output": f"ran {command}", "exit_code": 0, "truncated": False, "timed_out": False}
55
+ )
56
+ if action == "files/upload":
57
+ for item in body["files"]:
58
+ self.files[item["path"]] = base64.b64decode(item["content"])
59
+ return httpx.Response(
60
+ 200, json={"results": [{"path": item["path"], "error": None} for item in body["files"]]}
61
+ )
62
+ if action == "files/download":
63
+ results = [
64
+ (
65
+ {"path": path, "content": base64.b64encode(self.files[path]).decode(), "error": None}
66
+ if path in self.files
67
+ else {"path": path, "content": None, "error": "file_not_found"}
68
+ )
69
+ for path in body["paths"]
70
+ ]
71
+ return httpx.Response(200, json={"results": results})
72
+ if action == "" and request.method == "DELETE":
73
+ del self.sandboxes[parts[2]]
74
+ return httpx.Response(204)
75
+ return httpx.Response(405)
76
+
77
+
78
+ @pytest.fixture
79
+ def api() -> FakeApi:
80
+ return FakeApi()
81
+
82
+
83
+ @pytest.fixture
84
+ def client(api: FakeApi) -> SandboxClient:
85
+ return SandboxClient(
86
+ "http://platform.test/",
87
+ api.token,
88
+ transport=httpx.MockTransport(api.handle),
89
+ async_transport=httpx.MockTransport(api.handle),
90
+ )
91
+
92
+
93
+ def test_needs_url_and_token(monkeypatch: pytest.MonkeyPatch) -> None:
94
+ monkeypatch.delenv("DOTHEDEV_SANDBOX_URL", raising=False)
95
+ monkeypatch.delenv("DOTHEDEV_SANDBOX_TOKEN", raising=False)
96
+ with pytest.raises(ValueError, match="DOTHEDEV_SANDBOX_URL"):
97
+ SandboxClient()
98
+
99
+
100
+ def test_execute_and_errors(api: FakeApi, client: SandboxClient) -> None:
101
+ sandbox = client.get_or_create("thread:t1", thread_id="t1")
102
+ assert sandbox.id == "sb-1" and sandbox.key == "thread:t1"
103
+ result = sandbox.execute("ls", timeout=5)
104
+ assert (result.output, result.exit_code, result.truncated) == ("ran ls", 0, False)
105
+ assert json.loads(api.requests[-1].content) == {"command": "ls", "timeout": 5}
106
+ assert "timed out" in sandbox.execute("slow").output
107
+ # Deep agents adds its own truncation note from `truncated`; the output stays as the sandbox gave it
108
+ assert (sandbox.execute("loud").output, sandbox.execute("loud").truncated) == ("y\ny", True)
109
+ with pytest.raises(SandboxError) as error:
110
+ client.get_or_create("x", template="missing")
111
+ assert error.value.status_code == 422
112
+
113
+
114
+ def test_a_removed_sandbox_is_got_again(api: FakeApi, client: SandboxClient) -> None:
115
+ sandbox = client.get_or_create("thread:t1", thread_id="t1")
116
+ api.sandboxes.clear()
117
+ assert sandbox.execute("ls").output == "ran ls"
118
+ assert sandbox.id == "sb-1" and list(api.sandboxes) == ["sb-1"]
119
+ assert api.sandboxes["sb-1"]["thread_id"] == "t1"
120
+
121
+
122
+ def test_files_round_trip_in_batches(api: FakeApi, client: SandboxClient, monkeypatch: pytest.MonkeyPatch) -> None:
123
+ monkeypatch.setattr(module, "BATCH", 2)
124
+ sandbox = client.get_or_create("k")
125
+ files = [(f"/app/{index}.txt", f"file {index}".encode()) for index in range(5)]
126
+ assert [item.error for item in sandbox.upload_files(files)] == [None] * 5
127
+ uploads = [request for request in api.requests if request.url.path.endswith("/files/upload")]
128
+ assert len(uploads) == 3
129
+ downloaded = sandbox.download_files(["/app/0.txt", "/app/4.txt", "/nope"])
130
+ assert [(item.content, item.error) for item in downloaded] == [
131
+ (b"file 0", None),
132
+ (b"file 4", None),
133
+ (None, "file_not_found"),
134
+ ]
135
+
136
+
137
+ def test_async_execute(client: SandboxClient) -> None:
138
+ async def main() -> str:
139
+ sandbox = client.get_or_create("k")
140
+ result = await sandbox.aexecute("pwd")
141
+ await client.aclose()
142
+ return result.output
143
+
144
+ assert asyncio.run(main()) == "ran pwd"
145
+
146
+
147
+ def test_destroy_is_safe_to_repeat(api: FakeApi, client: SandboxClient) -> None:
148
+ sandbox = client.get_or_create("k")
149
+ sandbox.destroy()
150
+ sandbox.destroy()
151
+ assert api.sandboxes == {}
152
+
153
+
154
+ def test_sandbox_for_thread_remembers_each_thread(
155
+ api: FakeApi, client: SandboxClient, monkeypatch: pytest.MonkeyPatch
156
+ ) -> None:
157
+ monkeypatch.setattr(module, "_client", client)
158
+ monkeypatch.setattr(module, "_threads", module.OrderedDict())
159
+ first = sandbox_for_thread("t1")
160
+ assert sandbox_for_thread("t1") is first
161
+ assert sandbox_for_thread("t2").id != first.id
162
+ creates = [request for request in api.requests if request.url.path == "/sandbox/v1/sandboxes"]
163
+ assert [json.loads(request.content)["key"] for request in creates] == ["thread:t1", "thread:t2"]
164
+ with pytest.raises(ValueError, match="thread"):
165
+ sandbox_for_thread("")
166
+
167
+
168
+ class ScriptedModel(GenericFakeChatModel):
169
+ def bind_tools(self, tools: Any, **kwargs: Any) -> "ScriptedModel":
170
+ return self
171
+
172
+
173
+ def _sqrt_agent() -> Any:
174
+ call = {"name": "execute", "id": "c1", "args": {"command": "python3 -c 'print(4 ** 0.5)'"}}
175
+ model = ScriptedModel(messages=iter([AIMessage(content="", tool_calls=[call]), AIMessage(content="2.0")] * 2))
176
+ return create_deep_agent(model=model, backend=ThreadSandbox(), checkpointer=InMemorySaver())
177
+
178
+
179
+ def test_thread_sandbox_runs_each_threads_commands_in_its_own_sandbox(
180
+ api: FakeApi, client: SandboxClient, monkeypatch: pytest.MonkeyPatch
181
+ ) -> None:
182
+ monkeypatch.setattr(module, "_client", client)
183
+ monkeypatch.setattr(module, "_threads", module.OrderedDict())
184
+ # Deep agents 0.7 takes backend instances only; a factory fails when the agent is built
185
+ agent = _sqrt_agent()
186
+ for thread in ("t1", "t2"):
187
+ out = agent.invoke(
188
+ {"messages": [{"role": "user", "content": "sqrt 4"}]}, {"configurable": {"thread_id": thread}}
189
+ )
190
+ assert "ran python3" in out["messages"][2].content
191
+ execs = [request.url.path for request in api.requests if request.url.path.endswith("/exec")]
192
+ assert execs == ["/sandbox/v1/sandboxes/sb-1/exec", "/sandbox/v1/sandboxes/sb-2/exec"]
193
+ assert [item["key"] for item in api.sandboxes.values()] == ["thread:t1", "thread:t2"]
194
+
195
+
196
+ def test_thread_sandbox_async(api: FakeApi, client: SandboxClient, monkeypatch: pytest.MonkeyPatch) -> None:
197
+ monkeypatch.setattr(module, "_client", client)
198
+ monkeypatch.setattr(module, "_threads", module.OrderedDict())
199
+
200
+ async def main() -> str:
201
+ out = await _sqrt_agent().ainvoke(
202
+ {"messages": [{"role": "user", "content": "sqrt 4"}]}, {"configurable": {"thread_id": "t1"}}
203
+ )
204
+ await client.aclose()
205
+ return str(out["messages"][2].content)
206
+
207
+ assert "ran python3" in asyncio.run(main())
208
+ assert [item["key"] for item in api.sandboxes.values()] == ["thread:t1"]
209
+
210
+
211
+ def test_thread_sandbox_needs_a_thread() -> None:
212
+ with pytest.raises(RuntimeError):
213
+ ThreadSandbox().execute("ls")