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.
- dothedev_sandbox-0.1.0/.gitignore +19 -0
- dothedev_sandbox-0.1.0/LICENSE +21 -0
- dothedev_sandbox-0.1.0/PKG-INFO +64 -0
- dothedev_sandbox-0.1.0/README.md +44 -0
- dothedev_sandbox-0.1.0/pyproject.toml +64 -0
- dothedev_sandbox-0.1.0/src/dothedev_sandbox/__init__.py +5 -0
- dothedev_sandbox-0.1.0/src/dothedev_sandbox/client.py +455 -0
- dothedev_sandbox-0.1.0/src/dothedev_sandbox/py.typed +0 -0
- dothedev_sandbox-0.1.0/tests/test_client.py +213 -0
|
@@ -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")
|