adk-code-mode 0.1.0__py3-none-any.whl
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/__about__.py +4 -0
- adk_code_mode/__init__.py +22 -0
- adk_code_mode/_artifact_tools.py +152 -0
- adk_code_mode/callback.py +81 -0
- adk_code_mode/executor.py +607 -0
- adk_code_mode/output.py +97 -0
- adk_code_mode/py.typed +1 -0
- adk_code_mode/runtime/__init__.py +9 -0
- adk_code_mode/runtime/base.py +88 -0
- adk_code_mode/runtime/docker.py +331 -0
- adk_code_mode/runtime/protocol.py +193 -0
- adk_code_mode/tools/__init__.py +4 -0
- adk_code_mode/tools/catalog.py +95 -0
- adk_code_mode/tools/dispatcher.py +290 -0
- adk_code_mode/tools/namespacing.py +200 -0
- adk_code_mode/tools/normaliser.py +69 -0
- adk_code_mode/tools/stubs.py +446 -0
- adk_code_mode/workspace/__init__.py +8 -0
- adk_code_mode/workspace/files.py +44 -0
- adk_code_mode-0.1.0.dist-info/METADATA +351 -0
- adk_code_mode-0.1.0.dist-info/RECORD +23 -0
- adk_code_mode-0.1.0.dist-info/WHEEL +4 -0
- adk_code_mode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Render the tool catalog string injected into the model's system prompt.
|
|
5
|
+
|
|
6
|
+
The catalog is grouped by module: ``# tools.<namespace>`` sections (sorted),
|
|
7
|
+
then ``# tools`` for any top-level tools. Each section opens with an import
|
|
8
|
+
line and is followed by ``.pyi``-style function definitions (signature +
|
|
9
|
+
docstring + ``...`` body). The callback
|
|
10
|
+
(``adk_code_mode.callback.code_mode_before_model_callback``) wraps the result
|
|
11
|
+
in ``<tools>`` / ``</tools>`` tags before appending it to the system prompt;
|
|
12
|
+
this module returns just the inner content.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from adk_code_mode.tools.namespacing import NamespacedTool
|
|
18
|
+
from adk_code_mode.tools.stubs import RenderedTool, render_tool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _render_entry(rt: RenderedTool) -> str:
|
|
22
|
+
"""One ``.pyi``-style entry: signature + docstring + ``...`` placeholder."""
|
|
23
|
+
return f"{rt.signature_for('catalog')}\n {rt.docstring}\n ...\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _render_module_section(*, header: str, import_line: str, entries: list[str]) -> str:
|
|
27
|
+
blocks = "\n".join(entries)
|
|
28
|
+
return f"# {header}\n\n{import_line}\n\n{blocks}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_catalog(namespaced: list[NamespacedTool]) -> str:
|
|
32
|
+
"""Render the full tool catalog (without ``<tools>`` wrapper).
|
|
33
|
+
|
|
34
|
+
Namespaced sections come first in namespace-sorted order, then top-level
|
|
35
|
+
tools. Tools within a section are sorted by attribute name for
|
|
36
|
+
determinism.
|
|
37
|
+
"""
|
|
38
|
+
grouped: dict[str | None, list[NamespacedTool]] = {}
|
|
39
|
+
for nt in namespaced:
|
|
40
|
+
grouped.setdefault(nt.namespace, []).append(nt)
|
|
41
|
+
|
|
42
|
+
sections: list[str] = []
|
|
43
|
+
|
|
44
|
+
for ns in sorted(n for n in grouped if n is not None):
|
|
45
|
+
tools = sorted(grouped[ns], key=lambda t: t.attribute)
|
|
46
|
+
rendered = [render_tool(t) for t in tools]
|
|
47
|
+
names = ", ".join(rt.attribute for rt in rendered)
|
|
48
|
+
entries = [_render_entry(rt) for rt in rendered]
|
|
49
|
+
sections.append(
|
|
50
|
+
_render_module_section(
|
|
51
|
+
header=f"tools.{ns}",
|
|
52
|
+
import_line=f"from tools.{ns} import {names}",
|
|
53
|
+
entries=entries,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
top_level = sorted(grouped.get(None, []), key=lambda t: t.attribute)
|
|
58
|
+
if top_level:
|
|
59
|
+
rendered = [render_tool(t) for t in top_level]
|
|
60
|
+
names = ", ".join(rt.attribute for rt in rendered)
|
|
61
|
+
entries = [_render_entry(rt) for rt in rendered]
|
|
62
|
+
sections.append(
|
|
63
|
+
_render_module_section(
|
|
64
|
+
header="tools",
|
|
65
|
+
import_line=f"from tools import {names}",
|
|
66
|
+
entries=entries,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return "\n".join(sections)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_OVERFLOW_PROSE = (
|
|
74
|
+
"A `tools` package is available in the sandbox. List `/tools/` with "
|
|
75
|
+
"`pathlib.Path('/tools').iterdir()`. Each entry is either a `.py` file "
|
|
76
|
+
"(a top-level tool, importable as `from tools import <name>`) or a "
|
|
77
|
+
"subdirectory (a namespace, with tools importable as "
|
|
78
|
+
"`from tools.<namespace> import <name>`). To see a tool's signature and "
|
|
79
|
+
"docstring, read its `.py` file with `open(...).read()`."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_overflow_catalog() -> str:
|
|
84
|
+
"""Catalog body used when the full catalog exceeds ``max_catalog_chars``.
|
|
85
|
+
|
|
86
|
+
Drops every tool section in favour of a prose explanation of how the
|
|
87
|
+
model can navigate ``/tools/`` from Python.
|
|
88
|
+
"""
|
|
89
|
+
return _OVERFLOW_PROSE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"render_catalog",
|
|
94
|
+
"render_overflow_catalog",
|
|
95
|
+
]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Host-side tool dispatcher for code-mode calls.
|
|
5
|
+
|
|
6
|
+
Replicates the before/after/on-error callback chain from
|
|
7
|
+
``google.adk.flows.llm_flows.functions._execute_single_function_call_async``
|
|
8
|
+
using only public attributes on ``InvocationContext`` and ``LlmAgent``. This
|
|
9
|
+
insulates us from private-API drift.
|
|
10
|
+
|
|
11
|
+
Call sequence matches ADK's own flow:
|
|
12
|
+
|
|
13
|
+
1. ``plugin_manager.run_before_tool_callback``
|
|
14
|
+
2. ``agent.canonical_before_tool_callbacks`` (first non-``None`` wins)
|
|
15
|
+
3. ``tool.run_async(args=..., tool_context=...)``
|
|
16
|
+
4. On exception: ``plugin_manager.run_on_tool_error_callback`` →
|
|
17
|
+
``agent.canonical_on_tool_error_callbacks``. If no replacement, re-raise.
|
|
18
|
+
5. ``plugin_manager.run_after_tool_callback``
|
|
19
|
+
6. ``agent.canonical_after_tool_callbacks`` (first non-``None`` replaces result)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import copy
|
|
26
|
+
import inspect
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from google.adk.agents.invocation_context import InvocationContext
|
|
31
|
+
from google.adk.events.event_actions import EventActions
|
|
32
|
+
from google.adk.tools.base_tool import BaseTool
|
|
33
|
+
from google.adk.tools.tool_context import ToolContext
|
|
34
|
+
from google.genai import types as genai_types
|
|
35
|
+
|
|
36
|
+
from adk_code_mode.tools.namespacing import Registry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _deep_merge_dicts(d1: dict[str, Any], d2: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Recursively merge ``d2`` into ``d1``. Mirrors ADK's ``deep_merge_dicts``."""
|
|
41
|
+
for key, value in d2.items():
|
|
42
|
+
if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict):
|
|
43
|
+
d1[key] = _deep_merge_dicts(d1[key], value)
|
|
44
|
+
else:
|
|
45
|
+
d1[key] = value
|
|
46
|
+
return d1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DispatchResult:
|
|
51
|
+
"""Result of a single tool call."""
|
|
52
|
+
|
|
53
|
+
ok: bool
|
|
54
|
+
value: Any = None
|
|
55
|
+
error_type: str | None = None
|
|
56
|
+
error_message: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UnsupportedToolActionError(RuntimeError):
|
|
60
|
+
"""Raised when a tool requests ADK actions code mode cannot surface."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Dispatcher:
|
|
64
|
+
"""Dispatches host-side tool calls on behalf of the sandbox."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
invocation_context: InvocationContext,
|
|
70
|
+
registry: Registry,
|
|
71
|
+
execution_id: str,
|
|
72
|
+
per_tool_timeout_seconds: float | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._ctx = invocation_context
|
|
75
|
+
self._registry = registry
|
|
76
|
+
self._execution_id = execution_id
|
|
77
|
+
self._counter = 0
|
|
78
|
+
self._per_tool_timeout = per_tool_timeout_seconds
|
|
79
|
+
self._merge_lock = asyncio.Lock()
|
|
80
|
+
self._artifact_delta: dict[str, int] = {}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def artifact_delta(self) -> dict[str, int]:
|
|
84
|
+
return dict(self._artifact_delta)
|
|
85
|
+
|
|
86
|
+
def _next_call_id(self) -> str:
|
|
87
|
+
self._counter += 1
|
|
88
|
+
return f"code_mode-{self._execution_id}-{self._counter}"
|
|
89
|
+
|
|
90
|
+
def _agent(self, invocation_context: InvocationContext | Any) -> Any:
|
|
91
|
+
"""Return the invocation's agent if it carries ADK callback attributes.
|
|
92
|
+
|
|
93
|
+
Duck-typed so custom agent subclasses and test doubles work without
|
|
94
|
+
needing to inherit from ``LlmAgent``.
|
|
95
|
+
"""
|
|
96
|
+
agent = invocation_context.agent
|
|
97
|
+
if agent is None:
|
|
98
|
+
return None
|
|
99
|
+
if (
|
|
100
|
+
hasattr(agent, "canonical_before_tool_callbacks")
|
|
101
|
+
or hasattr(agent, "canonical_after_tool_callbacks")
|
|
102
|
+
or hasattr(agent, "canonical_on_tool_error_callbacks")
|
|
103
|
+
):
|
|
104
|
+
return agent
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def _new_tool_context(
|
|
108
|
+
self, invocation_context: InvocationContext | Any, call_id: str
|
|
109
|
+
) -> ToolContext:
|
|
110
|
+
synthetic_call = genai_types.FunctionCall(id=call_id, name="code_mode")
|
|
111
|
+
return ToolContext(
|
|
112
|
+
invocation_context=invocation_context,
|
|
113
|
+
function_call_id=synthetic_call.id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def dispatch(
|
|
117
|
+
self, name: str, args: dict[str, Any], timeout: float | None = None
|
|
118
|
+
) -> DispatchResult:
|
|
119
|
+
"""Resolve and run one tool call. Never raises."""
|
|
120
|
+
try:
|
|
121
|
+
nt = self._registry.resolve_call(name)
|
|
122
|
+
except KeyError as exc:
|
|
123
|
+
return DispatchResult(ok=False, error_type="ToolNotFound", error_message=str(exc))
|
|
124
|
+
|
|
125
|
+
tool = nt.resolved.tool
|
|
126
|
+
call_args = copy.deepcopy(args)
|
|
127
|
+
# Clamp, don't override: the per-call wire timeout is attacker-controllable
|
|
128
|
+
# (sandbox code can call _rpc_client.call directly with any value), so the
|
|
129
|
+
# host's per_tool_timeout_seconds must remain a true ceiling.
|
|
130
|
+
if timeout is None:
|
|
131
|
+
effective_timeout = self._per_tool_timeout
|
|
132
|
+
elif self._per_tool_timeout is None:
|
|
133
|
+
effective_timeout = timeout
|
|
134
|
+
else:
|
|
135
|
+
effective_timeout = min(timeout, self._per_tool_timeout)
|
|
136
|
+
try:
|
|
137
|
+
call = self._run_and_merge(tool, call_args)
|
|
138
|
+
if effective_timeout is not None:
|
|
139
|
+
result = await asyncio.wait_for(call, timeout=effective_timeout)
|
|
140
|
+
else:
|
|
141
|
+
result = await call
|
|
142
|
+
return DispatchResult(ok=True, value=result)
|
|
143
|
+
except asyncio.TimeoutError:
|
|
144
|
+
return DispatchResult(
|
|
145
|
+
ok=False,
|
|
146
|
+
error_type="TimeoutError",
|
|
147
|
+
error_message=f"tool {tool.name!r} exceeded timeout of {effective_timeout}s",
|
|
148
|
+
)
|
|
149
|
+
except asyncio.CancelledError:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return DispatchResult(
|
|
153
|
+
ok=False,
|
|
154
|
+
error_type=type(exc).__name__,
|
|
155
|
+
error_message=str(exc),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def _run_and_merge(self, tool: BaseTool, args: dict[str, Any]) -> Any:
|
|
159
|
+
# Mirrors ADK's handle_function_calls_async: tools share live session.state
|
|
160
|
+
# for reads, write to per-call state_delta, then deltas deep-merge back in.
|
|
161
|
+
response, tool_context = await self._run_with_callbacks(
|
|
162
|
+
invocation_context=self._ctx,
|
|
163
|
+
tool=tool,
|
|
164
|
+
args=args,
|
|
165
|
+
)
|
|
166
|
+
async with self._merge_lock:
|
|
167
|
+
_deep_merge_dicts(self._ctx.session.state, tool_context.actions.state_delta)
|
|
168
|
+
self._artifact_delta.update(tool_context.actions.artifact_delta)
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
async def _run_with_callbacks(
|
|
172
|
+
self,
|
|
173
|
+
*,
|
|
174
|
+
invocation_context: InvocationContext | Any,
|
|
175
|
+
tool: BaseTool,
|
|
176
|
+
args: dict[str, Any],
|
|
177
|
+
) -> tuple[Any, ToolContext]:
|
|
178
|
+
call_id = self._next_call_id()
|
|
179
|
+
tool_context = self._new_tool_context(invocation_context, call_id)
|
|
180
|
+
plugin_manager = invocation_context.plugin_manager
|
|
181
|
+
agent = self._agent(invocation_context)
|
|
182
|
+
|
|
183
|
+
response: Any = await plugin_manager.run_before_tool_callback(
|
|
184
|
+
tool=tool, tool_args=args, tool_context=tool_context
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if response is None and agent is not None:
|
|
188
|
+
for callback in getattr(agent, "canonical_before_tool_callbacks", []):
|
|
189
|
+
response = callback(tool=tool, args=args, tool_context=tool_context)
|
|
190
|
+
if inspect.isawaitable(response):
|
|
191
|
+
response = await response
|
|
192
|
+
if response:
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
if response is None:
|
|
196
|
+
try:
|
|
197
|
+
response = await tool.run_async(args=args, tool_context=tool_context)
|
|
198
|
+
except BaseException as tool_error:
|
|
199
|
+
override = await self._run_on_error(
|
|
200
|
+
invocation_context, tool, args, tool_context, tool_error
|
|
201
|
+
)
|
|
202
|
+
if override is not None:
|
|
203
|
+
response = override
|
|
204
|
+
else:
|
|
205
|
+
raise
|
|
206
|
+
|
|
207
|
+
altered = await plugin_manager.run_after_tool_callback(
|
|
208
|
+
tool=tool, tool_args=args, tool_context=tool_context, result=response
|
|
209
|
+
)
|
|
210
|
+
if altered is None and agent is not None:
|
|
211
|
+
for callback in getattr(agent, "canonical_after_tool_callbacks", []):
|
|
212
|
+
altered = callback(
|
|
213
|
+
tool=tool, args=args, tool_context=tool_context, tool_response=response
|
|
214
|
+
)
|
|
215
|
+
if inspect.isawaitable(altered):
|
|
216
|
+
altered = await altered
|
|
217
|
+
if altered:
|
|
218
|
+
break
|
|
219
|
+
if altered is not None:
|
|
220
|
+
response = altered
|
|
221
|
+
|
|
222
|
+
self._ensure_supported_actions(tool.name, tool_context.actions)
|
|
223
|
+
|
|
224
|
+
if tool.is_long_running and not response:
|
|
225
|
+
# ADK's normal flow keeps a pending function call open and resumes when the
|
|
226
|
+
# async result arrives. Code mode has no such resume path — the sandbox is
|
|
227
|
+
# blocked waiting for a synchronous tool result.
|
|
228
|
+
raise UnsupportedToolActionError(
|
|
229
|
+
f"tool {tool.name!r} is long-running and returned no immediate response; "
|
|
230
|
+
"long-running tools that yield asynchronously are not supported in code mode"
|
|
231
|
+
)
|
|
232
|
+
return response, tool_context
|
|
233
|
+
|
|
234
|
+
async def _run_on_error(
|
|
235
|
+
self,
|
|
236
|
+
invocation_context: InvocationContext | Any,
|
|
237
|
+
tool: BaseTool,
|
|
238
|
+
args: dict[str, Any],
|
|
239
|
+
tool_context: ToolContext,
|
|
240
|
+
error: BaseException,
|
|
241
|
+
) -> Any:
|
|
242
|
+
plugin_manager = invocation_context.plugin_manager
|
|
243
|
+
# Plugin hook only accepts ``Exception``; promote other ``BaseException``
|
|
244
|
+
# subclasses (e.g. ``KeyboardInterrupt``) without trying to hand them off.
|
|
245
|
+
if not isinstance(error, Exception):
|
|
246
|
+
return None
|
|
247
|
+
error_response = await plugin_manager.run_on_tool_error_callback(
|
|
248
|
+
tool=tool, tool_args=args, tool_context=tool_context, error=error
|
|
249
|
+
)
|
|
250
|
+
if error_response is not None:
|
|
251
|
+
return error_response
|
|
252
|
+
agent = self._agent(invocation_context)
|
|
253
|
+
if agent is None:
|
|
254
|
+
return None
|
|
255
|
+
for callback in getattr(agent, "canonical_on_tool_error_callbacks", []):
|
|
256
|
+
error_response = callback(tool=tool, args=args, tool_context=tool_context, error=error)
|
|
257
|
+
if inspect.isawaitable(error_response):
|
|
258
|
+
error_response = await error_response
|
|
259
|
+
if error_response is not None:
|
|
260
|
+
return error_response
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def _ensure_supported_actions(self, tool_name: str, actions: EventActions) -> None:
|
|
264
|
+
unsupported: list[str] = []
|
|
265
|
+
if actions.requested_tool_confirmations:
|
|
266
|
+
unsupported.append("tool confirmations")
|
|
267
|
+
if actions.requested_auth_configs:
|
|
268
|
+
unsupported.append("credential requests")
|
|
269
|
+
if actions.render_ui_widgets:
|
|
270
|
+
unsupported.append("UI widgets")
|
|
271
|
+
if actions.transfer_to_agent:
|
|
272
|
+
unsupported.append("agent transfer")
|
|
273
|
+
if actions.escalate:
|
|
274
|
+
unsupported.append("escalation")
|
|
275
|
+
if actions.compaction is not None:
|
|
276
|
+
unsupported.append("event compaction")
|
|
277
|
+
if actions.end_of_agent:
|
|
278
|
+
unsupported.append("end_of_agent")
|
|
279
|
+
if actions.agent_state is not None:
|
|
280
|
+
unsupported.append("agent state")
|
|
281
|
+
if actions.rewind_before_invocation_id is not None:
|
|
282
|
+
unsupported.append("rewind")
|
|
283
|
+
if unsupported:
|
|
284
|
+
joined = ", ".join(unsupported)
|
|
285
|
+
raise UnsupportedToolActionError(
|
|
286
|
+
f"tool {tool_name!r} requested unsupported ADK actions in code mode: {joined}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = ["Dispatcher", "DispatchResult", "UnsupportedToolActionError"]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Group normalised tools into dotted Python namespaces.
|
|
5
|
+
|
|
6
|
+
``slack_send_message`` → ``tools.slack.send_message``. The model writes
|
|
7
|
+
``from tools.slack import send_message``; the dispatcher looks up the original
|
|
8
|
+
``BaseTool.name`` via the registry built here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import keyword
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from google.adk.tools.base_toolset import BaseToolset
|
|
18
|
+
|
|
19
|
+
from adk_code_mode.tools.normaliser import ResolvedTool
|
|
20
|
+
|
|
21
|
+
_IDENT_STRIP = re.compile(r"[^a-zA-Z0-9_]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolSurfaceError(ValueError):
|
|
25
|
+
"""Raised when tools cannot be mapped to a safe/generated Python surface."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DuplicateToolNameError(ToolSurfaceError):
|
|
29
|
+
"""Raised when two resolved tools share the same raw ADK tool name."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NamespaceCollisionError(ToolSurfaceError):
|
|
33
|
+
"""Raised when two toolsets collapse onto the same generated namespace."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PythonNameCollisionError(ToolSurfaceError):
|
|
37
|
+
"""Raised when generated Python identifiers collide."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class NamespacedTool:
|
|
42
|
+
"""A resolved tool with a chosen dotted path."""
|
|
43
|
+
|
|
44
|
+
resolved: ResolvedTool
|
|
45
|
+
namespace: str | None
|
|
46
|
+
"""The module name the tool lives in, e.g. ``"slack"``. ``None`` for top-level."""
|
|
47
|
+
attribute: str
|
|
48
|
+
"""The Python identifier used for the tool function inside its module."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def dotted_path(self) -> str:
|
|
52
|
+
if self.namespace:
|
|
53
|
+
return f"{self.namespace}.{self.attribute}"
|
|
54
|
+
return self.attribute
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def tool_name(self) -> str:
|
|
58
|
+
return self.resolved.tool.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sanitise_identifier(raw: str, *, fallback: str = "tool") -> str:
|
|
62
|
+
cleaned = _IDENT_STRIP.sub("_", raw).strip("_").lower()
|
|
63
|
+
if not cleaned:
|
|
64
|
+
cleaned = fallback
|
|
65
|
+
if cleaned[0].isdigit():
|
|
66
|
+
cleaned = f"_{cleaned}"
|
|
67
|
+
if keyword.iskeyword(cleaned):
|
|
68
|
+
cleaned = f"{cleaned}_"
|
|
69
|
+
return cleaned
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _toolset_namespace(toolset: BaseToolset) -> str:
|
|
73
|
+
"""Pick a stable module name for a toolset."""
|
|
74
|
+
name_attr = getattr(toolset, "name", None)
|
|
75
|
+
if isinstance(name_attr, str) and name_attr.strip():
|
|
76
|
+
return _sanitise_identifier(name_attr)
|
|
77
|
+
type_name = type(toolset).__name__
|
|
78
|
+
for suffix in ("Toolset", "ToolSet", "Tools"):
|
|
79
|
+
if type_name.endswith(suffix):
|
|
80
|
+
type_name = type_name[: -len(suffix)]
|
|
81
|
+
break
|
|
82
|
+
return _sanitise_identifier(type_name, fallback="toolset")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build(tools: list[ResolvedTool]) -> list[NamespacedTool]:
|
|
86
|
+
"""Assign a dotted path to every tool and resolve all collisions.
|
|
87
|
+
|
|
88
|
+
Stable / deterministic: tools from the same toolset share a namespace; bare
|
|
89
|
+
tools live at the top level. Any ambiguity in the generated Python surface
|
|
90
|
+
is rejected up front.
|
|
91
|
+
"""
|
|
92
|
+
out: list[NamespacedTool] = []
|
|
93
|
+
seen_attributes: set[tuple[str | None, str]] = set()
|
|
94
|
+
seen_raw_names: dict[str, NamespacedTool] = {}
|
|
95
|
+
namespace_sources: dict[str, BaseToolset] = {}
|
|
96
|
+
bare_attributes: dict[str, NamespacedTool] = {}
|
|
97
|
+
for resolved in tools:
|
|
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
|
+
)
|
|
106
|
+
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."
|
|
111
|
+
)
|
|
112
|
+
if namespace is not None and resolved.toolset is not None:
|
|
113
|
+
existing_source = namespace_sources.get(namespace)
|
|
114
|
+
if existing_source is not None and existing_source is not resolved.toolset:
|
|
115
|
+
raise NamespaceCollisionError(
|
|
116
|
+
f"Toolsets {type(existing_source).__name__!r} and "
|
|
117
|
+
f"{type(resolved.toolset).__name__!r} both map to generated namespace "
|
|
118
|
+
f"{namespace!r}. Override ``BaseToolset.name`` on one of them so the "
|
|
119
|
+
"generated import paths stay unique."
|
|
120
|
+
)
|
|
121
|
+
namespace_sources[namespace] = resolved.toolset
|
|
122
|
+
attribute = _sanitise_identifier(resolved.tool.name, fallback="tool")
|
|
123
|
+
if namespace is not None and namespace in bare_attributes:
|
|
124
|
+
colliding_bare = bare_attributes[namespace]
|
|
125
|
+
raise PythonNameCollisionError(
|
|
126
|
+
f"Top-level tool {colliding_bare.tool_name!r} (Python name {namespace!r}) "
|
|
127
|
+
f"collides with namespace {namespace!r} generated from "
|
|
128
|
+
f"{type(resolved.toolset).__name__ if resolved.toolset else 'tool'}. "
|
|
129
|
+
"Rename the bare tool or override the toolset's ``name`` so the surfaces don't share an identifier."
|
|
130
|
+
)
|
|
131
|
+
key = (namespace, attribute)
|
|
132
|
+
if key in seen_attributes:
|
|
133
|
+
dotted = f"{namespace}.{attribute}" if namespace else attribute
|
|
134
|
+
raise PythonNameCollisionError(
|
|
135
|
+
f"Generated Python identifier collision for {dotted!r}: ADK tool names "
|
|
136
|
+
f"would sanitise to the same identifier. Rename one of the tools so the "
|
|
137
|
+
"import surface is unique."
|
|
138
|
+
)
|
|
139
|
+
seen_attributes.add(key)
|
|
140
|
+
nt = NamespacedTool(resolved=resolved, namespace=namespace, attribute=attribute)
|
|
141
|
+
seen_raw_names[resolved.tool.name] = nt
|
|
142
|
+
if namespace is None:
|
|
143
|
+
bare_attributes[attribute] = nt
|
|
144
|
+
out.append(nt)
|
|
145
|
+
|
|
146
|
+
namespace_names = set(namespace_sources.keys())
|
|
147
|
+
for bare, nt in bare_attributes.items():
|
|
148
|
+
if bare in namespace_names:
|
|
149
|
+
source = namespace_sources[bare]
|
|
150
|
+
raise PythonNameCollisionError(
|
|
151
|
+
f"Top-level tool {nt.tool_name!r} (Python name {bare!r}) collides with "
|
|
152
|
+
f"namespace {bare!r} generated from {type(source).__name__}. "
|
|
153
|
+
"Rename the bare tool or override the toolset's ``name``."
|
|
154
|
+
)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _origin(resolved: ResolvedTool) -> str:
|
|
159
|
+
"""Human-readable origin of a resolved tool, for error messages."""
|
|
160
|
+
if resolved.toolset is not None:
|
|
161
|
+
return f"toolset {type(resolved.toolset).__name__}"
|
|
162
|
+
return "top-level tools"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Registry:
|
|
166
|
+
"""Bidirectional lookup: dotted path ↔ ``BaseTool.name``."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, tools: list[NamespacedTool]) -> None:
|
|
169
|
+
self._by_path: dict[str, NamespacedTool] = {t.dotted_path: t for t in tools}
|
|
170
|
+
by_tool_name: dict[str, NamespacedTool] = {}
|
|
171
|
+
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
|
|
179
|
+
self._by_tool_name = by_tool_name
|
|
180
|
+
self._tools = list(tools)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def tools(self) -> list[NamespacedTool]:
|
|
184
|
+
return list(self._tools)
|
|
185
|
+
|
|
186
|
+
def by_dotted_path(self, path: str) -> NamespacedTool | None:
|
|
187
|
+
return self._by_path.get(path)
|
|
188
|
+
|
|
189
|
+
def by_tool_name(self, tool_name: str) -> NamespacedTool | None:
|
|
190
|
+
return self._by_tool_name.get(tool_name)
|
|
191
|
+
|
|
192
|
+
def resolve_call(self, name: str) -> NamespacedTool:
|
|
193
|
+
"""Accept either the dotted path or the original ``BaseTool.name``."""
|
|
194
|
+
hit = self._by_path.get(name) or self._by_tool_name.get(name)
|
|
195
|
+
if hit is None:
|
|
196
|
+
raise KeyError(f"no tool registered for {name!r}")
|
|
197
|
+
return hit
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = ["NamespacedTool", "Registry", "build"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Normalise heterogeneous tool inputs into a flat ``list[BaseTool]``.
|
|
5
|
+
|
|
6
|
+
Accepts any mix of:
|
|
7
|
+
|
|
8
|
+
- plain Python callables (wrapped as ``FunctionTool``)
|
|
9
|
+
- ``BaseTool`` instances (kept as-is)
|
|
10
|
+
- ``BaseToolset`` instances (expanded via ``get_tools_with_prefix``)
|
|
11
|
+
|
|
12
|
+
Toolsets are async. We collect their tools inside the caller's event loop.
|
|
13
|
+
Each resolved tool remembers the toolset it came from (if any) so namespacing
|
|
14
|
+
can group them later.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Callable, Sequence
|
|
21
|
+
|
|
22
|
+
from google.adk.agents.readonly_context import ReadonlyContext
|
|
23
|
+
from google.adk.tools.base_tool import BaseTool
|
|
24
|
+
from google.adk.tools.base_toolset import BaseToolset
|
|
25
|
+
from google.adk.tools.function_tool import FunctionTool
|
|
26
|
+
|
|
27
|
+
ToolInput = BaseTool | BaseToolset | Callable[..., Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ResolvedTool:
|
|
32
|
+
"""A single tool with provenance for namespacing."""
|
|
33
|
+
|
|
34
|
+
tool: BaseTool
|
|
35
|
+
toolset: BaseToolset | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def resolve(
|
|
39
|
+
inputs: Sequence[ToolInput],
|
|
40
|
+
readonly_context: ReadonlyContext,
|
|
41
|
+
) -> list[ResolvedTool]:
|
|
42
|
+
"""Flatten the given inputs into a list of ``ResolvedTool``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
inputs: The mixed sequence passed to ``CodeModeCodeExecutor(tools=...)``.
|
|
46
|
+
readonly_context: Forwarded to ``BaseToolset.get_tools_with_prefix``.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A flat list preserving input order. Toolset tools appear in the order
|
|
50
|
+
the toolset returned them.
|
|
51
|
+
"""
|
|
52
|
+
resolved: list[ResolvedTool] = []
|
|
53
|
+
for item in inputs:
|
|
54
|
+
if isinstance(item, BaseTool):
|
|
55
|
+
resolved.append(ResolvedTool(tool=item, toolset=None))
|
|
56
|
+
elif isinstance(item, BaseToolset):
|
|
57
|
+
tools = await item.get_tools_with_prefix(readonly_context)
|
|
58
|
+
for tool in tools:
|
|
59
|
+
resolved.append(ResolvedTool(tool=tool, toolset=item))
|
|
60
|
+
elif callable(item):
|
|
61
|
+
resolved.append(ResolvedTool(tool=FunctionTool(item), toolset=None))
|
|
62
|
+
else:
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"Unsupported tool input {item!r}: expected BaseTool, BaseToolset, or callable"
|
|
65
|
+
)
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["ResolvedTool", "ToolInput", "resolve"]
|