adk-code-mode 1.4.0__tar.gz → 1.6.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 (78) hide show
  1. adk_code_mode-1.6.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/CHANGELOG.md +15 -0
  3. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/PKG-INFO +13 -37
  4. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/README.md +11 -35
  5. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  6. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/__about__.py +1 -1
  7. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tool.py +9 -2
  8. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/namespacing.py +20 -23
  9. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/stubs.py +173 -17
  10. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_catalog.py +12 -0
  11. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_integration.py +10 -1
  12. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_namespacing.py +48 -2
  13. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_stubs_json_schema.py +231 -0
  14. adk_code_mode-1.4.0/.release-please-manifest.json +0 -3
  15. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.githooks/pre-commit +0 -0
  16. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.githooks/pre-push +0 -0
  17. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.github/workflows/ci.yml +0 -0
  18. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.github/workflows/release-please.yml +0 -0
  19. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.gitignore +0 -0
  20. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.prettierrc +0 -0
  21. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.vscode/settings.json +0 -0
  22. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/.zed/settings.json +0 -0
  23. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/LICENSE +0 -0
  24. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/Makefile +0 -0
  25. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/docker/Dockerfile +0 -0
  26. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/pyproject.toml +0 -0
  27. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/release-please-config.json +0 -0
  28. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/README.md +0 -0
  29. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/pyproject.toml +0 -0
  30. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  31. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  32. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
  33. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +0 -0
  34. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  35. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
  36. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/scripts/install-git-hooks.sh +0 -0
  37. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/__init__.py +0 -0
  38. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/_artifact_tools.py +0 -0
  39. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/metadata.py +0 -0
  40. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/output.py +0 -0
  41. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/py.typed +0 -0
  42. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  43. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/runtime/base.py +0 -0
  44. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/runtime/docker.py +0 -0
  45. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/runtime/protocol.py +0 -0
  46. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/runtime/remote.py +0 -0
  47. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
  48. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/__init__.py +0 -0
  49. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/catalog.py +0 -0
  50. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
  51. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  52. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  53. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/src/adk_code_mode/workspace/files.py +0 -0
  54. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/__init__.py +0 -0
  55. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/_docker_helpers.py +0 -0
  56. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/_fake_runtime.py +0 -0
  57. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/conftest.py +0 -0
  58. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_agent_with_fake_llm.py +0 -0
  59. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_artifact_tools.py +0 -0
  60. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_dispatcher.py +0 -0
  61. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_docker_image.py +0 -0
  62. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_docker_integration.py +0 -0
  63. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_host_loop.py +0 -0
  64. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_metadata.py +0 -0
  65. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_normaliser.py +0 -0
  66. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_output.py +0 -0
  67. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_protocol.py +0 -0
  68. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_remote_backend.py +0 -0
  69. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_remote_integration.py +0 -0
  70. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_runtime_docker.py +0 -0
  71. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_sandbox_entry.py +0 -0
  72. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_sandbox_rpc_client.py +0 -0
  73. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_stubs.py +0 -0
  74. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_tool.py +0 -0
  75. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_tool_result_artifacts.py +0 -0
  76. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_version.py +0 -0
  77. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/tests/test_workspace_files.py +0 -0
  78. {adk_code_mode-1.4.0 → adk_code_mode-1.6.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.6.0"
3
+ }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [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)
4
+
5
+
6
+ ### Features
7
+
8
+ * bound an execute_code block by default ([1609c0d](https://github.com/a2anet/adk-code-mode/commit/1609c0d24b8563cba51434cd1214b4299aeefc86))
9
+ * 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))
10
+
11
+ ## [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)
12
+
13
+
14
+ ### Features
15
+
16
+ * scope tool-name uniqueness to each namespace ([#17](https://github.com/a2anet/adk-code-mode/issues/17)) ([437bc0d](https://github.com/a2anet/adk-code-mode/commit/437bc0dc930521a933e5c25b4bd6103e9434fc27))
17
+
3
18
  ## [1.4.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.3.0...adk-code-mode-v1.4.0) (2026-07-28)
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.4.0
3
+ Version: 1.6.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
@@ -85,13 +85,17 @@ Build an `ExecuteCodeTool`, give it the tools the sandboxed code may call, and a
85
85
 
86
86
  ```python
87
87
  from google.adk.agents import LlmAgent
88
+ from google.adk.features import FeatureName, override_feature_enabled
88
89
  from adk_code_mode import ExecuteCodeTool, RemoteBackend
89
90
 
91
+ # Required for docstrings to show full tool schemas
92
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
93
+
90
94
  tool = ExecuteCodeTool(
91
95
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
92
96
  backend=RemoteBackend(
93
- url="https://sandbox-xyz.run.app", # your deployed sandbox URL
94
- token="your-secret-token", # bearer token for auth
97
+ url="https://sandbox-xyz.run.app",
98
+ token="your-secret-token",
95
99
  ),
96
100
  )
97
101
 
@@ -112,8 +116,12 @@ root_agent = LlmAgent(
112
116
  > **`UnsafeLocalDockerBackend` is not safe for production or multi-tenant use.** See [Safety](#-safety).
113
117
 
114
118
  ```python
119
+ from google.adk.features import FeatureName, override_feature_enabled
115
120
  from adk_code_mode import ExecuteCodeTool, UnsafeLocalDockerBackend
116
121
 
122
+ # Required for docstrings to show full tool schemas
123
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
124
+
117
125
  tool = ExecuteCodeTool(
118
126
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
119
127
  backend=UnsafeLocalDockerBackend(image="ghcr.io/a2anet/adk-code-mode:latest"),
@@ -292,7 +300,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
292
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). |
293
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. |
294
302
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
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. |
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. |
296
304
  | `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
297
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`. |
298
306
 
@@ -377,38 +385,6 @@ Tags are indented; their content is not. The `<tools-package>` body is Python so
377
385
 
378
386
  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.
379
387
 
380
- #### Schema fidelity
381
-
382
- Stubs are rendered from each tool's declaration, so whatever the declaration drops can never reach the model. Nested object properties, their descriptions and JSON Schema's validation keywords all land in the docstring, because no Python type expresses them:
383
-
384
- ~~~python
385
- def create_minimum_spend_rule(*, space_id: int, conditions: dict[str, Any] | None = ..., label: str | None = ...) -> Any:
386
- """Creates a new minimum spend rule under the MinimumSpend policy.
387
-
388
- Args:
389
- space_id: The unique identifier of the venue space. (format=int32)
390
- conditions: Conditions that must all be satisfied for the rule to apply (AND logic).
391
- daysOfWeek: list[int] | None - Days of the week (1=Monday, 7=Sunday). (items: minimum=1, maximum=7)
392
- timeRange: dict[str, Any] | None - Time window of the booking start time.
393
- from: str (pattern=^([01][0-9]|2[0-3]):[0-5][0-9]$, example=18:00)
394
- to: str (example=23:59)
395
- label: (maxLength=255)
396
- """
397
- ~~~
398
-
399
- Every JSON Schema 2020-12 Validation and Meta-Data keyword is covered; `tests/test_stubs_json_schema.py` reads the keyword set from the meta-schemas and fails if one goes unclassified.
400
-
401
- > [!IMPORTANT]
402
- > **Enable ADK's `JSON_SCHEMA_FOR_FUNC_DECL` feature to get the full picture.** It is off by default, and without it ADK builds declarations through a Gemini `types.Schema`, which reduces an `allOf`-wrapped `$ref` — how OpenAPI 3.0 spells a nullable reference — to a bare `{"type": "OBJECT"}`. The referenced properties and description are gone before this package sees them, and the field renders as an empty `dict[str, Any]`.
403
- >
404
- > ```python
405
- > from google.adk.features import FeatureName, override_feature_enabled
406
- >
407
- > override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
408
- > ```
409
- >
410
- > Or set `ADK_ENABLE_JSON_SCHEMA_FOR_FUNC_DECL=1` in the environment. Do this before building any tool. It changes how declarations are built for every tool in the process, so if the same process also exposes tools to the model directly, check the model layer accepts `parameters_json_schema` — ADK's LiteLLM integration does, and `ExecuteCodeTool` declares itself that way regardless.
411
-
412
388
  #### Fitting the budget
413
389
 
414
390
  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.
@@ -58,13 +58,17 @@ Build an `ExecuteCodeTool`, give it the tools the sandboxed code may call, and a
58
58
 
59
59
  ```python
60
60
  from google.adk.agents import LlmAgent
61
+ from google.adk.features import FeatureName, override_feature_enabled
61
62
  from adk_code_mode import ExecuteCodeTool, RemoteBackend
62
63
 
64
+ # Required for docstrings to show full tool schemas
65
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
66
+
63
67
  tool = ExecuteCodeTool(
64
68
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
65
69
  backend=RemoteBackend(
66
- url="https://sandbox-xyz.run.app", # your deployed sandbox URL
67
- token="your-secret-token", # bearer token for auth
70
+ url="https://sandbox-xyz.run.app",
71
+ token="your-secret-token",
68
72
  ),
69
73
  )
70
74
 
@@ -85,8 +89,12 @@ root_agent = LlmAgent(
85
89
  > **`UnsafeLocalDockerBackend` is not safe for production or multi-tenant use.** See [Safety](#-safety).
86
90
 
87
91
  ```python
92
+ from google.adk.features import FeatureName, override_feature_enabled
88
93
  from adk_code_mode import ExecuteCodeTool, UnsafeLocalDockerBackend
89
94
 
95
+ # Required for docstrings to show full tool schemas
96
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
97
+
90
98
  tool = ExecuteCodeTool(
91
99
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
92
100
  backend=UnsafeLocalDockerBackend(image="ghcr.io/a2anet/adk-code-mode:latest"),
@@ -265,7 +273,7 @@ All settings are `ExecuteCodeTool` constructor arguments:
265
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). |
266
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. |
267
275
  | `max_code_chars` | `1_000_000` | Rejects oversized code payloads before starting a container. |
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. |
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. |
269
277
  | `per_tool_timeout_seconds` | `None` | Caps each individual tool call made from within the sandbox. |
270
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`. |
271
279
 
@@ -350,38 +358,6 @@ Tags are indented; their content is not. The `<tools-package>` body is Python so
350
358
 
351
359
  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.
352
360
 
353
- #### Schema fidelity
354
-
355
- Stubs are rendered from each tool's declaration, so whatever the declaration drops can never reach the model. Nested object properties, their descriptions and JSON Schema's validation keywords all land in the docstring, because no Python type expresses them:
356
-
357
- ~~~python
358
- def create_minimum_spend_rule(*, space_id: int, conditions: dict[str, Any] | None = ..., label: str | None = ...) -> Any:
359
- """Creates a new minimum spend rule under the MinimumSpend policy.
360
-
361
- Args:
362
- space_id: The unique identifier of the venue space. (format=int32)
363
- conditions: Conditions that must all be satisfied for the rule to apply (AND logic).
364
- daysOfWeek: list[int] | None - Days of the week (1=Monday, 7=Sunday). (items: minimum=1, maximum=7)
365
- timeRange: dict[str, Any] | None - Time window of the booking start time.
366
- from: str (pattern=^([01][0-9]|2[0-3]):[0-5][0-9]$, example=18:00)
367
- to: str (example=23:59)
368
- label: (maxLength=255)
369
- """
370
- ~~~
371
-
372
- Every JSON Schema 2020-12 Validation and Meta-Data keyword is covered; `tests/test_stubs_json_schema.py` reads the keyword set from the meta-schemas and fails if one goes unclassified.
373
-
374
- > [!IMPORTANT]
375
- > **Enable ADK's `JSON_SCHEMA_FOR_FUNC_DECL` feature to get the full picture.** It is off by default, and without it ADK builds declarations through a Gemini `types.Schema`, which reduces an `allOf`-wrapped `$ref` — how OpenAPI 3.0 spells a nullable reference — to a bare `{"type": "OBJECT"}`. The referenced properties and description are gone before this package sees them, and the field renders as an empty `dict[str, Any]`.
376
- >
377
- > ```python
378
- > from google.adk.features import FeatureName, override_feature_enabled
379
- >
380
- > override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
381
- > ```
382
- >
383
- > Or set `ADK_ENABLE_JSON_SCHEMA_FOR_FUNC_DECL=1` in the environment. Do this before building any tool. It changes how declarations are built for every tool in the process, so if the same process also exposes tools to the model directly, check the model layer accepts `parameters_json_schema` — ADK's LiteLLM integration does, and `ExecuteCodeTool` declares itself that way regardless.
384
-
385
361
  #### Fitting the budget
386
362
 
387
363
  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.
@@ -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.4.0" # x-release-please-version
4
+ __version__ = "1.6.0" # x-release-please-version
@@ -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.4.0" # x-release-please-version
4
+ __version__ = "1.6.0" # 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
@@ -26,7 +26,7 @@ class ToolSurfaceError(ValueError):
26
26
 
27
27
 
28
28
  class DuplicateToolNameError(ToolSurfaceError):
29
- """Raised when two resolved tools share the same raw ADK tool name."""
29
+ """Raised when two tools in the same namespace share a raw ADK tool name."""
30
30
 
31
31
 
32
32
  class NamespaceCollisionError(ToolSurfaceError):
@@ -91,23 +91,22 @@ def build(tools: list[ResolvedTool]) -> list[NamespacedTool]:
91
91
  """
92
92
  out: list[NamespacedTool] = []
93
93
  seen_attributes: set[tuple[str | None, str]] = set()
94
- seen_raw_names: dict[str, NamespacedTool] = {}
94
+ seen_raw_names: dict[tuple[str | None, str], NamespacedTool] = {}
95
95
  namespace_sources: dict[str, BaseToolset] = {}
96
96
  bare_attributes: dict[str, NamespacedTool] = {}
97
97
  for resolved in tools:
98
98
  namespace = _toolset_namespace(resolved.toolset) if resolved.toolset else None
99
- if resolved.tool.name in seen_raw_names:
100
- other = seen_raw_names[resolved.tool.name]
101
- candidate = (
102
- f"{namespace}.{_sanitise_identifier(resolved.tool.name, fallback='tool')}"
103
- if namespace
104
- else _sanitise_identifier(resolved.tool.name, fallback="tool")
105
- )
99
+ # Raw names only have to be unique *within* a namespace: dispatch goes
100
+ # through the dotted path, so two toolsets may each expose ``list_items``.
101
+ raw_key = (namespace, resolved.tool.name)
102
+ if raw_key in seen_raw_names:
103
+ other = seen_raw_names[raw_key]
106
104
  raise DuplicateToolNameError(
107
- f"Duplicate ADK tool name {resolved.tool.name!r} is not supported; this would "
108
- f"make host dispatch ambiguous between {other.dotted_path!r} (from "
109
- f"{_origin(other.resolved)}) and {candidate!r} (from {_origin(resolved)}). "
110
- "Rename one of the tools or apply distinct ``tool_name_prefix`` values."
105
+ f"Duplicate ADK tool name {resolved.tool.name!r} within namespace "
106
+ f"{namespace or 'tools'!r} is not supported; this would make host dispatch "
107
+ f"ambiguous for {other.dotted_path!r} (from {_origin(other.resolved)} and "
108
+ f"{_origin(resolved)}). Rename one of the tools or apply distinct "
109
+ "``tool_name_prefix`` values."
111
110
  )
112
111
  if namespace is not None and resolved.toolset is not None:
113
112
  existing_source = namespace_sources.get(namespace)
@@ -138,7 +137,7 @@ def build(tools: list[ResolvedTool]) -> list[NamespacedTool]:
138
137
  )
139
138
  seen_attributes.add(key)
140
139
  nt = NamespacedTool(resolved=resolved, namespace=namespace, attribute=attribute)
141
- seen_raw_names[resolved.tool.name] = nt
140
+ seen_raw_names[raw_key] = nt
142
141
  if namespace is None:
143
142
  bare_attributes[attribute] = nt
144
143
  out.append(nt)
@@ -167,15 +166,12 @@ class Registry:
167
166
 
168
167
  def __init__(self, tools: list[NamespacedTool]) -> None:
169
168
  self._by_path: dict[str, NamespacedTool] = {t.dotted_path: t for t in tools}
170
- by_tool_name: dict[str, NamespacedTool] = {}
169
+ # Generated stubs call ``_call(<dotted path>, ...)``, so this map is only a
170
+ # fallback for callers that know the raw ADK name. A name shared by two
171
+ # namespaces is ambiguous and simply doesn't resolve through it.
172
+ by_tool_name: dict[str, NamespacedTool | None] = {}
171
173
  for tool in tools:
172
- existing = by_tool_name.get(tool.tool_name)
173
- if existing is not None:
174
- raise DuplicateToolNameError(
175
- "Duplicate ADK tool name "
176
- f"{tool.tool_name!r} is not supported; use unique tool names."
177
- )
178
- by_tool_name[tool.tool_name] = tool
174
+ by_tool_name[tool.tool_name] = None if tool.tool_name in by_tool_name else tool
179
175
  self._by_tool_name = by_tool_name
180
176
  self._tools = list(tools)
181
177
 
@@ -187,10 +183,11 @@ class Registry:
187
183
  return self._by_path.get(path)
188
184
 
189
185
  def by_tool_name(self, tool_name: str) -> NamespacedTool | None:
186
+ """Resolve a raw ADK name, or ``None`` if unknown or shared by two namespaces."""
190
187
  return self._by_tool_name.get(tool_name)
191
188
 
192
189
  def resolve_call(self, name: str) -> NamespacedTool:
193
- """Accept either the dotted path or the original ``BaseTool.name``."""
190
+ """Accept either the dotted path or an unambiguous original ``BaseTool.name``."""
194
191
  hit = self._by_path.get(name) or self._by_tool_name.get(name)
195
192
  if hit is None:
196
193
  raise KeyError(f"no tool registered for {name!r}")
@@ -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:
@@ -83,6 +83,18 @@ def test_render_catalog_includes_top_level_tools_section() -> None:
83
83
  assert "def echo(*, message: str) -> Any:" in catalog
84
84
 
85
85
 
86
+ def test_render_catalog_keeps_a_shared_tool_name_in_both_namespaces() -> None:
87
+ bookings = _FakeToolset("bookings")
88
+ inventory = _FakeToolset("inventory")
89
+ a = _SchemaTool("list_items", description="List bookings.", schema={"type": "object"})
90
+ b = _SchemaTool("list_items", description="List inventory.", schema={"type": "object"})
91
+ catalog = render_catalog(_build([(a, bookings), (b, inventory)]))
92
+ assert "from tools.bookings import list_items" in catalog
93
+ assert "from tools.inventory import list_items" in catalog
94
+ assert '"""List bookings.' in catalog
95
+ assert '"""List inventory.' in catalog
96
+
97
+
86
98
  def test_render_catalog_groups_tools_by_namespace_and_sorts() -> None:
87
99
  slack = _FakeToolset("slack")
88
100
  gmail = _FakeToolset("gmail")
@@ -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()
@@ -54,15 +54,46 @@ def test_toolset_tools_share_namespace() -> None:
54
54
  assert ns[1].dotted_path == "slack.list_channels"
55
55
 
56
56
 
57
- def test_duplicate_raw_tool_names_raise() -> None:
57
+ def test_duplicate_raw_tool_names_in_one_namespace_raise() -> None:
58
+ slack = _NamedToolset("Slack")
59
+ tools = [
60
+ ResolvedTool(tool=_Tool("ping"), toolset=slack),
61
+ ResolvedTool(tool=_Tool("ping"), toolset=slack),
62
+ ]
63
+ with pytest.raises(namespacing.DuplicateToolNameError):
64
+ namespacing.build(tools)
65
+
66
+
67
+ def test_duplicate_bare_tool_names_raise() -> None:
58
68
  tools = [
59
69
  ResolvedTool(tool=_Tool("ping"), toolset=None),
60
- ResolvedTool(tool=_Tool("ping"), toolset=_NamedToolset("Slack")),
70
+ ResolvedTool(tool=_Tool("ping"), toolset=None),
61
71
  ]
62
72
  with pytest.raises(namespacing.DuplicateToolNameError):
63
73
  namespacing.build(tools)
64
74
 
65
75
 
76
+ def test_same_tool_name_in_two_namespaces_is_allowed() -> None:
77
+ tools = [
78
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Bookings")),
79
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Inventory")),
80
+ ]
81
+ ns = namespacing.build(tools)
82
+ assert [t.dotted_path for t in ns] == ["bookings.list_items", "inventory.list_items"]
83
+
84
+
85
+ def test_bare_tool_may_share_a_name_with_a_namespaced_tool() -> None:
86
+ tools = [
87
+ ResolvedTool(tool=_Tool("ping"), toolset=None),
88
+ ResolvedTool(tool=_Tool("ping"), toolset=_NamedToolset("Slack")),
89
+ ]
90
+ ns = namespacing.build(tools)
91
+ assert [t.dotted_path for t in ns] == ["ping", "slack.ping"]
92
+ reg = namespacing.Registry(ns)
93
+ assert reg.resolve_call("ping") is ns[0]
94
+ assert reg.resolve_call("slack.ping") is ns[1]
95
+
96
+
66
97
  def test_python_identifier_collisions_raise() -> None:
67
98
  tools = [
68
99
  ResolvedTool(tool=_Tool("ping"), toolset=None),
@@ -98,3 +129,18 @@ def test_registry_bidirectional_lookup() -> None:
98
129
  assert reg.resolve_call("send_message") is ns[0]
99
130
  with pytest.raises(KeyError):
100
131
  reg.resolve_call("nope")
132
+
133
+
134
+ def test_registry_dotted_paths_still_resolve_when_a_name_is_shared() -> None:
135
+ ns = namespacing.build(
136
+ [
137
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Bookings")),
138
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Inventory")),
139
+ ]
140
+ )
141
+ reg = namespacing.Registry(ns)
142
+ assert reg.resolve_call("bookings.list_items") is ns[0]
143
+ assert reg.resolve_call("inventory.list_items") is ns[1]
144
+ assert reg.by_tool_name("list_items") is None
145
+ with pytest.raises(KeyError):
146
+ reg.resolve_call("list_items")
@@ -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.4.0"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes