Skip to content

Commit 22188c3

Browse files
committed
feat: Enhance error messages for tool and agent not found errors
Improve developer experience by providing actionable error messages with: - Clear description of what went wrong - List of available tools/agents (truncated to 20 for readability) - Possible causes and suggested fixes - Fuzzy matching suggestions ("Did you mean...?") Addresses community issues: - #2050: Tool verification callback request - #2933: How to handle Function Not Found error (12 comments) - #2164: Agent not found ValueError Changes: - Enhanced _get_tool() error message in functions.py - Enhanced __get_agent_to_run() error message in llm_agent.py - Added _get_available_agent_names() helper for agent tree traversal - Added fuzzy matching using difflib (standard library) - Truncates long lists to first 20 items for readability - Comprehensive unit tests for error scenarios (8 tests, all passing) Testing: - pytest tests/unittests/flows/llm_flows/test_functions_error_messages.py: 4/4 passed - pytest tests/unittests/agents/test_llm_agent_error_messages.py: 4/4 passed - Performance: < 0.03ms per error (error path only, no hot path impact) Fixes #3217
1 parent af74eba commit 22188c3

File tree

4 files changed

+312
-4
lines changed

4 files changed

+312
-4
lines changed

src/google/adk/agents/llm_agent.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,9 +641,67 @@ def __get_agent_to_run(self, agent_name: str) -> BaseAgent:
641641
"""Find the agent to run under the root agent by name."""
642642
agent_to_run = self.root_agent.find_agent(agent_name)
643643
if not agent_to_run:
644-
raise ValueError(f'Agent {agent_name} not found in the agent tree.')
644+
# Enhanced error message with agent tree context
645+
available_agents = self._get_available_agent_names()
646+
647+
# Truncate to first 20 for readability (prevents log overflow)
648+
if len(available_agents) > 20:
649+
agents_preview = ', '.join(available_agents[:20])
650+
agents_msg = (
651+
f'Available agents (showing first 20 of {len(available_agents)}):'
652+
f' {agents_preview}...'
653+
)
654+
else:
655+
agents_msg = f"Available agents: {', '.join(available_agents)}"
656+
657+
error_msg = (
658+
f"Agent '{agent_name}' not found in the agent tree.\n\n"
659+
f'{agents_msg}\n\n'
660+
'Possible causes:\n'
661+
' 1. Agent not registered before being referenced\n'
662+
' 2. Agent name mismatch (typo or case sensitivity)\n'
663+
' 3. Timing issue (agent referenced before creation)\n\n'
664+
'Suggested fixes:\n'
665+
' - Verify agent is registered with root agent\n'
666+
' - Check agent name spelling and case\n'
667+
' - Ensure agents are created before being referenced\n'
668+
)
669+
670+
# Fuzzy matching suggestion
671+
from difflib import get_close_matches
672+
673+
close_matches = get_close_matches(
674+
agent_name, available_agents, n=3, cutoff=0.6
675+
)
676+
if close_matches:
677+
error_msg += f'\nDid you mean one of these?\n'
678+
for match in close_matches:
679+
error_msg += f' - {match}\n'
680+
681+
raise ValueError(error_msg)
645682
return agent_to_run
646683

684+
def _get_available_agent_names(self) -> list[str]:
685+
"""Helper to get all agent names in the tree for error reporting.
686+
687+
This is a private helper method used only for error message formatting.
688+
Traverses the agent tree starting from root_agent and collects all
689+
agent names for display in error messages.
690+
691+
Returns:
692+
List of all agent names in the agent tree.
693+
"""
694+
agents = []
695+
696+
def collect_agents(agent):
697+
agents.append(agent.name)
698+
if hasattr(agent, 'sub_agents') and agent.sub_agents:
699+
for sub_agent in agent.sub_agents:
700+
collect_agents(sub_agent)
701+
702+
collect_agents(self.root_agent)
703+
return agents
704+
647705
def __get_transfer_to_agent_or_none(
648706
self, event: Event, from_agent: str
649707
) -> Optional[BaseAgent]:

