adk-code-mode-sandbox 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adk-code-mode-sandbox
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: In-sandbox runtime shim for adk-code-mode. Installs inside the container image the host runtime launches.
5
5
  Project-URL: Documentation, https://github.com/A2ANet/adk-code-mode#readme
6
6
  Project-URL: Issues, https://github.com/A2ANet/adk-code-mode/issues
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "0.2.2" # x-release-please-version
4
+ __version__ = "0.3.0" # x-release-please-version
@@ -14,13 +14,16 @@ are supported, chosen by env var (checked in order):
14
14
  - ``ADK_CODE_MODE_CONTROL_FD`` → inherited fd(s). Accepts one fd
15
15
  (bidirectional) or two comma-separated fds (read,write).
16
16
 
17
- Installs the RPC client, announces readiness, then waits for a ``run`` frame.
18
- On receipt, execs the user code with stdout/stderr on the normal streams and
19
- any ``tools.*`` calls funnelling through the RPC client.
17
+ Installs the RPC client, announces readiness once, then loops handling ``run``
18
+ frames until a ``shutdown`` frame (or disconnect) ends the turn. Each block
19
+ runs against one persistent globals dict (state carries across the turn) with
20
+ its stdout/stderr captured and shipped back in a per-block ``OutputFrame``; any
21
+ ``tools.*`` calls funnel through the RPC client.
20
22
  """
21
23
 
22
24
  from __future__ import annotations
23
25
 
26
+ import io
24
27
  import os
25
28
  import socket
26
29
  import sys
@@ -31,6 +34,7 @@ from adk_code_mode_sandbox import _rpc_client
31
34
  from adk_code_mode_sandbox._rpc_client import RpcClient
32
35
  from adk_code_mode_sandbox.protocol import (
33
36
  DoneFrame,
37
+ OutputFrame,
34
38
  ReadyFrame,
35
39
  RunFrame,
36
40
  ShutdownFrame,
@@ -132,9 +136,12 @@ def _make_globals() -> dict[str, Any]:
132
136
  }
133
137
 
134
138
 
135
- def _run_code(code: str) -> int:
136
- """Execute user code. Returns the exit code (0 ok, 1 on exception)."""
137
- globs = _make_globals()
139
+ def _exec_into(code: str, globs: dict[str, Any]) -> int:
140
+ """Execute user code into ``globs``. Returns the exit code (0 ok, 1 on exception).
141
+
142
+ ``globs`` is the persistent per-connection globals dict, so names bound by
143
+ one code block are visible to later blocks in the same turn.
144
+ """
138
145
  try:
139
146
  compiled = compile(code, "<code-mode>", "exec")
140
147
  except SyntaxError:
@@ -156,6 +163,26 @@ def _run_code(code: str) -> int:
156
163
  return 0
157
164
 
158
165
 
166
+ def run_block(code: str, globs: dict[str, Any]) -> tuple[str, str, int]:
167
+ """Run one code block with its stdout/stderr captured.
168
+
169
+ Redirects ``sys.stdout`` / ``sys.stderr`` to in-memory buffers for the
170
+ duration of the block so the captured text can be shipped back in an
171
+ ``OutputFrame`` (both the TCP and HTTP transports emit one per block).
172
+ """
173
+ old_stdout, old_stderr = sys.stdout, sys.stderr
174
+ capture_out = io.StringIO()
175
+ capture_err = io.StringIO()
176
+ sys.stdout = capture_out
177
+ sys.stderr = capture_err
178
+ try:
179
+ exit_code = _exec_into(code, globs)
180
+ finally:
181
+ sys.stdout = old_stdout
182
+ sys.stderr = old_stderr
183
+ return capture_out.getvalue(), capture_err.getvalue(), exit_code
184
+
185
+
159
186
  def main() -> int:
160
187
  if os.environ.get("ADK_CODE_MODE_CONTROL_HTTP"):
161
188
  import asyncio
@@ -174,6 +201,9 @@ def main() -> int:
174
201
  _prepare_workdir()
175
202
  _sanitize_environ()
176
203
 
204
+ # One persistent globals dict + one /workspace for the whole connection, so
205
+ # variables and files carry across the turn's successive code blocks.
206
+ globs = _make_globals()
177
207
  client.send(ReadyFrame())
178
208
 
179
209
  while True:
@@ -181,10 +211,9 @@ def main() -> int:
181
211
  if isinstance(frame, ShutdownFrame):
182
212
  return 0
183
213
  if isinstance(frame, RunFrame):
184
- exit_code = _run_code(frame.code)
185
- sys.stdout.flush()
186
- sys.stderr.flush()
214
+ stdout_text, stderr_text, exit_code = run_block(frame.code, globs)
187
215
  client.send(DoneFrame(exit_code=exit_code))
216
+ client.send(OutputFrame(stdout=stdout_text, stderr=stderr_text, exit_code=exit_code))
188
217
  continue
189
218
  # Any other frame kind is a host protocol error; keep going but log.
190
219
  print(
@@ -1,12 +1,13 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- """HTTP/WebSocket server mode for the sandbox (single-use).
4
+ """HTTP/WebSocket server mode for the sandbox (one connection per turn).
5
5
 
6
6
  Activated by ``ADK_CODE_MODE_CONTROL_HTTP=1``. The container accepts exactly
7
- one WebSocket connection, executes user code in-process, then exits. The
8
- hosting platform (Cloud Run, Fargate, K8s, etc.) is expected to create a new
9
- container for each request.
7
+ one WebSocket connection and holds it open for the whole turn, executing one or
8
+ more code blocks in-process with a persistent globals dict and ``/workspace``,
9
+ then exits when the turn ends (``ShutdownFrame`` or disconnect). The hosting
10
+ platform (Cloud Run, Fargate, K8s, etc.) creates a new container per turn.
10
11
 
11
12
  Files (tools and workspace) are transferred as tar archives over binary
12
13
  WebSocket frames; control frames use the standard JSON-lines protocol
@@ -14,11 +15,16 @@ over text WebSocket frames.
14
15
 
15
16
  Connection protocol::
16
17
 
17
- 1. Client sends binary: tools tar.gz
18
- 2. Client sends binary: workspace tar.gz
19
- 3. Bidirectional text frames: JSON-lines control protocol
20
- 4. After code execution, server sends text: OutputFrame
21
- 5. Server sends binary: workspace tar.gz (updated files)
18
+ 1. Client sends binary: tools tar.gz (once)
19
+ 2. Server sends text: ReadyFrame (once)
20
+ 3. Per code block, in fixed frame order:
21
+ a. Client sends binary: workspace tar.gz (this block's inputs; may be empty)
22
+ b. Client sends text: RunFrame
23
+ c. Bidirectional text frames: JSON-lines tool-call protocol
24
+ d. Server sends text: DoneFrame
25
+ e. Server sends text: OutputFrame
26
+ f. Server sends binary: workspace tar.gz (updated files)
27
+ 4. Client sends text: ShutdownFrame (or disconnects) to end the turn.
22
28
  """
