adk-code-mode 0.2.1__tar.gz → 0.3.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 (76) hide show
  1. adk_code_mode-0.3.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/CHANGELOG.md +20 -0
  3. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/PKG-INFO +59 -17
  4. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/README.md +58 -16
  5. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  6. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +38 -9
  7. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +101 -68
  8. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +6 -2
  9. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/__about__.py +1 -1
  10. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/executor.py +413 -84
  11. adk_code_mode-0.3.0/src/adk_code_mode/runtime/base.py +126 -0
  12. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/docker.py +56 -33
  13. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/protocol.py +6 -2
  14. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/remote.py +116 -25
  15. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/_fake_runtime.py +42 -12
  16. adk_code_mode-0.3.0/tests/conftest.py +37 -0
  17. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_docker_integration.py +43 -0
  18. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_host_loop.py +4 -1
  19. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_integration.py +345 -1
  20. adk_code_mode-0.3.0/tests/test_remote_backend.py +82 -0
  21. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_remote_integration.py +144 -0
  22. adk_code_mode-0.2.1/.release-please-manifest.json +0 -3
  23. adk_code_mode-0.2.1/src/adk_code_mode/runtime/base.py +0 -88
  24. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.githooks/pre-commit +0 -0
  25. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.githooks/pre-push +0 -0
  26. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.github/workflows/ci.yml +0 -0
  27. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.github/workflows/release-please.yml +0 -0
  28. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.gitignore +0 -0
  29. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.prettierrc +0 -0
  30. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.vscode/settings.json +0 -0
  31. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.zed/settings.json +0 -0
  32. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/AGENTS.md +0 -0
  33. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/CLAUDE.md +0 -0
  34. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/LICENSE +0 -0
  35. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/Makefile +0 -0
  36. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/docker/Dockerfile +0 -0
  37. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/pyproject.toml +0 -0
  38. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/release-please-config.json +0 -0
  39. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/README.md +0 -0
  40. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/pyproject.toml +0 -0
  41. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  42. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  43. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  44. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/scripts/install-git-hooks.sh +0 -0
  45. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/__init__.py +0 -0
  46. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/_artifact_tools.py +0 -0
  47. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/callback.py +0 -0
  48. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/output.py +0 -0
  49. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/py.typed +0 -0
  50. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  51. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/__init__.py +0 -0
  52. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/catalog.py +0 -0
  53. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
  54. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/namespacing.py +0 -0
  55. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  56. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/stubs.py +0 -0
  57. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  58. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/workspace/files.py +0 -0
  59. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/__init__.py +0 -0
  60. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/_docker_helpers.py +0 -0
  61. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_agent_with_fake_llm.py +0 -0
  62. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_artifact_tools.py +0 -0
  63. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_callback.py +0 -0
  64. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_catalog.py +0 -0
  65. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_dispatcher.py +0 -0
  66. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_docker_image.py +0 -0
  67. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_namespacing.py +0 -0
  68. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_normaliser.py +0 -0
  69. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_output.py +0 -0
  70. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_protocol.py +0 -0
  71. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_runtime_docker.py +0 -0
  72. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_sandbox_rpc_client.py +0 -0
  73. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_stubs.py +0 -0
  74. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_version.py +0 -0
  75. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_workspace_files.py +0 -0
  76. {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [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
+
5
+
6
+ ### Features
7
+
8
+ * hold one sandbox container open per turn (protocol v2) ([#6](https://github.com/a2anet/adk-code-mode/issues/6)) ([aebe17d](https://github.com/a2anet/adk-code-mode/commit/aebe17d2bbbf21cc0f05852efaf910ff6adc3154))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * lower startup probe period in `README.md` for faster cold starts ([e251af2](https://github.com/a2anet/adk-code-mode/commit/e251af2cf7307c9944361e1a0e37c7191822a9e1))
14
+ * note HTTP `/health` startup probe in `README.md` Cloud Run section ([d58fb10](https://github.com/a2anet/adk-code-mode/commit/d58fb10f7aeda95e5c8cf29263a13e1333f661dd))
15
+
16
+ ## [0.2.2](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v0.2.1...adk-code-mode-v0.2.2) (2026-05-08)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * use `python` fences instead of `tool_code` in `CodeModeCodeExecutor` ([efcc11e](https://github.com/a2anet/adk-code-mode/commit/efcc11e1020c7544223074e00a8649f9c85d4ad6))
22
+
3
23
  ## [0.2.1](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v0.2.0...adk-code-mode-v0.2.1) (2026-05-05)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adk-code-mode
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -42,14 +42,14 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
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
44
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
45
- - **Production-ready remote sandbox** — `RemoteBackend` connects to an isolated, single-use container over WebSocket. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.).
45
+ - **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
46
  - **Local development** — `UnsafeLocalDockerBackend` runs the sandbox against your local Docker daemon for fast iteration. **Not for production** — see [Safety](#-safety).
47
47
 
48
48
  | | BuiltIn | AgentEngineSandbox | VertexAi | Container | Gke | CodeMode |
49
49
  | ----------------------------------- | ------- | ------------------------------- | ------------------------------- | --------- | --- | ------------------------ |
50
50
  | Call ADK tools from code | no | no | no | no | no | yes (with limitations) |
51
51
  | Extra Python packages | no | no (more than stdlib but fixed) | no (more than stdlib but fixed) | yes | yes | yes |
52
- | Variables are stateful | no | yes | yes | no | no | no |
52
+ | Variables are stateful | no | yes | yes | no | no | yes (within a turn) |
53
53
  | Input files | no | yes | yes | no | no | yes |
54
54
  | Output files | no | yes | yes | no | no | yes |
55
55
  | Storage | no | yes (via variables) | yes (via variables) | no | no | yes (via ADK Artifacts) |
@@ -146,17 +146,16 @@ print(send_message(channel="C123", text="hi"))
146
146
 
147
147
  ## 🌐 Remote Deployment
148
148
 
149
- **Every execution runs in its own container.** The container accepts exactly one WebSocket connection, executes the user's code, returns results, and exits. The hosting platform destroys the container after each request — no cross-tenant data leakage, no residual state. You **must** configure your platform for one container per request (`--concurrency 1` on Cloud Run, or equivalent).
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
150
 
151
151
  Setting `ADK_CODE_MODE_CONTROL_HTTP=1` activates HTTP mode. The container:
152
152
 
153
153
  1. Starts a WebSocket server on port 8080 (configurable via `PORT`)
154
154
  2. Accepts exactly one connection (rejects further connections with 503)
155
- 3. Receives tools and workspace as tar archives over binary WebSocket frames
155
+ 3. Receives the tools tar **once**, then announces readiness
156
156
  4. Sanitizes the environment (strips all env vars except a safe allowlist)
157
- 5. Executes user code with tools proxied back to the host over the same WebSocket
158
- 6. Returns stdout/stderr and updated workspace files
159
- 7. Exits
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)
160
159
 
161
160
  ### Deploy to Cloud Run
162
161
 
@@ -192,6 +191,8 @@ gcloud run deploy adk-code-mode-sandbox \
192
191
  --cpu 1 \
193
192
  --memory 1Gi \
194
193
  --concurrency 1 \
194
+ --timeout 3600 \
195
+ --max-instances 120 \
195
196
  --allow-unauthenticated \
196
197
  --vpc-connector=adk-sandbox-connector \
197
198
  --vpc-egress=all-traffic \
@@ -199,6 +200,8 @@ gcloud run deploy adk-code-mode-sandbox \
199
200
  --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest"
200
201
  ```
201
202
 
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.
204
+
202
205
  Then in your agent:
203
206
 
204
207
  ```python
@@ -208,15 +211,17 @@ RemoteBackend(
208
211
  )
209
212
  ```
210
213
 
211
- > **`--concurrency 1` is critical for security.** Without this flag, Cloud Run may route multiple requests to the same container. The sandbox rejects the second connection, but the misconfiguration itself is a risk.
214
+ > **`--concurrency 1` is critical for security.** It pins one turn to one container. Without this flag, Cloud Run may route multiple turns to the same container. The sandbox rejects the second connection, but the misconfiguration itself is a risk.
212
215
 
213
216
  > **`--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.
214
217
 
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
+
215
220
  ### Deploy on other platforms
216
221
 
217
222
  The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):
218
223
 
219
- 1. **One container per request.** Each container handles exactly one execution and exits.
224
+ 1. **One container per turn.** Each container handles exactly one turn (one or more code blocks) and exits.
220
225
  2. **Block all outbound network access.** Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
221
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
227
  4. **Authenticate connections.** Set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth (IAM, NetworkPolicy, security groups) on top.
@@ -231,23 +236,29 @@ Required env vars:
231
236
  | `ADK_CODE_MODE_MAX_UPLOAD_TOOLS` | no | 100 MiB | Max tools tar archive size |
232
237
  | `ADK_CODE_MODE_MAX_UPLOAD_WORKSPACE` | no | 100 MiB | Max workspace tar archive size |
233
238
 
234
- The same upload limits (plus a download limit) are configurable on `RemoteBackend`:
239
+ Connection tuning, retry, and the same upload limits (plus a download limit) are configurable on `RemoteBackend`:
235
240
 
236
241
  ```python
237
242
  RemoteBackend(
238
243
  url="...",
239
244
  token="...",
245
+ connect_timeout=10.0, # seconds to wait for the WS handshake (default)
246
+ start_attempts=3, # connect attempts before giving up (default)
247
+ start_retry_delay_seconds=1.0, # linear backoff base: delay * attempt (default)
248
+ start_retry_jitter_seconds=0.25, # uniform jitter added per retry (default)
240
249
  max_upload_tools_bytes=100 * 1024 * 1024, # 100 MiB (default)
241
250
  max_upload_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
242
251
  max_download_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
243
252
  )
244
253
  ```
245
254
 
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
+
246
257
  ## 🗂️ Storage
247
258
 
248
259
  Code Mode exposes two file surfaces:
249
260
 
250
- - **`/workspace`** — per-run working directory. ADK `input_files` are staged here before code runs (`open("input.csv")` works). Files created or modified under `/workspace` are returned as `CodeExecutionResult.output_files` but are not re-hydrated next turn unless persisted via `save_artifact`.
261
+ - **`/workspace`** — the turn's working directory. It persists across the turn's code blocks and resets between turns. ADK `input_files` for a block are staged here before that block runs (`open("input.csv")` works). Files whose content changed in a block are returned as that block's `CodeExecutionResult.output_files`; nothing under `/workspace` is re-hydrated on the next turn unless persisted via `save_artifact`.
251
262
 
252
263
  - **ADK Artifacts** — persistent cross-turn storage. `CodeModeCodeExecutor` injects three tools into the catalog:
253
264
 
@@ -324,9 +335,40 @@ CodeModeCodeExecutor(
324
335
  )
325
336
  ```
326
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
+ ### Turn-scoped sessions
341
+
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.
368
+
327
369
  ## 🏗️ Architecture
328
370
 
329
- **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. The `before_model_callback` resolves tools, renders the catalog, and appends it to the system prompt. At execution time, it generates a `tools/` Python package of thin stubs, stages `input_files` into `/workspace`, and launches the sandbox.
371
+ **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. The `before_model_callback` resolves tools, renders the catalog, and appends it to the system prompt. At execution time, it generates a `tools/` Python package of thin stubs, stages the block's `input_files` into `/workspace`, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
330
372
 
331
373
  **Sandbox wheel (`adk-code-mode-sandbox`).** Pre-installed in the container image. When model code calls a stub, it sends a JSON-Lines frame over the control connection; the host runs the real tool (with callbacks and plugins) and sends the result back.
332
374
 
@@ -370,8 +412,8 @@ print(send_message(channel="C123", text="hi"))
370
412
  """
371
413
 
372
414
  # How to use files and variables in between executions
373
- Code is executed in a new environment each time.
374
- 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.
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`.
375
417
 
376
418
  <code-mode>
377
419
 
@@ -416,7 +458,7 @@ Text and JSON-like MIME types travel as plain strings in artifact tools; binary
416
458
 
417
459
  `RemoteBackend` is designed for multi-tenant production use where untrusted users submit arbitrary Python code:
418
460
 
419
- - **One container per execution.** Fresh container per request no shared filesystem, memory, or processes.
461
+ - **One container per turn (one tenant, one invocation).** Within a turn the process/filesystem are reused across that turn's code blocks; the container is destroyed at turn end, with **no cross-turn or cross-tenant sharing**.
420
462
  - **Environment sanitization.** All env vars are stripped except a safe allowlist (`PATH`, `HOME`, `USER`, locale vars, Python config) before user code runs.
421
463
  - **Credentials never enter the sandbox.** API keys, OAuth tokens, and connection strings stay in the host process. The container only receives tool results.
422
464
  - **Bearer token authentication.** WebSocket connections without a valid token are rejected. Always set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth on top.
@@ -441,7 +483,7 @@ Named "Unsafe" intentionally: it binds a TCP listener on `0.0.0.0`, communicates
441
483
  ## ⚠️ Limitations
442
484
 
443
485
  - **No credential-requesting tools.** Tools that need ADK to request credentials, confirmations, UI widgets, agent transfer, escalation, or that yield without an immediate response are rejected with a structured error.
444
- - **No state across executions.** Variables don't survive between turns. Use `save_artifact` / `load_artifact` to persist, or `/workspace` within a single run.
486
+ - **State is turn-scoped.** Variables and `/workspace` files persist across code blocks **within** a turn, but reset between turns. Use `save_artifact` / `load_artifact` to persist across turns.
445
487
  - **No runtime package installation.** The sandbox ships with the Python Standard Library and the runtime's own dependencies only. Extra packages must be baked into the image at build time.
446
488
 
447
489
  ## 🛠️ Development
@@ -15,14 +15,14 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
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
17
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
18
- - **Production-ready remote sandbox** — `RemoteBackend` connects to an isolated, single-use container over WebSocket. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.).
18
+ - **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
19
  - **Local development** — `UnsafeLocalDockerBackend` runs the sandbox against your local Docker daemon for fast iteration. **Not for production** — see [Safety](#-safety).
20
20
 
21
21
  | | BuiltIn | AgentEngineSandbox | VertexAi | Container | Gke | CodeMode |
22
22
  | ----------------------------------- | ------- | ------------------------------- | ------------------------------- | --------- | --- | ------------------------ |
23
23
  | Call ADK tools from code | no | no | no | no | no | yes (with limitations) |
24
24
  | Extra Python packages | no | no (more than stdlib but fixed) | no (more than stdlib but fixed) | yes | yes | yes |
25
- | Variables are stateful | no | yes | yes | no | no | no |
25
+ | Variables are stateful | no | yes | yes | no | no | yes (within a turn) |
26
26
  | Input files | no | yes | yes | no | no | yes |
27
27
  | Output files | no | yes | yes | no | no | yes |
28
28
  | Storage | no | yes (via variables) | yes (via variables) | no | no | yes (via ADK Artifacts) |
@@ -119,17 +119,16 @@ print(send_message(channel="C123", text="hi"))
119
119
 
120
120
  ## 🌐 Remote Deployment
121
121
 
122
- **Every execution runs in its own container.** The container accepts exactly one WebSocket connection, executes the user's code, returns results, and exits. The hosting platform destroys the container after each request — no cross-tenant data leakage, no residual state. You **must** configure your platform for one container per request (`--concurrency 1` on Cloud Run, or equivalent).
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
123
 
124
124
  Setting `ADK_CODE_MODE_CONTROL_HTTP=1` activates HTTP mode. The container:
125
125
 
126
126
  1. Starts a WebSocket server on port 8080 (configurable via `PORT`)
127
127
  2. Accepts exactly one connection (rejects further connections with 503)
128
- 3. Receives tools and workspace as tar archives over binary WebSocket frames
128
+ 3. Receives the tools tar **once**, then announces readiness
129
129
  4. Sanitizes the environment (strips all env vars except a safe allowlist)
130
- 5. Executes user code with tools proxied back to the host over the same WebSocket
131
- 6. Returns stdout/stderr and updated workspace files
132
- 7. Exits
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)
133
132
 
134
133
  ### Deploy to Cloud Run
135
134
 
@@ -165,6 +164,8 @@ gcloud run deploy adk-code-mode-sandbox \
165
164
  --cpu 1 \
166
165
  --memory 1Gi \
167
166
  --concurrency 1 \
167
+ --timeout 3600 \
168
+ --max-instances 120 \
168
169
  --allow-unauthenticated \
169
170
  --vpc-connector=adk-sandbox-connector \
170
171
  --vpc-egress=all-traffic \
@@ -172,6 +173,8 @@ gcloud run deploy adk-code-mode-sandbox \
172
173
  --set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest"
173
174
  ```
174
175
 
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.
177
+
175
178
  Then in your agent:
176
179
 
177
180
  ```python
@@ -181,15 +184,17 @@ RemoteBackend(
181
184
  )
182
185
  ```
183
186
 
184
- > **`--concurrency 1` is critical for security.** Without this flag, Cloud Run may route multiple requests to the same container. The sandbox rejects the second connection, but the misconfiguration itself is a risk.
187
+ > **`--concurrency 1` is critical for security.** It pins one turn to one container. Without this flag, Cloud Run may route multiple turns to the same container. The sandbox rejects the second connection, but the misconfiguration itself is a risk.
185
188
 
186
189
  > **`--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.
187
190
 
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
+
188
193
  ### Deploy on other platforms
189
194
 
190
195
  The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):
191
196
 
192
- 1. **One container per request.** Each container handles exactly one execution and exits.
197
+ 1. **One container per turn.** Each container handles exactly one turn (one or more code blocks) and exits.
193
198
  2. **Block all outbound network access.** Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
194
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
200
  4. **Authenticate connections.** Set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth (IAM, NetworkPolicy, security groups) on top.
@@ -204,23 +209,29 @@ Required env vars:
204
209
  | `ADK_CODE_MODE_MAX_UPLOAD_TOOLS` | no | 100 MiB | Max tools tar archive size |
205
210
  | `ADK_CODE_MODE_MAX_UPLOAD_WORKSPACE` | no | 100 MiB | Max workspace tar archive size |
206
211
 
207
- The same upload limits (plus a download limit) are configurable on `RemoteBackend`:
212
+ Connection tuning, retry, and the same upload limits (plus a download limit) are configurable on `RemoteBackend`:
208
213
 
209
214
  ```python
210
215
  RemoteBackend(
211
216
  url="...",
212
217
  token="...",
218
+ connect_timeout=10.0, # seconds to wait for the WS handshake (default)
219
+ start_attempts=3, # connect attempts before giving up (default)
220
+ start_retry_delay_seconds=1.0, # linear backoff base: delay * attempt (default)
221
+ start_retry_jitter_seconds=0.25, # uniform jitter added per retry (default)
213
222
  max_upload_tools_bytes=100 * 1024 * 1024, # 100 MiB (default)
214
223
  max_upload_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
215
224
  max_download_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
216
225
  )
217
226
  ```
218
227
 
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
+
219
230
  ## 🗂️ Storage
220
231
 
221
232
  Code Mode exposes two file surfaces:
222
233
 
223
- - **`/workspace`** — per-run working directory. ADK `input_files` are staged here before code runs (`open("input.csv")` works). Files created or modified under `/workspace` are returned as `CodeExecutionResult.output_files` but are not re-hydrated next turn unless persisted via `save_artifact`.
234
+ - **`/workspace`** — the turn's working directory. It persists across the turn's code blocks and resets between turns. ADK `input_files` for a block are staged here before that block runs (`open("input.csv")` works). Files whose content changed in a block are returned as that block's `CodeExecutionResult.output_files`; nothing under `/workspace` is re-hydrated on the next turn unless persisted via `save_artifact`.
224
235
 
225
236
  - **ADK Artifacts** — persistent cross-turn storage. `CodeModeCodeExecutor` injects three tools into the catalog:
226
237
 
@@ -297,9 +308,40 @@ CodeModeCodeExecutor(
297
308
  )
298
309
  ```
299
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
+ ### Turn-scoped sessions
314
+
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.
341
+
300
342
  ## 🏗️ Architecture
301
343
 
302
- **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. The `before_model_callback` resolves tools, renders the catalog, and appends it to the system prompt. At execution time, it generates a `tools/` Python package of thin stubs, stages `input_files` into `/workspace`, and launches the sandbox.
344
+ **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. The `before_model_callback` resolves tools, renders the catalog, and appends it to the system prompt. At execution time, it generates a `tools/` Python package of thin stubs, stages the block's `input_files` into `/workspace`, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
303
345
 
304
346
  **Sandbox wheel (`adk-code-mode-sandbox`).** Pre-installed in the container image. When model code calls a stub, it sends a JSON-Lines frame over the control connection; the host runs the real tool (with callbacks and plugins) and sends the result back.
305
347
 
@@ -343,8 +385,8 @@ print(send_message(channel="C123", text="hi"))
343
385
  """
344
386
 
345
387
  # How to use files and variables in between executions
346
- Code is executed in a new environment each time.
347
- 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.
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`.
348
390
 
349
391
  <code-mode>
350
392
 
@@ -389,7 +431,7 @@ Text and JSON-like MIME types travel as plain strings in artifact tools; binary
389
431
 
390
432
  `RemoteBackend` is designed for multi-tenant production use where untrusted users submit arbitrary Python code:
391
433
 
392
- - **One container per execution.** Fresh container per request no shared filesystem, memory, or processes.
434
+ - **One container per turn (one tenant, one invocation).** Within a turn the process/filesystem are reused across that turn's code blocks; the container is destroyed at turn end, with **no cross-turn or cross-tenant sharing**.
393
435
  - **Environment sanitization.** All env vars are stripped except a safe allowlist (`PATH`, `HOME`, `USER`, locale vars, Python config) before user code runs.
394
436
  - **Credentials never enter the sandbox.** API keys, OAuth tokens, and connection strings stay in the host process. The container only receives tool results.
395
437
  - **Bearer token authentication.** WebSocket connections without a valid token are rejected. Always set `ADK_CODE_MODE_AUTH_TOKEN` and layer platform-level auth on top.
@@ -414,7 +456,7 @@ Named "Unsafe" intentionally: it binds a TCP listener on `0.0.0.0`, communicates
414
456
  ## ⚠️ Limitations
415
457
 
416
458
  - **No credential-requesting tools.** Tools that need ADK to request credentials, confirmations, UI widgets, agent transfer, escalation, or that yield without an immediate response are rejected with a structured error.
417
- - **No state across executions.** Variables don't survive between turns. Use `save_artifact` / `load_artifact` to persist, or `/workspace` within a single run.
459
+ - **State is turn-scoped.** Variables and `/workspace` files persist across code blocks **within** a turn, but reset between turns. Use `save_artifact` / `load_artifact` to persist across turns.
418
460
  - **No runtime package installation.** The sandbox ships with the Python Standard Library and the runtime's own dependencies only. Extra packages must be baked into the image at build time.
419
461
 
420
462
  ## 🛠️ Development
@@ -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.2.1" # x-release-please-version
4
+ __version__ = "0.3.0" # x-release-please-version
@@ -14,13 +14,16 @@ are supported, chosen by env var (checked in order):
14
14
  - ``ADK_CODE_MODE_CONTROL_FD`` → inherited fd(s). Accepts one fd
15
15
  (bidirectional) or two comma-separated fds (read,write).
16
16
 
17
- Installs the RPC client, announces readiness, then waits for a ``run`` frame.
18
- On receipt, execs the user code with stdout/stderr on the normal streams and
19
- any ``tools.*`` calls funnelling through the RPC client.
17
+ Installs the RPC client, announces readiness once, then loops handling ``run``
18
+ frames until a ``shutdown`` frame (or disconnect) ends the turn. Each block
19
+ runs against one persistent globals dict (state carries across the turn) with
20
+ its stdout/stderr captured and shipped back in a per-block ``OutputFrame``; any
21
+ ``tools.*`` calls funnel through the RPC client.
20
22
  """
21
23
 
22
24
  from __future__ import annotations
23
25
 
26
+ import io
24
27
  import os
25
28
  import socket
26
29
  import sys
@@ -31,6 +34,7 @@ from adk_code_mode_sandbox import _rpc_client
31
34
  from adk_code_mode_sandbox._rpc_client import RpcClient
32
35
  from adk_code_mode_sandbox.protocol import (
33
36
  DoneFrame,
37
+ OutputFrame,
34
38
  ReadyFrame,
35
39
  RunFrame,
36
40
  ShutdownFrame,
@@ -132,9 +136,12 @@ def _make_globals() -> dict[str, Any]:
132
136
  }
133
137
 
134
138
 
135
- def _run_code(code: str) -> int:
136
- """Execute user code. Returns the exit code (0 ok, 1 on exception)."""
137
- globs = _make_globals()
139
+ def _exec_into(code: str, globs: dict[str, Any]) -> int:
140
+ """Execute user code into ``globs``. Returns the exit code (0 ok, 1 on exception).
141
+
142
+ ``globs`` is the persistent per-connection globals dict, so names bound by
143
+ one code block are visible to later blocks in the same turn.
144
+ """
138
145
  try:
139
146
  compiled = compile(code, "<code-mode>", "exec")
140
147
  except SyntaxError:
@@ -156,6 +163,26 @@ def _run_code(code: str) -> int:
156
163
  return 0
157
164
 
158
165
 
166
+ def run_block(code: str, globs: dict[str, Any]) -> tuple[str, str, int]:
167
+ """Run one code block with its stdout/stderr captured.
168
+
169
+ Redirects ``sys.stdout`` / ``sys.stderr`` to in-memory buffers for the
170
+ duration of the block so the captured text can be shipped back in an
171
+ ``OutputFrame`` (both the TCP and HTTP transports emit one per block).
172
+ """
173
+ old_stdout, old_stderr = sys.stdout, sys.stderr
174
+ capture_out = io.StringIO()
175
+ capture_err = io.StringIO()
176
+ sys.stdout = capture_out
177
+ sys.stderr = capture_err
178
+ try:
179
+ exit_code = _exec_into(code, globs)
180
+ finally:
181
+ sys.stdout = old_stdout
182
+ sys.stderr = old_stderr
183
+ return capture_out.getvalue(), capture_err.getvalue(), exit_code
184
+
185
+
159
186
  def main() -> int:
160
187
  if os.environ.get("ADK_CODE_MODE_CONTROL_HTTP"):
161
188
  import asyncio
@@ -174,6 +201,9 @@ def main() -> int:
174
201
  _prepare_workdir()
175
202
  _sanitize_environ()
176
203
 
204
+ # One persistent globals dict + one /workspace for the whole connection, so
205
+ # variables and files carry across the turn's successive code blocks.
206
+ globs = _make_globals()
177
207
  client.send(ReadyFrame())
178
208
 
179
209
  while True:
@@ -181,10 +211,9 @@ def main() -> int:
181
211
  if isinstance(frame, ShutdownFrame):
182
212
  return 0
183
213
  if isinstance(frame, RunFrame):
184
- exit_code = _run_code(frame.code)
185
- sys.stdout.flush()
186
- sys.stderr.flush()
214
+ stdout_text, stderr_text, exit_code = run_block(frame.code, globs)
187
215
  client.send(DoneFrame(exit_code=exit_code))
216
+ client.send(OutputFrame(stdout=stdout_text, stderr=stderr_text, exit_code=exit_code))
188
217
  continue
189
218
  # Any other frame kind is a host protocol error; keep going but log.
190
219
  print(