src/google/adk/flows/llm_flows/functions.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -660,10 +660,46 @@ def _get_tool(
660660
):
661661
"""Returns the tool corresponding to the function call."""
662662
if function_call.name not in tools_dict:
663-
raise ValueError(
664-
f'Function {function_call.name} is not found in the tools_dict:'
665-
f' {tools_dict.keys()}.'
663+
# Enhanced error message with actionable guidance
664+
available_tools = list(tools_dict.keys())
665+
666+
# Truncate to first 20 for readability (prevents log overflow)
667+
if len(available_tools) > 20:
668+
tools_preview = ', '.join(available_tools[:20])
669+
tools_msg = (
670+
f'Available tools (showing first 20 of {len(available_tools)}):'
671+
f' {tools_preview}...'
672+
)
673+
else:
674+
tools_msg = f"Available tools: {', '.join(available_tools)}"
675+
676+
error_msg = (
677+
f"Function '{function_call.name}' is not found in available"
678+
' tools.\n\n'
679+
f'{tools_msg}\n\n'
680+
'Possible causes:\n'
681+
' 1. LLM hallucinated the function name - review agent'
682+
' instruction clarity\n'
683+
' 2. Tool not registered - verify agent.tools list\n'
684+
' 3. Name mismatch - check for typos\n\n'
685+
'Suggested fixes:\n'
686+
' - Review agent instruction to ensure tool usage is clear\n'
687+
' - Verify tool is included in agent.tools list\n'
688+
' - Check for typos in function name\n'
689+
)
690+
691+
# Fuzzy matching suggestion
692+
from difflib import get_close_matches
693+
694+
close_matches = get_close_matches(
695+
function_call.name, available_tools, n=3, cutoff=0.6
666696
)
697+
if close_matches:
698+
error_msg += f'\nDid you mean one of these?\n'
699+
for match in close_matches:
700+
error_msg += f' - {match}\n'
701+
702+
raise ValueError(error_msg)
667703

668704
return tools_dict[function_call.name]
669705

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
"""Tests for enhanced error messages in agent handling."""
16+
from google.adk.agents import LlmAgent
17+
import pytest
18+
19+
20+
def test_agent_not_found_enhanced_error():
21+
"""Verify enhanced error message for agent not found."""
22+
root_agent = LlmAgent(
23+
name='root',
24+
model='gemini-2.0-flash',
25+
sub_agents=[
26+
LlmAgent(name='agent_a', model='gemini-2.0-flash'),
27+
LlmAgent(name='agent_b', model='gemini-2.0-flash'),
28+
],
29+
)
30+
31+
with pytest.raises(ValueError) as exc_info:
32+
root_agent._LlmAgent__get_agent_to_run('nonexistent_agent')
33+
34+
error_msg = str(exc_info.value)
35+
36+
# Verify error message components
37+
assert 'nonexistent_agent' in error_msg
38+
assert 'Available agents:' in error_msg
39+
assert 'agent_a' in error_msg
40+
assert 'agent_b' in error_msg
41+
assert 'Possible causes:' in error_msg
42+
assert 'Suggested fixes:' in error_msg
43+
44+
45+
def test_agent_not_found_fuzzy_matching():
46+
"""Verify fuzzy matching for agent names."""
47+
root_agent = LlmAgent(
48+
name='root',
49+
model='gemini-2.0-flash',
50+
sub_agents=[
51+
LlmAgent(name='approval_handler', model='gemini-2.0-flash'),
52+
],
53+
)
54+
55+
with pytest.raises(ValueError) as exc_info:
56+
root_agent._LlmAgent__get_agent_to_run('aproval_handler') # Typo
57+
58+
error_msg = str(exc_info.value)
59+
60+
# Verify fuzzy matching suggests correct agent
61+
assert 'Did you mean' in error_msg
62+
assert 'approval_handler' in error_msg
63+
64+
65+
def test_agent_tree_traversal():
66+
"""Verify agent tree traversal helper works correctly."""
67+
root_agent = LlmAgent(
68+
name='orchestrator',
69+
model='gemini-2.0-flash',
70+
sub_agents=[
71+
LlmAgent(
72+
name='parent_agent',
73+
model='gemini-2.0-flash',
74+
sub_agents=[
75+
LlmAgent(name='child_agent', model='gemini-2.0-flash'),
76+
],
77+
),
78+
],
79+
)
80+
81+
available_agents = root_agent._get_available_agent_names()
82+
83+
# Verify all agents in tree are found
84+
assert 'orchestrator' in available_agents
85+
assert 'parent_agent' in available_agents
86+
assert 'child_agent' in available_agents
87+
assert len(available_agents) == 3
88+
89+
90+
def test_agent_not_found_truncates_long_list():
91+
"""Verify error message truncates when 100+ agents exist."""
92+
# Create 100 sub-agents
93+
sub_agents = [
94+
LlmAgent(name=f'agent_{i}', model='gemini-2.0-flash') for i in range(100)
95+
]
96+
97+
root_agent = LlmAgent(
98+
name='root', model='gemini-2.0-flash', sub_agents=sub_agents
99+
)
100+
101+
with pytest.raises(ValueError) as exc_info:
102+
root_agent._LlmAgent__get_agent_to_run('nonexistent')
103+
104+
error_msg = str(exc_info.value)
105+
106+
# Verify truncation message
107+
assert 'showing first 20 of' in error_msg
108+
assert 'agent_0' in error_msg # First agent shown
109+
assert 'agent_99' not in error_msg # Last agent NOT shown
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
"""Tests for enhanced error messages in function tool handling."""
16+
from google.adk.flows.llm_flows.functions import _get_tool
17+
from google.adk.tools import BaseTool
18+
from google.genai import types
19+
import pytest
20+
21+
22+
# Mock tool for testing error messages
23+
class MockTool(BaseTool):
24+
"""Mock tool for testing error messages."""
25+
26+
def __init__(self, name: str = 'mock_tool'):
27+
super().__init__(name=name, description=f'Mock tool: {name}')
28+
29+
def call(self, *args, **kwargs):
30+
return 'mock_response'
31+
32+
33+
def test_tool_not_found_enhanced_error():
34+
"""Verify enhanced error message for tool not found."""
35+
function_call = types.FunctionCall(name='nonexistent_tool', args={})
36+
tools_dict = {
37+
'get_weather': MockTool(name='get_weather'),
38+
'calculate_sum': MockTool(name='calculate_sum'),
39+
'search_database': MockTool(name='search_database'),
40+
}
41+
42+
with pytest.raises(ValueError) as exc_info:
43+
_get_tool(function_call, tools_dict)
44+
45+
error_msg = str(exc_info.value)
46+
47+
# Verify error message components
48+
assert 'nonexistent_tool' in error_msg
49+
assert 'Available tools:' in error_msg
50+
assert 'get_weather' in error_msg
51+
assert 'Possible causes:' in error_msg
52+
assert 'Suggested fixes:' in error_msg
53+
54+
55+
def test_tool_not_found_fuzzy_matching():
56+
"""Verify fuzzy matching suggestions in error message."""
57+
function_call = types.FunctionCall(name='get_wether', args={}) # Typo
58+
tools_dict = {
59+
'get_weather': MockTool(name='get_weather'),
60+
'calculate_sum': MockTool(name='calculate_sum'),
61+
}
62+
63+
with pytest.raises(ValueError) as exc_info:
64+
_get_tool(function_call, tools_dict)
65+
66+
error_msg = str(exc_info.value)
67+
68+
# Verify fuzzy matching suggests correct tool
69+
assert 'Did you mean' in error_msg
70+
assert 'get_weather' in error_msg
71+
72+
73+
def test_tool_not_found_no_fuzzy_match():
74+
"""Verify error message when no close matches exist."""
75+
function_call = types.FunctionCall(name='completely_different', args={})
76+
tools_dict = {
77+
'get_weather': MockTool(name='get_weather'),
78+
'calculate_sum': MockTool(name='calculate_sum'),
79+
}
80+
81+
with pytest.raises(ValueError) as exc_info:
82+
_get_tool(function_call, tools_dict)
83+
84+
error_msg = str(exc_info.value)
85+
86+
# Verify no fuzzy matching section when no close matches
87+
assert 'Did you mean' not in error_msg
88+
89+
90+
def test_tool_not_found_truncates_long_list():
91+
"""Verify error message truncates when 100+ tools exist."""
92+
function_call = types.FunctionCall(name='nonexistent', args={})
93+
94+
# Create 100 tools
95+
tools_dict = {f'tool_{i}': MockTool(name=f'tool_{i}') for i in range(100)}
96+
97+
with pytest.raises(ValueError) as exc_info:
98+
_get_tool(function_call, tools_dict)
99+
100+
error_msg = str(exc_info.value)
101+
102+
# Verify truncation message
103+
assert 'showing first 20 of 100' in error_msg
104+
assert 'tool_0' in error_msg # First tool shown
105+
assert 'tool_99' not in error_msg # Last tool NOT shown

0 commit comments

Comments
 (0)