adk-code-mode 0.3.0__tar.gz → 0.4.1__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 (77) hide show
  1. adk_code_mode-0.4.1/.release-please-manifest.json +3 -0
  2. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/CHANGELOG.md +27 -0
  3. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/PKG-INFO +52 -86
  4. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/README.md +51 -85
  5. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/docker/Dockerfile +2 -2
  6. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  7. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +8 -6
  8. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/__about__.py +1 -1
  9. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/__init__.py +14 -0
  10. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/callback.py +1 -1
  11. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/executor.py +46 -10
  12. adk_code_mode-0.4.1/src/adk_code_mode/tool_result_artifacts.py +187 -0
  13. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_remote_integration.py +5 -1
  14. adk_code_mode-0.4.1/tests/test_tool_result_artifacts.py +177 -0
  15. adk_code_mode-0.3.0/.release-please-manifest.json +0 -3
  16. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.githooks/pre-commit +0 -0
  17. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.githooks/pre-push +0 -0
  18. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.github/workflows/ci.yml +0 -0
  19. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.github/workflows/release-please.yml +0 -0
  20. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.gitignore +0 -0
  21. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.prettierrc +0 -0
  22. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.vscode/settings.json +0 -0
  23. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/.zed/settings.json +0 -0
  24. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/AGENTS.md +0 -0
  25. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/CLAUDE.md +0 -0
  26. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/LICENSE +0 -0
  27. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/Makefile +0 -0
  28. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/pyproject.toml +0 -0
  29. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/release-please-config.json +0 -0
  30. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/README.md +0 -0
  31. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/pyproject.toml +0 -0
  32. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  33. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  34. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
  35. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  36. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
  37. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/scripts/install-git-hooks.sh +0 -0
  38. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/_artifact_tools.py +0 -0
  39. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/output.py +0 -0
  40. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/py.typed +0 -0
  41. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/runtime/__init__.py +0 -0
  42. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/runtime/base.py +0 -0
  43. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/runtime/docker.py +0 -0
  44. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/runtime/protocol.py +0 -0
  45. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/runtime/remote.py +0 -0
  46. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/__init__.py +0 -0
  47. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/catalog.py +0 -0
  48. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/dispatcher.py +0 -0
  49. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/namespacing.py +0 -0
  50. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/normaliser.py +0 -0
  51. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/tools/stubs.py +0 -0
  52. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/workspace/__init__.py +0 -0
  53. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/src/adk_code_mode/workspace/files.py +0 -0
  54. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/__init__.py +0 -0
  55. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/_docker_helpers.py +0 -0
  56. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/_fake_runtime.py +0 -0
  57. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/conftest.py +0 -0
  58. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_agent_with_fake_llm.py +0 -0
  59. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_artifact_tools.py +0 -0
  60. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_callback.py +0 -0
  61. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_catalog.py +0 -0
  62. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_dispatcher.py +0 -0
  63. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_docker_image.py +0 -0
  64. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_docker_integration.py +0 -0
  65. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_host_loop.py +0 -0
  66. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_integration.py +0 -0
  67. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_namespacing.py +0 -0
  68. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_normaliser.py +0 -0
  69. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_output.py +0 -0
  70. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_protocol.py +0 -0
  71. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_remote_backend.py +0 -0
  72. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_runtime_docker.py +0 -0
  73. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_sandbox_rpc_client.py +0 -0
  74. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_stubs.py +0 -0
  75. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_version.py +0 -0
  76. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/tests/test_workspace_files.py +0 -0
  77. {adk_code_mode-0.3.0 → adk_code_mode-0.4.1}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.1"
