Skip to content

Commit f3250bd

Browse files
DeanChensjcopybara-github
authored andcommitted
fix: Fix built-in search tools in agent hierarchy when transfer_to_agent is present
Gemini models reject requests that combine built-in search tools (such as GoogleSearchTool or VertexAiSearchTool) with function declarations (such as transfer_to_agent) with "400 INVALID_ARGUMENT: Tool use with function calling is unsupported". This change addresses this in two ways: 1. When built-in search tools (GoogleSearchTool / VertexAiSearchTool) have `bypass_multi_tools_limit=True` in an agent hierarchy, `multiple_tools` now accounts for transfer targets so the tool is converted to its function-tool equivalent (GoogleSearchAgentTool / DiscoveryEngineSearchTool), allowing it to cleanly coexist with `transfer_to_agent`. 2. When built-in search tools are used without bypass in an agent hierarchy, `_AgentTransferLlmRequestProcessor` skips injecting `transfer_to_agent` and transfer instructions, ensuring only the built-in search tool is sent to the model without conflict. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 967423502
1 parent 2aa2b46 commit f3250bd

5 files changed

Lines changed: 367 additions & 22 deletions

File tree

‎src/google/adk/agents/llm_agent.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,9 @@ async def canonical_tools(
783783
# We may need to wrap some built-in tools if there are other tools
784784
# because the built-in tools cannot be used together with other tools.
785785
# TODO: Remove once the workaround is no longer needed.
786-
multiple_tools = len(self.tools) > 1
786+
from ..flows.llm_flows.agent_transfer import _get_transfer_targets
787+
788+
multiple_tools = len(self.tools) > 1 or bool(_get_transfer_targets(self))
787789
model = self.canonical_model
788790

789791
results = await asyncio.gather(*(

‎src/google/adk/flows/llm_flows/agent_transfer.py‎

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,17 @@ async def run_async(
4343
self, invocation_context: InvocationContext, llm_request: LlmRequest
4444
) -> AsyncGenerator[Event, None]:
4545
agent = as_llm_agent(invocation_context)
46-
if not hasattr(agent, 'disallow_transfer_to_parent'):
47-
return
48-
4946
transfer_targets = _get_transfer_targets(agent)
5047
if not transfer_targets:
5148
return
5249

50+
if err_msg := _get_incompatible_builtin_tool_error(agent):
51+
sub_agents = getattr(agent, 'sub_agents', None) or []
52+
sub_agent_targets = [t for t in transfer_targets if t in sub_agents]
53+
if sub_agent_targets:
54+
raise ValueError(err_msg)
55+
return
56+
5357
transfer_to_agent_tool = _build_transfer_tool(transfer_targets)
5458

5559
llm_request.append_instructions([
@@ -159,7 +163,7 @@ def _build_transfer_instructions(
159163
return si
160164

161165

162-
def _get_transfer_targets(agent: LlmAgent) -> list[BaseAgent]:
166+
def _get_transfer_targets(agent: BaseAgent) -> list[BaseAgent]:
163167
"""Gets the list of agents that the current agent can transfer to.
164168
165169
The transfer targets include:
@@ -170,32 +174,35 @@ def _get_transfer_targets(agent: LlmAgent) -> list[BaseAgent]:
170174
not disallow transfer to peers.
171175
172176
Args:
173-
agent: The LlmAgent for which to find transfer targets.
177+
agent: The BaseAgent for which to find transfer targets.
174178
175179
Returns:
176180
A list of BaseAgent instances that are valid transfer targets.
177181
"""
182+
if not hasattr(agent, 'disallow_transfer_to_parent'):
183+
return []
184+
178185
result = []
179-
result.extend([
180-
sub_agent
181-
for sub_agent in agent.sub_agents
182-
if not hasattr(sub_agent, 'mode')
183-
or sub_agent.mode not in ('single_turn', 'task')
184-
])
185-
186-
if not agent.parent_agent or not hasattr(
187-
agent.parent_agent, 'disallow_transfer_to_parent'
188-
):
186+
if hasattr(agent, 'sub_agents') and agent.sub_agents:
187+
result.extend([
188+
sub_agent
189+
for sub_agent in agent.sub_agents
190+
if not hasattr(sub_agent, 'mode')
191+
or sub_agent.mode not in ('single_turn', 'task')
192+
])
193+
194+
parent = getattr(agent, 'parent_agent', None)
195+
if not parent or not hasattr(parent, 'disallow_transfer_to_parent'):
189196
return result
190197

191-
if not agent.disallow_transfer_to_parent:
192-
result.append(agent.parent_agent)
198+
if not getattr(agent, 'disallow_transfer_to_parent', False):
199+
result.append(parent)
193200

194-
if not agent.disallow_transfer_to_peers:
201+
if not getattr(agent, 'disallow_transfer_to_peers', False):
195202
result.extend([
196203
peer_agent
197-
for peer_agent in agent.parent_agent.sub_agents
198-
if peer_agent.name != agent.name
204+
for peer_agent in getattr(parent, 'sub_agents', []) or []
205+
if getattr(peer_agent, 'name', None) != getattr(agent, 'name', None)
199206
and (
200207
not hasattr(peer_agent, 'mode')
201208
or peer_agent.mode not in ('single_turn', 'task')
@@ -219,3 +226,38 @@ def _build_transfer_tool(
219226
return TransferToAgentTool(
220227
agent_names=[target.name for target in transfer_targets]
221228
)
229+
230+
231+
def _get_incompatible_builtin_tool_error(agent: BaseAgent) -> str | None:
232+
"""Returns an error message if the agent uses built-in tools incompatible with function calling."""
233+
tools = getattr(agent, 'tools', None)
234+
if not tools:
235+
return None
236+
237+
from ...tools.enterprise_search_tool import EnterpriseWebSearchTool
238+
from ...tools.google_search_tool import GoogleSearchTool
239+
from ...tools.vertex_ai_search_tool import VertexAiSearchTool
240+
241+
agent_name = getattr(agent, 'name', '')
242+
for tool in tools:
243+
if (
244+
isinstance(tool, (GoogleSearchTool, VertexAiSearchTool))
245+
and not tool.bypass_multi_tools_limit
246+
):
247+
return (
248+
f"Agent '{agent_name}' has sub-agent transfer targets but is"
249+
f' configured with {tool.__class__.__name__} without'
250+
' bypass_multi_tools_limit=True. Gemini API does not allow built-in'
251+
' search tools to be combined with function calling (agent'
252+
' delegation). To enable both search and sub-agent delegation, set'
253+
' bypass_multi_tools_limit=True on GoogleSearchTool or'
254+
' VertexAiSearchTool.'
255+
)
256+
if isinstance(tool, EnterpriseWebSearchTool):
257+
return (
258+
f"Agent '{agent_name}' has sub-agent transfer targets but is"
259+
' configured with EnterpriseWebSearchTool. Gemini API does not allow'
260+
' EnterpriseWebSearchTool to be combined with function calling'
261+
' (agent delegation).'
262+
)
263+
return None

‎src/google/adk/flows/llm_flows/base_llm_flow.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,9 @@ async def _process_agent_tools(
492492
return
493493
agent = cast('LlmAgent', raw_agent)
494494

495-
multiple_tools = len(agent.tools) > 1
495+
from .agent_transfer import _get_transfer_targets
496+
497+
multiple_tools = len(agent.tools) > 1 or bool(_get_transfer_targets(agent))
496498
model = agent.canonical_model
497499

498500
from ...agents.llm_agent import _convert_tool_union_to_tools

‎tests/unittests/agents/test_llm_agent_fields.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from google.adk.planners.built_in_planner import BuiltInPlanner
3434
from google.adk.sessions.in_memory_session_service import InMemorySessionService
3535
from google.adk.tools.base_toolset import BaseToolset
36+
from google.adk.tools.enterprise_search_tool import EnterpriseWebSearchTool
3637
from google.adk.tools.function_tool import FunctionTool
3738
from google.adk.tools.google_search_tool import google_search
3839
from google.adk.tools.google_search_tool import GoogleSearchTool
@@ -557,6 +558,115 @@ async def test_handle_vais_only(self):
557558
assert tools[0].name == 'vertex_ai_search'
558559
assert tools[0].__class__.__name__ == 'VertexAiSearchTool'
559560

561+
async def test_handle_google_search_in_hierarchy_with_bypass(self):
562+
"""Test that google_search with bypass is wrapped when in an agent hierarchy."""
563+
search_agent = LlmAgent(
564+
name='search_agent',
565+
model='gemini-pro',
566+
tools=[GoogleSearchTool(bypass_multi_tools_limit=True)],
567+
)
568+
_ = LlmAgent(
569+
name='root_agent',
570+
model='gemini-pro',
571+
sub_agents=[search_agent],
572+
)
573+
ctx = await _create_readonly_context(search_agent)
574+
tools = await search_agent.canonical_tools(ctx)
575+
576+
assert len(tools) == 1
577+
assert tools[0].name == 'google_search_agent'
578+
assert tools[0].__class__.__name__ == 'GoogleSearchAgentTool'
579+
580+
async def test_handle_google_search_in_hierarchy_no_bypass(self):
581+
"""Test that google_search without bypass is not wrapped even in a hierarchy."""
582+
search_agent = LlmAgent(
583+
name='search_agent',
584+
model='gemini-pro',
585+
tools=[google_search],
586+
)
587+
_ = LlmAgent(
588+
name='root_agent',
589+
model='gemini-pro',
590+
sub_agents=[search_agent],
591+
)
592+
ctx = await _create_readonly_context(search_agent)
593+
tools = await search_agent.canonical_tools(ctx)
594+
595+
assert len(tools) == 1
596+
assert tools[0].name == 'google_search'
597+
assert tools[0].__class__.__name__ == 'GoogleSearchTool'
598+
599+
@mock.patch(
600+
'google.auth.default',
601+
mock.MagicMock(return_value=('credentials', 'project')),
602+
)
603+
async def test_handle_vais_in_hierarchy_with_bypass(self):
604+
"""Test that VertexAiSearchTool with bypass is replaced when in an agent hierarchy."""
605+
search_agent = LlmAgent(
606+
name='search_agent',
607+
model='gemini-pro',
608+
tools=[
609+
VertexAiSearchTool(
610+
data_store_id='test_data_store_id',
611+
bypass_multi_tools_limit=True,
612+
),
613+
],
614+
)
615+
_ = LlmAgent(
616+
name='root_agent',
617+
model='gemini-pro',
618+
sub_agents=[search_agent],
619+
)
620+
ctx = await _create_readonly_context(search_agent)
621+
tools = await search_agent.canonical_tools(ctx)
622+
623+
assert len(tools) == 1
624+
assert tools[0].name == 'discovery_engine_search'
625+
assert tools[0].__class__.__name__ == 'DiscoveryEngineSearchTool'
626+
627+
async def test_handle_vais_in_hierarchy_no_bypass(self):
628+
"""Test that VertexAiSearchTool without bypass is not replaced even in a hierarchy."""
629+
search_agent = LlmAgent(
630+
name='search_agent',
631+
model='gemini-pro',
632+
tools=[
633+
VertexAiSearchTool(
634+
data_store_id='test_data_store_id',
635+
bypass_multi_tools_limit=False,
636+
),
637+
],
638+
)
639+
_ = LlmAgent(
640+
name='root_agent',
641+
model='gemini-pro',
642+
sub_agents=[search_agent],
643+
)
644+
ctx = await _create_readonly_context(search_agent)
645+
tools = await search_agent.canonical_tools(ctx)
646+
647+
assert len(tools) == 1
648+
assert tools[0].name == 'vertex_ai_search'
649+
assert tools[0].__class__.__name__ == 'VertexAiSearchTool'
650+
651+
async def test_handle_enterprise_web_search_in_hierarchy(self):
652+
"""Enterprise web search without bypass remains a built-in search tool in a hierarchy."""
653+
search_agent = LlmAgent(
654+
name='search_agent',
655+
model='gemini-pro',
656+
tools=[EnterpriseWebSearchTool()],
657+
)
658+
_ = LlmAgent(
659+
name='root_agent',
660+
model='gemini-pro',
661+
sub_agents=[search_agent],
662+
)
663+
ctx = await _create_readonly_context(search_agent)
664+
tools = await search_agent.canonical_tools(ctx)
665+
666+
assert len(tools) == 1
667+
assert tools[0].name == 'enterprise_web_search'
668+
assert tools[0].__class__.__name__ == 'EnterpriseWebSearchTool'
669+
560670
async def test_multiple_tools_resolution(self):
561671
"""Test that multiple tools are resolved correctly."""
562672

0 commit comments

Comments
 (0)