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.
Files changed (80) hide show
  1. adk_code_mode-1.5.0/.release-please-manifest.json +3 -0
  2. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/CHANGELOG.md +19 -0
  3. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/PKG-INFO +11 -3
  4. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/README.md +10 -2
  5. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/pyproject.toml +7 -0
  6. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__about__.py +1 -1
  7. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/__about__.py +1 -1
  8. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/namespacing.py +20 -23
  9. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/stubs.py +226 -17
  10. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_catalog.py +12 -0
  11. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_namespacing.py +48 -2
  12. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_stubs.py +133 -0
  13. adk_code_mode-1.5.0/tests/test_stubs_json_schema.py +530 -0
  14. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/uv.lock +2 -0
  15. adk_code_mode-1.3.0/.release-please-manifest.json +0 -3
  16. adk_code_mode-1.3.0/AGENTS.md +0 -15
  17. adk_code_mode-1.3.0/CLAUDE.md +0 -1
  18. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.githooks/pre-commit +0 -0
  19. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.githooks/pre-push +0 -0
  20. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.github/workflows/ci.yml +0 -0
  21. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.github/workflows/release-please.yml +0 -0
  22. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.gitignore +0 -0
  23. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.prettierrc +0 -0
  24. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.vscode/settings.json +0 -0
  25. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/.zed/settings.json +0 -0
  26. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/LICENSE +0 -0
  27. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/Makefile +0 -0
  28. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/docker/Dockerfile +0 -0
  29. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/release-please-config.json +0 -0
  30. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/README.md +0 -0
  31. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/pyproject.toml +0 -0
  32. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__init__.py +0 -0
  33. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/__main__.py +0 -0
  34. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_entry.py +0 -0
  35. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_http_server.py +0 -0
  36. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/_rpc_client.py +0 -0
  37. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/sandbox-wheel/src/adk_code_mode_sandbox/protocol.py +0 -0
  38. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/scripts/install-git-hooks.sh +0 -0
  39. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/__init__.py +0 -0
  40. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/_artifact_tools.py +0 -0
  41. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/metadata.py +0 -0
  42. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/output.py +0 -0
  43. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/py.typed +0 -0
  44. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/__init__.py +0 -0
  45. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/base.py +0 -0
  46. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/docker.py +0 -0
  47. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/protocol.py +0 -0
  48. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/runtime/remote.py +0 -0
  49. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tool.py +0 -0
  50. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tool_result_artifacts.py +0 -0
  51. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/__init__.py +0 -0
  52. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/catalog.py +0 -0
  53. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/dispatcher.py +0 -0
  54. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/tools/normaliser.py +0 -0
  55. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/workspace/__init__.py +0 -0
  56. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/src/adk_code_mode/workspace/files.py +0 -0
  57. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/__init__.py +0 -0
  58. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/_docker_helpers.py +0 -0
  59. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/_fake_runtime.py +0 -0
  60. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/conftest.py +0 -0
  61. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_agent_with_fake_llm.py +0 -0
  62. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_artifact_tools.py +0 -0
  63. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_dispatcher.py +0 -0
  64. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_docker_image.py +0 -0
  65. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_docker_integration.py +0 -0
  66. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_host_loop.py +0 -0
  67. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_integration.py +0 -0
  68. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_metadata.py +0 -0
  69. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_normaliser.py +0 -0
  70. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_output.py +0 -0
  71. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_protocol.py +0 -0
  72. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_remote_backend.py +0 -0
  73. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_remote_integration.py +0 -0
  74. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_runtime_docker.py +0 -0
  75. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_sandbox_entry.py +0 -0
  76. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_sandbox_rpc_client.py +0 -0
  77. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_tool.py +0 -0
  78. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_tool_result_artifacts.py +0 -0
  79. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_version.py +0 -0
  80. {adk_code_mode-1.3.0 → adk_code_mode-1.5.0}/tests/test_workspace_files.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.5.0"
3
+ }
@@ -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.0
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", # your deployed sandbox URL
94
- token="your-secret-token", # bearer token for auth
97
+ url="https://sandbox-xyz.run.app",
98
+ token="your-secret-token",
95
99
  ),
96
100
  )
97
101
 