23
29
 
24
30
  from __future__ import annotations
@@ -29,7 +35,6 @@ import logging
29
35
  import os
30
36
  import queue as queue_mod
31
37
  import shutil
32
- import sys
33
38
  import tarfile
34
39
  import tempfile
35
40
  from http import HTTPStatus
@@ -39,18 +44,22 @@ import websockets
39
44
  import websockets.asyncio.server
40
45
 
41
46
  from adk_code_mode_sandbox._entry import (
47
+ _make_globals,
42
48
  _prepare_sys_path,
43
49
  _prepare_workdir,
44
- _run_code,
45
50
  _sanitize_environ,
51
+ run_block,
46
52
  )
47
53
  from adk_code_mode_sandbox._rpc_client import RpcClient
48
54
  from adk_code_mode_sandbox import _rpc_client
49
55
  from adk_code_mode_sandbox.protocol import (
50
56
  DoneFrame,
51
57
  OutputFrame,
58
+ ProtocolError,
52
59
  ReadyFrame,
53
60
  RunFrame,
61
+ ShutdownFrame,
62
+ decode,
54
63
  encode,
55
64
  )
56
65
 
@@ -156,7 +165,7 @@ async def _handle_connection(ws: Any) -> None:
156
165
  workspace_dir = tempfile.mkdtemp(prefix="adk-cm-ws-")
157
166
 
158
167
  try:
159
- # 1. Receive and extract tools tar
168
+ # 1. Receive and extract the tools tar (once for the whole connection).
160
169
  tools_data = await ws.recv()
161
170
  if not isinstance(tools_data, (bytes, bytearray)):
