Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/google/adk/agents/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion src/google/adk/agents/llm_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions src/google/adk/models/llm_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=<your_schema> (base_model is deprecated)."
)

self.config.response_schema = schema
self.config.response_mime_type = "application/json"
31 changes: 26 additions & 5 deletions src/google/adk/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
173 changes: 173 additions & 0 deletions tests/unittests/agents/test_improved_error_messages.py
Original file line number Diff line number Diff line change
@@ -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'),
)
Loading