diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index bb1af0a1d4..26d6dd0825 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -643,7 +643,10 @@ def canonical_live_model(self) -> BaseLlm: def set_default_model(cls, model: Union[str, BaseLlm]) -> None: """Overrides the default model used when an agent has no model set.""" if not isinstance(model, (str, BaseLlm)): - raise TypeError('Default model must be a model name or BaseLlm.') + raise TypeError( + 'Default model must be a model name (str) or BaseLlm instance,' + f' got {type(model).__name__}.' + ) if isinstance(model, str) and not model: raise ValueError('Default model must be a non-empty string.') cls._default_model = model @@ -660,7 +663,10 @@ def _resolve_default_model(cls) -> BaseLlm: def set_default_live_model(cls, model: Union[str, BaseLlm]) -> None: """Overrides the default model used for live mode when an agent has no model set.""" if not isinstance(model, (str, BaseLlm)): - raise TypeError('Default live model must be a model name or BaseLlm.') + raise TypeError( + 'Default live model must be a model name (str) or BaseLlm' + f' instance, got {type(model).__name__}.' + ) if isinstance(model, str) and not model: raise ValueError('Default live model must be a non-empty string.') cls._default_live_model = model @@ -1080,22 +1086,31 @@ def validate_generate_content_config( if not generate_content_config: return types.GenerateContentConfig() if generate_content_config.tools: - raise ValueError('All tools must be set via LlmAgent.tools.') + raise ValueError( + 'All tools must be set via LlmAgent.tools, not via' + ' generate_content_config.tools. Move your tools to the' + ' LlmAgent(tools=[...]) parameter.' + ) if generate_content_config.system_instruction: raise ValueError( - 'System instruction must be set via LlmAgent.instruction.' + 'System instruction must be set via LlmAgent.instruction, not' + ' via generate_content_config.system_instruction. Move your' + ' instruction to LlmAgent(instruction="...").' ) if generate_content_config.response_schema: raise ValueError( - 'Response schema must be set via LlmAgent.output_schema.' + 'Response schema must be set via LlmAgent.output_schema, not' + ' via generate_content_config.response_schema. Move your' + ' schema to LlmAgent(output_schema=...).' ) if ( generate_content_config.http_options and generate_content_config.http_options.base_url ): raise ValueError( - 'Base URL is a transport setting and must be set on the model or' - ' its client, not via LlmAgent.generate_content_config.' + 'Base URL is a transport setting and must be set on the model' + ' or its client, not via' + ' LlmAgent.generate_content_config.http_options.base_url.' ) return generate_content_config diff --git a/src/google/adk/agents/llm_agent_config.py b/src/google/adk/agents/llm_agent_config.py index 896ce3ccc0..ea4f5bbac4 100644 --- a/src/google/adk/agents/llm_agent_config.py +++ b/src/google/adk/agents/llm_agent_config.py @@ -77,7 +77,11 @@ class LlmAgentConfig(BaseAgentConfig): @model_validator(mode='after') def _validate_model_sources(self) -> LlmAgentConfig: if self.model and self.model_code: - raise ValueError('Only one of `model` or `model_code` should be set.') + raise ValueError( + 'Only one of `model` or `model_code` should be set, but both' + f' were provided. Got model={self.model!r} and' + f' model_code={self.model_code!r}.' + ) return self diff --git a/src/google/adk/models/llm_request.py b/src/google/adk/models/llm_request.py index 48fc51df84..c93c7c5de3 100644 --- a/src/google/adk/models/llm_request.py +++ b/src/google/adk/models/llm_request.py @@ -257,7 +257,10 @@ def append_instructions( return [] # Invalid input - raise TypeError("instructions must be list[str] or types.Content") + raise TypeError( + "instructions must be list[str] or types.Content, got" + f" {type(instructions).__name__}." + ) def append_tools(self, tools: list[BaseTool]) -> None: """Appends tools to the request. @@ -311,7 +314,10 @@ def set_output_schema( """ schema = output_schema or base_model if schema is None: - raise ValueError("Either output_schema or base_model must be provided.") + raise ValueError( + "Either output_schema or base_model must be provided." + " Pass output_schema= (base_model is deprecated)." + ) self.config.response_schema = schema self.config.response_mime_type = "application/json" diff --git a/src/google/adk/runners.py b/src/google/adk/runners.py index adfd7e84e8..995fb926af 100644 --- a/src/google/adk/runners.py +++ b/src/google/adk/runners.py @@ -291,9 +291,23 @@ def _resolve_app( # Validate mutual exclusivity. provided = sum(x is not None for x in (app, agent, node)) if provided > 1: - raise ValueError('Only one of app, agent, or node may be provided.') + provided_args = [] + if app is not None: + provided_args.append(f'app={type(app).__name__}') + if agent is not None: + provided_args.append(f'agent={type(agent).__name__}') + if node is not None: + provided_args.append(f'node={type(node).__name__}') + args_str = ', '.join(provided_args) + raise ValueError( + 'Only one of app, agent, or node may be provided, but got:' + f' {args_str}. Pass exactly one to Runner().' + ) if provided == 0: - raise ValueError('One of app, agent, or node must be provided.') + raise ValueError( + 'One of app, agent, or node must be provided. Got none.' + ' Pass exactly one to Runner().' + ) # Handle deprecated plugins argument. if plugins is not None: @@ -443,9 +457,13 @@ def _resolve_invocation_id( session.events, function_responses[0].id ) if not fc_event: + fr_id = function_responses[0].id + fr_name = function_responses[0].name raise ValueError( - 'Function call event not found for function response id:' - f' {function_responses[0].id}' + 'Function call event not found for function response' + f' (id={fr_id!r}, name={fr_name!r}). Ensure the function' + ' call ID matches an existing function call in the session' + ' history.' ) if invocation_id and invocation_id != fc_event.invocation_id: @@ -787,11 +805,14 @@ def _resolve_invocation_id_from_fr( if fr_ids: raise ValueError( f'Function call not found for function response ids: {fr_ids}.' + ' Ensure each function response ID matches an existing function' + ' call in the session history.' ) if len(invocation_ids) > 1: raise ValueError( 'Function responses resolve to multiple' - f' invocations: {invocation_ids}.' + f' invocations: {invocation_ids}. All function responses in a' + ' single message must belong to the same invocation.' ) return invocation_ids.pop() diff --git a/tests/unittests/agents/test_improved_error_messages.py b/tests/unittests/agents/test_improved_error_messages.py new file mode 100644 index 0000000000..14ff985e95 --- /dev/null +++ b/tests/unittests/agents/test_improved_error_messages.py @@ -0,0 +1,173 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for improved error messages with actionable context. + +These tests verify that error messages include: +- The actual type/value that was provided (not just what was expected) +- Actionable guidance on how to fix the issue +- Enough context for users to debug without additional investigation +""" + +from unittest.mock import MagicMock + +from google.adk.agents.llm_agent import LlmAgent +from google.adk.models.llm_request import LlmRequest +from google.genai import types +import pytest + +# --- Runner._resolve_app tests --- + + +class TestRunnerResolveAppErrors: + """Tests for Runner._resolve_app error messages.""" + + def test_multiple_args_shows_which_were_provided(self): + """Error message should list which arguments were actually provided.""" + from google.adk.runners import Runner + + mock_agent = LlmAgent(name='test', model='gemini-2.5-flash') + mock_app = MagicMock() + mock_app.__class__.__name__ = 'App' + + with pytest.raises(ValueError, match=r'but got:.*app=.*agent='): + Runner._resolve_app( + app=mock_app, + app_name=None, + agent=mock_agent, + node=None, + plugins=None, + ) + + def test_no_args_message_includes_guidance(self): + """Error message should tell the user to pass exactly one argument.""" + from google.adk.runners import Runner + + with pytest.raises(ValueError, match=r'Got none.*Pass exactly one'): + Runner._resolve_app( + app=None, + app_name=None, + agent=None, + node=None, + plugins=None, + ) + + +# --- LlmAgent.set_default_model tests --- + + +class TestSetDefaultModelErrors: + """Tests for LlmAgent.set_default_model error messages.""" + + def test_wrong_type_shows_actual_type(self): + """TypeError should include the actual type that was passed.""" + with pytest.raises(TypeError, match=r'got int'): + LlmAgent.set_default_model(123) + + def test_wrong_type_list_shows_actual_type(self): + """TypeError should show 'list' when a list is passed.""" + with pytest.raises(TypeError, match=r'got list'): + LlmAgent.set_default_model(['gemini-2.5-flash']) + + def test_empty_string_still_raises_value_error(self): + """Empty string should still raise ValueError (not changed).""" + with pytest.raises(ValueError, match=r'non-empty string'): + LlmAgent.set_default_model('') + + +# --- LlmAgent.set_default_live_model tests --- + + +class TestSetDefaultLiveModelErrors: + """Tests for LlmAgent.set_default_live_model error messages.""" + + def test_wrong_type_shows_actual_type(self): + """TypeError should include the actual type that was passed.""" + with pytest.raises(TypeError, match=r'got dict'): + LlmAgent.set_default_live_model({}) + + def test_empty_string_still_raises_value_error(self): + """Empty string should still raise ValueError (not changed).""" + with pytest.raises(ValueError, match=r'non-empty string'): + LlmAgent.set_default_live_model('') + + +# --- LlmAgent.validate_generate_content_config tests --- + + +class TestValidateGenerateContentConfigErrors: + """Tests for LlmAgent.validate_generate_content_config error messages.""" + + def test_tools_error_includes_move_guidance(self): + """Error should tell users to move tools to LlmAgent(tools=[...]).""" + config = types.GenerateContentConfig( + tools=[types.Tool(function_declarations=[])] + ) + with pytest.raises(ValueError, match=r'Move your tools'): + LlmAgent.validate_generate_content_config(config) + + def test_system_instruction_error_includes_move_guidance(self): + """Error should tell users to move instruction to LlmAgent(instruction=...).""" + config = types.GenerateContentConfig(system_instruction='You are helpful.') + with pytest.raises(ValueError, match=r'Move your instruction'): + LlmAgent.validate_generate_content_config(config) + + def test_response_schema_error_includes_move_guidance(self): + """Error should tell users to move schema to LlmAgent(output_schema=...).""" + config = types.GenerateContentConfig(response_schema={'type': 'string'}) + with pytest.raises(ValueError, match=r'Move your schema'): + LlmAgent.validate_generate_content_config(config) + + +# --- LlmRequest.append_instructions tests --- + + +class TestAppendInstructionsErrors: + """Tests for LlmRequest.append_instructions error messages.""" + + def test_wrong_type_shows_actual_type(self): + """TypeError should include the actual type that was passed.""" + request = LlmRequest() + with pytest.raises(TypeError, match=r'got int'): + request.append_instructions(42) + + def test_wrong_type_dict_shows_actual_type(self): + """TypeError should show 'dict' when a dict is passed.""" + request = LlmRequest() + with pytest.raises(TypeError, match=r'got dict'): + request.append_instructions({'instruction': 'test'}) + + +# --- LlmAgentConfig tests --- + + +class TestLlmAgentConfigErrors: + """Tests for LlmAgentConfig validation error messages.""" + + def test_both_model_sources_shows_actual_values(self): + """Error should include the actual model and model_code values.""" + import warnings + + from google.adk.agents.common_configs import CodeConfig + from google.adk.agents.llm_agent_config import LlmAgentConfig + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + with pytest.raises(ValueError, match=r'both were provided.*Got model='): + LlmAgentConfig( + name='test_agent', + instruction='test', + model='gemini-2.5-flash', + model_code=CodeConfig(name='my_module.MyModel'), + )