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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ agentbox