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.
- adk_code_mode-0.3.0/.release-please-manifest.json +3 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/CHANGELOG.md +20 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/PKG-INFO +59 -17
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/README.md +58 -16
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +38 -9
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +101 -68
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +6 -2
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/__about__.py +1 -1
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/executor.py +413 -84
- adk_code_mode-0.3.0/src/adk_code_mode/runtime/base.py +126 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/docker.py +56 -33
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/protocol.py +6 -2
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/remote.py +116 -25
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/_fake_runtime.py +42 -12
- adk_code_mode-0.3.0/tests/conftest.py +37 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_docker_integration.py +43 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_host_loop.py +4 -1
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_integration.py +345 -1
- adk_code_mode-0.3.0/tests/test_remote_backend.py +82 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_remote_integration.py +144 -0
- adk_code_mode-0.2.1/.release-please-manifest.json +0 -3
- adk_code_mode-0.2.1/src/adk_code_mode/runtime/base.py +0 -88
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.githooks/pre-commit +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.githooks/pre-push +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.github/workflows/ci.yml +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.github/workflows/release-please.yml +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.gitignore +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.prettierrc +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.vscode/settings.json +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/.zed/settings.json +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/AGENTS.md +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/CLAUDE.md +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/LICENSE +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/Makefile +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/docker/Dockerfile +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/pyproject.toml +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/release-please-config.json +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/README.md +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/pyproject.toml +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/scripts/install-git-hooks.sh +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/_artifact_tools.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/callback.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/output.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/py.typed +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/runtime/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/catalog.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/namespacing.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/normaliser.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/tools/stubs.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/workspace/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/src/adk_code_mode/workspace/files.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/__init__.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/_docker_helpers.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_agent_with_fake_llm.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_artifact_tools.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_callback.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_catalog.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_dispatcher.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_docker_image.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_namespacing.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_normaliser.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_output.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_protocol.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_runtime_docker.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_sandbox_rpc_client.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_stubs.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_version.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/tests/test_workspace_files.py +0 -0
- {adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/uv.lock +0 -0
|
@@ -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.
|
|
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
|
|
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 |
|
|
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
|
|
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
|
|
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.
|
|
158
|
-
6.
|
|
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
|
|
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
|
|
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
|
-
|
|
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`** —
|
|
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
|
|
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
|
-
|
|
374
|
-
To
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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 |
|
|
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
|
|
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
|
|
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.
|
|
131
|
-
6.
|
|
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
|
|
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
|
|
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
|
-
|
|
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`** —
|
|
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
|
|
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
|
-
|
|
347
|
-
To
|
|
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
|
|
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
|
-
- **
|
|
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
|
{adk_code_mode-0.2.1 → adk_code_mode-0.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py
RENAMED
|
@@ -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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
136
|
-
"""Execute user code
|
|
137
|
-
|
|
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 =
|
|
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(
|