agentboxes 0.2.0__py3-none-any.whl
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.
agentbox/__init__.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Python client for an agentbox server. Standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tarfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
12
|
+
from urllib import error, request
|
|
13
|
+
|
|
14
|
+
__all__ = ["Agentbox", "Sandbox", "ExecResult", "AgentboxError", "SandboxNotFound", "PoolExhausted", "ExecTimeout"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentboxError(Exception):
|
|
18
|
+
"""Any non-2xx answer from the server. `code` is the server's error name, for example ValidationError."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, status: int, code: str):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SandboxNotFound(AgentboxError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PoolExhausted(AgentboxError):
|
|
31
|
+
"""The server's live sandbox limit, or this key's limit, is reached."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ExecResult:
|
|
36
|
+
exit_code: int
|
|
37
|
+
stdout: str
|
|
38
|
+
stderr: str
|
|
39
|
+
duration_ms: int
|
|
40
|
+
timed_out: bool
|
|
41
|
+
truncated: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ExecTimeout(Exception):
|
|
45
|
+
"""The per-exec timeout killed the command. `result` holds the output collected before that."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, result: ExecResult):
|
|
48
|
+
super().__init__(f"command killed after {result.duration_ms} ms")
|
|
49
|
+
self.result = result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _result(data: Dict[str, Any]) -> ExecResult:
|
|
53
|
+
return ExecResult(data["exitCode"], data["stdout"], data["stderr"], data["durationMs"], data["timedOut"], data["truncated"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Agentbox:
|
|
57
|
+
def __init__(self, base_url: str = "http://127.0.0.1:7788", api_key: Optional[str] = None, timeout: float = 600.0):
|
|
58
|
+
self.base_url = base_url.rstrip("/")
|
|
59
|
+
self.api_key = api_key
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
def create(
|
|
63
|
+
self,
|
|
64
|
+
template: Optional[str] = None,
|
|
65
|
+
allow_hosts: Optional[List[str]] = None,
|
|
66
|
+
secrets: Optional[Dict[str, Union[str, Dict[str, str]]]] = None,
|
|
67
|
+
memory_mb: Optional[int] = None,
|
|
68
|
+
cpus: Optional[float] = None,
|
|
69
|
+
disk_mb: Optional[int] = None,
|
|
70
|
+
pids: Optional[int] = None,
|
|
71
|
+
ttl_seconds: Optional[int] = None,
|
|
72
|
+
) -> "Sandbox":
|
|
73
|
+
body = {
|
|
74
|
+
"template": template,
|
|
75
|
+
"allowHosts": allow_hosts,
|
|
76
|
+
"secrets": secrets,
|
|
77
|
+
"memoryMb": memory_mb,
|
|
78
|
+
"cpus": cpus,
|
|
79
|
+
"diskMb": disk_mb,
|
|
80
|
+
"pids": pids,
|
|
81
|
+
"ttlSeconds": ttl_seconds,
|
|
82
|
+
}
|
|
83
|
+
info = self._json("POST", "/sandboxes", {k: v for k, v in body.items() if v is not None})
|
|
84
|
+
return Sandbox(self, info)
|
|
85
|
+
|
|
86
|
+
def get(self, sandbox_id: str) -> "Sandbox":
|
|
87
|
+
return Sandbox(self, self._json("GET", f"/sandboxes/{sandbox_id}"))
|
|
88
|
+
|
|
89
|
+
def list(self, all: bool = False) -> List[Dict[str, Any]]:
|
|
90
|
+
return self._json("GET", "/sandboxes?all=true" if all else "/sandboxes")
|
|
91
|
+
|
|
92
|
+
def templates(self) -> List[Dict[str, Any]]:
|
|
93
|
+
return self._json("GET", "/templates")
|
|
94
|
+
|
|
95
|
+
def stats(self) -> Dict[str, Any]:
|
|
96
|
+
return self._json("GET", "/stats")
|
|
97
|
+
|
|
98
|
+
# Sends a request and raises a typed error for any non-2xx answer.
|
|
99
|
+
def request(self, method: str, path: str, body: Optional[bytes] = None, content_type: Optional[str] = None, stream: bool = False):
|
|
100
|
+
headers = {}
|
|
101
|
+
if self.api_key is not None:
|
|
102
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
103
|
+
if content_type is not None:
|
|
104
|
+
headers["Content-Type"] = content_type
|
|
105
|
+
req = request.Request(self.base_url + path, data=body, method=method, headers=headers)
|
|
106
|
+
try:
|
|
107
|
+
response = request.urlopen(req, timeout=self.timeout)
|
|
108
|
+
except error.HTTPError as http_error:
|
|
109
|
+
code, message = "HttpError", f"{method} {path} failed with status {http_error.code}"
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(http_error.read().decode("utf-8"))
|
|
112
|
+
code, message = payload.get("error", code), payload.get("message", message)
|
|
113
|
+
except (ValueError, UnicodeDecodeError):
|
|
114
|
+
pass
|
|
115
|
+
if http_error.code == 404:
|
|
116
|
+
raise SandboxNotFound(message, 404, code) from None
|
|
117
|
+
if http_error.code == 429:
|
|
118
|
+
raise PoolExhausted(message, 429, code) from None
|
|
119
|
+
raise AgentboxError(message, http_error.code, code) from None
|
|
120
|
+
if stream:
|
|
121
|
+
return response
|
|
122
|
+
with response:
|
|
123
|
+
return response.read()
|
|
124
|
+
|
|
125
|
+
def _json(self, method: str, path: str, body: Any = None) -> Any:
|
|
126
|
+
raw = self.request(method, path, None if body is None else json.dumps(body).encode("utf-8"), "application/json")
|
|
127
|
+
return json.loads(raw.decode("utf-8")) if raw else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
Event = Union[Tuple[str, str], Tuple[str, ExecResult]]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Sandbox:
|
|
134
|
+
def __init__(self, client: Agentbox, info: Dict[str, Any]):
|
|
135
|
+
self.client = client
|
|
136
|
+
self.info = info
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def id(self) -> str:
|
|
140
|
+
return self.info["id"]
|
|
141
|
+
|
|
142
|
+
def _exec_body(self, cmd: List[str], timeout_ms: Optional[int], cwd: Optional[str], env: Optional[Dict[str, str]]) -> Dict[str, Any]:
|
|
143
|
+
body: Dict[str, Any] = {"cmd": cmd}
|
|
144
|
+
if timeout_ms is not None:
|
|
145
|
+
body["timeoutMs"] = timeout_ms
|
|
146
|
+
if cwd is not None:
|
|
147
|
+
body["cwd"] = cwd
|
|
148
|
+
if env is not None:
|
|
149
|
+
body["env"] = env
|
|
150
|
+
return body
|
|
151
|
+
|
|
152
|
+
# Runs a command and waits for it. Raises ExecTimeout when the server killed it at the deadline.
|
|
153
|
+
def exec(self, cmd: List[str], timeout_ms: Optional[int] = None, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> ExecResult:
|
|
154
|
+
result = _result(self.client._json("POST", f"/sandboxes/{self.id}/exec", self._exec_body(cmd, timeout_ms, cwd, env)))
|
|
155
|
+
if result.timed_out:
|
|
156
|
+
raise ExecTimeout(result)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
# Yields ("stdout" | "stderr", line) as the command runs, then ("exit", ExecResult) with empty output fields.
|
|
160
|
+
def exec_stream(self, cmd: List[str], timeout_ms: Optional[int] = None, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> Iterator[Event]:
|
|
161
|
+
body = json.dumps(self._exec_body(cmd, timeout_ms, cwd, env)).encode("utf-8")
|
|
162
|
+
response = self.client.request("POST", f"/sandboxes/{self.id}/exec/stream", body, "application/json", stream=True)
|
|
163
|
+
pending = {"stdout": "", "stderr": ""}
|
|
164
|
+
with response:
|
|
165
|
+
for event, data in _sse(response):
|
|
166
|
+
if event in pending:
|
|
167
|
+
pending[event] += data
|
|
168
|
+
while "\n" in pending[event]:
|
|
169
|
+
line, pending[event] = pending[event].split("\n", 1)
|
|
170
|
+
yield (event, line + "\n")
|
|
171
|
+
elif event == "exit":
|
|
172
|
+
for name in ("stdout", "stderr"):
|
|
173
|
+
if pending[name]:
|
|
174
|
+
yield (name, pending[name])
|
|
175
|
+
yield ("exit", ExecResult(data["exitCode"], "", "", data["durationMs"], data["timedOut"], data["truncated"]))
|
|
176
|
+
elif event == "error":
|
|
177
|
+
raise AgentboxError(data["message"], 0, data["error"])
|
|
178
|
+
|
|
179
|
+
def write_file(self, path: str, content: Union[str, bytes]) -> None:
|
|
180
|
+
data = content.encode("utf-8") if isinstance(content, str) else content
|
|
181
|
+
self.client.request("PUT", f"/sandboxes/{self.id}/files?path={request.quote(path, safe='')}", data, "application/octet-stream")
|
|
182
|
+
|
|
183
|
+
def read_file(self, path: str) -> str:
|
|
184
|
+
return self.read_file_bytes(path).decode("utf-8")
|
|
185
|
+
|
|
186
|
+
def read_file_bytes(self, path: str) -> bytes:
|
|
187
|
+
return self.client.request("GET", f"/sandboxes/{self.id}/files?path={request.quote(path, safe='')}")
|
|
188
|
+
|
|
189
|
+
def upload_tar(self, tar: bytes, directory: str = "/workspace") -> None:
|
|
190
|
+
self.client.request("PUT", f"/sandboxes/{self.id}/archive?path={request.quote(directory, safe='')}", tar, "application/x-tar")
|
|
191
|
+
|
|
192
|
+
def download_tar(self, directory: str = "/workspace") -> bytes:
|
|
193
|
+
return self.client.request("GET", f"/sandboxes/{self.id}/archive?path={request.quote(directory, safe='')}")
|
|
194
|
+
|
|
195
|
+
# Sends a local directory's contents into the sandbox.
|
|
196
|
+
def upload_directory(self, local_dir: str, directory: str = "/workspace") -> None:
|
|
197
|
+
buffer = io.BytesIO()
|
|
198
|
+
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
199
|
+
for name in sorted(os.listdir(local_dir)):
|
|
200
|
+
archive.add(os.path.join(local_dir, name), arcname=name)
|
|
201
|
+
self.upload_tar(buffer.getvalue(), directory)
|
|
202
|
+
|
|
203
|
+
# Writes a sandbox directory's contents into a local directory, refusing members that would leave it.
|
|
204
|
+
def download_directory(self, directory: str, local_dir: str) -> None:
|
|
205
|
+
with tarfile.open(fileobj=io.BytesIO(self.download_tar(directory)), mode="r") as archive:
|
|
206
|
+
for member in archive.getmembers():
|
|
207
|
+
if member.name.startswith("/") or ".." in member.name.split("/"):
|
|
208
|
+
raise AgentboxError(f"archive from the sandbox has an unsafe path: {member.name}", 0, "UnsafeArchive")
|
|
209
|
+
archive.extractall(local_dir)
|
|
210
|
+
|
|
211
|
+
def egress_log(self) -> Dict[str, Any]:
|
|
212
|
+
return self.client._json("GET", f"/sandboxes/{self.id}/egress-log")
|
|
213
|
+
|
|
214
|
+
def extend(self, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
|
|
215
|
+
self.info = self.client._json("POST", f"/sandboxes/{self.id}/extend", {} if ttl_seconds is None else {"ttlSeconds": ttl_seconds})
|
|
216
|
+
return self.info
|
|
217
|
+
|
|
218
|
+
def refresh(self) -> Dict[str, Any]:
|
|
219
|
+
self.info = self.client._json("GET", f"/sandboxes/{self.id}")
|
|
220
|
+
return self.info
|
|
221
|
+
|
|
222
|
+
def destroy(self) -> None:
|
|
223
|
+
self.info = self.client._json("DELETE", f"/sandboxes/{self.id}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Parses a Server-Sent Events body into (event, data) pairs, with data JSON-decoded.
|
|
227
|
+
def _sse(response) -> Iterator[Tuple[str, Any]]:
|
|
228
|
+
event, data = None, None
|
|
229
|
+
for raw in response:
|
|
230
|
+
line = raw.decode("utf-8").rstrip("\n")
|
|
231
|
+
if line.startswith("event: "):
|
|
232
|
+
event = line[7:]
|
|
233
|
+
elif line.startswith("data: "):
|
|
234
|
+
data = line[6:]
|
|
235
|
+
elif line == "" and event is not None and data is not None:
|
|
236
|
+
yield event, json.loads(data)
|
|
237
|
+
event, data = None, None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentboxes
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for an agentbox server: sandboxes for AI agents on your own Linux box
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# agentbox (Python client)
|
|
10
|
+
|
|
11
|
+
Standard library only, Python 3.9+. Install with `pip install agentboxes` (the import is `agentbox`), or from a clone with `pip install -e sdk-python`.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from agentbox import Agentbox, ExecTimeout
|
|
15
|
+
|
|
16
|
+
client = Agentbox("http://127.0.0.1:7788", api_key=None)
|
|
17
|
+
box = client.create(allow_hosts=["pypi.org"], ttl_seconds=300)
|
|
18
|
+
result = box.exec(["python", "-c", "print(2+2)"]) # ExecResult(exit_code, stdout, stderr, duration_ms, truncated)
|
|
19
|
+
box.write_file("main.py", "print('hello')")
|
|
20
|
+
print(box.read_file("main.py"))
|
|
21
|
+
for event in box.exec_stream(["python", "main.py"]): # ("stdout" | "stderr", line) ... then ("exit", ExecResult)
|
|
22
|
+
print(event)
|
|
23
|
+
box.upload_directory("./myrepo", "repo")
|
|
24
|
+
box.download_directory("repo", "./out")
|
|
25
|
+
box.extend()
|
|
26
|
+
box.destroy()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Errors: `SandboxNotFound`, `PoolExhausted`, `ExecTimeout` (with `.result`), and `AgentboxError` with `status` and `code` for everything else.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
agentbox/__init__.py,sha256=IacLx4bTgCNAON0gEXGkm-TJeNz9HStPROew1hAg9L8,10139
|
|
2
|
+
agentboxes-0.2.0.dist-info/METADATA,sha256=a5PraFoki1ict8e-JSNUDWAqp1UDT1ZFrqZUtfnPHO8,1166
|
|
3
|
+
agentboxes-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
agentboxes-0.2.0.dist-info/top_level.txt,sha256=lNpen_9W4iU-XQIGpizrWb3jS7tj6k27fUBWT4u0HEQ,9
|
|
5
|
+
agentboxes-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentbox
|