adk-code-mode 1.6.0__tar.gz → 2.0.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.
- adk_code_mode-2.0.0/.release-please-manifest.json +3 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/CHANGELOG.md +19 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/PKG-INFO +3 -2
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/README.md +2 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +56 -7
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +19 -7
- adk_code_mode-2.0.0/sandbox-wheel/src/adk_code_mode_sandbox/_interrupt.py +67 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +19 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +18 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/__about__.py +1 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/base.py +1 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/protocol.py +18 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tool.py +140 -42
- adk_code_mode-2.0.0/src/adk_code_mode/tool_failures.py +54 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tool_result_artifacts.py +3 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/dispatcher.py +15 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/stubs.py +14 -8
- adk_code_mode-2.0.0/tests/_rest_tool.py +49 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_dispatcher.py +106 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_host_loop.py +65 -3
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_integration.py +90 -7
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_protocol.py +15 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_remote_integration.py +146 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_sandbox_entry.py +28 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_sandbox_rpc_client.py +50 -1
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_stubs.py +7 -5
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_tool.py +6 -0
- adk_code_mode-1.6.0/.release-please-manifest.json +0 -3
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.githooks/pre-commit +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.githooks/pre-push +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.github/workflows/ci.yml +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.github/workflows/release-please.yml +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.gitignore +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.prettierrc +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.vscode/settings.json +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.zed/settings.json +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/LICENSE +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/Makefile +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/docker/Dockerfile +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/pyproject.toml +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/release-please-config.json +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/README.md +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/pyproject.toml +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/scripts/install-git-hooks.sh +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/_artifact_tools.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/metadata.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/output.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/py.typed +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/docker.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/remote.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/catalog.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/namespacing.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/normaliser.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/workspace/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/workspace/files.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/__init__.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/_docker_helpers.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/_fake_runtime.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/conftest.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_agent_with_fake_llm.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_artifact_tools.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_catalog.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_docker_image.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_docker_integration.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_metadata.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_namespacing.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_normaliser.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_output.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_remote_backend.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_runtime_docker.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_stubs_json_schema.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_tool_result_artifacts.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_version.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_workspace_files.py +0 -0
- {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/uv.lock +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.6.1...adk-code-mode-v2.0.0) (2026-09-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* sandbox code that checked a tool result for an `error` key now gets an exception instead, and the sandbox image must be rebuilt for protocol version 3.
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* raise when a tool call fails instead of returning the error ([#22](https://github.com/a2anet/adk-code-mode/issues/22)) ([489083d](https://github.com/a2anet/adk-code-mode/commit/489083d988cd7e958e94f3ef6acba6443284abd8))
|
|
13
|
+
* stop a timed-out block without losing the turn's state ([#23](https://github.com/a2anet/adk-code-mode/issues/23)) ([4720156](https://github.com/a2anet/adk-code-mode/commit/47201568d0bb06527e32f850b7abb1b465e36920))
|
|
14
|
+
|
|
15
|
+
## [1.6.1](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.6.0...adk-code-mode-v1.6.1) (2026-09-22)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* exit the sandbox when the host disconnects mid-block ([bd3fb0f](https://github.com/a2anet/adk-code-mode/commit/bd3fb0fb0d8a0cab9370f2ad6db56dfacf566cb4))
|
|
21
|
+
|
|
3
22
|
## [1.6.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.5.0...adk-code-mode-v1.6.0) (2026-08-29)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: adk-code-mode
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A "Code Mode" sandboxed code-execution tool for ADK agents to interact with tools, files, and custom packages with Python
|
|
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
|
|
@@ -39,6 +39,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
|
|
|
39
39
|
## ✨ Features
|
|
40
40
|
|
|
41
41
|
- **Call ADK tools from sandbox code** — imports against the `tools` package proxy back to the host and run through ADK's `before_tool` / `after_tool` / `on_error` callbacks and the plugin manager exactly as direct tool calls would.
|
|
42
|
+
- **Failed tool calls raise** — a REST tool whose API answers with an HTTP error raises `HTTPStatusError` (with `status_code` and `body`), and any other failure, including an MCP tool that reports one, raises `ToolError`. ADK returns these as values for a model to read, but sandbox code reads return values, so it would otherwise carry on as if the call had worked.
|
|
42
43
|
- **Bake any Python package into the image** — extend the published base image with anything the model's code needs to `import`, no runtime `pip install` required. The sandbox reports what it has installed, so the model is told about it automatically.
|
|
43
44
|
- **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`. Files the code creates or changes are saved as artifacts automatically too.
|
|
44
45
|
- **Tool results saved as artifacts** — on by default; every tool's result is persisted as a `code_mode.tool_result` artifact (with optional model-supplied name/description) so hosts can forward outputs and large results stay out of the prompt. Opt out with `save_tool_results_as_artifacts=False`.
|
|
@@ -300,7 +301,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
|
|
|
300
301
|
| `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
|
|
301
302
|
| `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
|
|
302
303
|
| `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
|
|
303
|
-
| `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. `None` lifts
|
|
304
|
+
| `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. A block past the cap is stopped with its output so far, keeping the turn's variables and working directory. If it won't stop within a few seconds or leaves a worker thread or child process running, its sandbox is restarted and its output and state are lost. The model is told the cap. `None` lifts it, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
|
|
304
305
|
| `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
|
|
305
306
|
| `session_idle_timeout_seconds` | `600` | Idle reaper: closes a turn's container once it goes untouched this long. Backstop for turns that never call `release_invocation`. |
|
|
306
307
|
|
|
@@ -12,6 +12,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
|
|
|
12
12
|
## ✨ Features
|
|
13
13
|
|
|
14
14
|
- **Call ADK tools from sandbox code** — imports against the `tools` package proxy back to the host and run through ADK's `before_tool` / `after_tool` / `on_error` callbacks and the plugin manager exactly as direct tool calls would.
|
|
15
|
+
- **Failed tool calls raise** — a REST tool whose API answers with an HTTP error raises `HTTPStatusError` (with `status_code` and `body`), and any other failure, including an MCP tool that reports one, raises `ToolError`. ADK returns these as values for a model to read, but sandbox code reads return values, so it would otherwise carry on as if the call had worked.
|
|
15
16
|
- **Bake any Python package into the image** — extend the published base image with anything the model's code needs to `import`, no runtime `pip install` required. The sandbox reports what it has installed, so the model is told about it automatically.
|
|
16
17
|
- **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`. Files the code creates or changes are saved as artifacts automatically too.
|
|
17
18
|
- **Tool results saved as artifacts** — on by default; every tool's result is persisted as a `code_mode.tool_result` artifact (with optional model-supplied name/description) so hosts can forward outputs and large results stay out of the prompt. Opt out with `save_tool_results_as_artifacts=False`.
|
|
@@ -273,7 +274,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
|
|
|
273
274
|
| `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
|
|
274
275
|
| `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
|
|
275
276
|
| `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
|
|
276
|
-
| `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. `None` lifts
|
|
277
|
+
| `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. A block past the cap is stopped with its output so far, keeping the turn's variables and working directory. If it won't stop within a few seconds or leaves a worker thread or child process running, its sandbox is restarted and its output and state are lost. The model is told the cap. `None` lifts it, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
|
|
277
278
|
| `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
|
|
278
279
|
| `session_idle_timeout_seconds` | `600` | Idle reaper: closes a turn's container once it goes untouched this long. Backstop for turns that never call `release_invocation`. |
|
|
279
280
|
|
{adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py
RENAMED
|
@@ -28,11 +28,13 @@ import io
|
|
|
28
28
|
import os
|
|
29
29
|
import platform
|
|
30
30
|
import socket
|
|
31
|
+
import subprocess
|
|
31
32
|
import sys
|
|
33
|
+
import threading
|
|
32
34
|
import traceback
|
|
33
35
|
from typing import Any
|
|
34
36
|
|
|
35
|
-
from adk_code_mode_sandbox import _rpc_client
|
|
37
|
+
from adk_code_mode_sandbox import _interrupt, _rpc_client
|
|
36
38
|
from adk_code_mode_sandbox._rpc_client import RpcClient
|
|
37
39
|
from adk_code_mode_sandbox.protocol import (
|
|
38
40
|
DoneFrame,
|
|
@@ -184,7 +186,8 @@ def _exec_into(code: str, globs: dict[str, Any]) -> int:
|
|
|
184
186
|
traceback.print_exc()
|
|
185
187
|
return 1
|
|
186
188
|
try:
|
|
187
|
-
|
|
189
|
+
with _interrupt.running():
|
|
190
|
+
exec(compiled, globs)
|
|
188
191
|
except SystemExit as exc:
|
|
189
192
|
code_val = exc.code
|
|
190
193
|
if code_val is None:
|
|
@@ -199,7 +202,54 @@ def _exec_into(code: str, globs: dict[str, Any]) -> int:
|
|
|
199
202
|
return 0
|
|
200
203
|
|
|
201
204
|
|
|
202
|
-
def
|
|
205
|
+
def _has_linux_child_processes(task_dir: str = "/proc/self/task") -> bool:
|
|
206
|
+
"""Check each Linux thread for child processes still running."""
|
|
207
|
+
checked = False
|
|
208
|
+
try:
|
|
209
|
+
with os.scandir(task_dir) as tasks:
|
|
210
|
+
for task in tasks:
|
|
211
|
+
try:
|
|
212
|
+
with open(f"{task.path}/children") as children:
|
|
213
|
+
checked = True
|
|
214
|
+
if children.read().strip():
|
|
215
|
+
return True
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
continue
|
|
218
|
+
except OSError:
|
|
219
|
+
return True
|
|
220
|
+
return not checked
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _has_child_processes() -> bool:
|
|
224
|
+
"""Check whether user code left a child process running."""
|
|
225
|
+
if sys.platform == "linux":
|
|
226
|
+
return _has_linux_child_processes()
|
|
227
|
+
try:
|
|
228
|
+
probe = subprocess.Popen(["ps", "-axo", "pid=,ppid="], stdout=subprocess.PIPE, text=True)
|
|
229
|
+
try:
|
|
230
|
+
output, _ = probe.communicate(timeout=2)
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
probe.kill()
|
|
233
|
+
probe.wait()
|
|
234
|
+
return True
|
|
235
|
+
except OSError:
|
|
236
|
+
return True
|
|
237
|
+
if probe.returncode != 0:
|
|
238
|
+
return True
|
|
239
|
+
return any(
|
|
240
|
+
int(pid) != probe.pid and int(ppid) == os.getpid()
|
|
241
|
+
for pid, ppid in (line.split() for line in output.splitlines())
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _has_background_work() -> bool:
|
|
246
|
+
"""Check whether code remains active outside the interrupted execution thread."""
|
|
247
|
+
reader_thread = _rpc_client.get()._reader_thread
|
|
248
|
+
trusted = {threading.current_thread(), threading.main_thread(), reader_thread}
|
|
249
|
+
return any(thread not in trusted for thread in threading.enumerate()) or _has_child_processes()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_block(code: str, globs: dict[str, Any]) -> tuple[str, str, int, bool]:
|
|
203
253
|
"""Run one code block with its stdout/stderr captured.
|
|
204
254
|
|
|
205
255
|
Redirects ``sys.stdout`` / ``sys.stderr`` to in-memory buffers for the
|
|
@@ -216,7 +266,7 @@ def run_block(code: str, globs: dict[str, Any]) -> tuple[str, str, int]:
|
|
|
216
266
|
finally:
|
|
217
267
|
sys.stdout = old_stdout
|
|
218
268
|
sys.stderr = old_stderr
|
|
219
|
-
return capture_out.getvalue(), capture_err.getvalue(), exit_code
|
|
269
|
+
return capture_out.getvalue(), capture_err.getvalue(), exit_code, _has_background_work()
|
|
220
270
|
|
|
221
271
|
|
|
222
272
|
def main() -> int:
|
|
@@ -228,7 +278,6 @@ def main() -> int:
|
|
|
228
278
|
port = int(os.environ.get("PORT", "8080"))
|
|
229
279
|
token = os.environ.get(AUTH_TOKEN_ENV)
|
|
230
280
|
asyncio.run(serve(port, token))
|
|
231
|
-
return 0
|
|
232
281
|
|
|
233
282
|
reader, writer = _open_control_streams()
|
|
234
283
|
client = RpcClient(reader=reader, writer=writer)
|
|
@@ -247,8 +296,8 @@ def main() -> int:
|
|
|
247
296
|
if isinstance(frame, ShutdownFrame):
|
|
248
297
|
return 0
|
|
249
298
|
if isinstance(frame, RunFrame):
|
|
250
|
-
stdout_text, stderr_text, exit_code = run_block(frame.code, globs)
|
|
251
|
-
client.send(DoneFrame(exit_code=exit_code))
|
|
299
|
+
stdout_text, stderr_text, exit_code, background_work = run_block(frame.code, globs)
|
|
300
|
+
client.send(DoneFrame(exit_code=exit_code, background_work=background_work))
|
|
252
301
|
client.send(OutputFrame(stdout=stdout_text, stderr=stderr_text, exit_code=exit_code))
|
|
253
302
|
continue
|
|
254
303
|
# Any other frame kind is a host protocol error; keep going but log.
|
{adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py
RENAMED
|
@@ -38,7 +38,7 @@ import shutil
|
|
|
38
38
|
import tarfile
|
|
39
39
|
import tempfile
|
|
40
40
|
from http import HTTPStatus
|
|
41
|
-
from typing import Any
|
|
41
|
+
from typing import Any, NoReturn
|
|
42
42
|
|
|
43
43
|
import websockets
|
|
44
44
|
import websockets.asyncio.server
|
|
@@ -112,10 +112,11 @@ class _WsBridgeWriter:
|
|
|
112
112
|
pass
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
async def serve(port: int, token: str | None) ->
|
|
115
|
+
async def serve(port: int, token: str | None) -> NoReturn:
|
|
116
116
|
"""Start the WebSocket server on *port* with optional bearer *token* auth.
|
|
117
117
|
|
|
118
|
-
Accepts exactly one WebSocket connection, runs user code, then
|
|
118
|
+
Accepts exactly one WebSocket connection, runs user code, then exits the
|
|
119
|
+
process.
|
|
119
120
|
"""
|
|
120
121
|
stop = asyncio.Event()
|
|
121
122
|
accepted = False
|
|
@@ -136,9 +137,15 @@ async def serve(port: int, token: str | None) -> None:
|
|
|
136
137
|
return None
|
|
137
138
|
|
|
138
139
|
async def handler(ws: Any) -> None:
|
|
140
|
+
turn = asyncio.create_task(_handle_connection(ws))
|
|
141
|
+
# End the turn when the socket closes, even mid-block: user code that
|
|
142
|
+
# ignores the disconnect would otherwise keep this single-use container
|
|
143
|
+
# alive, answering every later connection with "Busy".
|
|
144
|
+
ws_closed = asyncio.create_task(ws.wait_closed())
|
|
145
|
+
ws_closed.add_done_callback(lambda _: turn.cancel())
|
|
139
146
|
try:
|
|
140
|
-
await
|
|
141
|
-
except websockets.ConnectionClosed:
|
|
147
|
+
await turn
|
|
148
|
+
except (websockets.ConnectionClosed, asyncio.CancelledError):
|
|
142
149
|
logger.debug("client disconnected")
|
|
143
150
|
except Exception:
|
|
144
151
|
logger.exception("error handling WebSocket connection")
|
|
@@ -154,6 +161,9 @@ async def serve(port: int, token: str | None) -> None:
|
|
|
154
161
|
):
|
|
155
162
|
logger.info("sandbox HTTP server listening on port %d", port)
|
|
156
163
|
await stop.wait()
|
|
164
|
+
# Don't let interpreter shutdown join a worker thread still running user code
|
|
165
|
+
# the host has given up on.
|
|
166
|
+
os._exit(0)
|
|
157
167
|
|
|
158
168
|
|
|
159
169
|
async def _handle_connection(ws: Any) -> None:
|
|
@@ -248,7 +258,7 @@ async def _run_one_block(
|
|
|
248
258
|
_rpc_client.install(client)
|
|
249
259
|
pump_task = asyncio.create_task(_pump_ws_to_reader(ws, bridge_reader))
|
|
250
260
|
try:
|
|
251
|
-
stdout_text, stderr_text, exit_code = await loop.run_in_executor(
|
|
261
|
+
stdout_text, stderr_text, exit_code, background_work = await loop.run_in_executor(
|
|
252
262
|
None, run_block, run_frame.code, globs
|
|
253
263
|
)
|
|
254
264
|
finally:
|
|
@@ -259,7 +269,9 @@ async def _run_one_block(
|
|
|
259
269
|
pass
|
|
260
270
|
|
|
261
271
|
# d-f. DoneFrame, OutputFrame, then the updated workspace tar (binary).
|
|
262
|
-
await ws.send(
|
|
272
|
+
await ws.send(
|
|
273
|
+
encode(DoneFrame(exit_code=exit_code, background_work=background_work)).decode("utf-8")
|
|
274
|
+
)
|
|
263
275
|
await ws.send(
|
|
264
276
|
encode(OutputFrame(stdout=stdout_text, stderr=stderr_text, exit_code=exit_code)).decode(
|
|
265
277
|
"utf-8"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Stop the running code block when the host asks, keeping the sandbox's state.
|
|
5
|
+
|
|
6
|
+
The host sends an ``InterruptFrame`` when a block runs past its timeout. Raising
|
|
7
|
+
``CodeTimeout`` in the thread running the block ends it like any other exception,
|
|
8
|
+
so its output so far, the globals and the working directory all survive for the
|
|
9
|
+
turn's next block. Code stuck outside the interpreter (a C call, a long
|
|
10
|
+
``time.sleep``) only sees the exception once it returns, which is why the host
|
|
11
|
+
still falls back to restarting the sandbox after a grace period.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ctypes
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodeTimeout(BaseException):
|
|
23
|
+
"""Raised in running code when the host stops it at its timeout.
|
|
24
|
+
|
|
25
|
+
A ``BaseException`` so the code's own ``except Exception`` can't swallow it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
message = ""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *args: object) -> None:
|
|
31
|
+
# The interpreter raises an asynchronous exception without arguments.
|
|
32
|
+
super().__init__(*(args or (type(self).message,)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
_running_thread: int | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _set_async_exc(thread_id: int, exc: type[BaseException] | None) -> None:
|
|
40
|
+
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
41
|
+
ctypes.c_ulong(thread_id), ctypes.py_object(exc) if exc else None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def running() -> Iterator[None]:
|
|
47
|
+
"""Mark the current thread as the one running a code block."""
|
|
48
|
+
global _running_thread
|
|
49
|
+
with _lock:
|
|
50
|
+
_running_thread = threading.get_ident()
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
with _lock:
|
|
55
|
+
_running_thread = None
|
|
56
|
+
# A stop that arrived as the block finished must not fire in the
|
|
57
|
+
# sandbox's own code afterwards.
|
|
58
|
+
_set_async_exc(threading.get_ident(), None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def interrupt(message: str) -> None:
|
|
62
|
+
"""Raise ``CodeTimeout`` with ``message`` in the running block, if there is one."""
|
|
63
|
+
with _lock:
|
|
64
|
+
if _running_thread is None:
|
|
65
|
+
return
|
|
66
|
+
CodeTimeout.message = message
|
|
67
|
+
_set_async_exc(_running_thread, CodeTimeout)
|
{adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py
RENAMED
|
@@ -19,8 +19,10 @@ import threading
|
|
|
19
19
|
import uuid
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from adk_code_mode_sandbox import _interrupt
|
|
22
23
|
from adk_code_mode_sandbox.protocol import (
|
|
23
24
|
Frame,
|
|
25
|
+
InterruptFrame,
|
|
24
26
|
ProtocolError,
|
|
25
27
|
ToolCallFrame,
|
|
26
28
|
ToolResultFrame,
|
|
@@ -38,6 +40,15 @@ class ToolError(RuntimeError):
|
|
|
38
40
|
self.trace = trace
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
class HTTPStatusError(Exception):
|
|
44
|
+
"""Raised inside the sandbox when a tool's API answers with an HTTP error status."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, status_code: int, body: Any) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.status_code = status_code
|
|
49
|
+
self.body = body
|
|
50
|
+
|
|
51
|
+
|
|
41
52
|
class RpcClient:
|
|
42
53
|
"""Synchronous, thread-safe RPC client over the sandbox control pipe.
|
|
43
54
|
|
|
@@ -85,6 +96,8 @@ class RpcClient:
|
|
|
85
96
|
err = frame.error
|
|
86
97
|
if err is None:
|
|
87
98
|
raise ToolError("Error", "tool call failed without error payload")
|
|
99
|
+
if err.type == "HTTPStatusError" and err.status_code is not None:
|
|
100
|
+
raise HTTPStatusError(err.message, err.status_code, err.body)
|
|
88
101
|
raise ToolError(err.type, err.message, err.trace)
|
|
89
102
|
|
|
90
103
|
def _read_loop(self) -> None:
|
|
@@ -94,6 +107,11 @@ class RpcClient:
|
|
|
94
107
|
if not line:
|
|
95
108
|
raise ProtocolError("control pipe closed by host")
|
|
96
109
|
frame = decode(line)
|
|
110
|
+
# Handled here, off the thread running the code, so a stop reaches
|
|
111
|
+
# a block that is busy or waiting on a tool call.
|
|
112
|
+
if isinstance(frame, InterruptFrame):
|
|
113
|
+
_interrupt.interrupt(frame.message)
|
|
114
|
+
continue
|
|
97
115
|
with self._state_changed:
|
|
98
116
|
if isinstance(frame, ToolResultFrame):
|
|
99
117
|
self._pending_results[frame.id] = frame
|
|
@@ -126,4 +144,4 @@ def call(name: str, args: dict[str, Any], timeout: float | None = None) -> Any:
|
|
|
126
144
|
return get().call(name, args, timeout)
|
|
127
145
|
|
|
128
146
|
|
|
129
|
-
__all__ = ["RpcClient", "ToolError", "call", "get", "install"]
|
|
147
|
+
__all__ = ["HTTPStatusError", "RpcClient", "ToolError", "call", "get", "install"]
|
{adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py
RENAMED
|
@@ -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 =
|
|
25
|
+
PROTOCOL_VERSION = 4
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
FrameKind = Literal[
|
|
@@ -34,6 +34,7 @@ FrameKind = Literal[
|
|
|
34
34
|
"done",
|
|
35
35
|
"output",
|
|
36
36
|
"shutdown",
|
|
37
|
+
"interrupt",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
|
|
@@ -81,6 +82,8 @@ class ToolErrorPayload:
|
|
|
81
82
|
type: str
|
|
82
83
|
message: str
|
|
83
84
|
trace: str | None = None
|
|
85
|
+
status_code: int | None = None
|
|
86
|
+
body: Any = None
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
@dataclass(frozen=True)
|
|
@@ -108,6 +111,7 @@ class DoneFrame:
|
|
|
108
111
|
"""Sandbox → host. User code finished. Host drains stdout/stderr next."""
|
|
109
112
|
|
|
110
113
|
exit_code: int = 0
|
|
114
|
+
background_work: bool = False
|
|
111
115
|
kind: Literal["done"] = "done"
|
|
112
116
|
|
|
113
117
|
|
|
@@ -132,6 +136,14 @@ class ShutdownFrame:
|
|
|
132
136
|
kind: Literal["shutdown"] = "shutdown"
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class InterruptFrame:
|
|
141
|
+
"""Host → sandbox. Stop the running block, raising ``message`` in its code."""
|
|
142
|
+
|
|
143
|
+
message: str = ""
|
|
144
|
+
kind: Literal["interrupt"] = "interrupt"
|
|
145
|
+
|
|
146
|
+
|
|
135
147
|
Frame = (
|
|
136
148
|
ReadyFrame
|
|
137
149
|
| RunFrame
|
|
@@ -141,6 +153,7 @@ Frame = (
|
|
|
141
153
|
| DoneFrame
|
|
142
154
|
| OutputFrame
|
|
143
155
|
| ShutdownFrame
|
|
156
|
+
| InterruptFrame
|
|
144
157
|
)
|
|
145
158
|
|
|
146
159
|
|
|
@@ -153,6 +166,7 @@ _KIND_TO_CLS: dict[str, type] = {
|
|
|
153
166
|
"done": DoneFrame,
|
|
154
167
|
"output": OutputFrame,
|
|
155
168
|
"shutdown": ShutdownFrame,
|
|
169
|
+
"interrupt": InterruptFrame,
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
|
|
@@ -199,6 +213,8 @@ def decode(line: bytes | str) -> Frame:
|
|
|
199
213
|
type=str(err.get("type", "Error")),
|
|
200
214
|
message=str(err.get("message", "")),
|
|
201
215
|
trace=err.get("trace"),
|
|
216
|
+
status_code=err.get("status_code"),
|
|
217
|
+
body=err.get("body"),
|
|
202
218
|
)
|
|
203
219
|
valid_fields = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
|
204
220
|
filtered = {k: v for k, v in obj.items() if k in valid_fields}
|
|
@@ -221,6 +237,7 @@ __all__ = [
|
|
|
221
237
|
"DoneFrame",
|
|
222
238
|
"OutputFrame",
|
|
223
239
|
"ShutdownFrame",
|
|
240
|
+
"InterruptFrame",
|
|
224
241
|
"ProtocolError",
|
|
225
242
|
"encode",
|
|
226
243
|
"decode",
|
|
@@ -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 =
|
|
25
|
+
PROTOCOL_VERSION = 4
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
FrameKind = Literal[
|
|
@@ -34,6 +34,7 @@ FrameKind = Literal[
|
|
|
34
34
|
"done",
|
|
35
35
|
"output",
|
|
36
36
|
"shutdown",
|
|
37
|
+
"interrupt",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
|
|
@@ -81,6 +82,8 @@ class ToolErrorPayload:
|
|
|
81
82
|
type: str
|
|
82
83
|
message: str
|
|
83
84
|
trace: str | None = None
|
|
85
|
+
status_code: int | None = None
|
|
86
|
+
body: Any = None
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
@dataclass(frozen=True)
|
|
@@ -108,6 +111,7 @@ class DoneFrame:
|
|
|
108
111
|
"""Sandbox → host. User code finished. Host drains stdout/stderr next."""
|
|
109
112
|
|
|
110
113
|
exit_code: int = 0
|
|
114
|
+
background_work: bool = False
|
|
111
115
|
kind: Literal["done"] = "done"
|
|
112
116
|
|
|
113
117
|
|
|
@@ -132,6 +136,14 @@ class ShutdownFrame:
|
|
|
132
136
|
kind: Literal["shutdown"] = "shutdown"
|
|
133
137
|
|
|
134
138
|
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class InterruptFrame:
|
|
141
|
+
"""Host → sandbox. Stop the running block, raising ``message`` in its code."""
|
|
142
|
+
|
|
143
|
+
message: str = ""
|
|
144
|
+
kind: Literal["interrupt"] = "interrupt"
|
|
145
|
+
|
|
146
|
+
|
|
135
147
|
Frame = (
|
|
136
148
|
ReadyFrame
|
|
137
149
|
| RunFrame
|
|
@@ -141,6 +153,7 @@ Frame = (
|
|
|
141
153
|
| DoneFrame
|
|
142
154
|
| OutputFrame
|
|
143
155
|
| ShutdownFrame
|
|
156
|
+
| InterruptFrame
|
|
144
157
|
)
|
|
145
158
|
|
|
146
159
|
|
|
@@ -153,6 +166,7 @@ _KIND_TO_CLS: dict[str, type] = {
|
|
|
153
166
|
"done": DoneFrame,
|
|
154
167
|
"output": OutputFrame,
|
|
155
168
|
"shutdown": ShutdownFrame,
|
|
169
|
+
"interrupt": InterruptFrame,
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
|
|
@@ -199,6 +213,8 @@ def decode(line: bytes | str) -> Frame:
|
|
|
199
213
|
type=str(err.get("type", "Error")),
|
|
200
214
|
message=str(err.get("message", "")),
|
|
201
215
|
trace=err.get("trace"),
|
|
216
|
+
status_code=err.get("status_code"),
|
|
217
|
+
body=err.get("body"),
|
|
202
218
|
)
|
|
203
219
|
valid_fields = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
|
204
220
|
filtered = {k: v for k, v in obj.items() if k in valid_fields}
|
|
@@ -221,6 +237,7 @@ __all__ = [
|
|
|
221
237
|
"DoneFrame",
|
|
222
238
|
"OutputFrame",
|
|
223
239
|
"ShutdownFrame",
|
|
240
|
+
"InterruptFrame",
|
|
224
241
|
"ProtocolError",
|
|
225
242
|
"encode",
|
|
226
243
|
"decode",
|