162
171
  raise ValueError("expected binary frame for tools tar")
@@ -166,83 +175,107 @@ async def _handle_connection(ws: Any) -> None:
166
175
  )
167
176
  _extract_tar(tools_data, tools_dir)
168
177
 
169
- # 2. Receive and extract workspace tar
170
- ws_data = await ws.recv()
171
- if not isinstance(ws_data, (bytes, bytearray)):
172
- raise ValueError("expected binary frame for workspace tar")
173
- if len(ws_data) > max_workspace:
174
- raise ValueError(
175
- f"workspace archive ({len(ws_data):,} bytes) exceeds limit ({max_workspace:,} bytes)"
176
- )
177
- _extract_tar(ws_data, workspace_dir)
178
-
179
- # 3. Sanitize environment before user code can read it
178
+ # 2. Sanitize environment, then prepare sys.path + a persistent /workspace.
180
179
  _sanitize_environ()
181
-
182
- # 4. Prepare sandbox (sys.path, workdir)
183
180
  _prepare_sys_path(tools_dir)
184
181
  _prepare_workdir(workspace_dir)
185
182
 
186
- # 5. Set up async↔sync bridge and install RPC client
183
+ # 3. One persistent globals dict for the connection, so variables set in
184
+ # one block are visible to later blocks in the same turn.
185
+ globs = _make_globals()
187
186
  loop = asyncio.get_running_loop()
188
- bridge_reader = _WsBridgeReader()
189
- bridge_writer = _WsBridgeWriter(loop, ws)
190
- client = RpcClient(reader=bridge_reader, writer=bridge_writer)
191
- _rpc_client.install(client)
192
187
 
193
- # 6. Run user code on a worker thread while pumping WS messages
194
- pump_task = asyncio.create_task(_pump_ws_to_reader(ws, bridge_reader))
188
+ # 4. Announce readiness once (carries the protocol version).
189
+ await ws.send(encode(ReadyFrame()).decode("utf-8"))
195
190
 
196
- stdout_text, stderr_text, exit_code = await loop.run_in_executor(
197
- None, _run_user_code, client
198
- )
199
-
200
- pump_task.cancel()
201
- try:
202
- await pump_task
203
- except asyncio.CancelledError:
191
+ # 5. Run code blocks until the client ends the turn or disconnects.
192
+ while await _run_one_block(ws, loop, globs, workspace_dir, max_workspace):
204
193
  pass
205
-
206
- # 7. Send OutputFrame + updated workspace tar
207
- output = OutputFrame(
208
- stdout=stdout_text,
209
- stderr=stderr_text,
210
- exit_code=exit_code,
211
- )
212
- await ws.send(encode(output).decode("utf-8"))
213
- await ws.send(_create_tar(workspace_dir))
214
-
215
194
  finally:
216
195
  shutil.rmtree(tools_base, ignore_errors=True)
217
196
  shutil.rmtree(workspace_dir, ignore_errors=True)
218
197
 
219
198
 
220
- def _run_user_code(client: RpcClient) -> tuple[str, str, int]:
221
- """Execute one RunFrame's code. Runs on a worker thread."""
222
- client.send(ReadyFrame())
223
- frame = client.recv()
224
- if not isinstance(frame, RunFrame):
225
- return ("", f"expected RunFrame, got {type(frame).__name__}", 1)
199
+ async def _run_one_block(
200
+ ws: Any,
201
+ loop: asyncio.AbstractEventLoop,
202
+ globs: dict[str, Any],
203
+ workspace_dir: str,
204
+ max_workspace: int,
205
+ ) -> bool:
206
+ """Handle one code block. Returns ``False`` when the turn should end.
226
207
 
227
- old_stdout, old_stderr = sys.stdout, sys.stderr
228
- capture_out = io.StringIO()
229
- capture_err = io.StringIO()
230
- sys.stdout = capture_out
231
- sys.stderr = capture_err
208
+ The per-block frame order is fixed — the binary workspace tar first, then
209
+ the text ``RunFrame`` — which is what keeps the WebSocket from desyncing
210
+ across blocks.
211
+ """
212
+ # a. This block's workspace tar (binary), or a control frame ending the turn.
232
213
  try:
