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.
- adk_code_mode-1.4.0/.release-please-manifest.json +3 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/CHANGELOG.md +12 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/PKG-INFO +33 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/README.md +32 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/pyproject.toml +7 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/__about__.py +1 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/stubs.py +226 -17
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_stubs.py +133 -0
- adk_code_mode-1.4.0/tests/test_stubs_json_schema.py +530 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/uv.lock +2 -0
- adk_code_mode-1.3.0/.release-please-manifest.json +0 -3
- adk_code_mode-1.3.0/AGENTS.md +0 -15
- adk_code_mode-1.3.0/CLAUDE.md +0 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.githooks/pre-commit +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.githooks/pre-push +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.github/workflows/ci.yml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.github/workflows/release-please.yml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.gitignore +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.prettierrc +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.vscode/settings.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/.zed/settings.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/LICENSE +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/Makefile +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/docker/Dockerfile +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/release-please-config.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/README.md +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/pyproject.toml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/scripts/install-git-hooks.sh +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/_artifact_tools.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/metadata.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/output.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/py.typed +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/base.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/docker.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/runtime/remote.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tool.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/catalog.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/namespacing.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/tools/normaliser.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/workspace/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/src/adk_code_mode/workspace/files.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/_docker_helpers.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/_fake_runtime.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/conftest.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_agent_with_fake_llm.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_artifact_tools.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_catalog.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_dispatcher.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_docker_image.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_docker_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_host_loop.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_metadata.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_namespacing.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_normaliser.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_output.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_remote_backend.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_remote_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_runtime_docker.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_sandbox_entry.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_sandbox_rpc_client.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_tool.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_tool_result_artifacts.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_version.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.4.0}/tests/test_workspace_files.py +0 -0
|
@@ -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
|
+
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"]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
160
|
-
desc = (
|
|
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 =
|
|
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,
|
|
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
|