Skip to content

Commit f0d3b2e

Browse files
committed
feat: Implement the ToolboxTool wrapper and core logic
1 parent 269dc5c commit f0d3b2e

File tree

4 files changed

+481
-0
lines changed

4 files changed

+481
-0
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Any, Dict, Optional, Callable, Awaitable
16+
from typing_extensions import override
17+
18+
import toolbox_core
19+
from toolbox_core.tool import ToolboxTool as CoreToolboxTool
20+
from google.adk.tools.base_tool import BaseTool
21+
from google.adk.agents.readonly_context import ReadonlyContext
22+
23+
24+
class ToolboxContext:
25+
"""Context object passed to pre/post hooks."""
26+
def __init__(self, arguments: Dict[str, Any], tool_context: ReadonlyContext):
27+
self.arguments = arguments
28+
self.tool_context = tool_context
29+
self.result: Optional[Any] = None
30+
self.error: Optional[Exception] = None
31+
32+
33+
class ToolboxTool(BaseTool):
34+
"""
35+
A tool that delegates to a remote Toolbox tool, integrated with ADK.
36+
"""
37+
38+
def __init__(
39+
self,
40+
core_tool: CoreToolboxTool,
41+
pre_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
42+
post_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
43+
):
44+
"""
45+
Args:
46+
core_tool: The underlying toolbox_core.py tool instance.
47+
pre_hook: Async function called before execution. Can modify ctx.arguments.
48+
post_hook: Async function called after execution (finally block). Can inspect ctx.result/error.
49+
"""
50+
# We act as a proxy.
51+
# We need to extract metadata from the core tool to satisfy BaseTool's contract.
52+
# core_tool name, description etc. are available.
53+
54+
# Note: BaseTool in adk-python typically requires tool_name, tool_description etc.
55+
# We map them from the core tool.
56+
# core_tool.tool_def is usually available or we use the wrapper's attributes if exposed.
57+
# In toolbox-core 0.1.0+, tool instance is callable but metadata is also stored.
58+
# Assuming core_tool has typical attributes or we introspect.
59+
# For now, we rely on core_tool's string representation or assume it has name/doc.
60+
61+
name = getattr(core_tool, "__name__", "unknown_tool")
62+
description = getattr(core_tool, "__doc__", "No description provided.") or "No description provided."
63+
64+
# toolbox_core tools might not expose explicit args schema in a way BaseTool expects
65+
# (dict of name -> type). ADK tools usually define `tool_args` as a dict.
66+
# We might need to inspect the signature if not explicitly available.
67+
# For this implementation, we allow dynamic arguments similar to FunctionTool.
68+
# If BaseTool enforces providing `tool_args`, we might need to introspect.
69+
# However, BaseTool logic in adk-python often derives it.
70+
# We'll call super with minimal known info or defaults.
71+
72+
super().__init__(
73+
name=name,
74+
description=description,
75+
# We pass empty custom_metadata or whatever is needed
76+
custom_metadata={}
77+
)
78+
self._core_tool = core_tool
79+
self._pre_hook = pre_hook
80+
self._post_hook = post_hook
81+
82+
@override
83+
async def run_async(
84+
self,
85+
args: Dict[str, Any],
86+
tool_context: ReadonlyContext,
87+
) -> Any:
88+
# Create context
89+
ctx = ToolboxContext(arguments=args, tool_context=tool_context)
90+
91+
# 1. Pre-hook
92+
if self._pre_hook:
93+
await self._pre_hook(ctx)
94+
95+
# 2. ADK Auth Integration (3LO)
96+
# We check if we need to inject a user token managed by ADK.
97+
# This typically happens if the toolset was configured with USER_IDENTITY.
98+
# We don't know the configuration here explicitly unless passed,
99+
# BUT the convention is: if we can get a token from tool_context, use it.
100+
# Or better: The client (ToolboxClient) didn't set auth headers for USER_IDENTITY,
101+
# so we must do it here.
102+
103+
# How do we know if we logic requires it?
104+
# We can try to fetch; if it exists, we assume we should use it?
105+
# Or we rely on the fact that `core_tool` instance itself might have bound auth getters?
106+
# Actually, toolbox-core tools support `add_auth_token_getters`.
107+
# We need to bridge ADK's `get_auth_response` to toolbox-core's getter.
108+
109+
# ADK 3LO flow:
110+
# response = tool_context.get_auth_response()
111+
# if not response:
112+
# tool_context.request_credential(...)
113+
# return "Please authenticate..."
114+
# else:
115+
# token = response.token
116+
117+
# If we have a way to detect 3LO requirement, we trigger it.
118+
# For now, we'll try to retrieve an auth response if one is potentially available.
119+
# But `toolbox-core` tool will fail if it needs auth and doesn't get it.
120+
# The proper pattern is: wrap the core tool with a dynamically injected getter
121+
# that calls into ADK logic.
122+
123+
# However, `toolbox-core`'s `auth_token_getters` expects a callable.
124+
# Be careful: `tool_context` is available here in `run_async`, but `auth_token_getters`
125+
# are usually bound locally.
126+
127+
# We'll define a token getter that closes over `tool_context`.
128+
# We'll define a token getter that closes over `tool_context` if needed in future.
129+
# For now, unused.
130+
131+
# Let's try to get a token if available, but fundamentally, ADK expects the tool to return
132+
# a request for credentials if missing.
133+
auth_response = tool_context.get_auth_response()
134+
135+
# NOTE: We can't easily tell if this specific tool *needs* user auth without metadata.
136+
# Strategy: We assume that if the tool fails with an auth error, we might fallback?
137+
# OR we rely on configuration.
138+
# Ideally, `ToolboxToolset` would have configured this tool with a special "trigger" if needed.
139+
140+
# For now, we will proceed to execute.
141+
# If `USER_IDENTITY` was configured, we might have injected a special marker or getter.
142+
# Revisit this logic if explicit "Force Auth" flag is needed on the tool.
143+
144+
try:
145+
# Execute the core tool
146+
# mapping: toolbox-core expects (arg1=..., arg2=...)
147+
# We unpack ctx.arguments
148+
149+
# For 3LO support: We really need to know if we should be requesting credentials.
150+
# For this MVP, we will rely on static auth or pre-existing headers.
151+
# If we want to support 3LO properly, we need to handle the `tool_context.request_credential` flow.
152+
# We'll check if `auth_token` argument is present or if we have a bound getter.
153+
154+
# We execute:
155+
result = await self._core_tool(**ctx.arguments)
156+
157+
ctx.result = result
158+
return result
159+
160+
except Exception as e:
161+
# TODO: Inspect e for Auth errors to trigger 3LO if configured?
162+
ctx.error = e
163+
raise e
164+
finally:
165+
if self._post_hook:
166+
await self._post_hook(ctx)
167+
168+
def bind_params(self, bounded_params: Dict[str, Any]) -> 'ToolboxTool':
169+
"""Allows runtime binding of parameters, delegating to core tool."""
170+
new_core_tool = self._core_tool.bind_params(bounded_params)
171+
# Return a new wrapper
172+
return ToolboxTool(
173+
core_tool=new_core_tool,
174+
pre_hook=self._pre_hook,
175+
post_hook=self._post_hook
176+
)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Any, Dict, List, Optional, Mapping, Union, Callable, Awaitable
16+
from typing_extensions import override
17+
18+
from google.adk.tools.base_tool import BaseTool
19+
from google.adk.tools.base_toolset import BaseToolset
20+
from google.adk.agents.readonly_context import ReadonlyContext
21+
22+
from .client import ToolboxClient
23+
from .credentials import CredentialConfig
24+
from .tool import ToolboxTool, ToolboxContext
25+
26+
class ToolboxToolset(BaseToolset):
27+
"""
28+
A Toolset that provides tools from a remote Toolbox server.
29+
"""
30+
31+
def __init__(
32+
self,
33+
server_url: str,
34+
toolset_name: Optional[str] = None,
35+
tool_names: Optional[List[str]] = None,
36+
credentials: Optional[CredentialConfig] = None,
37+
additional_headers: Optional[Dict[str, str]] = None,
38+
bound_params: Optional[Mapping[str, Union[Callable[[], Any], Any]]] = None,
39+
pre_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
40+
post_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
41+
**kwargs
42+
):
43+
"""
44+
Args:
45+
server_url: The URL of the Toolbox server.
46+
toolset_name: The name of the remote toolset to load.
47+
tool_names: Specific tool names to load (alternative to toolset_name).
48+
credentials: Authentication configuration.
49+
additional_headers: Extra static headers (e.g. for proxy auth).
50+
bound_params: Parameters to bind globally to loaded tools.
51+
pre_hook: Hook to run before every tool execution.
52+
post_hook: Hook to run after every tool execution.
53+
"""
54+
super().__init__()
55+
self._client = ToolboxClient(
56+
server_url=server_url,
57+
credentials=credentials,
58+
additional_headers=additional_headers,
59+
**kwargs
60+
)
61+
self._toolset_name = toolset_name
62+
self._tool_names = tool_names
63+
self._bound_params = bound_params
64+
self._pre_hook = pre_hook
65+
self._post_hook = post_hook
66+
67+
@override
68+
async def get_tools(
69+
self, readonly_context: Optional[ReadonlyContext] = None
70+
) -> List[BaseTool]:
71+
"""Loads tools from the toolbox server and wraps them."""
72+
# Note: We don't close the client after get_tools because tools might need it
73+
# (though currently tools are self-contained http-wise if they don't share session state).
74+
# Actually toolbox-core tools have their own client reference or use a shared one?
75+
# toolbox_core.ToolboxClient.load_tool returns a tool which...
76+
# wait, toolbox_core tools make HTTP calls. Do they hold a reference to the client session?
77+
# Yes, toolbox_core 0.1.0+ tools usually have an internal `client` or `session`.
78+
# So we must keep self._client alive or ensuring it's not closed prematurely.
79+
80+
tools = []
81+
if self._toolset_name:
82+
core_tools = await self._client.load_toolset(
83+
self._toolset_name,
84+
bound_params=self._bound_params
85+
)
86+
tools.extend(core_tools)
87+
88+
if self._tool_names:
89+
for name in self._tool_names:
90+
core_tool = await self._client.load_tool(
91+
name,
92+
bound_params=self._bound_params
93+
)
94+
tools.append(core_tool)
95+
96+
# Wrap all core tools in ToolboxTool
97+
return [
98+
ToolboxTool(
99+
core_tool=t,
100+
pre_hook=self._pre_hook,
101+
post_hook=self._post_hook
102+
)
103+
for t in tools
104+
]
105+
106+
async def close(self):
107+
await self._client.close()

0 commit comments

Comments
 (0)