aisolate-client 0.2.0__tar.gz → 0.3.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.
Files changed (33) hide show
  1. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/PKG-INFO +2 -2
  2. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/README.md +1 -1
  3. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/pyproject.toml +1 -1
  4. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/gateway_client.py +200 -28
  5. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/models.py +22 -21
  6. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/sandbox.py +148 -2
  7. aisolate_client-0.3.0/src/aisolate/client/tests/integration/conftest.py +101 -0
  8. aisolate_client-0.3.0/src/aisolate/client/tests/test_gateway_client_demux.py +75 -0
  9. aisolate_client-0.3.0/src/aisolate/client/tests/test_sandbox_workspace_prefix.py +47 -0
  10. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/codec.py +95 -9
  11. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/models.py +32 -1
  12. aisolate_client-0.3.0/src/aisolate/common/tests/test_codec_channel.py +96 -0
  13. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/PKG-INFO +2 -2
  14. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/SOURCES.txt +3 -0
  15. aisolate_client-0.2.0/src/aisolate/client/tests/integration/conftest.py +0 -81
  16. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/setup.cfg +0 -0
  17. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/__init__.py +0 -0
  18. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/__init__.py +0 -0
  19. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/exception.py +0 -0
  20. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/remote_executer.py +0 -0
  21. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/sandbox_manager.py +0 -0
  22. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/__init__.py +0 -0
  23. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/__init__.py +0 -0
  24. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/test_gateway_client.py +0 -0
  25. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/client/tests/integration/test_sandbox.py +0 -0
  26. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/__init__.py +0 -0
  27. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/errors.py +0 -0
  28. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/__init__.py +0 -0
  29. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/fixtures.py +0 -0
  30. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate/common/tests/utils.py +0 -0
  31. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/dependency_links.txt +0 -0
  32. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/requires.txt +0 -0
  33. {aisolate_client-0.2.0 → aisolate_client-0.3.0}/src/aisolate_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aisolate-client
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Client library for sandbox environments
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -16,7 +16,7 @@ Client library for sandbox environments
16
16
  ## Local Installation
17
17
 
18
18
  ```bash
19
- pip install path/to/aisolate_client-0.2.0-py3-none-any.whl
19
+ pip install path/to/aisolate_client-0.3.0-py3-none-any.whl
20
20
  ```
21
21
 
22
22
  ## Usage
@@ -5,7 +5,7 @@ Client library for sandbox environments
5
5
  ## Local Installation
6
6
 
