adk-code-mode 1.3.0__tar.gz → 1.4.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 (80) hide show
  1. adk_code_mode-1.4.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/CHANGELOG.md +12 -0
  3. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/PKG-INFO +33 -1
  4. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/README.md +32 -0
  5. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/pyproject.toml +7 -0
  6. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  7. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/__about__.py +1 -1
  8. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/stubs.py +226 -17
  9. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_stubs.py +133 -0
  10. adk_code_mode-1.4.0/tests/test_stubs_json_schema.py +530 -0
  11. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/uv.lock +2 -0
  12. adk_code_mode-1.3.0/.release-please-manifest.json +0 -3
  13. adk_code_mode-1.3.0/AGENTS.md +0 -15
  14. adk_code_mode-1.3.0/CLAUDE.md +0 -1
  15. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.githooks/pre-commit +0 -0
  16. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.githooks/pre-push +0 -0
  17. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.github/workflows/ci.yml +0 -0
  18. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.github/workflows/release-please.yml +0 -0
  19. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.gitignore +0 -0
  20. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.prettierrc +0 -0
  21. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.vscode/settings.json +0 -0
  22. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.zed/settings.json +0 -0
  23. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/LICENSE +0 -0
  24. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/Makefile +0 -0
  25. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/docker/Dockerfile +0 -0
  26. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/release-please-config.json +0 -0
  27. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/README.md +0 -0
  28. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/pyproject.toml +0 -0
  29. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  30. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  31. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
  32. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +0 -0
  33. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  34. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
  35. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/scripts/install-git-hooks.sh +0 -0
  36. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/__init__.py +0 -0
  37. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/_artifact_tools.py +0 -0
  38. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/metadata.py +0 -0
  39. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/output.py +0 -0
  40. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/py.typed +0 -0
  41. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  42. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/base.py +0 -0
  43. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/docker.py +0 -0
  44. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/protocol.py +0 -0
  45. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/remote.py +0 -0
  46. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tool.py +0 -0
  47. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
  48. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/__init__.py +0 -0
  49. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/catalog.py +0 -0
  50. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
  51. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/namespacing.py +0 -0
  52. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  53. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  54. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/workspace/files.py +0 -0
  55. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/__init__.py +0 -0
  56. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/_docker_helpers.py +0 -0
  57. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/_fake_runtime.py +0 -0
  58. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/conftest.py +0 -0
  59. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_agent_with_fake_llm.py +0 -0
  60. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_artifact_tools.py +0 -0
  61. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_catalog.py +0 -0
  62. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_dispatcher.py +0 -0
  63. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_docker_image.py +0 -0
  64. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_docker_integration.py +0 -0
  65. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_host_loop.py +0 -0
  66. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_integration.py +0 -0
  67. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_metadata.py +0 -0
  68. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_namespacing.py +0 -0
  69. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_normaliser.py +0 -0
  70. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_output.py +0 -0
  71. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_protocol.py +0 -0
  72. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_remote_backend.py +0 -0
  73. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_remote_integration.py +0 -0
  74. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_runtime_docker.py +0 -0
  75. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_sandbox_entry.py +0 -0
  76. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_sandbox_rpc_client.py +0 -0
  77. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_tool.py +0 -0
  78. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_tool_result_artifacts.py +0 -0
  79. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_version.py +0 -0
  80. {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_workspace_files.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.4.0"
3
+ }
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [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
+
5
+
6
+ ### Features
7
+
8
+ * surface nested schema properties and validation keywords in tool stubs ([608d14b](https://github.com/a2anet/adk-code-mode/commit/608d14bb115159b2991c7d8d4e3c641977fd0d66))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * document stub schema fidelity and the `JSON_SCHEMA_FOR_FUNC_DECL` flag in `README.md` ([219f02b](https://github.com/a2anet/adk-code-mode/commit/219f02bd493c2e2d7275a0034ef2a40db933859f))
14
+
3
15
  ## [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
16
 
5
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adk-code-mode
3
- Version: 1.3.0
3
+ Version: 1.4.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
@@ -377,6 +377,38 @@ Tags are indented; their content is not. The `<tools-package>` body is Python so
377
377
 
378
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.
379
379
 
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
+
380
412
  #### Fitting the budget
381
413
 
382
414
  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.
@@ -350,6 +350,38 @@ Tags are indented; their content is not. The `<tools-package>` body is Python so
350
350
 
351
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.
352
352
 
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
+
353
385
  #### Fitting the budget
354
386
 
355
387
  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.
@@ -35,6 +35,9 @@ docker = ["docker>=7.0.0"]
35
35
  [dependency-groups]
36
36
  dev = [
37
37
  "adk-code-mode[docker]",
38
+ # Ships the JSON Schema 2020-12 meta-schemas, which `test_stubs_json_schema`
39
+ # reads to enumerate the keyword set rather than hard-coding it.
40
+ "jsonschema-specifications>=2023.0.0",
38
41
  "mypy>=1.19.0",
39
42
  "pytest>=8.0.0",
40
43
  "pytest-asyncio>=0.23.0",
@@ -69,6 +72,10 @@ ignore_missing_imports = true
69
72
  module = ["websockets", "websockets.*"]
70
73
  ignore_missing_imports = true
71
74
 
75
+ [[tool.mypy.overrides]]
76
+ module = ["jsonschema_specifications", "jsonschema_specifications.*"]
77
+ ignore_missing_imports = true
78
+
72
79
  [tool.pytest.ini_options]
73
80
  testpaths = ["tests"]
74
81
  pythonpath = ["src", "sandbox-wheel/src"]
@@ -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.3.0" # x-release-please-version
4
+ __version__ = "1.4.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.3.0" # x-release-please-version
4
+ __version__ = "1.4.0" # x-release-please-version
@@ -32,6 +32,64 @@ _PRIMITIVES = {
32
32
  "null": "None",
33
33
  }
34
34
 
35
+ # Gemini `types.Schema` renames JSON Schema's keywords; map them back. A value of
36
+ # `None` drops the key (it carries nothing a caller can act on).
37
+ _KEY_ALIASES: dict[str, str | None] = {
38
+ "max_length": "maxLength",
39
+ "min_length": "minLength",
40
+ "max_items": "maxItems",
41
+ "min_items": "minItems",
42
+ "max_properties": "maxProperties",
43
+ "min_properties": "minProperties",
44
+ "any_of": "anyOf",
45
+ "additional_properties": "additionalProperties",
46
+ "property_ordering": None,
47
+ }
48
+
49
+ # Validation and annotation keywords worth showing a caller, in the order they
50
+ # read best. These never reach the signature — no Python type expresses
51
+ # `maxLength` — so the docstring is their only route to the model.
52
+ #
53
+ # Deliberately not surfaced: `not`, `if`/`then`/`else`, `dependentSchemas`,
54
+ # `propertyNames`, `unevaluated*` and `contains` (as a schema). They express
55
+ # conditional structure that reads as noise in a one-line summary, and none of
56
+ # them appear in the OpenAPI documents this renders in practice. `$schema`,
57
+ # `$id`, `$anchor`, `$comment` and `$vocabulary` describe the document rather
58
+ # than the value.
59
+ _CONSTRAINTS = (
60
+ "format",
61
+ "pattern",
62
+ "minLength",
63
+ "maxLength",
64
+ "minimum",
65
+ "maximum",
66
+ "exclusiveMinimum",
67
+ "exclusiveMaximum",
68
+ "multipleOf",
69
+ "minItems",
70
+ "maxItems",
71
+ "uniqueItems",
72
+ "minContains",
73
+ "maxContains",
74
+ "minProperties",
75
+ "maxProperties",
76
+ "contentEncoding",
77
+ "contentMediaType",
78
+ "const",
79
+ "example",
80
+ )
81
+
82
+ # Boolean annotations that change whether a caller should send a value at all.
83
+ _FLAGS = (
84
+ ("deprecated", "deprecated"),
85
+ ("readOnly", "read-only"),
86
+ ("writeOnly", "write-only"),
87
+ )
88
+
89
+ # Guards against self-referential schemas, which recurse forever otherwise. Not a
90
+ # size budget: the agent can always read the full stub with `help()`.
91
+ _MAX_SCHEMA_DEPTH = 8
92
+
35
93
  Target = Literal["stub", "catalog"]
36
94
 
37
95
 
@@ -59,12 +117,65 @@ def _schema_dict_from_declaration(declaration: Any) -> dict[str, Any]:
59
117
  return {}
60
118
 
61
119
 
120
+ def _effective_schema(schema: Any) -> dict[str, Any]:
121
+ """Normalise a schema fragment to plain JSON Schema and flatten ``allOf``.
122
+
123
+ Two shapes reach us. With ADK's ``JSON_SCHEMA_FOR_FUNC_DECL`` feature on,
124
+ ``parameters_json_schema`` carries untouched JSON Schema. With it off (the
125
+ default today) ADK converts through a Gemini ``types.Schema``, which
126
+ upper-cases ``type`` and snake-cases the validation keywords — and drops the
127
+ body of an ``allOf``-wrapped ``$ref`` entirely, which no amount of work here
128
+ can recover.
129
+
130
+ Merging ``allOf`` matters because ``allOf: [$ref] + nullable: true`` is how
131
+ OpenAPI 3.0 spells "nullable reference": without the merge the wrapper looks
132
+ like an empty schema and the target's description and properties are lost.
133
+ """
134
+ if not isinstance(schema, dict):
135
+ return {}
136
+
137
+ normalised: dict[str, Any] = {}
138
+ for key, value in schema.items():
139
+ canonical = _KEY_ALIASES.get(key, key)
140
+ if canonical is None or value is None:
141
+ continue
142
+ normalised.setdefault(canonical, value)
143
+
144
+ ty = normalised.get("type")
145
+ if isinstance(ty, str):
146
+ normalised["type"] = ty.lower()
147
+ elif isinstance(ty, list):
148
+ normalised["type"] = [t.lower() if isinstance(t, str) else t for t in ty]
149
+
150
+ variants = normalised.pop("allOf", None)
151
+ if isinstance(variants, list):
152
+ merged: dict[str, Any] = {}
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
158
+
159
+ # A one-variant union expresses no choice, so fold it in rather than lose
160
+ # the description and properties hanging off the single branch.
161
+ for key in ("anyOf", "oneOf"):
162
+ branches = normalised.get(key)
163
+ if isinstance(branches, list) and len(branches) == 1:
164
+ folded = _effective_schema(branches[0])
165
+ normalised.pop(key)
166
+ folded.update(normalised)
167
+ normalised = folded
168
+
169
+ return normalised
170
+
171
+
62
172
  def _schema_to_type(schema: Any) -> str:
63
173
  """Convert a JSON-Schema fragment to a Python type expression (PEP 604).
64
174
 
65
175
  Best-effort. Anything we can't resolve cleanly falls back to ``Any``.
66
176
  """
67
- if not isinstance(schema, dict):
177
+ schema = _effective_schema(schema)
178
+ if not schema:
68
179
  return "Any"
69
180
 
70
181
  # Compositions.
@@ -77,14 +188,6 @@ def _schema_to_type(schema: Any) -> str:
77
188
  if len(parts) == 1:
78
189
  return parts[0]
79
190
  return " | ".join(parts)
80
- if "allOf" in schema:
81
- merged: dict[str, Any] = {}
82
- for part in schema["allOf"]:
83
- if isinstance(part, dict):
84
- merged.update(part)
85
- if merged:
86
- return _schema_to_type(merged)
87
- return "Any"
88
191
 
89
192
  if "enum" in schema and schema["enum"]:
90
193
  lits: list[str] = []
@@ -128,7 +231,14 @@ def _schema_to_type(schema: Any) -> str:
128
231
  else:
129
232
  result = "list[Any]"
130
233
  elif ty == "object":
131
- result = "dict[str, Any]"
234
+ # An open map (`additionalProperties` as a schema, no fixed properties)
235
+ # types precisely; anything with declared keys stays `dict[str, Any]`
236
+ # and gets its shape from the docstring instead.
237
+ extra = schema.get("additionalProperties")
238
+ if isinstance(extra, dict) and not schema.get("properties"):
239
+ result = f"dict[str, {_schema_to_type(extra)}]"
240
+ else:
241
+ result = "dict[str, Any]"
132
242
  elif isinstance(ty, str) and ty in _PRIMITIVES:
133
243
  result = _PRIMITIVES[ty]
134
244
  else:
@@ -149,21 +259,120 @@ def _python_default(schema: dict[str, Any]) -> str | None:
149
259
  return None
150
260
 
151
261
 
152
- def _format_docstring(*, description: str, param_docs: list[tuple[str, str, str | None]]) -> str:
262
+ def _format_scalar(value: Any) -> str:
263
+ """Render a schema value compactly. Gemini widens integers to floats."""
264
+ if isinstance(value, bool):
265
+ return "true" if value else "false"
266
+ if isinstance(value, float) and value.is_integer():
267
+ return str(int(value))
268
+ if isinstance(value, str):
269
+ return value
270
+ return str(value)
271
+
272
+
273
+ def _constraint_suffix(schema: dict[str, Any], *, include_default: bool = False) -> str:
274
+ """Render a schema's validation keywords as ``(minimum=1, maximum=7)``.
275
+
276
+ Array constraints living on ``items`` are folded in under an ``items:``
277
+ prefix, so ``daysOfWeek`` shows its 1-7 bound rather than hiding it a level
278
+ down where the type expression has already collapsed to ``list[int]``.
279
+ """
280
+ parts = [f"{key}={_format_scalar(schema[key])}" for key in _CONSTRAINTS if key in schema]
281
+ examples = schema.get("examples")
282
+ if isinstance(examples, list) and examples:
283
+ parts.append("examples=" + ", ".join(_format_scalar(v) for v in examples[:2]))
284
+ if include_default and "default" in schema:
285
+ parts.append(f"default={_format_scalar(schema['default'])}")
286
+ parts.extend(label for key, label in _FLAGS if schema.get(key) is True)
287
+ if schema.get("additionalProperties") is False:
288
+ parts.append("no other keys")
289
+ dependent = schema.get("dependentRequired")
290
+ if isinstance(dependent, dict):
291
+ parts.extend(
292
+ f"{name} requires {', '.join(needed)}" for name, needed in dependent.items() if needed
293
+ )
294
+ pattern_properties = schema.get("patternProperties")
295
+ if isinstance(pattern_properties, dict) and pattern_properties:
296
+ parts.append("key patterns: " + ", ".join(pattern_properties))
297
+
298
+ items = schema.get("items")
299
+ if isinstance(items, dict):
300
+ item_schema = _effective_schema(items)
301
+ item_parts = [
302
+ f"{key}={_format_scalar(item_schema[key])}"
303
+ for key in _CONSTRAINTS
304
+ if key in item_schema
305
+ ]
306
+ if item_parts:
307
+ parts.append("items: " + ", ".join(item_parts))
308
+
309
+ return f" ({', '.join(parts)})" if parts else ""
310
+
311
+
312
+ def _prose(schema: dict[str, Any]) -> str:
313
+ """The schema's human text: ``description``, else ``title``.
314
+
315
+ Plenty of real schemas carry only a ``title``; dropping it would leave the
316
+ field with a type and nothing else.
317
+ """
318
+ for key in ("description", "title"):
319
+ text = str(schema.get(key, "")).strip()
320
+ if text:
321
+ return text
322
+ return ""
323
+
324
+
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.
327
+
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.
330
+ """
331
+ if depth >= _MAX_SCHEMA_DEPTH:
332
+ return []
333
+
334
+ properties = schema.get("properties")
335
+ 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
+ return []
340
+
341
+ required = set(schema.get("required") or [])
342
+ lines: list[str] = []
343
+ for name, raw in properties.items():
344
+ field = _effective_schema(raw)
345
+ head = f"{indent}{name}: {_schema_to_type(field)}"
346
+ if name in required:
347
+ head += " (required)"
348
+ description = _prose(field).splitlines()
349
+ if description:
350
+ head += f" - {description[0].strip()}"
351
+ lines.append(head + _constraint_suffix(field, include_default=True))
352
+ for extra in description[1:]:
353
+ lines.append(f"{indent} {extra.strip()}")
354
+ lines.extend(_describe_fields(field, indent=indent + " ", depth=depth + 1))
355
+ return lines
356
+
357
+
358
+ def _format_docstring(
359
+ *, description: str, param_docs: list[tuple[str, dict[str, Any], str | None]]
360
+ ) -> str:
153
361
  lines: list[str] = []
154
362
  summary = (description or "").strip() or "Call the tool."
155
363
  lines.extend(textwrap.wrap(summary, width=88) or [summary])
156
364
  if param_docs:
157
365
  lines.append("")
158
366
  lines.append("Args:")
159
- for name, desc, default_repr in param_docs:
160
- desc = (desc or "").strip()
367
+ for name, schema, default_repr in param_docs:
368
+ desc = _prose(schema)
161
369
  first, *rest = (desc or "").splitlines() or [""]
162
370
  suffix = f" (default: {default_repr})" if default_repr is not None else ""
163
- head = first + suffix
371
+ head = (first + _constraint_suffix(schema) + suffix).strip()
164
372
  lines.append(f" {name}: {head}" if head else f" {name}:")
165
373
  for extra in rest:
166
374
  lines.append(f" {extra}")
375
+ lines.extend(_describe_fields(schema, indent=" "))
167
376
  body = "\n ".join(lines)
168
377
  return f'"""{body}\n """'
169
378
 
@@ -279,7 +488,7 @@ def render_tool(nt: NamespacedTool) -> RenderedTool:
279
488
  ordered = sorted(props.items(), key=lambda kv: (kv[0] not in required, kv[0]))
280
489
 
281
490
  params: list[RenderedParam] = []
282
- param_docs: list[tuple[str, str, str | None]] = []
491
+ param_docs: list[tuple[str, dict[str, Any], str | None]] = []
283
492
 
284
493
  seen_param_names: dict[str, str] = {}
285
494
 
@@ -293,7 +502,7 @@ def render_tool(nt: NamespacedTool) -> RenderedTool:
293
502
  "of the tool parameters so the generated stub signature is unambiguous."
294
503
  )
295
504
  seen_param_names[py_name] = raw_name
296
- subschema = subschema if isinstance(subschema, dict) else {}
505
+ subschema = _effective_schema(subschema)
297
506
  type_expr = _schema_to_type(subschema)
298
507
  default = _python_default(subschema)
299
508
  is_required = raw_name in required
@@ -307,7 +516,7 @@ def render_tool(nt: NamespacedTool) -> RenderedTool:
307
516
  )
308
517
  )
309
518
  doc_default = default if (not is_required and default is not None) else None
310
- param_docs.append((py_name, str(subschema.get("description", "")), doc_default))
519
+ param_docs.append((py_name, subschema, doc_default))
311
520
 
312
521
  description = ""
313
522
  if declaration is not None and declaration.description:
@@ -252,3 +252,136 @@ def test_rendered_tool_catalog_signature_uses_ellipsis() -> None:
252
252
  sig = rt.signature_for("catalog")
253
253
  assert "include_archived: bool | None = ..." in sig
254
254
  assert "_MISSING" not in sig
255
+
256
+
257
+ def test_render_tool_expands_nested_object_properties() -> None:
258
+ """An object parameter can only ever be typed ``dict[str, Any]``, so its keys
259
+ reach the model through the docstring or not at all."""
260
+ tool = _SchemaTool(
261
+ "create_rule",
262
+ description="Create a rule.",
263
+ schema={
264
+ "type": "object",
265
+ "properties": {
266
+ "conditions": {
267
+ "type": "object",
268
+ "description": "Conditions that must all be satisfied (AND logic).",
269
+ "properties": {
270
+ "daysOfWeek": {
271
+ "type": "array",
272
+ "description": "Days of the week (1=Monday, 7=Sunday).",
273
+ "items": {"type": "integer", "minimum": 1, "maximum": 7},
274
+ },
275
+ "timeRange": {
276
+ "type": "object",
277
+ "properties": {
278
+ "from": {"type": "string", "example": "18:00"},
279
+ "to": {"type": "string"},
280
+ },
281
+ },
282
+ },
283
+ }
284
+ },
285
+ },
286
+ )
287
+ ns = _build([(tool, None)])
288
+ source = _stub_source(ns[0])
289
+ assert "conditions: dict[str, Any] | None" in source
290
+ assert "daysOfWeek: list[int] - Days of the week (1=Monday, 7=Sunday)." in source
291
+ assert "items: minimum=1, maximum=7" in source
292
+ assert "timeRange: dict[str, Any]" in source
293
+ assert "from: str (example=18:00)" in source
294
+ assert "to: str" in source
295
+
296
+
297
+ def test_render_tool_documents_validation_keywords() -> None:
298
+ """No Python type expresses ``maxLength``/``pattern``; the docstring is the
299
+ only route by which they reach the model."""
300
+ tool = _SchemaTool(
301
+ "save",
302
+ description="Save.",
303
+ schema={
304
+ "type": "object",
305
+ "properties": {
306
+ "label": {"type": "string", "maxLength": 255},
307
+ "code": {"type": "string", "pattern": "^[A-Z]{3}$"},
308
+ "ratio": {"type": "number", "minimum": 0, "maximum": 1},
309
+ "tags": {"type": "array", "minItems": 1, "items": {"type": "string"}},
310
+ },
311
+ },
312
+ )
313
+ ns = _build([(tool, None)])
314
+ source = _stub_source(ns[0])
315
+ assert "label: (maxLength=255)" in source
316
+ assert "code: (pattern=^[A-Z]{3}$)" in source
317
+ assert "ratio: (minimum=0, maximum=1)" in source
318
+ assert "tags: (minItems=1)" in source
319
+
320
+
321
+ def test_render_tool_merges_allof_wrapped_ref() -> None:
322
+ """``allOf: [$ref] + nullable`` is how OpenAPI 3.0 spells a nullable
323
+ reference; without merging, the target's description and keys are lost."""
324
+ tool = _SchemaTool(
325
+ "book",
326
+ description="Book.",
327
+ schema={
328
+ "type": "object",
329
+ "properties": {
330
+ "capacity": {
331
+ "allOf": [
332
+ {
333
+ "type": "object",
334
+ "description": "Min/max capacity range.",
335
+ "properties": {
336
+ "min": {"type": "integer"},
337
+ "max": {"type": "integer"},
338
+ },
339
+ }
340
+ ],
341
+ "nullable": True,
342
+ }
343
+ },
344
+ },
345
+ )
346
+ ns = _build([(tool, None)])
347
+ source = _stub_source(ns[0])
348
+ assert "capacity: Min/max capacity range." in source
349
+ assert "min: int" in source
350
+ assert "max: int" in source
351
+
352
+
353
+ def test_render_tool_normalises_gemini_schema_casing() -> None:
354
+ """With ADK's ``JSON_SCHEMA_FOR_FUNC_DECL`` off, schemas arrive via a Gemini
355
+ ``types.Schema``: upper-cased types and snake_cased keywords."""
356
+ tool = _SchemaTool(
357
+ "gemini_shaped",
358
+ description="Gemini shaped.",
359
+ schema={
360
+ "type": "OBJECT",
361
+ "properties": {
362
+ "label": {"type": "STRING", "max_length": 255},
363
+ "nested": {
364
+ "type": "OBJECT",
365
+ "properties": {"count": {"type": "INTEGER", "minimum": 1.0}},
366
+ },
367
+ },
368
+ },
369
+ )
370
+ ns = _build([(tool, None)])
371
+ source = _stub_source(ns[0])
372
+ assert "label: str | None" in source
373
+ assert "maxLength=255" in source
374
+ assert "count: int (minimum=1)" in source
375
+
376
+
377
+ def test_render_tool_stops_recursing_on_self_referential_schema() -> None:
378
+ """A schema that contains itself must not recurse forever."""
379
+ node: dict[str, object] = {"type": "object", "description": "A tree node."}
380
+ node["properties"] = {"child": node}
381
+ tool = _SchemaTool(
382
+ "walk", description="Walk.", schema={"type": "object", "properties": {"root": node}}
383
+ )
384
+ ns = _build([(tool, None)])
385
+ source = _stub_source(ns[0])
386
+ assert "root: A tree node." in source
387
+ assert source.count("child: dict[str, Any]") == stubs._MAX_SCHEMA_DEPTH