|
| 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 | + ) |
0 commit comments