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.
@@ -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"]