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.
- adk_code_mode/__about__.py +4 -0
- adk_code_mode/__init__.py +22 -0
- adk_code_mode/_artifact_tools.py +152 -0
- adk_code_mode/callback.py +81 -0
- adk_code_mode/executor.py +607 -0
- adk_code_mode/output.py +97 -0
- adk_code_mode/py.typed +1 -0
- adk_code_mode/runtime/__init__.py +9 -0
- adk_code_mode/runtime/base.py +88 -0
- adk_code_mode/runtime/docker.py +331 -0
- adk_code_mode/runtime/protocol.py +193 -0
- adk_code_mode/tools/__init__.py +4 -0
- adk_code_mode/tools/catalog.py +95 -0
- adk_code_mode/tools/dispatcher.py +290 -0
- adk_code_mode/tools/namespacing.py +200 -0
- adk_code_mode/tools/normaliser.py +69 -0
- adk_code_mode/tools/stubs.py +446 -0
- adk_code_mode/workspace/__init__.py +8 -0
- adk_code_mode/workspace/files.py +44 -0
- adk_code_mode-0.1.0.dist-info/METADATA +351 -0
- adk_code_mode-0.1.0.dist-info/RECORD +23 -0
- adk_code_mode-0.1.0.dist-info/WHEEL +4 -0
- adk_code_mode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
]
|