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.
Files changed (81) hide show
  1. adk_code_mode-2.0.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/CHANGELOG.md +19 -0
  3. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/PKG-INFO +3 -2
  4. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/README.md +2 -1
  5. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  6. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +56 -7
  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
  8. adk_code_mode-2.0.0/sandbox-wheel/src/adk_code_mode_sandbox/_interrupt.py +67 -0
  9. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +19 -1
  10. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +18 -1
  11. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/__about__.py +1 -1
  12. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/base.py +1 -0
  13. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/protocol.py +18 -1
  14. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tool.py +140 -42
  15. adk_code_mode-2.0.0/src/adk_code_mode/tool_failures.py +54 -0
  16. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tool_result_artifacts.py +3 -0
  17. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/dispatcher.py +15 -1
  18. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/stubs.py +14 -8
  19. adk_code_mode-2.0.0/tests/_rest_tool.py +49 -0
  20. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_dispatcher.py +106 -0
  21. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_host_loop.py +65 -3
  22. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_integration.py +90 -7
  23. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_protocol.py +15 -0
  24. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_remote_integration.py +146 -0
  25. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_sandbox_entry.py +28 -0
  26. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_sandbox_rpc_client.py +50 -1
  27. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_stubs.py +7 -5
  28. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_tool.py +6 -0
  29. adk_code_mode-1.6.0/.release-please-manifest.json +0 -3
  30. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.githooks/pre-commit +0 -0
  31. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.githooks/pre-push +0 -0
  32. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.github/workflows/ci.yml +0 -0
  33. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.github/workflows/release-please.yml +0 -0
  34. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.gitignore +0 -0
  35. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.prettierrc +0 -0
  36. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.vscode/settings.json +0 -0
  37. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/.zed/settings.json +0 -0
  38. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/LICENSE +0 -0
  39. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/Makefile +0 -0
  40. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/docker/Dockerfile +0 -0
  41. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/pyproject.toml +0 -0
  42. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/release-please-config.json +0 -0
  43. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/README.md +0 -0
  44. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/pyproject.toml +0 -0
  45. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  46. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  47. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/scripts/install-git-hooks.sh +0 -0
  48. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/__init__.py +0 -0
  49. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/_artifact_tools.py +0 -0
  50. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/metadata.py +0 -0
  51. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/output.py +0 -0
  52. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/py.typed +0 -0
  53. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  54. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/docker.py +0 -0
  55. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/runtime/remote.py +0 -0
  56. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/__init__.py +0 -0
  57. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/catalog.py +0 -0
  58. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/namespacing.py +0 -0
  59. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  60. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  61. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/src/adk_code_mode/workspace/files.py +0 -0
  62. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/__init__.py +0 -0
  63. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/_docker_helpers.py +0 -0
  64. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/_fake_runtime.py +0 -0
  65. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/conftest.py +0 -0
  66. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_agent_with_fake_llm.py +0 -0
  67. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_artifact_tools.py +0 -0
  68. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_catalog.py +0 -0
  69. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_docker_image.py +0 -0
  70. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_docker_integration.py +0 -0
  71. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_metadata.py +0 -0
  72. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_namespacing.py +0 -0
  73. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_normaliser.py +0 -0
  74. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_output.py +0 -0
  75. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_remote_backend.py +0 -0
  76. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_runtime_docker.py +0 -0
  77. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_stubs_json_schema.py +0 -0
  78. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_tool_result_artifacts.py +0 -0
  79. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_version.py +0 -0
  80. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/tests/test_workspace_files.py +0 -0
  81. {adk_code_mode-1.6.0 → adk_code_mode-2.0.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.0.0"
3
+ }
@@ -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: 1.6.0
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 the cap, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
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 the cap, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
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
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "1.6.0" # x-release-please-version
4
+ __version__ = "2.0.0" # x-release-please-version
@@ -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
- exec(compiled, globs)
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 run_block(code: str, globs: dict[str, Any]) -> tuple[str, str, int]:
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.
@@ -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) -> 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 returns.
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 _handle_connection(ws)
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(encode(DoneFrame(exit_code=exit_code)).decode("utf-8"))
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)
@@ -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"]
@@ -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 = 2
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",
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "1.6.0" # x-release-please-version
4
+ __version__ = "2.0.0" # x-release-please-version
@@ -42,6 +42,7 @@ class SandboxResult:
42
42
  stdout: str
43
43
  stderr: str
44
44
  exit_code: int
45
+ background_work: bool = False
45
46
 
46
47
 
47
48
  @dataclass(frozen=True)
@@ -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 = 2
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",