adk-code-mode 1.1.0__tar.gz → 1.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 (79) hide show
  1. adk_code_mode-1.3.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/CHANGELOG.md +14 -0
  3. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/PKG-INFO +59 -12
  4. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/README.md +58 -11
  5. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  6. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +37 -1
  7. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +4 -3
  8. {adk_code_mode-1.1.0/src/adk_code_mode/runtime → adk_code_mode-1.3.0/sandbox-wheel/src/adk_code_mode_sandbox}/protocol.py +11 -1
  9. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/__about__.py +1 -1
  10. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/_artifact_tools.py +4 -1
  11. adk_code_mode-1.3.0/src/adk_code_mode/metadata.py +149 -0
  12. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/runtime/docker.py +5 -0
  13. {adk_code_mode-1.1.0/sandbox-wheel/src/adk_code_mode_sandbox → adk_code_mode-1.3.0/src/adk_code_mode/runtime}/protocol.py +11 -1
  14. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/runtime/remote.py +5 -0
  15. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tool.py +73 -44
  16. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/catalog.py +20 -7
  17. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_agent_with_fake_llm.py +2 -2
  18. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_artifact_tools.py +42 -8
  19. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_docker_integration.py +34 -1
  20. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_host_loop.py +22 -2
  21. adk_code_mode-1.3.0/tests/test_metadata.py +207 -0
  22. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_protocol.py +6 -1
  23. adk_code_mode-1.3.0/tests/test_sandbox_entry.py +52 -0
  24. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_tool.py +36 -8
  25. adk_code_mode-1.1.0/.release-please-manifest.json +0 -3
  26. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.githooks/pre-commit +0 -0
  27. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.githooks/pre-push +0 -0
  28. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.github/workflows/ci.yml +0 -0
  29. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.github/workflows/release-please.yml +0 -0
  30. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.gitignore +0 -0
  31. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.prettierrc +0 -0
  32. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.vscode/settings.json +0 -0
  33. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/.zed/settings.json +0 -0
  34. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/AGENTS.md +0 -0
  35. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/CLAUDE.md +0 -0
  36. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/LICENSE +0 -0
  37. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/Makefile +0 -0
  38. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/docker/Dockerfile +0 -0
  39. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/pyproject.toml +0 -0
  40. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/release-please-config.json +0 -0
  41. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/README.md +0 -0
  42. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/pyproject.toml +0 -0
  43. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  44. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  45. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  46. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/scripts/install-git-hooks.sh +0 -0
  47. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/__init__.py +0 -0
  48. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/output.py +0 -0
  49. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/py.typed +0 -0
  50. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  51. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/runtime/base.py +0 -0
  52. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
  53. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/__init__.py +0 -0
  54. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
  55. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/namespacing.py +0 -0
  56. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  57. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/tools/stubs.py +0 -0
  58. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  59. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/src/adk_code_mode/workspace/files.py +0 -0
  60. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/__init__.py +0 -0
  61. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/_docker_helpers.py +0 -0
  62. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/_fake_runtime.py +0 -0
  63. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/conftest.py +0 -0
  64. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_catalog.py +0 -0
  65. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_dispatcher.py +0 -0
  66. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_docker_image.py +0 -0
  67. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_integration.py +0 -0
  68. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_namespacing.py +0 -0
  69. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_normaliser.py +0 -0
  70. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_output.py +0 -0
  71. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_remote_backend.py +0 -0
  72. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_remote_integration.py +0 -0
  73. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_runtime_docker.py +0 -0
  74. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_sandbox_rpc_client.py +0 -0
  75. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_stubs.py +0 -0
  76. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_tool_result_artifacts.py +0 -0
  77. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_version.py +0 -0
  78. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/tests/test_workspace_files.py +0 -0
  79. {adk_code_mode-1.1.0 → adk_code_mode-1.3.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.3.0"
3
+ }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.2.0...adk-code-mode-v1.3.0) (2026-07-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * describe the sandbox to the model in a tagged `<code-mode>` block ([#14](https://github.com/a2anet/adk-code-mode/issues/14)) ([662a281](https://github.com/a2anet/adk-code-mode/commit/662a28138e8632b434e5f1ecd52793f7d83ee097))
9
+
10
+ ## [1.2.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.1.0...adk-code-mode-v1.2.0) (2026-07-23)
11
+
12
+
13
+ ### Features
14
+
15
+ * add `custom_metadata` to the `save_artifact` tool ([dfd9a59](https://github.com/a2anet/adk-code-mode/commit/dfd9a59bf1718084a1d5c02cc1ea453269223f16))
16
+
3
17
  ## [1.1.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.0.0...adk-code-mode-v1.1.0) (2026-07-15)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adk-code-mode
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: A "Code Mode" sandboxed code-execution tool for ADK agents to interact with tools, files, and custom packages with Python
5
5
  Project-URL: Documentation, https://github.com/A2ANet/adk-code-mode#readme
6
6
  Project-URL: Issues, https://github.com/A2ANet/adk-code-mode/issues
@@ -39,7 +39,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
39
39
  ## ✨ Features
40
40
 
41
41
  - **Call ADK tools from sandbox code** — imports against the `tools` package proxy back to the host and run through ADK's `before_tool` / `after_tool` / `on_error` callbacks and the plugin manager exactly as direct tool calls would.
42
- - **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.
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. The sandbox reports what it has installed, so the model is told about it automatically.
43
43
  - **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`. Files the code creates or changes are saved as artifacts automatically too.
44
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`.
45
45
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
@@ -280,14 +280,16 @@ RUN pip install --no-cache-dir pandas==2.2.*
280
280
 
281
281
  The same image works for both `RemoteBackend` and `UnsafeLocalDockerBackend`. To build directly from this repo, run `make docker-image`.
282
282
 
283
+ Whatever you install is advertised to the model automatically — no host-side configuration. The sandbox reports its Python version and maps each top-level import name to the installed distribution and version that provides it; they appear in the `<code-mode>` block as `<python-version>` and `<installed-packages>`. Because the report only arrives once a container has booted, the first model call of a host process doesn't carry them yet; from then on the values are cached per sandbox image for the life of the process.
284
+
283
285
  ## ⚙️ Configuration
284
286
 
285
287
  All settings are `ExecuteCodeTool` constructor arguments:
286
288
 
287
289
  | Argument | Default | Purpose |
288
290
  | --- | --- | --- |
289
- | `append_function_stubs_to_system_instruction` | `True` | Appends a `<code-mode>` block listing available function signatures and docstrings to the system instruction on every model turn. If the rendered catalog would exceed `max_catalog_chars`, the model can still discover functions by listing `/tools/` and reading the generated stubs. |
290
- | `max_catalog_chars` | `50_000` | Maximum size of the appended function catalog. |
291
+ | `append_code_mode_metadata_to_system_instruction` | `True` | Appends the `<code-mode>` block Python version, installed packages, and the function catalog — to the system instruction on every model turn. Set `False` for a bare system instruction; the tool description then points the model at `/tools/` instead, and the Python version and package list are not surfaced at all. |
292
+ | `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
291
293
  | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
292
294
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
293
295
  | `timeout_seconds` | `None` | Caps overall execution time of one `execute_code` call. Defaults to the platform request timeout (e.g. Cloud Run `--timeout`); set explicitly for defense in depth. |
@@ -308,7 +310,7 @@ A sandbox container is held open for one **turn** (one ADK invocation) and reuse
308
310
 
309
311
  ## 🏗️ Architecture
310
312
 
311
- **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. `ExecuteCodeTool.process_llm_request` resolves tools and, when `append_function_stubs_to_system_instruction` is enabled, renders the catalog and appends it to the system instruction. At execution time (`run_async`), it generates a `tools/` Python package of thin stubs, stages the working directory, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
313
+ **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. `ExecuteCodeTool.process_llm_request` resolves tools and, when `append_code_mode_metadata_to_system_instruction` is enabled, renders the `<code-mode>` block and appends it to the system instruction. At execution time (`run_async`), it generates a `tools/` Python package of thin stubs, stages the working directory, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
312
314
 
313
315
  **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.
314
316
 
@@ -321,16 +323,36 @@ The only things crossing the boundary are: code, tool call arguments, tool retur
321
323
 
322
324
  ### What the model sees
323
325
 
324
- `execute_code` has a single `code: string` parameter. With `append_function_stubs_to_system_instruction` enabled, the system instruction also gets a `<code-mode>` reference catalog on every turn:
326
+ Two surfaces, deliberately kept apart. The **tool description** is the invariant contract of calling `execute_code` — the same for every deployment, and the model's only map when the `<code-mode>` block is disabled. `execute_code` has a single `code: string` parameter and this description:
325
327
 
326
328
  ~~~
327
- …your instruction…
329
+ Execute Python code in a sandboxed container. Custom tools are Python functions to
330
+ import from the `tools` package (e.g. `from tools.slack import send_message`). See
331
+ the `<code-mode>` section of the system instruction for the Python version,
332
+ preinstalled packages, and available functions. Print anything you want to see; only
333
+ stdout/stderr are returned. Variables and the working directory persist across calls
334
+ within the same turn and reset on the next turn — use `save_artifact` /
335
+ `load_artifact` (imported the same way) to persist across turns. Files created or
336
+ changed by the code are saved as artifacts automatically
337
+ ~~~
328
338
 
329
- Reference catalog of the Python functions available inside the execute_code sandbox.
330
- Import these functions from the `tools` package in the code you pass to execute_code.
339
+ With `append_code_mode_metadata_to_system_instruction` disabled that middle sentence is replaced by `List /tools/ and read a file's docstring to see what's available.`, so the description never points at a section that wasn't appended.
331
340
 
332
- <code-mode>
341
+ The **`<code-mode>` block** is the per-deployment inventory of the sandbox, appended to the system instruction on every model turn:
333
342
 
343
+ ~~~
344
+ …your instruction…
345
+
346
+ <code-mode>
347
+ <how-to-use>
348
+ This section describes the sandbox that `execute_code` runs in: the Python version available, the third-party packages preinstalled in it, and the functions you can import from the `tools` package.
349
+ </how-to-use>
350
+ <python-version>3.13.2</python-version>
351
+ <installed-packages>
352
+ pandas: pandas 2.3.3
353
+ yaml: PyYAML 6.0.2
354
+ </installed-packages>
355
+ <tools-package>
334
356
  # tools.slack
335
357
 
336
358
  from tools.slack import list_channels, send_message
@@ -347,13 +369,38 @@ def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any
347
369
 
348
370
  from tools import save_artifact, load_artifact, list_artifacts
349
371
 
350
-
372
+ </tools-package>
351
373
  </code-mode>
352
374
  ~~~
353
375
 
376
+ Tags are indented; their content is not. The `<tools-package>` body is Python source, where leading whitespace is meaningful. Each installed-package line is `<import name>: <distribution> <version>`; namespace imports supplied by multiple distributions list every provider in deterministic order. Import names and providers are sorted so the block stays byte-stable for prompt caching. `<python-version>` and `<installed-packages>` are omitted entirely — rather than rendered blank — when the sandbox hasn't reported yet or the image has no extra packages, since an empty tag reads as "none exist".
377
+
354
378
  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.
355
379
 
356
- If the rendered catalog would exceed `max_catalog_chars`, nothing is appended to the system instruction that turn — not even a fallback note. The model can still navigate the sandbox from Python:
380
+ #### Fitting the budget
381
+
382
+ A block over `max_code_mode_metadata_chars` degrades in tiers instead of vanishing. The Python version and package list survive every tier — they're small, and the model cannot discover them any other way.
383
+
384
+ 1. **Full** — as above.
385
+ 2. **Names only** — signatures and docstrings dropped, import lines kept:
386
+
387
+ ~~~
388
+ <tools-package>
389
+ # tools.slack
390
+
391
+ from tools.slack import list_channels, send_message
392
+ </tools-package>
393
+ ~~~
394
+
395
+ 3. **Pointer** — the catalog is replaced by a discovery note, and this tier is emitted no matter how small the budget:
396
+
397
+ ~~~
398
+ <tools-package>
399
+ The full catalog is too large to include here. List `/tools/` and read a file's docstring to see what's available.
400
+ </tools-package>
401
+ ~~~
402
+
403
+ At the last tier the model navigates the sandbox from Python instead:
357
404
 
358
405
  ```python
359
406
  import pathlib
@@ -12,7 +12,7 @@ Inspired by Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) and
12
12
  ## ✨ Features
13
13
 
14
14
  - **Call ADK tools from sandbox code** — imports against the `tools` package proxy back to the host and run through ADK's `before_tool` / `after_tool` / `on_error` callbacks and the plugin manager exactly as direct tool calls would.
15
- - **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.
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. The sandbox reports what it has installed, so the model is told about it automatically.
16
16
  - **Cross-turn persistence via ADK Artifacts** — `save_artifact` / `load_artifact` / `list_artifacts` are auto-injected and route through your configured `ArtifactService`. Files the code creates or changes are saved as artifacts automatically too.
17
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`.
18
18
  - **Bounded stdout/stderr** — overflow lands in a session artifact instead of poisoning the prompt.
@@ -253,14 +253,16 @@ RUN pip install --no-cache-dir pandas==2.2.*
253
253
 
254
254
  The same image works for both `RemoteBackend` and `UnsafeLocalDockerBackend`. To build directly from this repo, run `make docker-image`.
255
255
 
256
+ Whatever you install is advertised to the model automatically — no host-side configuration. The sandbox reports its Python version and maps each top-level import name to the installed distribution and version that provides it; they appear in the `<code-mode>` block as `<python-version>` and `<installed-packages>`. Because the report only arrives once a container has booted, the first model call of a host process doesn't carry them yet; from then on the values are cached per sandbox image for the life of the process.
257
+
256
258
  ## ⚙️ Configuration
257
259
 
258
260
  All settings are `ExecuteCodeTool` constructor arguments:
259
261
 
260
262
  | Argument | Default | Purpose |
261
263
  | --- | --- | --- |
262
- | `append_function_stubs_to_system_instruction` | `True` | Appends a `<code-mode>` block listing available function signatures and docstrings to the system instruction on every model turn. If the rendered catalog would exceed `max_catalog_chars`, the model can still discover functions by listing `/tools/` and reading the generated stubs. |
263
- | `max_catalog_chars` | `50_000` | Maximum size of the appended function catalog. |
264
+ | `append_code_mode_metadata_to_system_instruction` | `True` | Appends the `<code-mode>` block Python version, installed packages, and the function catalog — to the system instruction on every model turn. Set `False` for a bare system instruction; the tool description then points the model at `/tools/` instead, and the Python version and package list are not surfaced at all. |
265
+ | `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
264
266
  | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
265
267
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
266
268
  | `timeout_seconds` | `None` | Caps overall execution time of one `execute_code` call. Defaults to the platform request timeout (e.g. Cloud Run `--timeout`); set explicitly for defense in depth. |
@@ -281,7 +283,7 @@ A sandbox container is held open for one **turn** (one ADK invocation) and reuse
281
283
 
282
284
  ## 🏗️ Architecture
283
285
 
284
- **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. `ExecuteCodeTool.process_llm_request` resolves tools and, when `append_function_stubs_to_system_instruction` is enabled, renders the catalog and appends it to the system instruction. At execution time (`run_async`), it generates a `tools/` Python package of thin stubs, stages the working directory, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
286
+ **Host wheel (`adk-code-mode`).** Lives in the same process as your `LlmAgent`. `ExecuteCodeTool.process_llm_request` resolves tools and, when `append_code_mode_metadata_to_system_instruction` is enabled, renders the `<code-mode>` block and appends it to the system instruction. At execution time (`run_async`), it generates a `tools/` Python package of thin stubs, stages the working directory, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
285
287
 
286
288
  **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.
287
289
 
@@ -294,16 +296,36 @@ The only things crossing the boundary are: code, tool call arguments, tool retur
294
296
 
295
297
  ### What the model sees
296
298
 
297
- `execute_code` has a single `code: string` parameter. With `append_function_stubs_to_system_instruction` enabled, the system instruction also gets a `<code-mode>` reference catalog on every turn:
299
+ Two surfaces, deliberately kept apart. The **tool description** is the invariant contract of calling `execute_code` — the same for every deployment, and the model's only map when the `<code-mode>` block is disabled. `execute_code` has a single `code: string` parameter and this description:
298
300
 
299
301
  ~~~
300
- …your instruction…
302
+ Execute Python code in a sandboxed container. Custom tools are Python functions to
303
+ import from the `tools` package (e.g. `from tools.slack import send_message`). See
304
+ the `<code-mode>` section of the system instruction for the Python version,
305
+ preinstalled packages, and available functions. Print anything you want to see; only
306
+ stdout/stderr are returned. Variables and the working directory persist across calls
307
+ within the same turn and reset on the next turn — use `save_artifact` /
308
+ `load_artifact` (imported the same way) to persist across turns. Files created or
309
+ changed by the code are saved as artifacts automatically
310
+ ~~~
301
311
 
302
- Reference catalog of the Python functions available inside the execute_code sandbox.
303
- Import these functions from the `tools` package in the code you pass to execute_code.
312
+ With `append_code_mode_metadata_to_system_instruction` disabled that middle sentence is replaced by `List /tools/ and read a file's docstring to see what's available.`, so the description never points at a section that wasn't appended.
304
313
 
305
- <code-mode>
314
+ The **`<code-mode>` block** is the per-deployment inventory of the sandbox, appended to the system instruction on every model turn:
306
315
 
316
+ ~~~
317
+ …your instruction…
318
+
319
+ <code-mode>
320
+ <how-to-use>
321
+ This section describes the sandbox that `execute_code` runs in: the Python version available, the third-party packages preinstalled in it, and the functions you can import from the `tools` package.
322
+ </how-to-use>
323
+ <python-version>3.13.2</python-version>
324
+ <installed-packages>
325
+ pandas: pandas 2.3.3
326
+ yaml: PyYAML 6.0.2
327
+ </installed-packages>
328
+ <tools-package>
307
329
  # tools.slack
308
330
 
309
331
  from tools.slack import list_channels, send_message
@@ -320,13 +342,38 @@ def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any
320
342
 
321
343
  from tools import save_artifact, load_artifact, list_artifacts
322
344
 
323
-
345
+ </tools-package>
324
346
  </code-mode>
325
347
  ~~~
326
348
 
349
+ Tags are indented; their content is not. The `<tools-package>` body is Python source, where leading whitespace is meaningful. Each installed-package line is `<import name>: <distribution> <version>`; namespace imports supplied by multiple distributions list every provider in deterministic order. Import names and providers are sorted so the block stays byte-stable for prompt caching. `<python-version>` and `<installed-packages>` are omitted entirely — rather than rendered blank — when the sandbox hasn't reported yet or the image has no extra packages, since an empty tag reads as "none exist".
350
+
327
351
  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.
328
352
 
329
- If the rendered catalog would exceed `max_catalog_chars`, nothing is appended to the system instruction that turn — not even a fallback note. The model can still navigate the sandbox from Python:
353
+ #### Fitting the budget
354
+
355
+ A block over `max_code_mode_metadata_chars` degrades in tiers instead of vanishing. The Python version and package list survive every tier — they're small, and the model cannot discover them any other way.
356
+
357
+ 1. **Full** — as above.
358
+ 2. **Names only** — signatures and docstrings dropped, import lines kept:
359
+
360
+ ~~~
361
+ <tools-package>
362
+ # tools.slack
363
+
364
+ from tools.slack import list_channels, send_message
365
+ </tools-package>
366
+ ~~~
367
+
368
+ 3. **Pointer** — the catalog is replaced by a discovery note, and this tier is emitted no matter how small the budget:
369
+
370
+ ~~~
371
+ <tools-package>
372
+ The full catalog is too large to include here. List `/tools/` and read a file's docstring to see what's available.
373
+ </tools-package>
374
+ ~~~
375
+
376
+ At the last tier the model navigates the sandbox from Python instead:
330
377
 
331
378
  ```python
332
379
  import pathlib
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "1.1.0" # x-release-please-version
4
+ __version__ = "1.3.0" # x-release-please-version
@@ -23,8 +23,10 @@ its stdout/stderr captured and shipped back in a per-block ``OutputFrame``; any
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import importlib.metadata
26
27
  import io
27
28
  import os
29
+ import platform
28
30
  import socket
29
31
  import sys
30
32
  import traceback
@@ -40,6 +42,11 @@ from adk_code_mode_sandbox.protocol import (
40
42
  ShutdownFrame,
41
43
  )
42
44
 
45
+ # Distributions that ship with the sandbox itself rather than being installed by
46
+ # whoever built the image. Reporting them would tell the model it has tools it
47
+ # was never meant to use (`websockets` is this wheel's only dependency).
48
+ _PACKAGE_DENYLIST = frozenset({"adk-code-mode-sandbox", "websockets", "pip", "setuptools", "wheel"})
49
+
43
50
  CONTROL_FD_ENV = "ADK_CODE_MODE_CONTROL_FD"
44
51
  CONTROL_SOCKET_ENV = "ADK_CODE_MODE_CONTROL_SOCKET"
45
52
  CONTROL_TCP_ENV = "ADK_CODE_MODE_CONTROL_TCP"
@@ -50,6 +57,35 @@ TOOLS_DIR = os.environ.get(TOOLS_DIR_ENV, "/tools")
50
57
  WORKDIR = os.environ.get(WORKDIR_ENV, "/workspace")
51
58
 
52
59
 
60
+ def ready_frame() -> ReadyFrame:
61
+ """Build the ``ReadyFrame`` announcing this image's Python and packages.
62
+
63
+ Both transports send this, so image introspection stays in one place.
64
+ """
65
+ distributions = {
66
+ dist.name.casefold(): (dist.name, dist.version)
67
+ for dist in sorted(
68
+ importlib.metadata.distributions(),
69
+ key=lambda dist: (dist.name.casefold(), dist.name, dist.version),
70
+ )
71
+ if dist.name.casefold() not in _PACKAGE_DENYLIST
72
+ }
73
+ packages: dict[str, dict[str, str]] = {}
74
+ package_distributions = importlib.metadata.packages_distributions()
75
+ for import_name in sorted(package_distributions, key=lambda name: (name.casefold(), name)):
76
+ providers: dict[str, str] = {}
77
+ for distribution_name in sorted(
78
+ package_distributions[import_name],
79
+ key=lambda name: (name.casefold(), name),
80
+ ):
81
+ distribution = distributions.get(distribution_name.casefold())
82
+ if distribution is not None:
83
+ providers[distribution[0]] = distribution[1]
84
+ if providers:
85
+ packages[import_name] = providers
86
+ return ReadyFrame(python_version=platform.python_version(), packages=packages)
87
+
88
+
53
89
  def _open_control_streams() -> tuple[Any, Any]:
54
90
  """Open the host control pipe as a (reader, writer) pair."""
55
91
  tcp_endpoint = os.environ.get(CONTROL_TCP_ENV)
@@ -204,7 +240,7 @@ def main() -> int:
204
240
  # One persistent globals dict + one /workspace for the whole connection, so
205
241
  # variables and files carry across the turn's successive code blocks.
206
242
  globs = _make_globals()
207
- client.send(ReadyFrame())
243
+ client.send(ready_frame())
208
244
 
209
245
  while True:
210
246
  frame = client.recv()
@@ -49,6 +49,7 @@ from adk_code_mode_sandbox._entry import (
49
49
  _prepare_sys_path,
50
50
  _prepare_workdir,
51
51
  _sanitize_environ,
52
+ ready_frame,
52
53
  run_block,
53
54
  )
54
55
  from adk_code_mode_sandbox._rpc_client import RpcClient
@@ -57,7 +58,6 @@ from adk_code_mode_sandbox.protocol import (
57
58
  DoneFrame,
58
59
  OutputFrame,
59
60
  ProtocolError,
60
- ReadyFrame,
61
61
  RunFrame,
62
62
  ShutdownFrame,
63
63
  decode,
@@ -188,8 +188,9 @@ async def _handle_connection(ws: Any) -> None:
188
188
  globs = _make_globals()
189
189
  loop = asyncio.get_running_loop()
190
190
 
191
- # 4. Announce readiness once (carries the protocol version).
192
- await ws.send(encode(ReadyFrame()).decode("utf-8"))
191
+ # 4. Announce readiness once (carries the protocol version, Python
192
+ # version, and installed packages).
193
+ await ws.send(encode(ready_frame()).decode("utf-8"))
193
194
 
194
195
  # 5. Run code blocks until the client ends the turn or disconnects.
195
196
  while await _run_one_block(ws, loop, globs, workspace_dir, max_workspace):
@@ -39,10 +39,20 @@ FrameKind = Literal[
39
39
 
40
40
  @dataclass(frozen=True)
41
41
  class ReadyFrame:
42
- """Sandbox → host. Sent once after boot."""
42
+ """Sandbox → host. Sent once after boot.
43
+
44
+ ``python_version`` / ``packages`` describe the image the sandbox runs in so
45
+ the host can tell the model what it can import. ``packages`` maps each
46
+ top-level import name to the distributions that provide it and their
47
+ versions. Both are additive fields with defaults, so a host and a sandbox
48
+ on different releases still speak: ``decode`` drops unknown keys, and an
49
+ older sandbox that sends neither just leaves them empty.
50
+ """
43
51
 
44
52
  kind: Literal["ready"] = "ready"
45
53
  protocol_version: int = PROTOCOL_VERSION
54
+ python_version: str = ""
55
+ packages: dict[str, dict[str, str]] = field(default_factory=dict)
46
56
 
47
57
 
48
58
  @dataclass(frozen=True)
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "1.1.0" # x-release-please-version
4
+ __version__ = "1.3.0" # x-release-please-version
@@ -43,6 +43,7 @@ async def save_artifact(
43
43
  filename: str,
44
44
  content: str,
45
45
  mime_type: str | None = None,
46
+ custom_metadata: dict[str, Any] | None = None,
46
47
  tool_context: ToolContext,
47
48
  ) -> int:
48
49
  """Save an artifact to the session. Returns the new version number.
@@ -62,6 +63,8 @@ async def save_artifact(
62
63
  content=base64.b64encode(image_bytes).decode("ascii"),
63
64
  mime_type="image/png",
64
65
  )
66
+
67
+ ``custom_metadata`` is custom metadata to associate with the artifact.
65
68
  """
66
69
  if _is_text_mime(mime_type):
67
70
  part = genai_types.Part(
@@ -77,7 +80,7 @@ async def save_artifact(
77
80
  mime_type=mime_type or "application/octet-stream",
78
81
  )
79
82
  )
80
- return await tool_context.save_artifact(filename, part)
83
+ return await tool_context.save_artifact(filename, part, custom_metadata=custom_metadata)
81
84
 
82
85
 
83
86
  async def load_artifact(
@@ -0,0 +1,149 @@
1
+ # SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Assemble the ``<code-mode>`` block appended to the model's system instruction.
5
+
6
+ The block is the sandbox's *inventory* — which Python, which preinstalled
7
+ packages, which importable functions. The invariant contract of calling
8
+ ``execute_code`` lives in the tool's description instead, so the two never
9
+ restate each other.
10
+
11
+ The Python version and package list are reported by the sandbox itself in its
12
+ ``ReadyFrame``, which only arrives once a container has booted — i.e. after the
13
+ first ``execute_code`` call. They are cached here, keyed by the backend's
14
+ ``identity`` (its URL or image) rather than on ``ExecuteCodeTool``, so one warm
15
+ process serves every agent pointing at the same image and the value survives a
16
+ tool being rebuilt. Recording is monotonic and rendering is sorted: the block
17
+ must be byte-stable once known, because it sits in front of the whole prompt
18
+ and any churn re-writes the model provider's cached prefix.
19
+
20
+ Tags are indented; their text content is not. The ``<tools-package>`` body is
21
+ Python source, where leading whitespace is meaningful.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from adk_code_mode.runtime.protocol import ReadyFrame
29
+ from adk_code_mode.tools.catalog import render_catalog
30
+ from adk_code_mode.tools.namespacing import NamespacedTool
31
+
32
+ _OPEN = "<code-mode>"
33
+ _CLOSE = "</code-mode>"
34
+ _INDENT = " "
35
+
36
+ HOW_TO_USE = (
37
+ "This section describes the sandbox that `execute_code` runs in: the Python "
38
+ "version available, the third-party packages preinstalled in it, and the "
39
+ "functions you can import from the `tools` package."
40
+ )
41
+
42
+ DISCOVER_TOOLS = "List `/tools/` and read a file's docstring to see what's available."
43
+
44
+ _TOOLS_TOO_LARGE = f"The full catalog is too large to include here. {DISCOVER_TOOLS}"
45
+
46
+ # identity -> (python version, {import name: {distribution name: version}})
47
+ _ENVIRONMENTS: dict[str, tuple[str, dict[str, dict[str, str]]]] = {}
48
+
49
+
50
+ def backend_identity(backend: Any) -> str:
51
+ """Return the cache key for a backend's sandbox image.
52
+
53
+ Built-in backends expose ``identity`` (URL or image tag). Third-party
54
+ backends without one collapse to their class name, which is still stable
55
+ within a process.
56
+ """
57
+ return getattr(backend, "identity", None) or type(backend).__name__
58
+
59
+
60
+ def record(identity: str, frame: ReadyFrame) -> None:
61
+ """Cache the environment a sandbox reported on connect.
62
+
63
+ A frame carrying neither field is ignored rather than stored: sandboxes
64
+ older than this field, and any future reconnect that fails to report, must
65
+ not blank out an environment we already know.
66
+ """
67
+ if not frame.python_version and not frame.packages:
68
+ return
69
+ packages = {
70
+ import_name: dict(distributions) for import_name, distributions in frame.packages.items()
71
+ }
72
+ _ENVIRONMENTS[identity] = (frame.python_version, packages)
73
+
74
+
75
+ def reset() -> None:
76
+ """Drop every cached environment. For tests."""
77
+ _ENVIRONMENTS.clear()
78
+
79
+
80
+ def _tag(name: str, content: str) -> str:
81
+ # The catalog's sections end in a newline; strip so every tag closes flush
82
+ # against its content rather than after a stray blank line.
83
+ body = content.strip("\n")
84
+ return f"{_INDENT}<{name}>\n{body}\n{_INDENT}</{name}>"
85
+
86
+
87
+ def _inline_tag(name: str, value: str) -> str:
88
+ return f"{_INDENT}<{name}>{value}</{name}>"
89
+
90
+
91
+ def _wrap(children: list[str]) -> str:
92
+ return "\n".join([_OPEN, *children, _CLOSE])
93
+
94
+
95
+ def render(*, identity: str, namespaced: list[NamespacedTool], max_chars: int) -> str:
96
+ """Render the ``<code-mode>`` block, degrading until it fits ``max_chars``.
97
+
98
+ ``max_chars`` is measured against the final string, tags included. Tiers,
99
+ in order: the full catalog; import lines only (no signatures or
100
+ docstrings); a pointer telling the model to read ``/tools/`` itself. The
101
+ last tier is unconditional — an oversized tool surface must never cost the
102
+ model the Python version and package list, which are small and which it
103
+ cannot discover any other way.
104
+
105
+ Tags whose content is unknown (no ``ReadyFrame`` yet) or empty (a base
106
+ image with no extra packages, a tool with no tools) are omitted entirely
107
+ rather than rendered blank, which would read as "none available".
108
+ """
109
+ python_version, packages = _ENVIRONMENTS.get(identity, ("", {}))
110
+
111
+ head = [_tag("how-to-use", HOW_TO_USE)]
112
+ if python_version:
113
+ head.append(_inline_tag("python-version", python_version))
114
+ if packages:
115
+ lines = []
116
+ for import_name, distributions in sorted(
117
+ packages.items(),
118
+ key=lambda item: (item[0].casefold(), item[0]),
119
+ ):
120
+ providers = ", ".join(
121
+ f"{name} {version}"
122
+ for name, version in sorted(
123
+ distributions.items(),
124
+ key=lambda item: (item[0].casefold(), item[0]),
125
+ )
126
+ )
127
+ if providers:
128
+ lines.append(f"{import_name}: {providers}")
129
+ if lines:
130
+ head.append(_tag("installed-packages", "\n".join(lines)))
131
+
132
+ if not namespaced:
133
+ return _wrap(head)
134
+
135
+ for detail in ("full", "names"):
136
+ block = _wrap([*head, _tag("tools-package", render_catalog(namespaced, detail=detail))])
137
+ if len(block) <= max_chars:
138
+ return block
139
+ return _wrap([*head, _tag("tools-package", _TOOLS_TOO_LARGE)])
140
+
141
+
142
+ __all__ = [
143
+ "DISCOVER_TOOLS",
144
+ "HOW_TO_USE",
145
+ "backend_identity",
146
+ "record",
147
+ "render",
148
+ "reset",
149
+ ]
@@ -88,6 +88,11 @@ class UnsafeLocalDockerBackend:
88
88
  extra_env: Mapping[str, str] = field(default_factory=dict)
89
89
  run_kwargs: Mapping[str, Any] = field(default_factory=dict)
90
90
 
91
+ @property
92
+ def identity(self) -> str:
93
+ """Cache key for what this backend's sandbox image contains."""
94
+ return self.image
95
+
91
96
  async def start(
92
97
  self,
93
98
  *,
@@ -39,10 +39,20 @@ FrameKind = Literal[
39
39
 
40
40
  @dataclass(frozen=True)
41
41
  class ReadyFrame:
42
- """Sandbox → host. Sent once after boot."""
42
+ """Sandbox → host. Sent once after boot.
43
+
44
+ ``python_version`` / ``packages`` describe the image the sandbox runs in so
45
+ the host can tell the model what it can import. ``packages`` maps each
46
+ top-level import name to the distributions that provide it and their
47
+ versions. Both are additive fields with defaults, so a host and a sandbox
48
+ on different releases still speak: ``decode`` drops unknown keys, and an
49
+ older sandbox that sends neither just leaves them empty.
50
+ """
43
51
 
44
52
  kind: Literal["ready"] = "ready"
45
53
  protocol_version: int = PROTOCOL_VERSION
54
+ python_version: str = ""
55
+ packages: dict[str, dict[str, str]] = field(default_factory=dict)
46
56
 
47
57
 
48
58
  @dataclass(frozen=True)
@@ -77,6 +77,11 @@ class RemoteBackend:
77
77
  max_upload_workspace_bytes: int = 100 * 1024 * 1024
78
78
  max_download_workspace_bytes: int = 100 * 1024 * 1024
79
79
 
80
+ @property
81
+ def identity(self) -> str:
82
+ """Cache key for what this backend's sandbox image contains."""
83
+ return self.url
84
+
80
85
  async def start(
81
86
  self,
82
87
  *,