233
- exit_code = _run_code(frame.code)
214
+ first = await ws.recv()
215
+ except websockets.ConnectionClosed:
216
+ return False
217
+ if isinstance(first, str):
218
+ try:
219
+ frame = decode(first)
220
+ except ProtocolError:
221
+ return True
222
+ if isinstance(frame, ShutdownFrame):
223
+ return False
224
+ return True # unexpected control frame between blocks; ignore
225
+ if len(first) > max_workspace:
226
+ raise ValueError(
227
+ f"workspace archive ({len(first):,} bytes) exceeds limit ({max_workspace:,} bytes)"
228
+ )
229
+ _extract_tar(first, workspace_dir)
230
+
231
+ # b. The RunFrame (text).
232
+ run_msg = await ws.recv()
233
+ if not isinstance(run_msg, str):
234
+ raise ValueError("expected text frame for RunFrame")
235
+ run_frame = decode(run_msg)
236
+ if not isinstance(run_frame, RunFrame):
237
+ raise ValueError(f"expected RunFrame, got {type(run_frame).__name__}")
238
+
239
+ # c. Run user code on a worker thread while pumping incoming text frames
240
+ # (tool results) into the RPC bridge. A fresh bridge/client per block
241
+ # keeps the reader-thread lifecycle simple; the globals dict persists.
242
+ bridge_reader = _WsBridgeReader()
243
+ bridge_writer = _WsBridgeWriter(loop, ws)
244
+ client = RpcClient(reader=bridge_reader, writer=bridge_writer)
245
+ _rpc_client.install(client)
246
+ pump_task = asyncio.create_task(_pump_ws_to_reader(ws, bridge_reader))
247
+ try:
248
+ stdout_text, stderr_text, exit_code = await loop.run_in_executor(
249
+ None, run_block, run_frame.code, globs
250
+ )
234
251
  finally:
235
- sys.stdout = old_stdout
236
- sys.stderr = old_stderr
252
+ pump_task.cancel()
253
+ try:
254
+ await pump_task
255
+ except asyncio.CancelledError:
256
+ pass
237
257
 
238
- client.send(DoneFrame(exit_code=exit_code))
239
- return (capture_out.getvalue(), capture_err.getvalue(), exit_code)
258
+ # d-f. DoneFrame, OutputFrame, then the updated workspace tar (binary).
259
+ await ws.send(encode(DoneFrame(exit_code=exit_code)).decode("utf-8"))
260
+ await ws.send(
261
+ encode(OutputFrame(stdout=stdout_text, stderr=stderr_text, exit_code=exit_code)).decode(
262
+ "utf-8"
263
+ )
264
+ )
265
+ await ws.send(_create_tar(workspace_dir))
266
+ return True
240
267
 
241
268
 
242
269
  async def _pump_ws_to_reader(ws: Any, reader: _WsBridgeReader) -> None:
243
- """Forward incoming WS text frames to the bridge reader queue."""
270
+ """Forward incoming WS text frames (tool results) to the bridge reader queue.
271
+
272
+ Runs only while a block executes. Uses an explicit ``recv()`` loop (rather
273
+ than ``async for``) so it can be cancelled and a fresh pump started for the
274
+ next block. Binary frames are not expected mid-block and are ignored.
275
+ """
244
276
  try:
245
- async for msg in ws:
277
+ while True:
278
+ msg = await ws.recv()
246
279
  if isinstance(msg, str):
247
280
  data = msg.encode("utf-8")
248
281
  if not data.endswith(b"\n"):
@@ -22,7 +22,7 @@ import json
22
22
  from dataclasses import asdict, dataclass, field
23
23
  from typing import Any, Literal
24
24
 
25
- PROTOCOL_VERSION = 1
25
+ PROTOCOL_VERSION = 2
26
26
 
27
27
 
28
28
  FrameKind = Literal[
@@ -103,7 +103,11 @@ class DoneFrame:
103
103
 
104
104
  @dataclass(frozen=True)
105
105
  class OutputFrame:
106
- """Server → host. Captured stdout/stderr after subprocess exit (HTTP mode only)."""
106
+ """Sandbox → host. Captured stdout/stderr for one executed code block.
107
+
108
+ Sent once per block, right after that block's ``DoneFrame``. Under the
109
+ turn-scoped protocol (v2) a single connection produces one ``OutputFrame``
110
+ per ``RunFrame`` it handles."""
107
111
 
108
112
  stdout: str = ""
109
113
  stderr: str = ""