adk-code-mode 1.3.0__tar.gz → 1.5.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.5.0/.release-please-manifest.json +3 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/CHANGELOG.md +19 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/PKG-INFO +11 -3
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/README.md +10 -2
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/pyproject.toml +7 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/__about__.py +1 -1
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/namespacing.py +20 -23
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/stubs.py +226 -17
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_catalog.py +12 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_namespacing.py +48 -2
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_stubs.py +133 -0
- adk_code_mode-1.5.0/tests/test_stubs_json_schema.py +530 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.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.5.0}/.githooks/pre-commit +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.githooks/pre-push +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.github/workflows/ci.yml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.github/workflows/release-please.yml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.gitignore +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.prettierrc +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.vscode/settings.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.zed/settings.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/LICENSE +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/Makefile +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/docker/Dockerfile +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/release-please-config.json +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/README.md +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/pyproject.toml +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/scripts/install-git-hooks.sh +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/_artifact_tools.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/metadata.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/output.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/py.typed +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/base.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/docker.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/remote.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tool.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/catalog.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/normaliser.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/workspace/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/workspace/files.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/__init__.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/_docker_helpers.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/_fake_runtime.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/conftest.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_agent_with_fake_llm.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_artifact_tools.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_dispatcher.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_docker_image.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_docker_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_host_loop.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_metadata.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_normaliser.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_output.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_protocol.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_remote_backend.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_remote_integration.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_runtime_docker.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_sandbox_entry.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_sandbox_rpc_client.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_tool.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_tool_result_artifacts.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_version.py +0 -0
- {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_workspace_files.py +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.0](https://github.com/a2anet/adk-code-mode/compare/adk-code-mode-v1.4.0...adk-code-mode-v1.5.0) (2026-08-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
|
|
10
|
+
## [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)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* surface nested schema properties and validation keywords in tool stubs ([608d14b](https://github.com/a2anet/adk-code-mode/commit/608d14bb115159b2991c7d8d4e3c641977fd0d66))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* 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))
|
|
21
|
+
|
|
3
22
|
## [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
23
|
|
|
5
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adk-code-mode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.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",
|
|
94
|
-
token="your-secret-token",
|
|
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"),
|
|
@@ -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",
|
|
67
|
-
token="your-secret-token",
|
|
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"),
|
|
@@ -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"]
|
|
@@ -26,7 +26,7 @@ class ToolSurfaceError(ValueError):
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class DuplicateToolNameError(ToolSurfaceError):
|
|
29
|
-
"""Raised when two
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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}
|
|
108
|
-
f"
|
|
109
|
-
f"{
|
|
110
|
-
"Rename one of the tools or apply distinct
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}")
|
|
@@ -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:
|
|
@@ -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")
|
|
@@ -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
|
|
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=
|
|
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")
|