adk-code-mode 1.5.0__tar.gz → 1.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. adk_code_mode-1.6.1/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/CHANGELOG.md +15 -0
  3. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/PKG-INFO +3 -3
  4. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/README.md +1 -1
  5. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  6. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -1
  7. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +15 -5
  8. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/__about__.py +1 -1
  9. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tool.py +9 -2
  10. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/stubs.py +173 -17
  11. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_integration.py +10 -1
  12. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_remote_integration.py +35 -0
  13. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_stubs_json_schema.py +231 -0
  14. adk_code_mode-1.5.0/.release-please-manifest.json +0 -3
  15. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.githooks/pre-commit +0 -0
  16. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.githooks/pre-push +0 -0
  17. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.github/workflows/ci.yml +0 -0
  18. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.github/workflows/release-please.yml +0 -0
  19. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.gitignore +0 -0
  20. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.prettierrc +0 -0
  21. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.vscode/settings.json +0 -0
  22. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/.zed/settings.json +0 -0
  23. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/LICENSE +0 -0
  24. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/Makefile +0 -0
  25. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/docker/Dockerfile +0 -0
  26. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/pyproject.toml +0 -0
  27. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/release-please-config.json +0 -0
  28. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/README.md +0 -0
  29. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/pyproject.toml +0 -0
  30. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  31. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  32. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  33. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
  34. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/scripts/install-git-hooks.sh +0 -0
  35. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/__init__.py +0 -0
  36. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/_artifact_tools.py +0 -0
  37. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/metadata.py +0 -0
  38. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/output.py +0 -0
  39. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/py.typed +0 -0
  40. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/runtime/__init__.py +0 -0
  41. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/runtime/base.py +0 -0
  42. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/runtime/docker.py +0 -0
  43. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/runtime/protocol.py +0 -0
  44. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/runtime/remote.py +0 -0
  45. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tool_result_artifacts.py +0 -0
  46. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/__init__.py +0 -0
  47. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/catalog.py +0 -0
  48. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/dispatcher.py +0 -0
  49. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/namespacing.py +0 -0
  50. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/tools/normaliser.py +0 -0
  51. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/workspace/__init__.py +0 -0
  52. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/src/adk_code_mode/workspace/files.py +0 -0
  53. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/__init__.py +0 -0
  54. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/_docker_helpers.py +0 -0
  55. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/_fake_runtime.py +0 -0
  56. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/conftest.py +0 -0
  57. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_agent_with_fake_llm.py +0 -0
  58. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_artifact_tools.py +0 -0
  59. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_catalog.py +0 -0
  60. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_dispatcher.py +0 -0
  61. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_docker_image.py +0 -0
  62. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_docker_integration.py +0 -0
  63. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_host_loop.py +0 -0
  64. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_metadata.py +0 -0
  65. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_namespacing.py +0 -0
  66. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_normaliser.py +0 -0
  67. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_output.py +0 -0
  68. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_protocol.py +0 -0
  69. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_remote_backend.py +0 -0
  70. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_runtime_docker.py +0 -0
  71. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_sandbox_entry.py +0 -0
  72. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_sandbox_rpc_client.py +0 -0
  73. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_stubs.py +0 -0
  74. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_tool.py +0 -0
  75. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_tool_result_artifacts.py +0 -0
  76. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_version.py +0 -0
  77. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/tests/test_workspace_files.py +0 -0
  78. {adk_code_mode-1.5.0 → adk_code_mode-1.6.1}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.6.1"
3
+ }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.1](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.6.0...adk-code-mode-v1.6.1) (2026-09-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * exit the sandbox when the host disconnects mid-block ([bd3fb0f](https://github.com/a2anet/adk-code-mode/commit/bd3fb0fb0d8a0cab9370f2ad6db56dfacf566cb4))
9
+
10
+ ## [1.6.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.5.0...adk-code-mode-v1.6.0) (2026-08-29)
11
+
12
+
13
+ ### Features
14
+
15
+ * bound an execute_code block by default ([1609c0d](https://github.com/a2anet/adk-code-mode/commit/1609c0d24b8563cba51434cd1214b4299aeefc86))
16
+ * expand object oneOf/anyOf branches in tool stub docstrings ([#20](https://github.com/a2anet/adk-code-mode/issues/20)) ([7d42cb1](https://github.com/a2anet/adk-code-mode/commit/7d42cb1ee9d9e217d008776dc74a84034f4b67a9))
17
+
3
18
  ## [1.5.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.4.0...adk-code-mode-v1.5.0) (2026-08-10)
4
19
 
5
20
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: adk-code-mode
3
- Version: 1.5.0
3
+ Version: 1.6.1
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
@@ -300,7 +300,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
300
300
  | `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
301
301
  | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
302
302
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
303
- | `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. |
303
+ | `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. `None` lifts the cap, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
304
304
  | `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
305
305
  | `session_idle_timeout_seconds` | `600` | Idle reaper: closes a turn's container once it goes untouched this long. Backstop for turns that never call `release_invocation`. |
306
306
 
@@ -273,7 +273,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
273
273
  | `max_code_mode_metadata_chars` | `50_000` | Budget for the whole rendered `<code-mode>` block, tags included. Oversized blocks degrade in tiers rather than disappearing — see [What the model sees](#what-the-model-sees). |
274
274
  | `max_output_chars` | `50_000` | Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at `code_mode/stdout/<call-id>.txt` and the model sees a head-and-tail view pointing to it. |
275
275
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
276
- | `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. |
276
+ | `timeout_seconds` | `60` | Caps overall execution time of one `execute_code` call, and with it every tool the block calls. `None` lifts the cap, leaving the platform request timeout (e.g. Cloud Run `--timeout`) as the only bound. |
277
277
  | `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
278
278
  | `session_idle_timeout_seconds` | `600` | Idle reaper: closes a turn's container once it goes untouched this long. Backstop for turns that never call `release_invocation`. |
279
279
 
@@ -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.5.0" # x-release-please-version
4
+ __version__ = "1.6.1" # x-release-please-version
@@ -228,7 +228,6 @@ def main() -> int:
228
228
  port = int(os.environ.get("PORT", "8080"))
229
229
  token = os.environ.get(AUTH_TOKEN_ENV)
230
230
  asyncio.run(serve(port, token))
231
- return 0
232
231
 
233
232
  reader, writer = _open_control_streams()
234
233
  client = RpcClient(reader=reader, writer=writer)
@@ -38,7 +38,7 @@ import shutil
38
38
  import tarfile
39
39
  import tempfile
40
40
  from http import HTTPStatus
41
- from typing import Any
41
+ from typing import Any, NoReturn
42
42
 
43
43
  import websockets
44
44
  import websockets.asyncio.server
@@ -112,10 +112,11 @@ class _WsBridgeWriter:
112
112
  pass
113
113
 
114
114
 
115
- async def serve(port: int, token: str | None) -> None:
115
+ async def serve(port: int, token: str | None) -> NoReturn:
116
116
  """Start the WebSocket server on *port* with optional bearer *token* auth.
117
117
 
118
- Accepts exactly one WebSocket connection, runs user code, then returns.
118
+ Accepts exactly one WebSocket connection, runs user code, then exits the
119
+ process.
119
120
  """
120
121
  stop = asyncio.Event()
121
122
  accepted = False
@@ -136,9 +137,15 @@ async def serve(port: int, token: str | None) -> None:
136
137
  return None
137
138
 
138
139
  async def handler(ws: Any) -> None:
140
+ turn = asyncio.create_task(_handle_connection(ws))
141
+ # End the turn when the socket closes, even mid-block: user code that
142
+ # ignores the disconnect would otherwise keep this single-use container
143
+ # alive, answering every later connection with "Busy".
144
+ ws_closed = asyncio.create_task(ws.wait_closed())
145
+ ws_closed.add_done_callback(lambda _: turn.cancel())
139
146
  try:
140
- await _handle_connection(ws)
141
- except websockets.ConnectionClosed:
147
+ await turn
148
+ except (websockets.ConnectionClosed, asyncio.CancelledError):
142
149
  logger.debug("client disconnected")
143
150
  except Exception:
144
151
  logger.exception("error handling WebSocket connection")
@@ -154,6 +161,9 @@ async def serve(port: int, token: str | None) -> None:
154
161
  ):
155
162
  logger.info("sandbox HTTP server listening on port %d", port)
156
163
  await stop.wait()
164
+ # Don't let interpreter shutdown join a worker thread still running user code
165
+ # the host has given up on.
166
+ os._exit(0)
157
167
 
158
168
 
159
169
  async def _handle_connection(ws: Any) -> None:
@@ -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.5.0" # x-release-please-version
4
+ __version__ = "1.6.1" # x-release-please-version
@@ -97,6 +97,11 @@ class _BlockConnectionLost(Exception):
97
97
 
98
98
  logger = logging.getLogger("adk_code_mode.tool")
99
99
 
100
+ # A block is a model-authored program, so it is bounded by default: an unbounded one
101
+ # strands its container (the idle reaper skips turns still in use) and holds up
102
+ # whatever is waiting on the turn. Hosts that genuinely need longer pass their own.
103
+ DEFAULT_TIMEOUT_SECONDS = 60
104
+
100
105
  _REAPER_MAX_POLL_SECONDS = 30.0
101
106
 
102
107
  # One block runs at most twice: the original attempt plus a single reconnect, and
@@ -202,7 +207,7 @@ class ExecuteCodeTool(BaseTool):
202
207
  max_code_mode_metadata_chars: int = 50_000,
203
208
  max_output_chars: int = 50_000,
204
209
  max_code_chars: int = 1_000_000,
205
- timeout_seconds: int | None = None,
210
+ timeout_seconds: int | None = DEFAULT_TIMEOUT_SECONDS,
206
211
  per_tool_timeout_seconds: float | None = None,
207
212
  session_idle_timeout_seconds: float = 600,
208
213
  on_artifacts_saved: ArtifactsSavedCallback | None = None,
@@ -241,7 +246,9 @@ class ExecuteCodeTool(BaseTool):
241
246
  max_code_chars: Rejects oversized code payloads before starting a
242
247
  container.
243
248
  timeout_seconds: Caps overall execution time of one ``execute_code``
244
- call.
249
+ call, and with it every tool the block calls. ``None`` lifts the cap,
250
+ which leaves a runaway block stranding its container until the idle
251
+ reaper takes it.
245
252
  per_tool_timeout_seconds: Caps each individual tool call made from
246
253
  within the sandbox.
247
254
  session_idle_timeout_seconds: Idle reaper: closes a turn's container
@@ -151,10 +151,9 @@ def _effective_schema(schema: Any) -> dict[str, Any]:
151
151
  if isinstance(variants, list):
152
152
  merged: dict[str, Any] = {}
153
153
  for part in variants:
154
- merged.update(_effective_schema(part))
155
- # The wrapper's own keys win: `nullable` sits beside the `$ref`, not in it.
156
- merged.update(normalised)
157
- normalised = merged
154
+ merged = _merge_schema(merged, _effective_schema(part))
155
+ # Wrapper keys (e.g. `nullable`) win; `properties` / `required` still merge.
156
+ normalised = _merge_schema(merged, normalised)
158
157
 
159
158
  # A one-variant union expresses no choice, so fold it in rather than lose
160
159
  # the description and properties hanging off the single branch.
@@ -163,12 +162,42 @@ def _effective_schema(schema: Any) -> dict[str, Any]:
163
162
  if isinstance(branches, list) and len(branches) == 1:
164
163
  folded = _effective_schema(branches[0])
165
164
  normalised.pop(key)
166
- folded.update(normalised)
167
- normalised = folded
165
+ normalised = _merge_schema(folded, normalised)
168
166
 
169
167
  return normalised
170
168
 
171
169
 
170
+ def _merge_schema(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
171
+ """Combine two schemas the way ``allOf`` means: properties accumulate.
172
+
173
+ A shallow ``update`` replaces the whole ``properties`` dict, so OpenAPI 3.0
174
+ ``allOf: [$ref, {properties: extra}]`` would keep only ``extra``.
175
+ """
176
+ out = dict(base)
177
+ extra_props = extra.get("properties")
178
+ if isinstance(extra_props, dict):
179
+ props = dict(out.get("properties") or {})
180
+ for name, schema in extra_props.items():
181
+ existing = props.get(name)
182
+ if isinstance(existing, dict) and isinstance(schema, dict):
183
+ props[name] = _merge_schema(existing, schema)
184
+ else:
185
+ props[name] = schema
186
+ out["properties"] = props
187
+ extra_required = extra.get("required")
188
+ if isinstance(extra_required, list):
189
+ required = [name for name in (out.get("required") or []) if isinstance(name, str)]
190
+ for name in extra_required:
191
+ if isinstance(name, str) and name not in required:
192
+ required.append(name)
193
+ out["required"] = required
194
+ for key, value in extra.items():
195
+ if key in ("properties", "required"):
196
+ continue
197
+ out[key] = value
198
+ return out
199
+
200
+
172
201
  def _schema_to_type(schema: Any) -> str:
173
202
  """Convert a JSON-Schema fragment to a Python type expression (PEP 604).
174
203
 
@@ -322,23 +351,93 @@ def _prose(schema: dict[str, Any]) -> str:
322
351
  return ""
323
352
 
324
353
 
325
- def _describe_fields(schema: dict[str, Any], *, indent: str, depth: int = 0) -> list[str]:
326
- """Lines describing an object's properties, recursing into nested schemas.
354
+ def _const_or_single_enum(schema: dict[str, Any]) -> str | None:
355
+ """A branch tag value: ``const`` or a one-element ``enum``."""
356
+ if "const" in schema:
357
+ return _format_scalar(schema["const"])
358
+ enum = schema.get("enum")
359
+ if isinstance(enum, list) and len(enum) == 1:
360
+ return _format_scalar(enum[0])
361
+ return None
327
362
 
328
- The signature can only ever say ``dict[str, Any]`` for an object, so without
329
- this the key names, their types and their meanings never reach the model.
363
+
364
+ def _union_branch_tag(branch: dict[str, Any], *, discriminator: str | None) -> str | None:
365
+ """Label a union branch without implying a nested object key.
366
+
367
+ OpenAPI discriminator unions (``type: openapi | mcp``) often lose
368
+ ``discriminator`` once ADK inlines ``$ref`` into ``items``, leaving only a
369
+ single-value ``type`` enum on each object. Tag as ``(type=openapi)`` rather
370
+ than a fake ``openapi:`` key the model might nest under.
330
371
  """
331
- if depth >= _MAX_SCHEMA_DEPTH:
332
- return []
372
+ props = branch.get("properties")
373
+ if not isinstance(props, dict):
374
+ return None
375
+ names: list[str] = []
376
+ if discriminator:
377
+ names.append(discriminator)
378
+ if "type" not in names:
379
+ names.append("type")
380
+ for name in names:
381
+ raw = props.get(name)
382
+ if not isinstance(raw, dict):
383
+ continue
384
+ value = _const_or_single_enum(_effective_schema(raw))
385
+ if value is not None:
386
+ return f"{name}={value}"
387
+ return None
333
388
 
389
+
390
+ def _describe_union(
391
+ schema: dict[str, Any], variants: list[Any], *, indent: str, depth: int
392
+ ) -> list[str]:
393
+ """Expand object ``oneOf``/``anyOf`` branches into the docstring.
394
+
395
+ The signature collapses every object variant to ``dict[str, Any]``, so
396
+ without this the keys on a discriminator union never reach the model.
397
+ Primitive-only unions stay in the type expression and are skipped here.
398
+ """
399
+ disc = schema.get("discriminator")
400
+ discriminator = None
401
+ if isinstance(disc, dict):
402
+ name = disc.get("propertyName")
403
+ if isinstance(name, str) and name:
404
+ discriminator = name
405
+
406
+ parent_required = schema.get("required")
407
+ lines: list[str] = []
408
+ for raw in variants:
409
+ branch = _effective_schema(raw)
410
+ if isinstance(parent_required, list):
411
+ branch = _merge_schema(branch, {"required": parent_required})
412
+ nested = _describe_fields(branch, indent=indent, depth=depth + 1)
413
+ if not nested:
414
+ continue
415
+ if lines:
416
+ lines.append(f"{indent}-- or --")
417
+ tag = _union_branch_tag(branch, discriminator=discriminator)
418
+ description = _prose(branch).splitlines()
419
+ suffix = _constraint_suffix(branch)
420
+ if tag:
421
+ head = f"{indent}({tag})"
422
+ if description:
423
+ head += f" - {description[0].strip()}"
424
+ lines.append(head + suffix)
425
+ elif description:
426
+ lines.append(f"{indent}{description[0].strip()}{suffix}")
427
+ elif suffix:
428
+ lines.append(f"{indent}{suffix.strip()}")
429
+ for extra in description[1:]:
430
+ lines.append(f"{indent} {extra.strip()}")
431
+ lines.extend(nested)
432
+ return lines
433
+
434
+
435
+ def _describe_properties(schema: dict[str, Any], *, indent: str, depth: int) -> list[str]:
436
+ """One line per object property, plus nested expansion."""
334
437
  properties = schema.get("properties")
335
438
  if not isinstance(properties, dict) or not properties:
336
- items = schema.get("items")
337
- if isinstance(items, dict):
338
- return _describe_fields(_effective_schema(items), indent=indent, depth=depth + 1)
339
439
  return []
340
-
341
- required = set(schema.get("required") or [])
440
+ required = {name for name in (schema.get("required") or []) if isinstance(name, str)}
342
441
  lines: list[str] = []
343
442
  for name, raw in properties.items():
344
443
  field = _effective_schema(raw)
@@ -355,6 +454,63 @@ def _describe_fields(schema: dict[str, Any], *, indent: str, depth: int = 0) ->
355
454
  return lines
356
455
 
357
456
 
457
+ def _describe_prefix_items(schema: dict[str, Any], *, indent: str, depth: int) -> list[str]:
458
+ """Expand object (or union) slots of a ``prefixItems`` tuple.
459
+
460
+ Primitive slots are already in the ``tuple[...]`` type expression.
461
+ """
462
+ prefix_items = schema.get("prefixItems")
463
+ if not isinstance(prefix_items, list) or not prefix_items:
464
+ return []
465
+ lines: list[str] = []
466
+ for index, raw in enumerate(prefix_items):
467
+ field = _effective_schema(raw)
468
+ nested = _describe_fields(field, indent=indent + " ", depth=depth + 1)
469
+ if not nested:
470
+ continue
471
+ head = f"{indent}[{index}]: {_schema_to_type(field)}"
472
+ description = _prose(field).splitlines()
473
+ if description:
474
+ head += f" - {description[0].strip()}"
475
+ lines.append(head + _constraint_suffix(field, include_default=True))
476
+ for extra in description[1:]:
477
+ lines.append(f"{indent} {extra.strip()}")
478
+ lines.extend(nested)
479
+ return lines
480
+
481
+
482
+ def _describe_fields(schema: dict[str, Any], *, indent: str, depth: int = 0) -> list[str]:
483
+ """Lines describing an object's properties, recursing into nested schemas.
484
+
485
+ The signature can only ever say ``dict[str, Any]`` for an object, so without
486
+ this the key names, their types and their meanings never reach the model.
487
+ Multi-branch ``oneOf``/``anyOf`` is walked the same way: a single-branch
488
+ union is folded in ``_effective_schema``, but two object variants have no
489
+ ``properties`` at the union itself. Shared parent ``properties`` are listed
490
+ first, then the branches.
491
+ """
492
+ if depth >= _MAX_SCHEMA_DEPTH:
493
+ return []
494
+
495
+ schema = _effective_schema(schema)
496
+
497
+ variants = schema.get("anyOf") or schema.get("oneOf")
498
+ lines = _describe_properties(schema, indent=indent, depth=depth)
499
+ if isinstance(variants, list) and len(variants) > 1:
500
+ lines.extend(_describe_union(schema, variants, indent=indent, depth=depth))
501
+ if lines:
502
+ return lines
503
+
504
+ prefix_lines = _describe_prefix_items(schema, indent=indent, depth=depth)
505
+ if prefix_lines:
506
+ return prefix_lines
507
+
508
+ items = schema.get("items")
509
+ if isinstance(items, dict):
510
+ return _describe_fields(items, indent=indent, depth=depth + 1)
511
+ return lines
512
+
513
+
358
514
  def _format_docstring(
359
515
  *, description: str, param_docs: list[tuple[str, dict[str, Any], str | None]]
360
516
  ) -> str:
@@ -24,7 +24,11 @@ from google.genai import types as genai_types
24
24
 
25
25
  from adk_code_mode.runtime.base import SandboxConnectionError, SandboxResult, SandboxSession
26
26
  from adk_code_mode.runtime.protocol import PROTOCOL_VERSION, DoneFrame, Frame, ReadyFrame
27
- from adk_code_mode.tool import ExecuteCodeTool, ProtocolVersionMismatchError
27
+ from adk_code_mode.tool import (
28
+ DEFAULT_TIMEOUT_SECONDS,
29
+ ExecuteCodeTool,
30
+ ProtocolVersionMismatchError,
31
+ )
28
32
 
29
33
  from ._fake_runtime import FakeRuntime
30
34
 
@@ -362,6 +366,11 @@ async def test_timeout_terminates_hung_sandbox() -> None:
362
366
  assert "Execution exceeded timeout of 1s" in result["stderr"]
363
367
 
364
368
 
369
+ def test_a_block_is_bounded_without_the_host_saying_so() -> None:
370
+ tool = ExecuteCodeTool(tools=[], backend=FakeRuntime())
371
+ assert tool.timeout_seconds == DEFAULT_TIMEOUT_SECONDS
372
+
373
+
365
374
  @pytest.mark.asyncio
366
375
  async def test_timeout_does_not_wait_for_in_flight_tool_call() -> None:
367
376
  artifact_service = InMemoryArtifactService()
@@ -340,3 +340,38 @@ async def test_remote_close_shuts_the_container_down(
340
340
  break
341
341
  await asyncio.sleep(0.02)
342
342
  assert http_server.poll() is not None # container exited after ShutdownFrame
343
+
344
+
345
+ @pytest.mark.asyncio
346
+ async def test_remote_timed_out_block_shuts_the_container_down(
347
+ http_server: subprocess.Popen[bytes],
348
+ ) -> None:
349
+ """A block still running when the host gives up doesn't keep the server alive."""
350
+ artifact_service = InMemoryArtifactService()
351
+ session = Session(
352
+ id="remote-timeout",
353
+ app_name="test-app",
354
+ user_id="u1",
355
+ state={},
356
+ events=[],
357
+ last_update_time=0.0,
358
+ )
359
+ ctx = _make_ctx(artifact_service, session)
360
+
361
+ tool = ExecuteCodeTool(
362
+ tools=[],
363
+ backend=RemoteBackend(url=_server_url(http_server)),
364
+ max_output_chars=10_000,
365
+ timeout_seconds=1,
366
+ )
367
+ result = await tool.run_async(
368
+ args={"code": "import time\ntime.sleep(3600)\n"},
369
+ tool_context=_tool_context(ctx, call_id="remote-timeout-1"),
370
+ )
371
+ assert "exceeded timeout" in result["stderr"]
372
+
373
+ for _ in range(200):
374
+ if http_server.poll() is not None:
375
+ break
376
+ await asyncio.sleep(0.02)
377
+ assert http_server.poll() is not None # container exited despite the running block
@@ -224,6 +224,35 @@ class TestApplicatorVocabulary:
224
224
  required=["pair"],
225
225
  )
226
226
  assert "pair: tuple[str, int]" in source
227
+ # Primitive slots are already in the type; don't repeat them.
228
+ assert "[0]:" not in source
229
+ assert "[1]:" not in source
230
+
231
+ def test_prefix_items_object_slots_expand_their_properties(self) -> None:
232
+ source = _render(
233
+ {
234
+ "pair": {
235
+ "type": "array",
236
+ "prefixItems": [
237
+ {"type": "string"},
238
+ {
239
+ "type": "object",
240
+ "required": ["lat", "lng"],
241
+ "properties": {
242
+ "lat": {"type": "number"},
243
+ "lng": {"type": "number"},
244
+ },
245
+ },
246
+ ],
247
+ }
248
+ },
249
+ required=["pair"],
250
+ )
251
+ assert "pair: tuple[str, dict[str, Any]]" in source
252
+ assert "[1]: dict[str, Any]" in source
253
+ assert "lat: float (required)" in source
254
+ assert "lng: float (required)" in source
255
+ assert "[0]:" not in source
227
256
 
228
257
  def test_additional_properties_schema_types_an_open_map(self) -> None:
229
258
  source = _render(
@@ -268,6 +297,29 @@ class TestApplicatorVocabulary:
268
297
  assert "Min/max capacity range." in source
269
298
  assert "min: int" in source
270
299
 
300
+ def test_allof_merges_properties_from_each_branch(self) -> None:
301
+ """OpenAPI 3.0 extends a `$ref` with extra fields via a second `allOf`
302
+ object; those keys must join the first object's, not replace them."""
303
+ source = _render(
304
+ {
305
+ "page": {
306
+ "allOf": [
307
+ {
308
+ "type": "object",
309
+ "required": ["limit"],
310
+ "properties": {"limit": {"type": "integer"}},
311
+ },
312
+ {
313
+ "required": ["cursor"],
314
+ "properties": {"cursor": {"type": "string"}},
315
+ },
316
+ ]
317
+ }
318
+ }
319
+ )
320
+ assert "limit: int (required)" in source
321
+ assert "cursor: str (required)" in source
322
+
271
323
  def test_anyof_becomes_a_union_and_a_lone_branch_is_folded(self) -> None:
272
324
  source = _render(
273
325
  {
@@ -279,6 +331,185 @@ class TestApplicatorVocabulary:
279
331
  assert "many: str | int" in source
280
332
  assert "one: str" in source
281
333
  assert "Only branch." in source
334
+ # Primitive unions belong in the type expression, not a second listing.
335
+ assert "-- or --" not in source
336
+
337
+ def test_oneof_object_branches_expand_their_properties(self) -> None:
338
+ """A discriminator union has no `properties` at the `oneOf` itself.
339
+
340
+ Folding only the single-branch case leaves `list[dict[str, Any]]` with
341
+ an empty docstring — the shape of `create_agent(tools=...)`.
342
+ """
343
+ source = _render(
344
+ {
345
+ "tools": {
346
+ "type": "array",
347
+ "items": {
348
+ "oneOf": [
349
+ {
350
+ "type": "object",
351
+ "additionalProperties": False,
352
+ "required": ["type", "specUrl"],
353
+ "properties": {
354
+ "type": {"type": "string", "enum": ["openapi"]},
355
+ "specUrl": {"type": "string"},
356
+ "operationOverrides": {
357
+ "type": "array",
358
+ "items": {
359
+ "type": "object",
360
+ "required": ["sourceId", "enabled"],
361
+ "properties": {
362
+ "sourceId": {"type": "string"},
363
+ "enabled": {"type": "boolean"},
364
+ },
365
+ },
366
+ },
367
+ },
368
+ },
369
+ {
370
+ "type": "object",
371
+ "additionalProperties": False,
372
+ "required": ["type", "url"],
373
+ "properties": {
374
+ "type": {"type": "string", "enum": ["mcp"]},
375
+ "url": {"type": "string"},
376
+ "toolOverrides": {
377
+ "type": "array",
378
+ "items": {
379
+ "type": "object",
380
+ "required": ["sourceId", "enabled"],
381
+ "properties": {
382
+ "sourceId": {"type": "string"},
383
+ "enabled": {"type": "boolean"},
384
+ },
385
+ },
386
+ },
387
+ },
388
+ },
389
+ ]
390
+ },
391
+ }
392
+ },
393
+ required=["tools"],
394
+ )
395
+ assert "tools: list[dict[str, Any]]" in source
396
+ assert "(type=openapi) (no other keys)" in source
397
+ assert "specUrl: str (required)" in source
398
+ assert "operationOverrides: list[dict[str, Any]]" in source
399
+ assert "-- or --" in source
400
+ assert "(type=mcp) (no other keys)" in source
401
+ assert "url: str (required)" in source
402
+ assert "toolOverrides: list[dict[str, Any]]" in source
403
+ assert "sourceId: str (required)" in source
404
+ assert "enabled: bool (required)" in source
405
+
406
+ def test_oneof_uses_discriminator_property_for_the_branch_tag(self) -> None:
407
+ source = _render(
408
+ {
409
+ "tool": {
410
+ "discriminator": {"propertyName": "kind"},
411
+ "oneOf": [
412
+ {
413
+ "type": "object",
414
+ "properties": {
415
+ "kind": {"const": "http"},
416
+ "path": {"type": "string"},
417
+ },
418
+ },
419
+ {
420
+ "type": "object",
421
+ "properties": {
422
+ "kind": {"const": "stdio"},
423
+ "command": {"type": "string"},
424
+ },
425
+ },
426
+ ],
427
+ }
428
+ }
429
+ )
430
+ assert "(kind=http)" in source
431
+ assert "(kind=stdio)" in source
432
+ assert "path: str" in source
433
+ assert "command: str" in source
434
+
435
+ def test_parent_required_applies_to_oneof_branch_properties(self) -> None:
436
+ source = _render(
437
+ {
438
+ "target": {
439
+ "required": ["value"],
440
+ "oneOf": [
441
+ {
442
+ "type": "object",
443
+ "properties": {"value": {"type": "string"}},
444
+ },
445
+ {
446
+ "type": "object",
447
+ "properties": {"value": {"type": "integer"}},
448
+ },
449
+ ],
450
+ }
451
+ }
452
+ )
453
+ assert "value: str (required)" in source
454
+ assert "value: int (required)" in source
455
+
456
+ def test_oneof_branch_prose_is_preserved(self) -> None:
457
+ source = _render(
458
+ {
459
+ "target": {
460
+ "oneOf": [
461
+ {
462
+ "type": "object",
463
+ "title": "Email target",
464
+ "properties": {"value": {"type": "string"}},
465
+ },
466
+ {
467
+ "type": "object",
468
+ "description": "Phone target.",
469
+ "properties": {"value": {"type": "string"}},
470
+ },
471
+ ],
472
+ }
473
+ }
474
+ )
475
+ assert "Email target" in source
476
+ assert "Phone target." in source
477
+
478
+ def test_shared_properties_are_listed_before_oneof_branches(self) -> None:
479
+ source = _render(
480
+ {
481
+ "payment": {
482
+ "properties": {"id": {"type": "string"}},
483
+ "required": ["id"],
484
+ "oneOf": [
485
+ {
486
+ "type": "object",
487
+ "properties": {
488
+ "type": {"type": "string", "enum": ["card"]},
489
+ "cardNumber": {"type": "string"},
490
+ },
491
+ },
492
+ {
493
+ "type": "object",
494
+ "properties": {
495
+ "type": {"type": "string", "enum": ["bank"]},
496
+ "iban": {"type": "string"},
497
+ },
498
+ },
499
+ ],
500
+ }
501
+ }
502
+ )
503
+ assert "id: str (required)" in source
504
+ assert "(type=card)" in source
505
+ assert "cardNumber: str" in source
506
+ assert "(type=bank)" in source
507
+ assert "iban: str" in source
508
+ id_at = source.index("id: str (required)")
509
+ card_at = source.index("(type=card)")
510
+ or_at = source.index("-- or --")
511
+ bank_at = source.index("(type=bank)")
512
+ assert id_at < card_at < or_at < bank_at
282
513
 
283
514
 
284
515
  class TestMetaDataVocabulary:
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.5.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes