From a25cde5ca445607285fa93495ec414e39f4194c3 Mon Sep 17 00:00:00 2001 From: Rashmi Date: Tue, 15 Sep 2026 20:55:00 +0530 Subject: [PATCH] fix(tools): forward custom name/description from VertexAiSearchTool bypass path to DiscoveryEngineSearchTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When bypass_multi_tools_limit=True converts VertexAiSearchTool to a client-side DiscoveryEngineSearchTool, the resulting tool was always named 'discovery_engine_search' — an internal implementation name. Developers had no way to express a domain-specific tool name, forcing them to leak GCP internals into agent instructions or accept runtime ValueError: Tool 'search' not found crashes. DiscoveryEngineSearchTool already accepted name/description params but they were unreachable through the conversion path. - Add optional name/description kwargs to VertexAiSearchTool.__init__ stored as _bypass_tool_name/_bypass_tool_description to never shadow the base-class 'vertex_ai_search' name used in the grounding path - Forward them in llm_agent.py during the bypass conversion - Add 5 unit tests for the agent conversion path - Add 4 unit tests for DiscoveryEngineSearchTool constructor Fixes: #7100 (partial — addresses problem 3, hardcoded tool naming) Related: #7101 --- src/google/adk/agents/llm_agent.py | 2 + .../adk/tools/discovery_engine_search_tool.py | 10 ++ src/google/adk/tools/vertex_ai_search_tool.py | 18 +++ .../unittests/agents/test_llm_agent_fields.py | 129 ++++++++++++++++++ .../test_discovery_engine_search_tool.py | 32 +++++ 5 files changed, 191 insertions(+) diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index 0d9936b2efb..11bca98f3f7 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -178,6 +178,8 @@ async def _convert_tool_union_to_tools( search_engine_id=vais_tool.search_engine_id, filter=vais_tool.filter, max_results=vais_tool.max_results, + name=vais_tool._bypass_tool_name, + description=vais_tool._bypass_tool_description, ) ] from ..workflow._base_node import BaseNode diff --git a/src/google/adk/tools/discovery_engine_search_tool.py b/src/google/adk/tools/discovery_engine_search_tool.py index cba2a9cdbf3..b281b42c7e4 100644 --- a/src/google/adk/tools/discovery_engine_search_tool.py +++ b/src/google/adk/tools/discovery_engine_search_tool.py @@ -144,6 +144,8 @@ def __init__( *, search_result_mode: Optional[SearchResultMode] = None, location: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, ): """Initializes the DiscoveryEngineSearchTool. @@ -164,8 +166,16 @@ def __init__( location: Optional endpoint location override. Examples: "global", "us", "eu". If not specified, location is inferred from `data_store_id` or `search_engine_id` and defaults to "global". + name: Optional custom name for the tool. Defaults to + "discovery_engine_search". + description: Optional custom description for the tool. Defaults to + the docstring of discovery_engine_search. """ super().__init__(self.discovery_engine_search) + if name: + self.name = name + if description: + self.description = description if (data_store_id is None and search_engine_id is None) or ( data_store_id is not None and search_engine_id is not None ): diff --git a/src/google/adk/tools/vertex_ai_search_tool.py b/src/google/adk/tools/vertex_ai_search_tool.py index 17895df1ef6..c203e1775ae 100644 --- a/src/google/adk/tools/vertex_ai_search_tool.py +++ b/src/google/adk/tools/vertex_ai_search_tool.py @@ -71,6 +71,8 @@ def __init__( filter: Optional[str] = None, max_results: Optional[int] = None, bypass_multi_tools_limit: bool = False, + name: Optional[str] = None, + description: Optional[str] = None, ): """Initializes the Vertex AI Search tool. @@ -86,6 +88,19 @@ def __init__( max_results: The maximum number of results to return. bypass_multi_tools_limit: Whether to bypass the multi tools limitation, so that the tool can be used with other tools in the same agent. + name: Optional custom name for the tool. Only used when + ``bypass_multi_tools_limit=True``, in which case the tool is converted + to a client-side :class:`DiscoveryEngineSearchTool`. When ``None`` + (default) the converted tool is named ``discovery_engine_search``. + Has no effect when ``bypass_multi_tools_limit=False`` because the + built-in grounding path does not expose a callable tool name to the + model. Providing a domain-specific name (e.g. + ``"knowledge_base_search"``) prevents prompt-fragility issues where + lightweight models guess generic names like ``search`` and trigger a + ``ValueError: Tool 'search' not found`` at runtime. + description: Optional custom description for the tool. Only used when + ``bypass_multi_tools_limit=True``. When ``None`` (default) the + converted tool uses the docstring of its internal search function. Raises: ValueError: If both data_store_id and search_engine_id are not specified @@ -109,6 +124,9 @@ def __init__( self.filter = filter self.max_results = max_results self.bypass_multi_tools_limit = bypass_multi_tools_limit + # Stored separately so they never shadow the built-in grounding name. + self._bypass_tool_name = name + self._bypass_tool_description = description def _build_vertex_ai_search_config( self, readonly_context: ReadonlyContext diff --git a/tests/unittests/agents/test_llm_agent_fields.py b/tests/unittests/agents/test_llm_agent_fields.py index 78eb92524f8..a9a943f6322 100644 --- a/tests/unittests/agents/test_llm_agent_fields.py +++ b/tests/unittests/agents/test_llm_agent_fields.py @@ -723,6 +723,135 @@ async def test_handle_vais_in_hierarchy_no_bypass(self): assert tools[0].name == 'vertex_ai_search' assert tools[0].__class__.__name__ == 'VertexAiSearchTool' + @mock.patch( + 'google.auth.default', + mock.MagicMock(return_value=('credentials', 'project')), + ) + async def test_vais_bypass_custom_name_forwarded(self): + """Custom name on VertexAiSearchTool is forwarded to DiscoveryEngineSearchTool.""" + agent = LlmAgent( + name='test_agent', + model='gemini-pro', + tools=[ + self._my_tool, + VertexAiSearchTool( + data_store_id='test_data_store_id', + bypass_multi_tools_limit=True, + name='knowledge_base_search', + ), + ], + ) + ctx = await _create_readonly_context(agent) + tools = await agent.canonical_tools(ctx) + + assert len(tools) == 2 + assert tools[1].name == 'knowledge_base_search' + assert tools[1].__class__.__name__ == 'DiscoveryEngineSearchTool' + + @mock.patch( + 'google.auth.default', + mock.MagicMock(return_value=('credentials', 'project')), + ) + async def test_vais_bypass_custom_description_forwarded(self): + """Custom description on VertexAiSearchTool is forwarded to DiscoveryEngineSearchTool.""" + custom_desc = 'Search the internal knowledge base for product information.' + agent = LlmAgent( + name='test_agent', + model='gemini-pro', + tools=[ + self._my_tool, + VertexAiSearchTool( + data_store_id='test_data_store_id', + bypass_multi_tools_limit=True, + description=custom_desc, + ), + ], + ) + ctx = await _create_readonly_context(agent) + tools = await agent.canonical_tools(ctx) + + assert len(tools) == 2 + assert tools[1].description == custom_desc + assert tools[1].__class__.__name__ == 'DiscoveryEngineSearchTool' + + @mock.patch( + 'google.auth.default', + mock.MagicMock(return_value=('credentials', 'project')), + ) + async def test_vais_bypass_custom_name_and_description_forwarded(self): + """Both custom name and description are forwarded to DiscoveryEngineSearchTool.""" + custom_name = 'product_search' + custom_desc = 'Search the product catalogue.' + agent = LlmAgent( + name='test_agent', + model='gemini-pro', + tools=[ + self._my_tool, + VertexAiSearchTool( + data_store_id='test_data_store_id', + bypass_multi_tools_limit=True, + name=custom_name, + description=custom_desc, + ), + ], + ) + ctx = await _create_readonly_context(agent) + tools = await agent.canonical_tools(ctx) + + assert len(tools) == 2 + assert tools[1].name == custom_name + assert tools[1].description == custom_desc + assert tools[1].__class__.__name__ == 'DiscoveryEngineSearchTool' + + @mock.patch( + 'google.auth.default', + mock.MagicMock(return_value=('credentials', 'project')), + ) + async def test_vais_bypass_default_name_unchanged_when_no_custom_name(self): + """Default name is still 'discovery_engine_search' when no custom name provided.""" + agent = LlmAgent( + name='test_agent', + model='gemini-pro', + tools=[ + self._my_tool, + VertexAiSearchTool( + data_store_id='test_data_store_id', + bypass_multi_tools_limit=True, + ), + ], + ) + ctx = await _create_readonly_context(agent) + tools = await agent.canonical_tools(ctx) + + assert len(tools) == 2 + assert tools[1].name == 'discovery_engine_search' + assert tools[1].__class__.__name__ == 'DiscoveryEngineSearchTool' + + @mock.patch( + 'google.auth.default', + mock.MagicMock(return_value=('credentials', 'project')), + ) + async def test_vais_no_bypass_custom_name_does_not_affect_builtin_name(self): + """name param has no effect on the built-in grounding tool name (bypass=False).""" + agent = LlmAgent( + name='test_agent', + model='gemini-pro', + tools=[ + VertexAiSearchTool( + data_store_id='test_data_store_id', + bypass_multi_tools_limit=False, + name='should_be_ignored', + ), + ], + ) + ctx = await _create_readonly_context(agent) + tools = await agent.canonical_tools(ctx) + + assert len(tools) == 1 + # The built-in grounding tool always reports 'vertex_ai_search' + assert tools[0].name == 'vertex_ai_search' + assert tools[0].__class__.__name__ == 'VertexAiSearchTool' + async def test_handle_enterprise_web_search_in_hierarchy(self): """Enterprise web search without bypass remains a built-in search tool in a hierarchy.""" search_agent = LlmAgent( diff --git a/tests/unittests/tools/test_discovery_engine_search_tool.py b/tests/unittests/tools/test_discovery_engine_search_tool.py index 60de548ee33..87d09f27d2b 100644 --- a/tests/unittests/tools/test_discovery_engine_search_tool.py +++ b/tests/unittests/tools/test_discovery_engine_search_tool.py @@ -83,6 +83,38 @@ def test_init_with_data_store_specs_without_search_engine_id_raises_error( data_store_id="test_data_store", data_store_specs=[{"id": "123"}] ) + def test_init_default_name(self): + """Default name is 'discovery_engine_search' (derived from the method name).""" + tool = DiscoveryEngineSearchTool(data_store_id="test_data_store") + assert tool.name == "discovery_engine_search" + + def test_init_custom_name(self): + """Custom name overrides the default tool name.""" + tool = DiscoveryEngineSearchTool( + data_store_id="test_data_store", + name="knowledge_base_search", + ) + assert tool.name == "knowledge_base_search" + + def test_init_custom_description(self): + """Custom description overrides the default tool description.""" + custom_desc = "Search the internal product knowledge base." + tool = DiscoveryEngineSearchTool( + data_store_id="test_data_store", + description=custom_desc, + ) + assert tool.description == custom_desc + + def test_init_custom_name_and_description(self): + """Both custom name and description are applied simultaneously.""" + tool = DiscoveryEngineSearchTool( + data_store_id="test_data_store", + name="product_search", + description="Search the product catalogue.", + ) + assert tool.name == "product_search" + assert tool.description == "Search the product catalogue." + @pytest.mark.parametrize( ("tool_kwargs", "expected_endpoint"), [