adk-code-mode-sandbox 0.1.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.
@@ -0,0 +1,210 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ # Custom
210
+ .DS_Store
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: adk-code-mode-sandbox
3
+ Version: 0.1.0
4
+ Summary: In-sandbox runtime shim for adk-code-mode. Stdlib-only. Installs inside the container image the host runtime launches.
5
+ Project-URL: Documentation, https://github.com/A2ANet/adk-code-mode#readme
6
+ Project-URL: Issues, https://github.com/A2ANet/adk-code-mode/issues
7
+ Project-URL: Source, https://github.com/A2ANet/adk-code-mode
8
+ Author-email: A2A Net <hello@a2anet.com>
9
+ License-Expression: Apache-2.0
10
+ Keywords: adk,code-mode,sandbox
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # ADK Code Mode Sandbox
22
+
23
+ In-sandbox shim for [`adk-code-mode`](https://github.com/A2ANet/adk-code-mode). The host package launches a container that has this wheel installed, then talks to it over a TCP control connection so model code can call ADK tools that run on the host.
24
+
25
+ ## 📄 License
26
+
27
+ `adk-code-mode-sandbox` is distributed under the terms of the [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) license.
28
+
29
+ ## 🤝 Join the A2A Net Community
30
+
31
+ A2A Net is a site to find and share AI agents and open-source community. Join to share your A2A agents, ask questions, stay up-to-date with the latest A2A news, be the first to hear about open-source releases, tutorials, and more!
32
+
33
+ - 🌍 Site: [A2A Net](https://a2anet.com)
34
+ - 🤖 Discord: [Join the Discord](https://discord.gg/674NGXpAjU)
@@ -0,0 +1,14 @@
1
+ # ADK Code Mode Sandbox
2
+
3
+ In-sandbox shim for [`adk-code-mode`](https://github.com/A2ANet/adk-code-mode). The host package launches a container that has this wheel installed, then talks to it over a TCP control connection so model code can call ADK tools that run on the host.
4
+
5
+ ## 📄 License
6
+
7
+ `adk-code-mode-sandbox` is distributed under the terms of the [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) license.
8
+
9
+ ## 🤝 Join the A2A Net Community
10
+
11
+ A2A Net is a site to find and share AI agents and open-source community. Join to share your A2A agents, ask questions, stay up-to-date with the latest A2A news, be the first to hear about open-source releases, tutorials, and more!
12
+
13
+ - 🌍 Site: [A2A Net](https://a2anet.com)
14
+ - 🤖 Discord: [Join the Discord](https://discord.gg/674NGXpAjU)
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "adk-code-mode-sandbox"
7
+ dynamic = ["version"]
8
+ description = "In-sandbox runtime shim for adk-code-mode. Stdlib-only. Installs inside the container image the host runtime launches."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ keywords = ["adk", "code-mode", "sandbox"]
13
+ authors = [
14
+ { name = "A2A Net", email = "hello@a2anet.com" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Documentation = "https://github.com/A2ANet/adk-code-mode#readme"
29
+ Issues = "https://github.com/A2ANet/adk-code-mode/issues"
30
+ Source = "https://github.com/A2ANet/adk-code-mode"
31
+
32
+ [tool.hatch.version]
33
+ path = "src/adk_code_mode_sandbox/__about__.py"
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ __version__ = "0.1.0" # x-release-please-version
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ from adk_code_mode_sandbox.__about__ import __version__
5
+
6
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ from adk_code_mode_sandbox._entry import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
@@ -0,0 +1,155 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Sandbox entry point.
5
+
6
+ Invoked as ``python -m adk_code_mode_sandbox``. Three control-pipe transports
7
+ are supported, chosen by env var (checked in order):
8
+
9
+ - ``ADK_CODE_MODE_CONTROL_TCP`` → ``host:port`` TCP endpoint on the host
10
+ (used by the Docker runtime; works on mac/Windows/Linux uniformly).
11
+ - ``ADK_CODE_MODE_CONTROL_SOCKET`` → Unix domain socket path (used by the
12
+ in-process test ``FakeRuntime``; broken on Docker Desktop macOS so not
13
+ used by ``DockerRuntime``).
14
+ - ``ADK_CODE_MODE_CONTROL_FD`` → inherited fd(s). Accepts one fd
15
+ (bidirectional) or two comma-separated fds (read,write).
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.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import socket
26
+ import sys
27
+ import traceback
28
+ from typing import Any
29
+
30
+ from adk_code_mode_sandbox import _rpc_client
31
+ from adk_code_mode_sandbox._rpc_client import RpcClient
32
+ from adk_code_mode_sandbox.protocol import (
33
+ DoneFrame,
34
+ ReadyFrame,
35
+ RunFrame,
36
+ ShutdownFrame,
37
+ )
38
+
39
+ CONTROL_FD_ENV = "ADK_CODE_MODE_CONTROL_FD"
40
+ CONTROL_SOCKET_ENV = "ADK_CODE_MODE_CONTROL_SOCKET"
41
+ CONTROL_TCP_ENV = "ADK_CODE_MODE_CONTROL_TCP"
42
+ CONTROL_TOKEN_ENV = "ADK_CODE_MODE_CONTROL_TOKEN"
43
+ TOOLS_DIR_ENV = "ADK_CODE_MODE_TOOLS_DIR"
44
+ WORKDIR_ENV = "ADK_CODE_MODE_WORKDIR"
45
+ TOOLS_DIR = os.environ.get(TOOLS_DIR_ENV, "/tools")
46
+ WORKDIR = os.environ.get(WORKDIR_ENV, "/workspace")
47
+
48
+
49
+ def _open_control_streams() -> tuple[Any, Any]:
50
+ """Open the host control pipe as a (reader, writer) pair."""
51
+ tcp_endpoint = os.environ.get(CONTROL_TCP_ENV)
52
+ if tcp_endpoint:
53
+ host, _, port_s = tcp_endpoint.rpartition(":")
54
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
55
+ sock.connect((host, int(port_s)))
56
+ token = os.environ.get(CONTROL_TOKEN_ENV, "")
57
+ sock.sendall(token.encode("ascii") + b"\n")
58
+ return sock.makefile("rb", buffering=0), sock.makefile("wb", buffering=0)
59
+ sock_path = os.environ.get(CONTROL_SOCKET_ENV)
60
+ if sock_path:
61
+ usock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
62
+ usock.connect(sock_path)
63
+ return usock.makefile("rb", buffering=0), usock.makefile("wb", buffering=0)
64
+ raw = os.environ.get(CONTROL_FD_ENV, "3")
65
+ if "," in raw:
66
+ read_fd_s, write_fd_s = raw.split(",", 1)
67
+ read_fd, write_fd = int(read_fd_s), int(write_fd_s)
68
+ else:
69
+ read_fd = write_fd = int(raw)
70
+ return (
71
+ os.fdopen(read_fd, "rb", buffering=0, closefd=False),
72
+ os.fdopen(write_fd, "wb", buffering=0, closefd=False),
73
+ )
74
+
75
+
76
+ def _prepare_sys_path() -> None:
77
+ """Make the generated tools directory importable from user code."""
78
+ if os.path.isdir(TOOLS_DIR):
79
+ # TOOLS_DIR is the package directory itself (normally /tools), not a
80
+ # parent that contains tools/. Add the parent so `import tools` resolves
81
+ # directly to /tools/__init__.py instead of requiring /tools/tools.
82
+ package_parent = os.path.dirname(os.path.abspath(TOOLS_DIR)) or os.sep
83
+ if package_parent not in sys.path:
84
+ sys.path.insert(0, package_parent)
85
+
86
+
87
+ def _prepare_workdir() -> None:
88
+ os.makedirs(WORKDIR, exist_ok=True)
89
+ os.chdir(WORKDIR)
90
+
91
+
92
+ def _make_globals() -> dict[str, Any]:
93
+ """Build the globals dict for ``exec``. Looks like a normal ``__main__``."""
94
+ return {
95
+ "__name__": "__main__",
96
+ "__doc__": None,
97
+ "__package__": None,
98
+ "__loader__": None,
99
+ "__spec__": None,
100
+ "__builtins__": __builtins__,
101
+ }
102
+
103
+
104
+ def _run_code(code: str) -> int:
105
+ """Execute user code. Returns the exit code (0 ok, 1 on exception)."""
106
+ globs = _make_globals()
107
+ try:
108
+ compiled = compile(code, "<code-mode>", "exec")
109
+ except SyntaxError:
110
+ traceback.print_exc()
111
+ return 1
112
+ try:
113
+ exec(compiled, globs)
114
+ except SystemExit as exc:
115
+ code_val = exc.code
116
+ if code_val is None:
117
+ return 0
118
+ if isinstance(code_val, int):
119
+ return code_val
120
+ print(code_val, file=sys.stderr)
121
+ return 1
122
+ except BaseException:
123
+ traceback.print_exc()
124
+ return 1
125
+ return 0
126
+
127
+
128
+ def main() -> int:
129
+ reader, writer = _open_control_streams()
130
+ client = RpcClient(reader=reader, writer=writer)
131
+ _rpc_client.install(client)
132
+ _prepare_sys_path()
133
+ _prepare_workdir()
134
+
135
+ client.send(ReadyFrame())
136
+
137
+ while True:
138
+ frame = client.recv()
139
+ if isinstance(frame, ShutdownFrame):
140
+ return 0
141
+ if isinstance(frame, RunFrame):
142
+ exit_code = _run_code(frame.code)
143
+ sys.stdout.flush()
144
+ sys.stderr.flush()
145
+ client.send(DoneFrame(exit_code=exit_code))
146
+ continue
147
+ # Any other frame kind is a host protocol error; keep going but log.
148
+ print(
149
+ f"[adk-code-mode-sandbox] unexpected frame kind: {type(frame).__name__}",
150
+ file=sys.stderr,
151
+ )
152
+
153
+
154
+ if __name__ == "__main__":
155
+ raise SystemExit(main())
@@ -0,0 +1,129 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Blocking RPC client used by generated tool stubs inside the sandbox.
5
+
6
+ All tool stubs funnel through ``call(name, args)``. The global client instance
7
+ is installed by ``_entry`` before user code runs. Stubs import it lazily:
8
+
9
+ .. code-block:: python
10
+
11
+ from adk_code_mode_sandbox._rpc_client import call
12
+ result = call("slack.send_message", {"channel": "C1", "text": "hi"})
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import deque
18
+ import threading
19
+ import uuid
20
+ from typing import Any
21
+
22
+ from adk_code_mode_sandbox.protocol import (
23
+ Frame,
24
+ ProtocolError,
25
+ ToolCallFrame,
26
+ ToolResultFrame,
27
+ decode,
28
+ encode,
29
+ )
30
+
31
+
32
+ class ToolError(RuntimeError):
33
+ """Raised inside the sandbox when a host-side tool call fails."""
34
+
35
+ def __init__(self, error_type: str, message: str, trace: str | None = None) -> None:
36
+ super().__init__(f"{error_type}: {message}")
37
+ self.error_type = error_type
38
+ self.trace = trace
39
+
40
+
41
+ class RpcClient:
42
+ """Synchronous, thread-safe RPC client over the sandbox control pipe.
43
+
44
+ The control pipe is a pair of unbuffered streams: ``_out`` for frames going
45
+ to the host and ``_in`` for frames coming back. Both are line-oriented.
46
+ """
47
+
48
+ def __init__(self, *, reader: Any, writer: Any) -> None:
49
+ self._reader = reader
50
+ self._writer = writer
51
+ self._write_lock = threading.Lock()
52
+ self._state_lock = threading.Lock()
53
+ self._state_changed = threading.Condition(self._state_lock)
54
+ self._general_frames: deque[Frame] = deque()
55
+ self._pending_results: dict[str, ToolResultFrame] = {}
56
+ self._closed_error: Exception | None = None
57
+ self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
58
+ self._reader_thread.start()
59
+
60
+ def send(self, frame: Frame) -> None:
61
+ payload = encode(frame)
62
+ with self._write_lock:
63
+ self._writer.write(payload)
64
+ self._writer.flush()
65
+
66
+ def recv(self) -> Frame:
67
+ with self._state_changed:
68
+ while not self._general_frames and self._closed_error is None:
69
+ self._state_changed.wait()
70
+ if self._general_frames:
71
+ return self._general_frames.popleft()
72
+ raise self._closed_error or ProtocolError("control pipe closed by host")
73
+
74
+ def call(self, name: str, args: dict[str, Any], timeout: float | None = None) -> Any:
75
+ call_id = uuid.uuid4().hex
76
+ self.send(ToolCallFrame(id=call_id, name=name, args=args, timeout=timeout))
77
+ with self._state_changed:
78
+ while call_id not in self._pending_results and self._closed_error is None:
79
+ self._state_changed.wait()
80
+ frame = self._pending_results.pop(call_id, None)
81
+ if frame is None:
82
+ raise self._closed_error or ProtocolError("control pipe closed by host")
83
+ if frame.ok:
84
+ return frame.value
85
+ err = frame.error
86
+ if err is None:
87
+ raise ToolError("Error", "tool call failed without error payload")
88
+ raise ToolError(err.type, err.message, err.trace)
89
+
90
+ def _read_loop(self) -> None:
91
+ try:
92
+ while True:
93
+ line = self._reader.readline()
94
+ if not line:
95
+ raise ProtocolError("control pipe closed by host")
96
+ frame = decode(line)
97
+ with self._state_changed:
98
+ if isinstance(frame, ToolResultFrame):
99
+ self._pending_results[frame.id] = frame
100
+ else:
101
+ self._general_frames.append(frame)
102
+ self._state_changed.notify_all()
103
+ except Exception as exc:
104
+ with self._state_changed:
105
+ self._closed_error = exc
106
+ self._state_changed.notify_all()
107
+
108
+
109
+ _CLIENT: RpcClient | None = None
110
+
111
+
112
+ def install(client: RpcClient) -> None:
113
+ """Set the process-wide RPC client. Called by ``_entry`` at boot."""
114
+ global _CLIENT
115
+ _CLIENT = client
116
+
117
+
118
+ def get() -> RpcClient:
119
+ if _CLIENT is None:
120
+ raise RuntimeError("RPC client not installed; sandbox is not in a valid state")
121
+ return _CLIENT
122
+
123
+
124
+ def call(name: str, args: dict[str, Any], timeout: float | None = None) -> Any:
125
+ """Convenience: invoke a tool through the installed client."""
126
+ return get().call(name, args, timeout)
127
+
128
+
129
+ __all__ = ["RpcClient", "ToolError", "call", "get", "install"]
@@ -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
+ ]