adk-code-mode 0.1.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.
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Sandbox runtimes that host generated code."""
5
+
6
+ from adk_code_mode.runtime.base import SandboxHandle, SandboxRuntime
7
+ from adk_code_mode.runtime.docker import DockerRuntime
8
+
9
+ __all__ = ["DockerRuntime", "SandboxHandle", "SandboxRuntime"]
@@ -0,0 +1,88 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Sandbox runtime interfaces.
5
+
6
+ A runtime is responsible for:
7
+
8
+ - preparing an isolated environment with ``/tools`` and ``/workspace`` mounts
9
+ - launching ``python -m adk_code_mode_sandbox`` inside it
10
+ - exposing an async ``send(frame)`` / ``recv()`` control pipe
11
+ - draining stdout/stderr when the run ends
12
+ - tearing the environment down when the handle is closed
13
+
14
+ All public runtimes satisfy the same ``SandboxRuntime`` protocol so the
15
+ executor can stay runtime-agnostic.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import AsyncIterator, Mapping, Protocol, runtime_checkable
22
+
23
+ from adk_code_mode.runtime.protocol import Frame
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SandboxResult:
28
+ """Captured output after a run."""
29
+
30
+ stdout: str
31
+ stderr: str
32
+ exit_code: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SandboxFiles:
37
+ """In-memory file tree to mount into the sandbox.
38
+
39
+ Maps posix paths (relative to the mount point) to UTF-8 source bytes. The
40
+ runtime materialises these before the sandbox starts.
41
+ """
42
+
43
+ files: Mapping[str, str]
44
+
45
+
46
+ class SandboxHandle(Protocol):
47
+ """Live handle to a running sandbox."""
48
+
49
+ async def send(self, frame: Frame) -> None: ...
50
+
51
+ def frames(self) -> AsyncIterator[Frame]:
52
+ """Yield frames arriving on the control pipe until EOF."""
53
+ ...
54
+
55
+ async def wait(self) -> SandboxResult:
56
+ """Wait for the sandbox to exit and return captured output."""
57
+ ...
58
+
59
+ async def close(self) -> None:
60
+ """Terminate the sandbox if still running; release resources."""
61
+ ...
62
+
63
+
64
+ @runtime_checkable
65
+ class SandboxRuntime(Protocol):
66
+ """Factory for ``SandboxHandle`` instances."""
67
+
68
+ async def start(
69
+ self,
70
+ *,
71
+ tools_files: Mapping[str, str],
72
+ workdir_path: str,
73
+ timeout_seconds: int | None,
74
+ ) -> SandboxHandle:
75
+ """Launch a sandbox.
76
+
77
+ Args:
78
+ tools_files: posix-relative path → source, to be materialised into the
79
+ sandbox's ``/tools`` directory.
80
+ workdir_path: absolute path on the host to bind-mount into the
81
+ sandbox at ``/workspace`` and use as the sandbox's cwd.
82
+ timeout_seconds: if not ``None``, the runtime should kill the
83
+ sandbox after this many seconds.
84
+ """
85
+ ...
86
+
87
+
88
+ __all__ = ["SandboxFiles", "SandboxHandle", "SandboxResult", "SandboxRuntime"]
@@ -0,0 +1,331 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Docker-backed sandbox runtime.
5
+
6
+ The host:
7
+
8
+ 1. materialises the tools tree into a tempdir,
9
+ 2. opens a TCP listener on the host (ephemeral port),
10
+ 3. bind-mounts the tools tree and execution workspace into the container,
11
+ 4. starts ``python -m adk_code_mode_sandbox`` with the host endpoint in the env,
12
+ 5. the container reaches the listener via ``host.docker.internal`` (Docker
13
+ Desktop on mac/Windows provides this automatically; on Linux we add it as
14
+ an extra host alias of the host gateway),
15
+ 6. accepts a single connection from the sandbox and streams frames.
16
+
17
+ TCP (not Unix domain sockets) is used because UDS bind-mounts are broken on
18
+ Docker Desktop macOS — a long-standing virtiofs limitation.
19
+
20
+ By default the container is given network access only to the host gateway
21
+ via the default bridge network. For stricter setups, pass a custom network
22
+ through ``run_kwargs``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import os
29
+ import secrets
30
+ import shutil
31
+ import socket
32
+ import sys
33
+ import tempfile
34
+ import time
35
+ from collections.abc import AsyncIterator
36
+ from dataclasses import dataclass, field
37
+ from typing import Any, Mapping
38
+
39
+ from adk_code_mode.runtime.base import SandboxHandle, SandboxResult
40
+ from adk_code_mode.runtime.protocol import Frame, ProtocolError, decode, encode
41
+
42
+ _CONTAINER_TOOLS_MOUNT = "/tools"
43
+ _CONTAINER_WORKDIR_MOUNT = "/workspace"
44
+ _HOST_ALIAS = "host.docker.internal"
45
+ _TOKEN_ENV = "ADK_CODE_MODE_CONTROL_TOKEN"
46
+ _TOKEN_HANDSHAKE_TIMEOUT_S = 5.0
47
+
48
+
49
+ @dataclass
50
+ class DockerRuntime:
51
+ """Docker-backed :class:`SandboxRuntime`.
52
+
53
+ Args:
54
+ image: Fully-qualified container image tag. Must have
55
+ ``adk-code-mode-sandbox`` installed. Extend the published
56
+ ``ghcr.io/a2anet/adk-code-mode`` base image to bake in additional
57
+ Python packages.
58
+ network_mode: Docker network mode. Defaults to ``None``, which
59
+ means the kwarg is not passed to ``containers.run`` and
60
+ Docker picks the default bridge network so the container can
61
+ reach the host's TCP control listener. Override to use a
62
+ custom network.
63
+ mem_limit: Container memory limit. Defaults to ``"1g"``; pass
64
+ ``None`` to remove the cap.
65
+ cpu_period / cpu_quota: CPU throttle. Default to one full vCPU
66
+ (period 100_000 µs, quota 100_000 µs); pass ``None`` to
67
+ remove the cap.
68
+ read_only: Whether to mount the container rootfs read-only. Default True.
69
+ extra_env: Extra environment variables to set inside the container.
70
+ run_kwargs: Free-form overrides passed to ``docker_client.containers.run``.
71
+ """
72
+
73
+ image: str
74
+ network_mode: str | None = None
75
+ mem_limit: str | None = "1g"
76
+ cpu_period: int | None = 100_000
77
+ cpu_quota: int | None = 100_000
78
+ read_only: bool = True
79
+ extra_env: Mapping[str, str] = field(default_factory=dict)
80
+ run_kwargs: Mapping[str, Any] = field(default_factory=dict)
81
+
82
+ async def start(
83
+ self,
84
+ *,
85
+ tools_files: Mapping[str, str],
86
+ workdir_path: str,
87
+ timeout_seconds: int | None,
88
+ ) -> SandboxHandle:
89
+ import docker # local import so the rest of the package works without it
90
+
91
+ if self.network_mode == "none":
92
+ raise ValueError(
93
+ "DockerRuntime requires container network access to reach the host control listener; "
94
+ "network_mode='none' is unsupported"
95
+ )
96
+
97
+ loop = asyncio.get_running_loop()
98
+ tools_dir = tempfile.mkdtemp(prefix="adk-code-mode-tools-")
99
+ listener: socket.socket | None = None
100
+ try:
101
+ _materialise_tools(tools_files, tools_dir)
102
+
103
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
104
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
105
+ # The container connects via host.docker.internal / host-gateway, so
106
+ # the listener must be reachable off-host-loopback. A per-start token
107
+ # gates first-frame access — see _accept_connection.
108
+ listener.bind(("0.0.0.0", 0))
109
+ listener.listen(1)
110
+ host_port = listener.getsockname()[1]
111
+ control_token = secrets.token_urlsafe(32)
112
+
113
+ env = {
114
+ "ADK_CODE_MODE_CONTROL_TCP": f"{_HOST_ALIAS}:{host_port}",
115
+ _TOKEN_ENV: control_token,
116
+ "ADK_CODE_MODE_WORKDIR": _CONTAINER_WORKDIR_MOUNT,
117
+ **dict(self.extra_env),
118
+ }
119
+
120
+ volumes = {
121
+ tools_dir: {"bind": _CONTAINER_TOOLS_MOUNT, "mode": "ro"},
122
+ workdir_path: {"bind": _CONTAINER_WORKDIR_MOUNT, "mode": "rw"},
123
+ }
124
+
125
+ def _run_container() -> Any:
126
+ client = docker.from_env()
127
+ kwargs: dict[str, Any] = {
128
+ "detach": True,
129
+ "mem_limit": self.mem_limit,
130
+ "cpu_period": self.cpu_period,
131
+ "cpu_quota": self.cpu_quota,
132
+ "read_only": self.read_only,
133
+ "environment": env,
134
+ "volumes": volumes,
135
+ "stdout": True,
136
+ "stderr": True,
137
+ "tty": False,
138
+ "stdin_open": False,
139
+ "working_dir": _CONTAINER_WORKDIR_MOUNT,
140
+ **self.run_kwargs,
141
+ }
142
+ if sys.platform.startswith("linux"):
143
+ kwargs["extra_hosts"] = {_HOST_ALIAS: "host-gateway"}
144
+ if self.network_mode is not None:
145
+ kwargs["network_mode"] = self.network_mode
146
+ return client.containers.run(
147
+ self.image,
148
+ command=["python", "-m", "adk_code_mode_sandbox"],
149
+ **kwargs,
150
+ )
151
+
152
+ container = await loop.run_in_executor(None, _run_container)
153
+
154
+ try:
155
+ conn = await loop.run_in_executor(
156
+ None, _accept_connection, listener, 30.0, control_token
157
+ )
158
+ except BaseException:
159
+ try:
160
+ container.remove(force=True)
161
+ except Exception:
162
+ pass
163
+ raise
164
+
165
+ return _DockerSandboxHandle(
166
+ container=container,
167
+ control_sock=conn,
168
+ listener=listener,
169
+ tools_dir=tools_dir,
170
+ timeout_seconds=timeout_seconds,
171
+ )
172
+ except BaseException:
173
+ if listener is not None:
174
+ try:
175
+ listener.close()
176
+ except OSError:
177
+ pass
178
+ shutil.rmtree(tools_dir, ignore_errors=True)
179
+ raise
180
+
181
+
182
+ def _materialise_tools(files: Mapping[str, str], root: str) -> None:
183
+ for rel, source in files.items():
184
+ dest = os.path.join(root, rel)
185
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
186
+ with open(dest, "w", encoding="utf-8") as fh:
187
+ fh.write(source)
188
+
189
+
190
+ def _accept_connection(
191
+ listener: socket.socket, timeout: float, expected_token: str
192
+ ) -> socket.socket:
193
+ """Accept connections until one presents the expected token as its first line.
194
+
195
+ Bad connections are dropped silently so a process racing the sandbox onto the
196
+ listener cannot lock the real sandbox out — accept loops until ``timeout``.
197
+ """
198
+ deadline = time.monotonic() + timeout
199
+ expected = expected_token.encode("ascii")
200
+ while True:
201
+ remaining = max(0.0, deadline - time.monotonic())
202
+ if remaining == 0.0:
203
+ raise TimeoutError("timed out waiting for sandbox to authenticate")
204
+ listener.settimeout(remaining)
205
+ try:
206
+ conn, _ = listener.accept()
207
+ finally:
208
+ listener.settimeout(None)
209
+ try:
210
+ conn.settimeout(_TOKEN_HANDSHAKE_TIMEOUT_S)
211
+ line = _read_line(conn, max_bytes=512)
212
+ except (OSError, TimeoutError):
213
+ conn.close()
214
+ continue
215
+ if line == expected:
216
+ conn.setblocking(False)
217
+ return conn
218
+ conn.close()
219
+
220
+
221
+ def _read_line(conn: socket.socket, *, max_bytes: int) -> bytes:
222
+ buf = bytearray()
223
+ while len(buf) < max_bytes:
224
+ chunk = conn.recv(1)
225
+ if not chunk:
226
+ raise OSError("peer closed before sending token")
227
+ if chunk == b"\n":
228
+ return bytes(buf)
229
+ buf.extend(chunk)
230
+ raise OSError("token line exceeded max length")
231
+
232
+
233
+ class _DockerSandboxHandle(SandboxHandle):
234
+ def __init__(
235
+ self,
236
+ *,
237
+ container: Any,
238
+ control_sock: socket.socket,
239
+ listener: socket.socket,
240
+ tools_dir: str,
241
+ timeout_seconds: int | None,
242
+ ) -> None:
243
+ self._container = container
244
+ self._sock = control_sock
245
+ self._listener = listener
246
+ self._tools_dir = tools_dir
247
+ self._timeout = timeout_seconds
248
+ self._recv_buf = b""
249
+ self._lock = asyncio.Lock()
250
+ self._closed = False
251
+
252
+ async def send(self, frame: Frame) -> None:
253
+ payload = encode(frame)
254
+ async with self._lock:
255
+ await asyncio.get_running_loop().sock_sendall(self._sock, payload)
256
+
257
+ async def frames(self) -> AsyncIterator[Frame]:
258
+ loop = asyncio.get_running_loop()
259
+ while True:
260
+ while b"\n" not in self._recv_buf:
261
+ chunk = await loop.sock_recv(self._sock, 65536)
262
+ if not chunk:
263
+ return
264
+ self._recv_buf += chunk
265
+ line, self._recv_buf = self._recv_buf.split(b"\n", 1)
266
+ if not line:
267
+ continue
268
+ try:
269
+ yield decode(line)
270
+ except ProtocolError:
271
+ continue
272
+
273
+ async def wait(self) -> SandboxResult:
274
+ loop = asyncio.get_running_loop()
275
+
276
+ def _wait() -> dict[str, Any]:
277
+ return self._container.wait(timeout=self._timeout) or {}
278
+
279
+ try:
280
+ result = await loop.run_in_executor(None, _wait)
281
+ except Exception:
282
+ result = {"StatusCode": -1}
283
+ exit_code = int(result.get("StatusCode", 0))
284
+
285
+ # docker-py's Container.logs() does not support demux, so stdout and stderr
286
+ # have to be drained separately.
287
+ def _logs_stdout() -> bytes:
288
+ return self._container.logs(stdout=True, stderr=False) or b""
289
+
290
+ def _logs_stderr() -> bytes:
291
+ return self._container.logs(stdout=False, stderr=True) or b""
292
+
293
+ stdout_b = await loop.run_in_executor(None, _logs_stdout)
294
+ stderr_b = await loop.run_in_executor(None, _logs_stderr)
295
+ return SandboxResult(
296
+ stdout=stdout_b.decode("utf-8", errors="replace"),
297
+ stderr=stderr_b.decode("utf-8", errors="replace"),
298
+ exit_code=exit_code,
299
+ )
300
+
301
+ async def close(self) -> None:
302
+ if self._closed:
303
+ return
304
+ self._closed = True
305
+ loop = asyncio.get_running_loop()
306
+ try:
307
+ self._sock.close()
308
+ except OSError:
309
+ pass
310
+ try:
311
+ self._listener.close()
312
+ except OSError:
313
+ pass
314
+
315
+ def _kill_and_remove() -> None:
316
+ try:
317
+ self._container.reload()
318
+ if self._container.status == "running":
319
+ self._container.kill()
320
+ except Exception:
321
+ pass
322
+ try:
323
+ self._container.remove(force=True)
324
+ except Exception:
325
+ pass
326
+
327
+ await loop.run_in_executor(None, _kill_and_remove)
328
+ shutil.rmtree(self._tools_dir, ignore_errors=True)
329
+
330
+
331
+ __all__ = ["DockerRuntime"]
@@ -0,0 +1,193 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """JSON-lines wire protocol between the host and the sandbox.
5
+
6
+ This module is the shared kernel: a byte-identical copy lives in the
7
+ ``adk_code_mode_sandbox`` wheel. A CI check enforces the two files are
8
+ identical on every release.
9
+
10
+ Design rules:
11
+
12
+ - Zero dependencies outside the Python standard library. The sandbox wheel
13
+ must remain stdlib-only so the container image does not pull ADK / google-genai.
14
+ - Frames are JSON objects, one per newline-terminated UTF-8 line.
15
+ - Every frame carries a ``kind`` discriminator. Correlated pairs (e.g.
16
+ ``tool_call`` / ``tool_result``) carry a string ``id``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import asdict, dataclass, field
23
+ from typing import Any, Literal
24
+
25
+ PROTOCOL_VERSION = 1
26
+
27
+
28
+ FrameKind = Literal[
29
+ "ready",
30
+ "run",
31
+ "tool_call",
32
+ "tool_result",
33
+ "log",
34
+ "done",
35
+ "shutdown",
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ReadyFrame:
41
+ """Sandbox → host. Sent once after boot."""
42
+
43
+ kind: Literal["ready"] = "ready"
44
+ protocol_version: int = PROTOCOL_VERSION
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class RunFrame:
49
+ """Host → sandbox. Instructs the sandbox to execute the given code."""
50
+
51
+ code: str = ""
52
+ kind: Literal["run"] = "run"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ToolCallFrame:
57
+ """Sandbox → host. Request to invoke a host-side tool."""
58
+
59
+ id: str = ""
60
+ name: str = ""
61
+ args: dict[str, Any] = field(default_factory=dict)
62
+ timeout: float | None = None
63
+ kind: Literal["tool_call"] = "tool_call"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ToolErrorPayload:
68
+ """The structured error body inside a failing ``tool_result`` frame."""
69
+
70
+ type: str
71
+ message: str
72
+ trace: str | None = None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ToolResultFrame:
77
+ """Host → sandbox. Result for a previously issued ``tool_call``."""
78
+
79
+ id: str = ""
80
+ ok: bool = True
81
+ value: Any = None
82
+ error: ToolErrorPayload | None = None
83
+ kind: Literal["tool_result"] = "tool_result"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class LogFrame:
88
+ """Sandbox → host. Optional structured log line."""
89
+
90
+ level: str = "info"
91
+ msg: str = ""
92
+ kind: Literal["log"] = "log"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class DoneFrame:
97
+ """Sandbox → host. User code finished. Host drains stdout/stderr next."""
98
+
99
+ exit_code: int = 0
100
+ kind: Literal["done"] = "done"
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class ShutdownFrame:
105
+ """Host → sandbox. Graceful shutdown."""
106
+
107
+ kind: Literal["shutdown"] = "shutdown"
108
+
109
+
110
+ Frame = (
111
+ ReadyFrame | RunFrame | ToolCallFrame | ToolResultFrame | LogFrame | DoneFrame | ShutdownFrame
112
+ )
113
+
114
+
115
+ _KIND_TO_CLS: dict[str, type] = {
116
+ "ready": ReadyFrame,
117
+ "run": RunFrame,
118
+ "tool_call": ToolCallFrame,
119
+ "tool_result": ToolResultFrame,
120
+ "log": LogFrame,
121
+ "done": DoneFrame,
122
+ "shutdown": ShutdownFrame,
123
+ }
124
+
125
+
126
+ class ProtocolError(Exception):
127
+ """Raised when a frame cannot be decoded or is semantically invalid."""
128
+
129
+
130
+ def encode(frame: Frame) -> bytes:
131
+ """Serialise a frame to a newline-terminated UTF-8 byte string."""
132
+ payload = asdict(frame)
133
+ return (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
134
+
135
+
136
+ def decode(line: bytes | str) -> Frame:
137
+ """Deserialise a single frame line.
138
+
139
+ Accepts either bytes or str; lines may or may not have a trailing newline.
140
+ """
141
+ if isinstance(line, (bytes, bytearray)):
142
+ text = line.decode("utf-8")
143
+ else:
144
+ text = line
145
+ text = text.rstrip("\r\n")
146
+ if not text:
147
+ raise ProtocolError("empty frame")
148
+ try:
149
+ obj = json.loads(text)
150
+ except json.JSONDecodeError as exc:
151
+ raise ProtocolError(f"invalid JSON: {exc}") from exc
152
+ if not isinstance(obj, dict):
153
+ raise ProtocolError(f"frame must be a JSON object, got {type(obj).__name__}")
154
+ kind = obj.get("kind")
155
+ if not isinstance(kind, str):
156
+ raise ProtocolError("frame missing string `kind`")
157
+ cls = _KIND_TO_CLS.get(kind)
158
+ if cls is None:
159
+ raise ProtocolError(f"unknown frame kind: {kind!r}")
160
+ if cls is ToolResultFrame and obj.get("error") is not None:
161
+ err = obj["error"]
162
+ if not isinstance(err, dict):
163
+ raise ProtocolError("tool_result.error must be an object")
164
+ obj = dict(obj)
165
+ obj["error"] = ToolErrorPayload(
166
+ type=str(err.get("type", "Error")),
167
+ message=str(err.get("message", "")),
168
+ trace=err.get("trace"),
169
+ )
170
+ valid_fields = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
171
+ filtered = {k: v for k, v in obj.items() if k in valid_fields}
172
+ try:
173
+ return cls(**filtered) # type: ignore[no-any-return]
174
+ except TypeError as exc:
175
+ raise ProtocolError(f"invalid frame body for kind {kind!r}: {exc}") from exc
176
+
177
+
178
+ __all__ = [
179
+ "PROTOCOL_VERSION",
180
+ "FrameKind",
181
+ "Frame",
182
+ "ReadyFrame",
183
+ "RunFrame",
184
+ "ToolCallFrame",
185
+ "ToolResultFrame",
186
+ "ToolErrorPayload",
187
+ "LogFrame",
188
+ "DoneFrame",
189
+ "ShutdownFrame",
190
+ "ProtocolError",
191
+ "encode",
192
+ "decode",
193
+ ]
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Tool normalisation, stub generation, and host-side dispatch."""