3
+ }
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v0.4.0...adk-code-mode-v0.4.1) (2026-07-10)
4
+
5
+
6
+ ### Documentation
7
+
8
+ * note the sandbox writes to `/tools` in remote deployment guide ([96bb45e](https://github.com/a2anet/adk-code-mode/commit/96bb45e4e18d9d54709f4c10b6b09cf3567ee385))
9
+
10
+ ## [0.4.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v0.3.0...adk-code-mode-v0.4.0) (2026-07-09)
11
+
12
+
13
+ ### Features
14
+
15
+ * save tool results as artifacts with optional model-supplied naming ([76a3671](https://github.com/a2anet/adk-code-mode/commit/76a36713b2c52bd5b8dc51d3669226a47e7108e5))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * align code-mode example with the `<code-mode>` catalog and fix fences ([4a0cc6d](https://github.com/a2anet/adk-code-mode/commit/4a0cc6d0fc99d5425f6682cc489e76c9ddef7d46))
21
+ * clarify code-mode tools are a Python library, not function tools ([48d1018](https://github.com/a2anet/adk-code-mode/commit/48d1018152a2ed2a8e7361de6ce0f43b31b429ce))
22
+ * nudge the model to call tools via Python in the code-mode block ([141baf3](https://github.com/a2anet/adk-code-mode/commit/141baf3a44976e82cf437b2064666be893d6d409))
23
+ * update `CODE_MODE_SYSTEM_INSTRUCTION` and `README.md` ([933d42c](https://github.com/a2anet/adk-code-mode/commit/933d42cb237b9d6deeb6611511cba28a0d5e9261))
24
+
25
+
26
+ ### Documentation
27
+
28
+ * streamline `README.md` and fix broken code fences ([c237514](https://github.com/a2anet/adk-code-mode/commit/c2375149df48e21318967090b6c85d0b1f411ea2))
29
+
3
30
  ## [0.3.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v0.2.2...adk-code-mode-v0.3.0) (2026-07-06)
4
31
 
5
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adk-code-mode
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: A "Code Mode" Code Executor for ADK for 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
@@ -41,6 +41,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
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
42
  - **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.
43
43
  - **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`.
44
+ - **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`.
44
45
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
45
46
  - **Production-ready remote sandbox** — `RemoteBackend` connects to an isolated per-turn container over WebSocket, reused across the turn's code blocks. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.).
46
47
  - **Local development** — `UnsafeLocalDockerBackend` runs the sandbox against your local Docker daemon for fast iteration. **Not for production** — see [Safety](#-safety).
@@ -78,11 +79,12 @@ Requires Python 3.10+. Local development requires [Docker](https://docs.docker.c
78
79
 
79
80
  ## 🚀 Usage
80
81
 
81
- Build a `CodeModeCodeExecutor`, then wire three things into the agent:
82
+ Build a `CodeModeCodeExecutor`, then wire four things into the agent:
82
83
 
83
84
  - **`CODE_MODE_SYSTEM_INSTRUCTION`** — append to the agent's `instruction`. Teaches the model how to write code blocks and use artifacts.
84
85
  - **`code_mode_before_model_callback`** — set as `before_model_callback`. Injects the tool catalog (`<code-mode>` block) into the system prompt on every model turn.
85
86
  - **`generate_content_config`** with `function_calling_config.mode="NONE"` — disables native function calling so the model writes Python instead of attempting tool calls that fail with `MALFORMED_FUNCTION_CALL` (since `tools=[]`).
87
+ - **`release_invocation`** — call it from the agent's `after_agent_callback` to release the turn's sandbox container as soon as the turn ends. An idle reaper (`session_idle_timeout_seconds`, default `600`) is a backstop, so containers are still reclaimed without it — just later.
86
88
 
87
89
  ### Production (remote sandbox)
88
90
 
@@ -104,6 +106,9 @@ executor = CodeModeCodeExecutor(
104
106
  ),
105
107
  )
106
108
 
109
+ def _release_sandbox(callback_context):
110
+ executor.release_invocation(callback_context.invocation_id)
111
+
107
112
  root_agent = LlmAgent(
108
113
  name="assistant",
109
114
  model="gemini-2.5-pro",
@@ -116,6 +121,7 @@ root_agent = LlmAgent(
116
121
  ),
117
122
  ),
118
123
  before_model_callback=code_mode_before_model_callback(executor),
124
+ after_agent_callback=[_release_sandbox],
119
125
  )
120
126
  ```
121
127
 
@@ -146,16 +152,7 @@ print(send_message(channel="C123", text="hi"))
146
152
 
147
153
  ## 🌐 Remote Deployment
148
154
 
149
- **Every turn runs in its own container.** The container accepts exactly one WebSocket connection and holds it open for the whole turn, executing **one or more** code blocks with persistent globals + `/workspace`, then exits when the turn ends (`ShutdownFrame` or disconnect). The hosting platform destroys the container after each turn — no cross-turn or cross-tenant data leakage, no residual state. You **must** configure your platform for one container per turn (`--concurrency 1` on Cloud Run, or equivalent).
150
-
151
- Setting `ADK_CODE_MODE_CONTROL_HTTP=1` activates HTTP mode. The container:
152
-
153
- 1. Starts a WebSocket server on port 8080 (configurable via `PORT`)
154
- 2. Accepts exactly one connection (rejects further connections with 503)
155
- 3. Receives the tools tar **once**, then announces readiness
156
- 4. Sanitizes the environment (strips all env vars except a safe allowlist)
157
- 5. For each code block: receives that block's workspace tar, executes user code (tools proxied back to the host over the same WebSocket; globals + `/workspace` persist across blocks), then returns stdout/stderr and the updated workspace
158
- 6. Exits when the turn ends (`ShutdownFrame` or disconnect)
155
+ **Every turn runs in its own container**, which the platform destroys when the turn ends no cross-turn or cross-tenant state. The sandbox runs as a WebSocket server (set `ADK_CODE_MODE_CONTROL_HTTP=1`) and accepts exactly one connection, so you **must** configure your platform for one container per turn (`--concurrency 1` on Cloud Run, or equivalent).
159
156
 
160
157
  ### Deploy to Cloud Run
161
158
 
@@ -183,7 +180,7 @@ gcloud compute networks vpc-access connectors create adk-sandbox-connector \
183
180
  --region=<region> \
184
181
  --subnet=adk-sandbox-subnet
185
182
 
186
- # Deploy — note --concurrency 1 and --vpc-egress=all-traffic
183
+ # Deploy — note --concurrency 1, --vpc-egress=all-traffic, and the /health startup probe
187
184
  gcloud run deploy adk-code-mode-sandbox \
188
185
  --image <region>-docker.pkg.dev/<project>/<repository>/adk-code-mode-sandbox:latest \
189
186
  --region <region> \
@@ -197,10 +194,11 @@ gcloud run deploy adk-code-mode-sandbox \
197
194
  --vpc-connector=adk-sandbox-connector \
198
195
  --vpc-egress=all-traffic \
199
196
  --set-env-vars "ADK_CODE_MODE_CONTROL_HTTP=1" \
200
- --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest"
197
+ --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest" \
198
+ --startup-probe "httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80"
201
199
  ```
202
200
 
203
- > These flags are **recommendations to tune per deployment**, not hard requirements. `--timeout 3600` (Cloud Run's max) is the per-turn ceiling since the container holds the WebSocket for the whole turn; `--max-instances` should cover your peak concurrent *turns* (`120` covers a 10–100 target — verify your region's Cloud Run vCPU quota). Also add the `/health` startup probe below.
201
+ > These flags are **recommendations to tune per deployment**, not hard requirements. `--timeout 3600` (Cloud Run's max) is the per-turn ceiling since the container holds the WebSocket for the whole turn; `--max-instances` should cover your peak concurrent *turns* (`120` covers a 10–100 target — verify your region's Cloud Run vCPU quota). The `/health` startup probe avoids cold-start `HTTP 503`s — Cloud Run's default TCP probe opens a raw socket the WebSocket server rejects.
204
202
 
205
203
  Then in your agent:
206
204
 
@@ -215,22 +213,20 @@ RemoteBackend(
215
213
 
216
214
  > **`--vpc-egress=all-traffic` with a deny-all VPC is critical for security.** Without it, user code can make arbitrary outbound requests — including hitting the GCP metadata endpoint (`169.254.169.254`) to steal the service account token, exfiltrating data, or scanning your VPC. The sandbox only needs to _accept_ inbound connections; it never needs outbound access.
217
215
 
218
- > **Configure an HTTP startup probe against `/health`.** Cloud Run's default TCP probe opens a raw socket that the `websockets` server rejects as a malformed HTTP request, which can race with real traffic during cold starts and surface to clients as `HTTP 503` on the WebSocket handshake. Add `--startup-probe="httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80"` (or equivalent) so probes hit the `/health` endpoint the sandbox handles natively. Keep `periodSeconds` small (e.g. `3`): it sets how long Cloud Run waits before re-probing, so a short period lets a ready container start serving in ~3s instead of ~10s — raise `failureThreshold` to keep the same overall startup budget (`periodSeconds × failureThreshold`). Turn-scoping already gives demand-driven warmth (a container stays up for the whole turn), so `--min-instances=0` is fine; only set `--min-instances` if you want to eliminate cold starts at the cost of a paid warm pool.
219
-
220
216
  ### Deploy on other platforms
221
217
 
222
218
  The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):
223
219
 
224
220
  1. **One container per turn.** Each container handles exactly one turn (one or more code blocks) and exits.
225
221
  2. **Block all outbound network access.** Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
226
- 3. **Set a read-only root filesystem** where the platform supports it (e.g., `readOnlyRootFilesystem: true` in Kubernetes). The sandbox only writes to `/workspace`.
222
+ 3. **Keep `/workspace` and `/tools` writable.** The sandbox stages input files into `/workspace` and materialises the `tools` package into `/tools` at connect time. If you set a read-only root filesystem (e.g., `readOnlyRootFilesystem: true` in Kubernetes), mount both as writable volumes (e.g., an `emptyDir`).
227
223
  4. **Authenticate connections.** Set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth (IAM, NetworkPolicy, security groups) on top.
228
224
 
229
225
  Required env vars:
230
226
 
231
227
  | Env var | Required | Default | Purpose |
232
228
  | ------------------------------------ | -------- | ------- | -------------------------------- |
233
- | `ADK_CODE_MODE_CONTROL_HTTP` | yes | — | Set to `1` to activate HTTP mode |
229
+ | `ADK_CODE_MODE_CONTROL_HTTP` | yes | — | Set to `1` to run the sandbox as a WebSocket server (required for remote) |
234
230
  | `ADK_CODE_MODE_AUTH_TOKEN` | yes | — | Bearer token for WebSocket auth |
235
231
  | `PORT` | no | `8080` | Listen port |
236
232
  | `ADK_CODE_MODE_MAX_UPLOAD_TOOLS` | no | 100 MiB | Max tools tar archive size |
@@ -252,8 +248,6 @@ RemoteBackend(
252
248
  )
253
249
  ```
254
250
 
255
- `connect_timeout` defaults to `10s` — fail a stalled connect fast and retry rather than block. `RemoteBackend` retries transient connect failures up to `start_attempts` times with linear backoff + jitter; raise `connect_timeout` (and/or `start_attempts`) for platforms with slow cold starts.
256
-
257
251
  ## 🗂️ Storage
258
252
 
259
253
  Code Mode exposes two file surfaces:
@@ -287,6 +281,16 @@ async def on_saved(invocation_context, delta):
287
281
  CodeModeCodeExecutor(tools=..., backend=..., on_artifacts_saved=on_saved)
288
282
  ```
289
283
 
284
+ ### Tool results as artifacts
285
+
286
+ By default (`save_tool_results_as_artifacts=True`) every non-artifact tool is wrapped so its return value is saved as a session artifact tagged `code_mode.tool_result = "true"`. This lets a host forward tool outputs to the user (read the marker in `on_artifacts_saved`) and keeps large results out of the model's context — a result whose serialised form exceeds the threshold is replaced in the reply with a short note pointing at the artifact, which the model reloads with `load_artifact`.
287
+
288
+ Naming is transparent: the filename is derived from the tool name and call id. Each wrapped tool also gains two **optional** parameters — `artifact_name` and `artifact_description` — that the model may pass to name/describe the saved artifact; both land in the artifact's `custom_metadata` (`code_mode.artifact_name` / `code_mode.artifact_description`) alongside the marker. Set `save_tool_results_as_artifacts=False` to return tool results inline without persisting them.
289
+
290
+ ```python
291
+ CodeModeCodeExecutor(tools=..., backend=..., save_tool_results_as_artifacts=True)
292
+ ```
293
+
290
294
  ## 🐳 Sandbox Image
291
295
 
292
296
  The published base image (`ghcr.io/a2anet/adk-code-mode`) works as-is for tools whose execution is fully host-side. To bake in extra Python packages:
@@ -300,17 +304,18 @@ The same image works for both `RemoteBackend` and `UnsafeLocalDockerBackend`. To
300
304
 
301
305
  ## ⚙️ Configuration
302
306
 
303
- ### Catalog overflow
307
+ All settings are `CodeModeCodeExecutor` constructor arguments:
304
308
 
305
- `max_catalog_chars` (default `50_000`) is a soft cap on the rendered tool catalog in the system prompt. When exceeded, the per-tool sections are replaced with a short note telling the model how to navigate `/tools/` from Python.
309
+ | Argument | Default | Purpose |
310
+ | --- | --- | --- |
311
+ | `max_catalog_chars` | `50_000` | Soft cap on the rendered tool catalog in the system prompt. When exceeded, the per-tool sections are replaced with a short note telling the model how to navigate `/tools/` from Python. |
312
+ | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<execution-id>.txt` and the model sees a head-and-tail view pointing to it. |
313
+ | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
314
+ | `timeout_seconds` | `None` | Caps overall execution time. Defaults to the platform request timeout (e.g. Cloud Run `--timeout`); set explicitly for defense in depth. |
315
+ | `per_tool_timeout_seconds` | `None` | Caps each individual tool call. |
316
+ | `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
317
 
307
- ```python
308
- CodeModeCodeExecutor(tools=..., backend=..., max_catalog_chars=20_000)
309
- ```
310
-
311
- ### Output truncation
312
-
313
- `max_output_chars` (default `50_000`) caps stdout and stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<execution-id>.txt`, and the model sees a head-and-tail view with a marker pointing to it.
318
+ The model can read spilled stdout back from the overflow artifact:
314
319
 
315
320
  ```python
316
321
  from tools import load_artifact
@@ -318,53 +323,9 @@ spilled = load_artifact(filename="code_mode/stdout/<execution-id>.txt")
318
323
  print(spilled["data"][-2000:])
319
324
  ```
320
325
 
321
- ### Code size limit
322
-
323
- `max_code_chars` (default `1_000_000`) rejects oversized code payloads before starting a container.
324
-
325
- ### Timeouts
326
-
327
- `timeout_seconds` caps overall execution time; `per_tool_timeout_seconds` caps each individual tool call. Both default to `None` (relying on platform timeouts). Set them explicitly for defense in depth:
328
-
329
- ```python
330
- CodeModeCodeExecutor(
331
- tools=...,
332
- backend=...,
333
- timeout_seconds=30,
334
- per_tool_timeout_seconds=10,
335
- )
336
- ```
337
-
338
- `timeout_seconds` / `per_tool_timeout_seconds` stay `None` by default; with turn-scoping the effective per-turn wall-clock ceiling is the platform request timeout (e.g. Cloud Run `--timeout`).
339
-
340
326
  ### Turn-scoped sessions
341
327
 
342
- A sandbox container is held open for the duration of a **turn** (one ADK invocation) and reused across that turn's code blocks, so cold start is paid at most once per turn instead of once per block. State — Python globals **and** `/workspace` persists across a turn's blocks and resets between turns (use ADK Artifacts for cross-turn persistence).
343
-
344
- The container is released when the turn ends. Wire `code_executor.release_invocation(callback_context.invocation_id)` into your agent's `after_agent_callback` to free it the moment the turn finishes:
345
-
346
- ```python
347
- def _release(callback_context):
348
- code_executor.release_invocation(callback_context.invocation_id)
349
-
350
- agent = LlmAgent(..., after_agent_callback=[_release])
351
- ```
352
-
353
- Two safety nets cover turns that never signal a clean end: an idle reaper closes sessions idle longer than `session_idle_timeout_seconds` (default `600`), and a mid-turn connection loss (or a `timeout_seconds` firing) drops the session — in-turn state (globals + `/workspace`) is lost and the next block reconnects on a fresh container.
354
-
355
- If the connection drops **while a block is running**, the executor re-runs the block only when the code provably never reached the sandbox, so a reconnect can't duplicate a tool call's side effects. What happens depends on how far the block got before the drop:
356
-
357
- - **Not run** — the `RunFrame` never left the host, so the code did not execute; the executor reconnects on a fresh container and retries the block once automatically.
358
- - **Ran (output lost)** — a `DoneFrame` came back before the drop, so the code executed but its stdout/stderr and output files couldn't be returned; the block is **not** re-run.
359
- - **Unknown** — the `RunFrame` was sent but no `DoneFrame` arrived, so whether the code executed is unknowable; the block is **not** re-run.
360
-
361
- For the `Ran` and `Unknown` cases the block returns a short message in its `stderr` telling the model what happened, so it can decide whether to re-run the code or check for its effects rather than the executor silently repeating it.
362
-
363
- ```python
364
- CodeModeCodeExecutor(tools=..., backend=..., session_idle_timeout_seconds=600)
365
- ```
366
-
367
- > Turn-scoping is wire-protocol **v2**. The host wheel and the sandbox image must be upgraded together — a version mismatch fails fast with `ProtocolVersionMismatchError` — so rebuild and push the sandbox image when you upgrade.
328
+ A sandbox container is held open for one **turn** (one ADK invocation) and reused across that turn's code blocks, so cold start is paid at most once per turn. Python globals **and** `/workspace` persist across a turn's blocks and reset between turns use ADK Artifacts for cross-turn persistence. The container is released when the turn ends (via `release_invocation`, wired in [Usage](#-usage)) and destroyed by the platform, so no state survives into another turn or tenant.
368
329
 
369
330
  ## 🏗️ Architecture
370
331
 
@@ -383,37 +344,40 @@ The only things crossing the boundary are: code, tool call arguments, tool retur
383
344
 
384
345
  Your `instruction` (containing `CODE_MODE_SYSTEM_INSTRUCTION`) followed by a `<code-mode>` block appended by the callback:
385
346
 
386
- ```
347
+ ~~~
387
348
  …your instruction…
388
349
 
389
350
  # How to execute code and use tools
390
- Code you write in a fenced Python block (i.e. ```python) will be executed in a sandbox.
391
- The Python Standard Library and a custom set of tools are available to you.
392
- To see the result of your code, you need to print it.
393
-
394
- For example, if you had the following tool:
351
+ You have no callable function tools. To use a tool you must write Python code in a fenced Python block (i.e. ```python\n...\n```). Do not emit a function or tool call.
352
+ The Python Standard Library and a set of custom tools are available to you as an importable library, listed in the `<code-mode>` section below.
353
+ To see the result of your code, you need to print it. For example, if you had the following tool:
395
354
 
396
355
  ```
356
+ <code-mode>
357
+
397
358
  from tools.slack import send_message
398
359
 
399
360
  def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any:
400
361
  """Send a message to a Slack channel."""
401
362
  ...
363
+
364
+ </code-mode>
402
365
  ```
403
366
 
404
367
  To call the tool, you should write:
405
368
 
406
- """
369
+ ````
407
370
  ```python
408
371
  from tools.slack import send_message
409
372
 
410
373
  print(send_message(channel="C123", text="hi"))
411
374
  ```
412
- """
375
+ ````
413
376
 
414
377
  # How to use files and variables in between executions
415
- Within a turn the sandbox is stateful: variables you define and files you write under `/workspace` (the working directory) persist across the successive code blocks you run before replying to the user. They reset at the start of your next turn.
416
- To carry data across turns, use Artifacts: list them with the `list_artifacts` tool, save with `save_artifact`, and load with `load_artifact`.
378
+ You can reuse variables and files within a turn. Between turns, to reuse variables and files you must save and load Artifacts.
379
+ To list available Artifacts, use the `list_artifacts` tool, to save an Artifact, use the `save_artifact` tool, and to load an Artifact, use the `load_artifact` tool.
380
+ Tool results are automatically saved as Artifacts.
417
381
 
418
382
  <code-mode>
419
383
 
@@ -435,7 +399,9 @@ from tools import save_artifact, load_artifact, list_artifacts
435
399
 
436
400
 
437
401
  </code-mode>
438
- ```
402
+ ~~~
403
+
404
+ With `save_tool_results_as_artifacts` enabled (the default), each non-artifact tool above — e.g. `list_channels` and `send_message` — also carries two optional `artifact_name: str | None = ...` / `artifact_description: str | None = ...` parameters for naming its saved result.
439
405
 
440
406
  When the rendered catalog exceeds `max_catalog_chars`, the per-tool sections are replaced with:
441
407
 
@@ -14,6 +14,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
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
15
  - **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.
16
16
  - **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`.
17
+ - **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`.
17
18
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
18
19
  - **Production-ready remote sandbox** — `RemoteBackend` connects to an isolated per-turn container over WebSocket, reused across the turn's code blocks. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.).
19
20
  - **Local development** — `UnsafeLocalDockerBackend` runs the sandbox against your local Docker daemon for fast iteration. **Not for production** — see [Safety](#-safety).
@@ -51,11 +52,12 @@ Requires Python 3.10+. Local development requires [Docker](https://docs.docker.c
51
52
 
52
53
  ## 🚀 Usage
53
54
 
54
- Build a `CodeModeCodeExecutor`, then wire three things into the agent:
55
+ Build a `CodeModeCodeExecutor`, then wire four things into the agent:
55
56
 
56
57
  - **`CODE_MODE_SYSTEM_INSTRUCTION`** — append to the agent's `instruction`. Teaches the model how to write code blocks and use artifacts.
57
58
  - **`code_mode_before_model_callback`** — set as `before_model_callback`. Injects the tool catalog (`<code-mode>` block) into the system prompt on every model turn.
58
59
  - **`generate_content_config`** with `function_calling_config.mode="NONE"` — disables native function calling so the model writes Python instead of attempting tool calls that fail with `MALFORMED_FUNCTION_CALL` (since `tools=[]`).
60
+ - **`release_invocation`** — call it from the agent's `after_agent_callback` to release the turn's sandbox container as soon as the turn ends. An idle reaper (`session_idle_timeout_seconds`, default `600`) is a backstop, so containers are still reclaimed without it — just later.
59
61
 
60
62
  ### Production (remote sandbox)
61
63
 
@@ -77,6 +79,9 @@ executor = CodeModeCodeExecutor(
77
79
  ),
78
80
  )
79
81
 
82
+ def _release_sandbox(callback_context):
83
+ executor.release_invocation(callback_context.invocation_id)
84
+
80
85
  root_agent = LlmAgent(
81
86
  name="assistant",
82
87
  model="gemini-2.5-pro",
@@ -89,6 +94,7 @@ root_agent = LlmAgent(
89
94
  ),
90
95
  ),
91
96
  before_model_callback=code_mode_before_model_callback(executor),
97
+ after_agent_callback=[_release_sandbox],
92
98
  )
93
99
  ```
94
100
 
@@ -119,16 +125,7 @@ print(send_message(channel="C123", text="hi"))
119
125
 
120
126
  ## 🌐 Remote Deployment
121
127
 
122
- **Every turn runs in its own container.** The container accepts exactly one WebSocket connection and holds it open for the whole turn, executing **one or more** code blocks with persistent globals + `/workspace`, then exits when the turn ends (`ShutdownFrame` or disconnect). The hosting platform destroys the container after each turn — no cross-turn or cross-tenant data leakage, no residual state. You **must** configure your platform for one container per turn (`--concurrency 1` on Cloud Run, or equivalent).
123
-
124
- Setting `ADK_CODE_MODE_CONTROL_HTTP=1` activates HTTP mode. The container:
125
-
126
- 1. Starts a WebSocket server on port 8080 (configurable via `PORT`)
127
- 2. Accepts exactly one connection (rejects further connections with 503)
128
- 3. Receives the tools tar **once**, then announces readiness
129
- 4. Sanitizes the environment (strips all env vars except a safe allowlist)
130
- 5. For each code block: receives that block's workspace tar, executes user code (tools proxied back to the host over the same WebSocket; globals + `/workspace` persist across blocks), then returns stdout/stderr and the updated workspace
131
- 6. Exits when the turn ends (`ShutdownFrame` or disconnect)
128
+ **Every turn runs in its own container**, which the platform destroys when the turn ends no cross-turn or cross-tenant state. The sandbox runs as a WebSocket server (set `ADK_CODE_MODE_CONTROL_HTTP=1`) and accepts exactly one connection, so you **must** configure your platform for one container per turn (`--concurrency 1` on Cloud Run, or equivalent).
132
129
 
133
130
  ### Deploy to Cloud Run
134
131
 
@@ -156,7 +153,7 @@ gcloud compute networks vpc-access connectors create adk-sandbox-connector \
156
153
  --region=<region> \
157
154
  --subnet=adk-sandbox-subnet
158
155
 
159
- # Deploy — note --concurrency 1 and --vpc-egress=all-traffic
156
+ # Deploy — note --concurrency 1, --vpc-egress=all-traffic, and the /health startup probe
160
157
  gcloud run deploy adk-code-mode-sandbox \
161
158
  --image <region>-docker.pkg.dev/<project>/<repository>/adk-code-mode-sandbox:latest \
162
159
  --region <region> \
@@ -170,10 +167,11 @@ gcloud run deploy adk-code-mode-sandbox \
170
167
  --vpc-connector=adk-sandbox-connector \
171
168
  --vpc-egress=all-traffic \
172
169
  --set-env-vars "ADK_CODE_MODE_CONTROL_HTTP=1" \
173
- --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest"
170
+ --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest" \
171
+ --startup-probe "httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80"
174
172
  ```
175
173
 
176
- > These flags are **recommendations to tune per deployment**, not hard requirements. `--timeout 3600` (Cloud Run's max) is the per-turn ceiling since the container holds the WebSocket for the whole turn; `--max-instances` should cover your peak concurrent *turns* (`120` covers a 10–100 target — verify your region's Cloud Run vCPU quota). Also add the `/health` startup probe below.
174
+ > These flags are **recommendations to tune per deployment**, not hard requirements. `--timeout 3600` (Cloud Run's max) is the per-turn ceiling since the container holds the WebSocket for the whole turn; `--max-instances` should cover your peak concurrent *turns* (`120` covers a 10–100 target — verify your region's Cloud Run vCPU quota). The `/health` startup probe avoids cold-start `HTTP 503`s — Cloud Run's default TCP probe opens a raw socket the WebSocket server rejects.
177
175
 
178
176
  Then in your agent:
179
177
 
@@ -188,22 +186,20 @@ RemoteBackend(
188
186
 
189
187
  > **`--vpc-egress=all-traffic` with a deny-all VPC is critical for security.** Without it, user code can make arbitrary outbound requests — including hitting the GCP metadata endpoint (`169.254.169.254`) to steal the service account token, exfiltrating data, or scanning your VPC. The sandbox only needs to _accept_ inbound connections; it never needs outbound access.
190
188
 
191
- > **Configure an HTTP startup probe against `/health`.** Cloud Run's default TCP probe opens a raw socket that the `websockets` server rejects as a malformed HTTP request, which can race with real traffic during cold starts and surface to clients as `HTTP 503` on the WebSocket handshake. Add `--startup-probe="httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80"` (or equivalent) so probes hit the `/health` endpoint the sandbox handles natively. Keep `periodSeconds` small (e.g. `3`): it sets how long Cloud Run waits before re-probing, so a short period lets a ready container start serving in ~3s instead of ~10s — raise `failureThreshold` to keep the same overall startup budget (`periodSeconds × failureThreshold`). Turn-scoping already gives demand-driven warmth (a container stays up for the whole turn), so `--min-instances=0` is fine; only set `--min-instances` if you want to eliminate cold starts at the cost of a paid warm pool.
192
-
193
189
  ### Deploy on other platforms
194
190
 
195
191
  The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):
196
192
 
197
193
  1. **One container per turn.** Each container handles exactly one turn (one or more code blocks) and exits.
198
194
  2. **Block all outbound network access.** Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
199
- 3. **Set a read-only root filesystem** where the platform supports it (e.g., `readOnlyRootFilesystem: true` in Kubernetes). The sandbox only writes to `/workspace`.
195
+ 3. **Keep `/workspace` and `/tools` writable.** The sandbox stages input files into `/workspace` and materialises the `tools` package into `/tools` at connect time. If you set a read-only root filesystem (e.g., `readOnlyRootFilesystem: true` in Kubernetes), mount both as writable volumes (e.g., an `emptyDir`).
200
196
  4. **Authenticate connections.** Set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth (IAM, NetworkPolicy, security groups) on top.
201
197
 
202
198
  Required env vars:
203
199
 
204
200
  | Env var | Required | Default | Purpose |
205
201
  | ------------------------------------ | -------- | ------- | -------------------------------- |
206
- | `ADK_CODE_MODE_CONTROL_HTTP` | yes | — | Set to `1` to activate HTTP mode |
202
+ | `ADK_CODE_MODE_CONTROL_HTTP` | yes | — | Set to `1` to run the sandbox as a WebSocket server (required for remote) |
207
203
  | `ADK_CODE_MODE_AUTH_TOKEN` | yes | — | Bearer token for WebSocket auth |
208
204
  | `PORT` | no | `8080` | Listen port |
209
205
  | `ADK_CODE_MODE_MAX_UPLOAD_TOOLS` | no | 100 MiB | Max tools tar archive size |
@@ -225,8 +221,6 @@ RemoteBackend(
225
221
  )
226
222
  ```
227
223
 
228
- `connect_timeout` defaults to `10s` — fail a stalled connect fast and retry rather than block. `RemoteBackend` retries transient connect failures up to `start_attempts` times with linear backoff + jitter; raise `connect_timeout` (and/or `start_attempts`) for platforms with slow cold starts.
229
-
230
224
  ## 🗂️ Storage
231
225
 
232
226
  Code Mode exposes two file surfaces:
@@ -260,6 +254,16 @@ async def on_saved(invocation_context, delta):
260
254
  CodeModeCodeExecutor(tools=..., backend=..., on_artifacts_saved=on_saved)
261
255
  ```
262
256
 
257
+ ### Tool results as artifacts
258
+
259
+ By default (`save_tool_results_as_artifacts=True`) every non-artifact tool is wrapped so its return value is saved as a session artifact tagged `code_mode.tool_result = "true"`. This lets a host forward tool outputs to the user (read the marker in `on_artifacts_saved`) and keeps large results out of the model's context — a result whose serialised form exceeds the threshold is replaced in the reply with a short note pointing at the artifact, which the model reloads with `load_artifact`.
260
+
261
+ Naming is transparent: the filename is derived from the tool name and call id. Each wrapped tool also gains two **optional** parameters — `artifact_name` and `artifact_description` — that the model may pass to name/describe the saved artifact; both land in the artifact's `custom_metadata` (`code_mode.artifact_name` / `code_mode.artifact_description`) alongside the marker. Set `save_tool_results_as_artifacts=False` to return tool results inline without persisting them.
262
+
263
+ ```python
264
+ CodeModeCodeExecutor(tools=..., backend=..., save_tool_results_as_artifacts=True)
265
+ ```
266
+
263
267
  ## 🐳 Sandbox Image
264
268
 
265
269
  The published base image (`ghcr.io/a2anet/adk-code-mode`) works as-is for tools whose execution is fully host-side. To bake in extra Python packages:
@@ -273,17 +277,18 @@ The same image works for both `RemoteBackend` and `UnsafeLocalDockerBackend`. To
273
277
 
274
278
  ## ⚙️ Configuration
275
279
 
276
- ### Catalog overflow
280
+ All settings are `CodeModeCodeExecutor` constructor arguments:
277
281
 
278
- `max_catalog_chars` (default `50_000`) is a soft cap on the rendered tool catalog in the system prompt. When exceeded, the per-tool sections are replaced with a short note telling the model how to navigate `/tools/` from Python.
282
+ | Argument | Default | Purpose |
283
+ | --- | --- | --- |
284
+ | `max_catalog_chars` | `50_000` | Soft cap on the rendered tool catalog in the system prompt. When exceeded, the per-tool sections are replaced with a short note telling the model how to navigate `/tools/` from Python. |
285
+ | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<execution-id>.txt` and the model sees a head-and-tail view pointing to it. |
286
+ | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
287
+ | `timeout_seconds` | `None` | Caps overall execution time. Defaults to the platform request timeout (e.g. Cloud Run `--timeout`); set explicitly for defense in depth. |
288
+ | `per_tool_timeout_seconds` | `None` | Caps each individual tool call. |
289
+ | `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
290
 
280
- ```python
281
- CodeModeCodeExecutor(tools=..., backend=..., max_catalog_chars=20_000)
282
- ```
283
-
284
- ### Output truncation
285
-
286
- `max_output_chars` (default `50_000`) caps stdout and stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<execution-id>.txt`, and the model sees a head-and-tail view with a marker pointing to it.
291
+ The model can read spilled stdout back from the overflow artifact:
287
292
 
288
293
  ```python
289
294
  from tools import load_artifact
@@ -291,53 +296,9 @@ spilled = load_artifact(filename="code_mode/stdout/<execution-id>.txt")
291
296
  print(spilled["data"][-2000:])
292
297
  ```
293
298
 
294
- ### Code size limit
295
-
296
- `max_code_chars` (default `1_000_000`) rejects oversized code payloads before starting a container.
297
-
298
- ### Timeouts
299
-
300
- `timeout_seconds` caps overall execution time; `per_tool_timeout_seconds` caps each individual tool call. Both default to `None` (relying on platform timeouts). Set them explicitly for defense in depth:
301
-
302
- ```python
303
- CodeModeCodeExecutor(
304
- tools=...,
305
- backend=...,
306
- timeout_seconds=30,
307
- per_tool_timeout_seconds=10,
308
- )
309
- ```
310
-
311
- `timeout_seconds` / `per_tool_timeout_seconds` stay `None` by default; with turn-scoping the effective per-turn wall-clock ceiling is the platform request timeout (e.g. Cloud Run `--timeout`).
312
-
313
299
  ### Turn-scoped sessions
314
300
 
315
- A sandbox container is held open for the duration of a **turn** (one ADK invocation) and reused across that turn's code blocks, so cold start is paid at most once per turn instead of once per block. State — Python globals **and** `/workspace` persists across a turn's blocks and resets between turns (use ADK Artifacts for cross-turn persistence).
316
-
317
- The container is released when the turn ends. Wire `code_executor.release_invocation(callback_context.invocation_id)` into your agent's `after_agent_callback` to free it the moment the turn finishes:
318
-
319
- ```python
320
- def _release(callback_context):
321
- code_executor.release_invocation(callback_context.invocation_id)
322
-
323
- agent = LlmAgent(..., after_agent_callback=[_release])
324
- ```
325
-
326
- Two safety nets cover turns that never signal a clean end: an idle reaper closes sessions idle longer than `session_idle_timeout_seconds` (default `600`), and a mid-turn connection loss (or a `timeout_seconds` firing) drops the session — in-turn state (globals + `/workspace`) is lost and the next block reconnects on a fresh container.
327
-
328
- If the connection drops **while a block is running**, the executor re-runs the block only when the code provably never reached the sandbox, so a reconnect can't duplicate a tool call's side effects. What happens depends on how far the block got before the drop:
329
-
330
- - **Not run** — the `RunFrame` never left the host, so the code did not execute; the executor reconnects on a fresh container and retries the block once automatically.
331
- - **Ran (output lost)** — a `DoneFrame` came back before the drop, so the code executed but its stdout/stderr and output files couldn't be returned; the block is **not** re-run.
332
- - **Unknown** — the `RunFrame` was sent but no `DoneFrame` arrived, so whether the code executed is unknowable; the block is **not** re-run.
333
-
334
- For the `Ran` and `Unknown` cases the block returns a short message in its `stderr` telling the model what happened, so it can decide whether to re-run the code or check for its effects rather than the executor silently repeating it.
335
-
336
- ```python
337
- CodeModeCodeExecutor(tools=..., backend=..., session_idle_timeout_seconds=600)
338
- ```
339
-
340
- > Turn-scoping is wire-protocol **v2**. The host wheel and the sandbox image must be upgraded together — a version mismatch fails fast with `ProtocolVersionMismatchError` — so rebuild and push the sandbox image when you upgrade.
301
+ A sandbox container is held open for one **turn** (one ADK invocation) and reused across that turn's code blocks, so cold start is paid at most once per turn. Python globals **and** `/workspace` persist across a turn's blocks and reset between turns use ADK Artifacts for cross-turn persistence. The container is released when the turn ends (via `release_invocation`, wired in [Usage](#-usage)) and destroyed by the platform, so no state survives into another turn or tenant.
341
302
 
342
303
  ## 🏗️ Architecture
343
304
 
@@ -356,37 +317,40 @@ The only things crossing the boundary are: code, tool call arguments, tool retur
356
317
 
357
318
  Your `instruction` (containing `CODE_MODE_SYSTEM_INSTRUCTION`) followed by a `<code-mode>` block appended by the callback:
358
319
 
359
- ```
320
+ ~~~
360
321
  …your instruction…
361
322
 
362
323
  # How to execute code and use tools
363
- Code you write in a fenced Python block (i.e. ```python) will be executed in a sandbox.
364
- The Python Standard Library and a custom set of tools are available to you.
365
- To see the result of your code, you need to print it.
366
-
367
- For example, if you had the following tool:
324
+ You have no callable function tools. To use a tool you must write Python code in a fenced Python block (i.e. ```python\n...\n```). Do not emit a function or tool call.
325
+ The Python Standard Library and a set of custom tools are available to you as an importable library, listed in the `<code-mode>` section below.
326
+ To see the result of your code, you need to print it. For example, if you had the following tool:
368
327
 
369
328
  ```
329
+ <code-mode>
330
+
370
331
  from tools.slack import send_message
371
332
 
372
333
  def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any:
373
334
  """Send a message to a Slack channel."""
374
335
  ...
336
+
337
+ </code-mode>
375
338
  ```
376
339
 
377
340
  To call the tool, you should write:
378
341
 
379
- """
342
+ ````
380
343
  ```python
381
344
  from tools.slack import send_message
382
345
 
383
346
  print(send_message(channel="C123", text="hi"))
384
347
  ```
385
- """
348
+ ````
386
349
 
387
350
  # How to use files and variables in between executions
388
- Within a turn the sandbox is stateful: variables you define and files you write under `/workspace` (the working directory) persist across the successive code blocks you run before replying to the user. They reset at the start of your next turn.
389
- To carry data across turns, use Artifacts: list them with the `list_artifacts` tool, save with `save_artifact`, and load with `load_artifact`.
351
+ You can reuse variables and files within a turn. Between turns, to reuse variables and files you must save and load Artifacts.
352
+ To list available Artifacts, use the `list_artifacts` tool, to save an Artifact, use the `save_artifact` tool, and to load an Artifact, use the `load_artifact` tool.
353
+ Tool results are automatically saved as Artifacts.
390
354
 
391
355
  <code-mode>
392
356
 
@@ -408,7 +372,9 @@ from tools import save_artifact, load_artifact, list_artifacts
408
372
 
409
373
 
410
374
  </code-mode>
411
- ```
375
+ ~~~
376
+
377
+ With `save_tool_results_as_artifacts` enabled (the default), each non-artifact tool above — e.g. `list_channels` and `send_message` — also carries two optional `artifact_name: str | None = ...` / `artifact_description: str | None = ...` parameters for naming its saved result.
412
378
 
413
379
  When the rendered catalog exceeds `max_catalog_chars`, the per-tool sections are replaced with:
414
380
 
@@ -9,8 +9,8 @@ RUN pip install --no-cache-dir /tmp/adk_code_mode_sandbox-*.whl \
9
9
  && rm /tmp/adk_code_mode_sandbox-*.whl
10
10
 
11
11
  RUN useradd -m -d /home/sandbox sandbox \
12
- && mkdir -p /workspace \
13
- && chown sandbox:sandbox /workspace
12
+ && mkdir -p /workspace /tools \
13
+ && chown sandbox:sandbox /workspace /tools
14
14
  USER sandbox
15
15
 
16
16
  EXPOSE 8080
@@ -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__ = "0.3.0" # x-release-please-version
4
+ __version__ = "0.4.1" # x-release-please-version