7
7
  ```bash
8
- pip install path/to/aisolate_client-0.2.0-py3-none-any.whl
8
+ pip install path/to/aisolate_client-0.3.0-py3-none-any.whl
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aisolate-client"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Client library for sandbox environments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,13 +13,22 @@ from typing import (
13
13
  TypeVar,
14
14
  AsyncContextManager,
15
15
  )
16
- from urllib.parse import urljoin
16
+ from urllib.parse import quote, urljoin
17
17
 
18
18
  import httpx
19
19
  from websockets.exceptions import ConnectionClosed
20
20
  from websockets.asyncio.client import connect as ws_connect, ClientConnection
21
21
 
22
- from aisolate.common.codec import decode_kernel_message
22
+ from aisolate.common.codec import (
23
+ MAX_MULTIPLEX_FRAME_BYTES,
24
+ decode_frame,
25
+ decode_multiplex_frame,
26
+ )
27
+
28
+ #: Per-sandbox bound on the client's live change feed. A chatty (or hostile)
29
+ #: sandbox can only fill its own queue; drop-oldest on overflow keeps the shared
30
+ #: client's memory bounded, and the /workspace/changes resync recovers the gap.
31
+ CHANGES_QUEUE_MAXSIZE = 1024
23
32
  from aisolate.common.models import KernelMessage, KernelExecutionCompleted
24
33
  from .exception import (
25
34
  SandboxAPIError,
@@ -60,6 +69,7 @@ class GatewayClient:
60
69
  api_key: Optional[str] = None,
61
70
  *,
62
71
  timeout: float = 60.0,
72
+ connect_timeout: Optional[float] = None,
63
73
  session: Optional[httpx.AsyncClient] = None,
64
74
  ws_connect: Optional[
65
75
  Callable[..., AsyncContextManager[ClientConnection]]
@@ -67,10 +77,19 @@ class GatewayClient:
67
77
  ping_interval: Optional[float] = 30.0,
68
78
  ping_timeout: Optional[float] = 3600.0,
69
79
  ) -> None:
70
- """Initializes the GatewayClient configuration."""
80
+ """Initializes the GatewayClient configuration.
81
+
82
+ ``timeout`` is the blanket read/write/pool timeout of the httpx
83
+ session. ``connect_timeout`` (when set) bounds connection
84
+ establishment separately — including the WebSocket open — so a
85
+ long read timeout (e.g. a create request parked in the gateway's
86
+ admission queue) does not also slow down failure detection
87
+ against an unreachable or hung host.
88
+ """
71
89
  self.base_url = base_url.rstrip("/") + "/"
72
90
  self.api_key = api_key
73
91
  self._timeout = timeout
92
+ self._connect_timeout = connect_timeout
74
93
  self._ping_interval = ping_interval
75
94
  self._ping_timeout = ping_timeout
76
95
 
@@ -83,7 +102,13 @@ class GatewayClient:
83
102
  # WebSocket connection state
84
103
  self._ws: Optional[ClientConnection] = None
85
104
  self._listener_task: Optional[asyncio.Task] = None
105
+ # Per-request exec response queues, keyed by (globally unique) request_id.
86
106
  self._response_queues: Dict[str, Queue[KernelMessage]] = {}
107
+ # request_id → sandbox_id, so a per-sandbox failure fails only that
108
+ # sandbox's in-flight requests, never every co-tenant's.
109
+ self._request_sandbox: Dict[str, str] = {}
110
+ # Per-sandbox live change feeds (channel="changes"), bounded.
111
+ self._changes_queues: Dict[str, Queue[Any]] = {}
87
112
  self._is_active: bool = False
88
113
 
89
114
  async def start(self) -> "GatewayClient":
@@ -97,8 +122,13 @@ class GatewayClient:
97
122
  # 1. Set up the HTTPX session for standard API calls
98
123
  if self._session is None:
99
124
  headers = {"Authorization": f"Token {self.api_key}"} if self.api_key else {}
125
+ timeout = (
126
+ httpx.Timeout(self._timeout, connect=self._connect_timeout)
127
+ if self._connect_timeout is not None
128
+ else self._timeout
129
+ )
100
130
  self._session = httpx.AsyncClient(
101
- base_url=self.base_url, timeout=self._timeout, headers=headers
131
+ base_url=self.base_url, timeout=timeout, headers=headers
102
132
  )
103
133
 
104
134
  # 2. Establish the persistent WebSocket connection
@@ -112,17 +142,26 @@ class GatewayClient:
112
142
  ws_connect_func = self._ws_connect or ws_connect
113
143
  ws_cm = ws_connect_func(
114
144
  ws_url,
115
- open_timeout=self._timeout,
145
+ open_timeout=(
146
+ self._connect_timeout
147
+ if self._connect_timeout is not None
148
+ else self._timeout
149
+ ),
116
150
  additional_headers=headers,
117
151
  ping_interval=self._ping_interval,
118
152
  ping_timeout=self._ping_timeout,
119
- max_size=10 * 1024 * 1024, # 10 MB – prevent 1009 on large kernel output
153
+ # Defence in depth: the gateway already drops backend frames that
154
+ # would exceed this, scoped to one sandbox, so this cap is never the
155
+ # thing that closes the shared connection.
156
+ max_size=MAX_MULTIPLEX_FRAME_BYTES,
120
157
  )
121
158
 
122
159
  self._ws = await ws_cm.__aenter__()
123
160
 
124
161
  # 3. Start the background task to listen for and route all incoming messages
125
162
  self._response_queues = {}
163
+ self._request_sandbox = {}
164
+ self._changes_queues = {}
126
165
  self._listener_task = asyncio.create_task(self._listen_for_messages())
127
166
 
128
167
  self._is_active = True
@@ -173,50 +212,113 @@ class GatewayClient:
173
212
  """Context manager exit point. Closes the client."""
174
213
  await self.close(exc_type, exc, tb)
175
214
 
176
- async def _listen_for_messages(self):
215
+ def _changes_queue_for(self, sandbox_id: str) -> "Queue[Any]":
216
+ """The bounded live-change feed for one sandbox, created on demand."""
217
+ q = self._changes_queues.get(sandbox_id)
218
+ if q is None:
219
+ q = asyncio.Queue(maxsize=CHANGES_QUEUE_MAXSIZE)
220
+ self._changes_queues[sandbox_id] = q
221
+ return q
222
+
223
+ def _push_change(self, sandbox_id: str, change: Any) -> None:
224
+ """Enqueue a change for a sandbox, dropping the oldest if the feed is full.
225
+
226
+ Bounding per sandbox means a flooding sandbox can only fill its own
227
+ queue; the dropped record is recovered by the /workspace/changes resync.
177
228
  """
178
- Listens for all messages on the WebSocket and routes them to the
179
- correct response queue based on their 'request_id'.
229
+ q = self._changes_queue_for(sandbox_id)
230
+ if q.full():
231
+ try:
232
+ q.get_nowait()
233
+ except asyncio.QueueEmpty:
234
+ pass
235
+ q.put_nowait(change)
236
+
237
+ def _fail_sandbox_requests(self, sandbox_id: str, message: str) -> None:
238
+ """Fail only the in-flight exec requests belonging to one sandbox."""
239
+ error = SandboxConnectionError(message)
240
+ for request_id, sid in list(self._request_sandbox.items()):
241
+ if sid == sandbox_id:
242
+ queue = self._response_queues.get(request_id)
243
+ if queue is not None:
244
+ queue.put_nowait(error)
245
+
246
+ def _fail_all_requests(self, message: str) -> None:
247
+ """Fail every in-flight request — the whole connection is gone."""
248
+ error = SandboxConnectionError(message)
249
+ for queue in self._response_queues.values():
250
+ queue.put_nowait(error)
251
+
252
+ def _handle_sandbox_control(self, sandbox_id: str, control: Dict[str, Any]) -> None:
253
+ """Act on a gateway control signal scoped to one sandbox."""
254
+ if control.get("control") == "frame_dropped":
255
+ logger.warning(
256
+ "Gateway dropped a frame for sandbox %s (%s, %s bytes); "
257
+ "failing only its requests",
258
+ sandbox_id,
259
+ control.get("reason"),
260
+ control.get("size"),
261
+ )
262
+ self._fail_sandbox_requests(
263
+ sandbox_id, f"sandbox frame dropped: {control.get('reason')}"
264
+ )
265
+
266
+ async def _listen_for_messages(self):
267
+ """Route every inbound frame, isolating failures to one sandbox.
268
+
269
+ Each frame is wrapped by the gateway with its source ``sandbox_id``. A
270
+ frame that fails to decode fails only that sandbox's requests and the
271
+ listener keeps running for every co-tenant — one sandbox can never take
272
+ the shared connection (or another sandbox's work) down with it. Only a
273
+ real connection loss fails everyone.
180
274
  """
181
275
  if not self._ws:
182
276
  return
183
277
  try:
184
278
  async for msg in self._ws:
279
+ data = self._extract_message_data(msg)
280
+ if data is None:
281
+ continue
185
282
  try:
186
- data = self._extract_message_data(msg)
187
- if data is None:
283
+ sandbox_id, payload = decode_multiplex_frame(data)
284
+ except Exception:
285
+ # Can't attribute it to a sandbox — drop this frame only and
286
+ # keep listening; never tear the loop down over one frame.
287
+ logger.exception("Failed to peel multiplex envelope; dropping frame")
288
+ continue
289
+ try:
290
+ if isinstance(payload, dict):
291
+ self._handle_sandbox_control(sandbox_id, payload)
188
292
  continue
189
- request_id, kernel_msg = decode_kernel_message(data)
190
- if request_id and request_id in self._response_queues:
293
+ channel, request_id, kernel_msg = decode_frame(payload)
294
+ if channel == "changes":
295
+ # Unsolicited push, correlated by cursor, not request_id.
296
+ self._push_change(sandbox_id, kernel_msg)
297
+ elif request_id and request_id in self._response_queues:
191
298
  await self._response_queues[request_id].put(kernel_msg)
192
299
  else:
193
300
  logger.warning(
194
- f"Received message with unknown request_id: {request_id}"
301
+ "Unknown request_id %s for sandbox %s",
302
+ request_id,
303
+ sandbox_id,
195
304
  )
196
305
  except asyncio.CancelledError:
197
- # we were explicitly cancelled — just exit
198
306
  return
199
307
  except Exception:
200
308
  logger.exception(
201
- "Failed to decode or route incoming WebSocket message"
309
+ "Failed to decode/route frame; failing only sandbox %s",
310
+ sandbox_id,
202
311
  )
203
- # Propagate decode errors to all pending callers so they
204
- # don't hang forever waiting for KernelExecutionCompleted.
205
- error = SandboxConnectionError(
206
- "Failed to decode sandbox message"
312
+ self._fail_sandbox_requests(
313
+ sandbox_id, "Failed to decode sandbox message"
207
314
  )
208
- for queue in self._response_queues.values():
209
- await queue.put(error)
210
315
  except ConnectionClosed as e:
211
316
  logger.info(f"WebSocket connection closed cleanly (code={e.code}).")
212
317
  except Exception:
213
318
  logger.exception("Listener task terminated due to an unexpected error.")
214
319
 
215
- # If the connection drops, signal an error to all pending callers
216
- # by placing a sentinel value (e.g., an exception) in their queues.
217
- error = SandboxConnectionError("WebSocket connection lost")
218
- for queue in self._response_queues.values():
219
- await queue.put(error)
320
+ # The connection itself dropped: every sandbox's requests are dead.
321
+ self._fail_all_requests("WebSocket connection lost")
220
322
 
221
323
  async def execute_code(
222
324
  self,
@@ -246,6 +348,7 @@ class GatewayClient:
246
348
  request_id = str(uuid.uuid4())
247
349
  queue: Queue[KernelMessage | Exception] = asyncio.Queue()
248
350
  self._response_queues[request_id] = queue
351
+ self._request_sandbox[request_id] = sandbox_id
249
352
 
250
353
  try:
251
354
  payload_data: Dict[str, Any] = {"code": code, "env_vars": env_vars}
@@ -274,6 +377,7 @@ class GatewayClient:
274
377
  finally:
275
378
  # Clean up the queue for this specific request
276
379
  self._response_queues.pop(request_id, None)
380
+ self._request_sandbox.pop(request_id, None)
277
381
 
278
382
  async def _request(
279
383
  self,
@@ -297,6 +401,7 @@ class GatewayClient:
297
401
  http_method: Callable[..., Any] = getattr(self._session, method.lower())
298
402
 
299
403
  attempt = 0
404
+ retried_503 = False
300
405
  while True:
301
406
  try:
302
407
  # Only pass json parameter if it's provided
@@ -313,6 +418,17 @@ class GatewayClient:
313
418
  attempt += 1
314
419
  await asyncio.sleep(delay)
315
420
  except httpx.HTTPStatusError as exc:
421
+ # 503 = the gateway's RAM-budget admission queue is full
422
+ # or timed out; it sets Retry-After. One honored retry —
423
+ # callers that want to fail fast still get the error on
424
+ # sustained pressure.
425
+ if exc.response.status_code == 503 and not retried_503:
426
+ retried_503 = True
427
+ delay = min(
428
+ float(exc.response.headers.get("retry-after", 5)), 60.0
429
+ )
430
+ await asyncio.sleep(delay)
431
+ continue
316
432
  try:
317
433
  response_data = exc.response.json()
318
434
  except Exception:
@@ -360,7 +476,12 @@ class GatewayClient:
360
476
  payload["ramMb"] = ram_mb
361
477
  if vcpu is not None:
362
478
  payload["vcpu"] = vcpu
363
- return await self._request("POST", "sandboxes", json=payload)
479
+ # No transport retries here: callers (the agent's provisioning
480
+ # barrier) already retry creation with a fresh connection, and an
481
+ # inner re-POST of the same sandbox_id after a read timeout risks
482
+ # double admission of a request the gateway may still be holding
483
+ # in its queue. The 503 Retry-After branch is unaffected.
484
+ return await self._request("POST", "sandboxes", json=payload, retries=0)
364
485
 
365
486
  async def delete_sandbox(self, sandbox_id: str) -> None:
366
487
  await self._request("DELETE", f"sandboxes/{sandbox_id}")
@@ -403,3 +524,54 @@ class GatewayClient:
403
524
  "DELETE",
404
525
  f"sandboxes/{sandbox_id}/contexts/{context_id}",
405
526
  )
527
+
528
+ # Workspace (files) methods
529
+ async def list_tree(self, sandbox_id: str, root: str = "") -> Dict[str, Any]:
530
+ """List the sandbox workspace tree ({root, entries}).
531
+
532
+ ``root`` restricts the walk to one workspace subtree (per-agent subdir);
533
+ entry paths stay workspace-root-relative either way.
534
+ """
535
+ suffix = f"?root={quote(root, safe='')}" if root else ""
536
+ return await self._request(
537
+ "GET", f"sandboxes/{sandbox_id}/workspace/tree{suffix}"
538
+ )
539
+
540
+ async def read_file(self, sandbox_id: str, path: str) -> Dict[str, Any]:
541
+ """Read one workspace file (utf-8 text or base64 binary)."""
542
+ return await self._request(
543
+ "GET",
544
+ f"sandboxes/{sandbox_id}/workspace/file?path={quote(path, safe='')}",
545
+ )
546
+
547
+ async def write_file(
548
+ self,
549
+ sandbox_id: str,
550
+ path: str,
551
+ content: str,
552
+ encoding: str = "utf-8",
553
+ ) -> Dict[str, Any]:
554
+ """Create or overwrite one workspace file."""
555
+ return await self._request(
556
+ "PUT",
557
+ f"sandboxes/{sandbox_id}/workspace/file?path={quote(path, safe='')}",
558
+ json={"content": content, "encoding": encoding},
559
+ )
560
+
561
+ async def workspace_changes(self, sandbox_id: str, since: int = 0) -> Dict[str, Any]:
562
+ """Return the workspace change delta since ``since`` ({cursor, changes, snapshot})."""
563
+ return await self._request(
564
+ "GET",
565
+ f"sandboxes/{sandbox_id}/workspace/changes?since={int(since)}",
566
+ )
567
+
568
+ async def aiter_workspace_changes(self, sandbox_id: str) -> AsyncIterator[Any]:
569
+ """Yield each pushed ``WorkspaceChange`` for one sandbox as it arrives.
570
+
571
+ Attributed by the gateway's per-frame ``sandbox_id`` tag, so one client
572
+ multiplexing several sandboxes gets a separate, non-interleaved feed for
573
+ each — a chatty or hostile sandbox cannot pollute another's stream.
574
+ """
575
+ q = self._changes_queue_for(sandbox_id)
576
+ while True:
577
+ yield await q.get()
@@ -12,6 +12,7 @@ from aisolate.common.models import (
12
12
  ErrorOutput,
13
13
  ExecuteResult,
14
14
  KernelMessage,
15
+ ShellExitStatus,
15
16
  StreamOutput,
16
17
  )
17
18
 
@@ -124,27 +125,6 @@ class Execution:
124
125
  """Returns True if the execution produced a final answer."""
125
126
  return self.final_answer is not None
126
127
 
127
- @property
128
- def review_request(self) -> Optional[Dict[str, Any]]:
129
- """Returns the review request data if present in DisplayData.
130
-
131
- Checks for the custom MIME type 'application/vnd.aisolate.review-request+json'
132
- which is sent by the request_report_review() action.
133
- """
134
- for event in self.events:
135
- if isinstance(event, DisplayData):
136
- review_data = event.data.get(
137
- "application/vnd.aisolate.review-request+json"
138
- )
139
- if review_data:
140
- return review_data
141
- return None
142
-
143
- @property
144
- def has_review_request(self) -> bool:
145
- """Returns True if the execution requested a review."""
146
- return self.review_request is not None
147
-
148
128
  def _find_main_result_event(
149
129
  self,
150
130
  ) -> Optional[Union["ExecuteResult", "DisplayData"]]:
@@ -320,6 +300,27 @@ class Execution:
320
300
  if isinstance(e, StreamOutput) and e.name == "stdout"
321
301
  )
322
302
 
303
+ @property
304
+ def stderr(self) -> str:
305
+ """Concatenates all text from standard error streams."""
306
+ return "".join(
307
+ e.text
308
+ for e in self.events
309
+ if isinstance(e, StreamOutput) and e.name == "stderr"
310
+ )
311
+
312
+ @property
313
+ def exit_code(self) -> Optional[int]:
314
+ """The shell command's process exit code, if this was a shell run.
315
+
316
+ Carried by the ShellExecutor's ``shell_exit`` message; ``None`` for
317
+ kernel executions (which signal errors via ``ErrorOutput`` instead).
318
+ """
319
+ return next(
320
+ (e.exit_code for e in self.events if isinstance(e, ShellExitStatus)),
321
+ None,
322
+ )
323
+
323
324
  def __repr__(self) -> str:
324
325
  if self.has_error:
325
326
  status = f"error='{self.error.error_name}'"