@@ -112,8 +116,12 @@ root_agent = LlmAgent(
112
116
  > **`UnsafeLocalDockerBackend` is not safe for production or multi-tenant use.** See [Safety](#-safety).
113
117
 
114
118
  ```python
119
+ from google.adk.features import FeatureName, override_feature_enabled
115
120
  from adk_code_mode import ExecuteCodeTool, UnsafeLocalDockerBackend
116
121
 
122
+ # Required for docstrings to show full tool schemas
123
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
124
+
117
125
  tool = ExecuteCodeTool(
118
126
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
119
127
  backend=UnsafeLocalDockerBackend(image="ghcr.io/a2anet/adk-code-mode:latest"),
@@ -58,13 +58,17 @@ Build an `ExecuteCodeTool`, give it the tools the sandboxed code may call, and a
58
58
 
59
59
  ```python
60
60
  from google.adk.agents import LlmAgent
61
+ from google.adk.features import FeatureName, override_feature_enabled
61
62
  from adk_code_mode import ExecuteCodeTool, RemoteBackend
62
63
 
64
+ # Required for docstrings to show full tool schemas
65
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
66
+
63
67
  tool = ExecuteCodeTool(
64
68
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
65
69
  backend=RemoteBackend(
66
- url="https://sandbox-xyz.run.app", # your deployed sandbox URL
67
- token="your-secret-token", # bearer token for auth
70
+ url="https://sandbox-xyz.run.app",
71
+ token="your-secret-token",
68
72
  ),
69
73
  )
70
74
 
@@ -85,8 +89,12 @@ root_agent = LlmAgent(
85
89
  > **`UnsafeLocalDockerBackend` is not safe for production or multi-tenant use.** See [Safety](#-safety).
86
90
 
87
91
  ```python
92
+ from google.adk.features import FeatureName, override_feature_enabled
88
93
  from adk_code_mode import ExecuteCodeTool, UnsafeLocalDockerBackend
89
94
 
95
+ # Required for docstrings to show full tool schemas
96
+ override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)
97
+
90
98
  tool = ExecuteCodeTool(
91
99
  tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
92
100
  backend=UnsafeLocalDockerBackend(image="ghcr.io/a2anet/adk-code-mode:latest"),
@@ -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.5.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.5.0" # x-release-please-version
@@ -26,7 +26,7 @@ class ToolSurfaceError(ValueError):
26
26
 
27
27
 
28
28
  class DuplicateToolNameError(ToolSurfaceError):
29
- """Raised when two resolved tools share the same raw ADK tool name."""
29
+ """Raised when two tools in the same namespace share a raw ADK tool name."""
30
30
 
31
31
 
32
32
  class NamespaceCollisionError(ToolSurfaceError):
@@ -91,23 +91,22 @@ def build(tools: list[ResolvedTool]) -> list[NamespacedTool]:
91
91
  """
92
92
  out: list[NamespacedTool] = []
93
93
  seen_attributes: set[tuple[str | None, str]] = set()
94
- seen_raw_names: dict[str, NamespacedTool] = {}
94
+ seen_raw_names: dict[tuple[str | None, str], NamespacedTool] = {}
95
95
  namespace_sources: dict[str, BaseToolset] = {}
96
96
  bare_attributes: dict[str, NamespacedTool] = {}
97
97
  for resolved in tools:
98
98
  namespace = _toolset_namespace(resolved.toolset) if resolved.toolset else None
99
- if resolved.tool.name in seen_raw_names:
100
- other = seen_raw_names[resolved.tool.name]
101
- candidate = (
102
- f"{namespace}.{_sanitise_identifier(resolved.tool.name, fallback='tool')}"
103
- if namespace
104
- else _sanitise_identifier(resolved.tool.name, fallback="tool")
105
- )
99
+ # Raw names only have to be unique *within* a namespace: dispatch goes
100
+ # through the dotted path, so two toolsets may each expose ``list_items``.
101
+ raw_key = (namespace, resolved.tool.name)
102
+ if raw_key in seen_raw_names:
103
+ other = seen_raw_names[raw_key]
106
104
  raise DuplicateToolNameError(
107
- f"Duplicate ADK tool name {resolved.tool.name!r} is not supported; this would "
108
- f"make host dispatch ambiguous between {other.dotted_path!r} (from "
109
- f"{_origin(other.resolved)}) and {candidate!r} (from {_origin(resolved)}). "
110
- "Rename one of the tools or apply distinct ``tool_name_prefix`` values."
105
+ f"Duplicate ADK tool name {resolved.tool.name!r} within namespace "
106
+ f"{namespace or 'tools'!r} is not supported; this would make host dispatch "
107
+ f"ambiguous for {other.dotted_path!r} (from {_origin(other.resolved)} and "
108
+ f"{_origin(resolved)}). Rename one of the tools or apply distinct "
109
+ "``tool_name_prefix`` values."
111
110
  )
112
111
  if namespace is not None and resolved.toolset is not None:
113
112
  existing_source = namespace_sources.get(namespace)
@@ -138,7 +137,7 @@ def build(tools: list[ResolvedTool]) -> list[NamespacedTool]:
138
137
  )
139
138
  seen_attributes.add(key)
140
139
  nt = NamespacedTool(resolved=resolved, namespace=namespace, attribute=attribute)
141
- seen_raw_names[resolved.tool.name] = nt
140
+ seen_raw_names[raw_key] = nt
142
141
  if namespace is None:
143
142
  bare_attributes[attribute] = nt
144
143
  out.append(nt)
@@ -167,15 +166,12 @@ class Registry:
167
166
 
168
167
  def __init__(self, tools: list[NamespacedTool]) -> None:
169
168
  self._by_path: dict[str, NamespacedTool] = {t.dotted_path: t for t in tools}
170
- by_tool_name: dict[str, NamespacedTool] = {}
169
+ # Generated stubs call ``_call(<dotted path>, ...)``, so this map is only a
170
+ # fallback for callers that know the raw ADK name. A name shared by two
171
+ # namespaces is ambiguous and simply doesn't resolve through it.
172
+ by_tool_name: dict[str, NamespacedTool | None] = {}
171
173
  for tool in tools:
172
- existing = by_tool_name.get(tool.tool_name)
173
- if existing is not None:
174
- raise DuplicateToolNameError(
175
- "Duplicate ADK tool name "
176
- f"{tool.tool_name!r} is not supported; use unique tool names."
177
- )
178
- by_tool_name[tool.tool_name] = tool
174
+ by_tool_name[tool.tool_name] = None if tool.tool_name in by_tool_name else tool
179
175
  self._by_tool_name = by_tool_name
180
176
  self._tools = list(tools)
181
177
 
@@ -187,10 +183,11 @@ class Registry:
187
183
  return self._by_path.get(path)
188
184
 
189
185
  def by_tool_name(self, tool_name: str) -> NamespacedTool | None:
186
+ """Resolve a raw ADK name, or ``None`` if unknown or shared by two namespaces."""
190
187
  return self._by_tool_name.get(tool_name)
191
188
 
192
189
  def resolve_call(self, name: str) -> NamespacedTool:
193
- """Accept either the dotted path or the original ``BaseTool.name``."""
190
+ """Accept either the dotted path or an unambiguous original ``BaseTool.name``."""
194
191
  hit = self._by_path.get(name) or self._by_tool_name.get(name)
195
192
  if hit is None:
196
193
  raise KeyError(f"no tool registered for {name!r}")
@@ -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:
@@ -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 test_duplicate_raw_tool_names_raise() -> None:
57
+ def test_duplicate_raw_tool_names_in_one_namespace_raise() -> None:
58
+ slack = _NamedToolset("Slack")
59
+ tools = [
60
+ ResolvedTool(tool=_Tool("ping"), toolset=slack),
61
+ ResolvedTool(tool=_Tool("ping"), toolset=slack),
62
+ ]
63
+ with pytest.raises(namespacing.DuplicateToolNameError):
64
+ namespacing.build(tools)
65
+
66
+
67
+ def test_duplicate_bare_tool_names_raise() -> None:
58
68
  tools = [
59
69
  ResolvedTool(tool=_Tool("ping"), toolset=None),
60
- ResolvedTool(tool=_Tool("ping"), toolset=_NamedToolset("Slack")),
70
+ ResolvedTool(tool=_Tool("ping"), toolset=None),
61
71
  ]
62
72
  with pytest.raises(namespacing.DuplicateToolNameError):
63
73
  namespacing.build(tools)
64
74
 
65
75
 
76
+ def test_same_tool_name_in_two_namespaces_is_allowed() -> None:
77
+ tools = [
78
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Bookings")),
79
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Inventory")),
80
+ ]
81
+ ns = namespacing.build(tools)
82
+ assert [t.dotted_path for t in ns] == ["bookings.list_items", "inventory.list_items"]
83
+
84
+
85
+ def test_bare_tool_may_share_a_name_with_a_namespaced_tool() -> None:
86
+ tools = [
87
+ ResolvedTool(tool=_Tool("ping"), toolset=None),
88
+ ResolvedTool(tool=_Tool("ping"), toolset=_NamedToolset("Slack")),
89
+ ]
90
+ ns = namespacing.build(tools)
91
+ assert [t.dotted_path for t in ns] == ["ping", "slack.ping"]
92
+ reg = namespacing.Registry(ns)
93
+ assert reg.resolve_call("ping") is ns[0]
94
+ assert reg.resolve_call("slack.ping") is ns[1]
95
+
96
+
66
97
  def test_python_identifier_collisions_raise() -> None:
67
98
  tools = [
68
99
  ResolvedTool(tool=_Tool("ping"), toolset=None),
@@ -98,3 +129,18 @@ def test_registry_bidirectional_lookup() -> None:
98
129
  assert reg.resolve_call("send_message") is ns[0]
99
130
  with pytest.raises(KeyError):
100
131
  reg.resolve_call("nope")
132
+
133
+
134
+ def test_registry_dotted_paths_still_resolve_when_a_name_is_shared() -> None:
135
+ ns = namespacing.build(
136
+ [
137
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Bookings")),
138
+ ResolvedTool(tool=_Tool("list_items"), toolset=_NamedToolset("Inventory")),
139
+ ]
140
+ )
141
+ reg = namespacing.Registry(ns)
142
+ assert reg.resolve_call("bookings.list_items") is ns[0]
143
+ assert reg.resolve_call("inventory.list_items") is ns[1]
144
+ assert reg.by_tool_name("list_items") is None
145
+ with pytest.raises(KeyError):
146
+ reg.resolve_call("list_items")