chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,131 @@
# 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.
from typing import Optional
from typing import Union
class MockBlob:
"""Mocks a GCS Blob object.
This class provides mock implementations for a few common GCS Blob methods,
allowing the user to test code that interacts with GCS without actually
connecting to a real bucket.
"""
def __init__(self, name: str) -> None:
"""Initializes a MockBlob.
Args:
name: The name of the blob.
"""
self.name = name
self.content: Optional[bytes] = None
self.content_type: Optional[str] = None
self._exists: bool = False
def upload_from_string(
self, data: Union[str, bytes], content_type: Optional[str] = None
) -> None:
"""Mocks uploading data to the blob (from a string or bytes).
Args:
data: The data to upload (string or bytes).
content_type: The content type of the data (optional).
"""
if isinstance(data, str):
self.content = data.encode("utf-8")
elif isinstance(data, bytes):
self.content = data
else:
raise TypeError("data must be str or bytes")
if content_type:
self.content_type = content_type
self._exists = True
def download_as_text(self) -> str:
"""Mocks downloading the blob's content as text.
Returns:
str: The content of the blob as text.
Raises:
Exception: If the blob doesn't exist (hasn't been uploaded to).
"""
if self.content is None:
return b""
return self.content
def delete(self) -> None:
"""Mocks deleting a blob."""
self.content = None
self.content_type = None
self._exists = False
def exists(self) -> bool:
"""Mocks checking if the blob exists."""
return self._exists
class MockBucket:
"""Mocks a GCS Bucket object."""
def __init__(self, name: str) -> None:
"""Initializes a MockBucket.
Args:
name: The name of the bucket.
"""
self.name = name
self.blobs: dict[str, MockBlob] = {}
def blob(self, blob_name: str) -> MockBlob:
"""Mocks getting a Blob object (doesn't create it in storage).
Args:
blob_name: The name of the blob.
Returns:
A MockBlob instance.
"""
if blob_name not in self.blobs:
self.blobs[blob_name] = MockBlob(blob_name)
return self.blobs[blob_name]
def list_blobs(self, prefix: Optional[str] = None) -> list[MockBlob]:
"""Mocks listing blobs in a bucket, optionally with a prefix."""
if prefix:
return [
blob for name, blob in self.blobs.items() if name.startswith(prefix)
]
return list(self.blobs.values())
def exists(self) -> bool:
"""Mocks checking if the bucket exists."""
return True
class MockClient:
"""Mocks the GCS Client."""
def __init__(self) -> None:
"""Initializes MockClient."""
self.buckets: dict[str, MockBucket] = {}
def bucket(self, bucket_name: str) -> MockBucket:
"""Mocks getting a Bucket object."""
if bucket_name not in self.buckets:
self.buckets[bucket_name] = MockBucket(bucket_name)
return self.buckets[bucket_name]
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,420 @@
# 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.
from __future__ import annotations
from google.adk.evaluation import conversation_scenarios
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from google.adk.evaluation.simulation.user_simulator import Status
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.events.event import Event
from google.genai import types
from pydantic import ValidationError
import pytest
_INPUT_EVENTS = [
Event(
author="user",
content=types.Content(
parts=[types.Part(text="Can you help me?")], role="user"
),
invocation_id="inv1",
),
Event(
author="helpful_assistant",
content=types.Content(
parts=[
types.Part(
text="I'll get the user's name and greet them first.",
thought=True,
),
types.Part(
function_call=types.FunctionCall(name="get_user_name")
),
types.Part(
function_response=types.FunctionResponse(
name="get_user_name",
response={"name": "John Doe"},
)
),
types.Part(text="Hi John, what can I do for you?"),
],
role="model",
),
invocation_id="inv1",
),
]
_INPUT_EVENTS_LONG = _INPUT_EVENTS + [
Event(
author="user",
content=types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
),
invocation_id="inv2",
),
Event(
author="helpful_assistant",
content=types.Content(
parts=[
types.Part(
text="Sure, what is your departure date and destination?",
),
],
role="model",
),
invocation_id="inv2",
),
]
_EXPECTED_REWRITTEN_DIALOGUE = """user: Can you help me?
helpful_assistant: Hi John, what can I do for you?"""
_EXPECTED_REWRITTEN_DIALOGUE_LONG = _EXPECTED_REWRITTEN_DIALOGUE + """
user: I need to book a flight.
helpful_assistant: Sure, what is your departure date and destination?"""
def test_llm_backed_user_simulator_config_validation():
"""Tests for LlmBackedUserSimulatorConfig."""
config = LlmBackedUserSimulatorConfig(custom_instructions=None)
assert config.custom_instructions is None
valid_instructions = (
"{{ stop_signal }} {{ conversation_plan }} {{ conversation_history }}"
)
config = LlmBackedUserSimulatorConfig(custom_instructions=valid_instructions)
assert config.custom_instructions == valid_instructions
invalid_instructions = "Instructions with missing formatting placeholders"
with pytest.raises(ValidationError):
LlmBackedUserSimulatorConfig(custom_instructions=invalid_instructions)
class TestHelperMethods:
"""Test cases for LlmBackedUserSimulator helper methods."""
def test_convert_conversation_to_user_sim_pov(self):
"""Tests _convert_conversation_to_user_sim_pov method."""
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS
)
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS_LONG
)
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE_LONG
def test_summarize_conversation_with_function_calls(self):
"""Tests _summarize_conversation with include_function_calls=True."""
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
_INPUT_EVENTS, include_function_calls=True
)
expected = (
"user: Can you help me?\n\n"
"helpful_assistant called tool 'get_user_name' with args: None\n\n"
"Tool 'get_user_name' returned: {'name': 'John Doe'}\n\n"
"helpful_assistant: Hi John, what can I do for you?"
)
assert rewritten_dialogue == expected
async def to_async_iter(items):
for item in items:
yield item
@pytest.fixture
def mock_llm_agent(mocker):
"""Provides a mock LLM agent."""
mock_llm_registry_cls = mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry",
autospec=True,
)
mock_llm_registry = mocker.MagicMock()
mock_llm_registry_cls.return_value = mock_llm_registry
mock_agent = mocker.MagicMock()
mock_llm_registry.resolve.return_value.return_value = mock_agent
return mock_agent
@pytest.fixture
def conversation_scenario():
"""Provides a test conversation scenario."""
return conversation_scenarios.ConversationScenario(
starting_prompt="Hello", conversation_plan="test plan"
)
@pytest.fixture
def user_persona():
"""Provides a test user persona."""
return UserPersona(
id="test_persona",
description="A test persona",
behaviors=[
UserBehavior(
name="polite",
description="is polite",
behavior_instructions=["Always say please and thank you."],
violation_rubrics=["is rude"],
)
],
)
@pytest.fixture
def conversation_scenario_with_persona(user_persona):
"""Provides a test conversation scenario with a user persona."""
return conversation_scenarios.ConversationScenario(
starting_prompt="Hello",
conversation_plan="test plan with persona",
user_persona=user_persona,
)
@pytest.fixture
def simulator(mock_llm_agent, conversation_scenario):
"""Provides an LlmBackedUserSimulator instance for testing."""
config = LlmBackedUserSimulatorConfig(
model="test-model",
)
sim = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario
)
sim._invocation_count = 1 # Bypass starting prompt by default for tests
return sim
@pytest.fixture
def simulator_with_persona(mock_llm_agent, conversation_scenario_with_persona):
"""Provides an LlmBackedUserSimulator instance for testing."""
config = LlmBackedUserSimulatorConfig(
model="test-model",
)
sim = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario_with_persona
)
sim._invocation_count = 1 # Bypass starting prompt by default for tests
return sim
class TestLlmBackedUserSimulator:
"""Test cases for LlmBackedUserSimulator main methods."""
@pytest.mark.asyncio
async def test_get_llm_response_return_value(
self, simulator, mock_llm_agent, mocker
):
"""Tests that _get_llm_response returns the full response correctly."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[
types.Part(text="some thought", thought=True),
types.Part(text="Hello world!"),
]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
response, error_reason = await simulator._get_llm_response(
rewritten_dialogue=""
)
assert response == "Hello world!"
assert error_reason is None
@pytest.mark.asyncio
async def test_get_next_user_message_first_invocation(
self, simulator, mock_llm_agent, conversation_scenario
):
"""Tests that the first invocation returns the starting prompt."""
simulator._invocation_count = 0 # override testing default
next_user_message = await simulator.get_next_user_message(events=[])
expected_user_message = types.Content(
parts=[types.Part(text=conversation_scenario.starting_prompt)],
role="user",
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
mock_llm_agent.generate_content_async.assert_not_called()
@pytest.mark.asyncio
async def test_turn_limit_reached(self, conversation_scenario):
"""Tests get_next_user_message when the turn limit is reached."""
config = LlmBackedUserSimulatorConfig(
max_allowed_invocations=1,
)
simulator = LlmBackedUserSimulator(
config=config, conversation_scenario=conversation_scenario
)
simulator._invocation_count = 1
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
assert next_user_message.status == Status.TURN_LIMIT_REACHED
assert next_user_message.user_message is None
@pytest.mark.asyncio
async def test_stop_signal_detected(self, simulator, mock_llm_agent, mocker):
"""Tests get_next_user_message when the stop signal is detected."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="Thanks! Bye!</finished>")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
assert next_user_message.status == Status.STOP_SIGNAL_DETECTED
assert next_user_message.user_message is None
@pytest.mark.asyncio
async def test_no_message_generated_empty_response(
self, simulator, mock_llm_agent
):
"""Tests get_next_user_message when no message is generated (empty stream)."""
mock_llm_agent.generate_content_async.return_value = to_async_iter([])
with pytest.raises(
RuntimeError,
match="Failed to generate a user message: LLM returned empty response",
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_safety_blocked(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when response is safety blocked."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.content = None
mock_llm_response.error_code = "SAFETY"
mock_llm_response.error_message = "Blocked by safety"
mock_llm_response.parts = []
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
with pytest.raises(
RuntimeError,
match=(
"Failed to generate a user message: safety filters or other error"
" \\(code=SAFETY\\)"
),
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_thinking_only(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when response contains only thinking tokens."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.content = types.Content(
parts=[
types.Part(text="thinking...", thought=True),
]
)
mock_llm_response.error_code = None
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
with pytest.raises(
RuntimeError,
match=(
"Failed to generate a user message: LLM returned only thinking"
" tokens"
),
):
await simulator.get_next_user_message(events=_INPUT_EVENTS)
@pytest.mark.asyncio
async def test_get_next_user_message_success(
self, simulator, mock_llm_agent, mocker
):
"""Tests get_next_user_message when the user message is generated successfully."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="I need to book a flight.")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator.get_next_user_message(
events=_INPUT_EVENTS
)
expected_user_message = types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
@pytest.mark.asyncio
async def test_get_next_user_message_with_persona_success(
self, simulator_with_persona, mock_llm_agent, mocker
):
"""Tests get_next_user_message when the user message is generated successfully."""
mock_llm_response = mocker.create_autospec(
types.GenerateContentResponse, instance=True
)
mock_llm_response.error_code = None
mock_llm_response.content = types.Content(
parts=[types.Part(text="I need to book a flight.")]
)
mock_llm_response.parts = mock_llm_response.content.parts
mock_llm_agent.generate_content_async.return_value = to_async_iter(
[mock_llm_response]
)
next_user_message = await simulator_with_persona.get_next_user_message(
events=_INPUT_EVENTS
)
expected_user_message = types.Content(
parts=[types.Part(text="I need to book a flight.")], role="user"
)
assert next_user_message.status == Status.SUCCESS
assert next_user_message.user_message == expected_user_message
@@ -0,0 +1,280 @@
# 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.
import textwrap
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _get_user_simulator_instructions_template
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import get_llm_backed_user_simulator_prompt
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import is_valid_user_simulator_template
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
Default template
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Stop signal
{{stop_signal}}
""").strip()
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
Persona template
# Persona Description
{{persona.description}}
{% for b in persona.behaviors %}
## {{ b.name }}
{{ b.description }}
Instructions:
{{ b.get_behavior_instructions_str() }}
{% endfor %}
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Stop signal
{{stop_signal}}
""").strip()
class TestGetUserSimulatorInstructionsTemplate:
"""Test cases for _get_user_simulator_instructions_template."""
def test_get_user_simulator_instructions_template_default(self):
assert (
_get_user_simulator_instructions_template()
== _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
)
def test_get_user_simulator_instructions_template_with_custom_instructions(
self,
):
custom_instructions = "custom instructions"
assert (
_get_user_simulator_instructions_template(
custom_instructions=custom_instructions
)
== custom_instructions
)
def test_get_user_simulator_instructions_template_with_persona(self):
user_persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
assert (
_get_user_simulator_instructions_template(user_persona=user_persona)
== _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
)
def test_get_user_simulator_instructions_template_with_bad_custom_instructions_raises_error(
self,
):
custom_instructions = "custom instructions"
user_persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
with pytest.raises(ValueError):
_get_user_simulator_instructions_template(
custom_instructions=custom_instructions, user_persona=user_persona
)
sample_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="Test behavior",
description="Test behavior description",
behavior_instructions=["instruction 1", "instruction 2"],
violation_rubrics=["rubric 1"],
)
],
)
class TestGetLlmBackedUserSimulatorPrompt:
"""Test cases for get_llm_backed_user_simulator_prompt."""
def test_get_llm_backed_user_simulator_prompt_default(self, mocker):
mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE",
_MOCK_DEFAULT_TEMPLATE,
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
)
expected_prompt = textwrap.dedent("""\
Default template
# Conversation Plan
test plan
# Conversation History
test history
# Stop signal
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_with_custom_instructions(self):
custom_instructions = textwrap.dedent("""\
Custom instructions:
# Past history
{{conversation_plan}}
# Plan
{{conversation_plan}}
# Finished!
{{stop_signal}}""").strip()
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
custom_instructions=custom_instructions,
)
expected_prompt = textwrap.dedent("""\
Custom instructions:
# Past history
test plan
# Plan
test plan
# Finished!
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_with_persona(self, mocker):
mocker.patch(
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE",
_MOCK_PERSONA_TEMPLATE,
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=sample_persona,
)
expected_prompt = textwrap.dedent("""\
Persona template
# Persona Description
Test persona description
## Test behavior
Test behavior description
Instructions:
* instruction 1
* instruction 2
# Conversation Plan
test plan
# Conversation History
test history
# Stop signal
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_renders_persona_templates_in_sandbox(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="Behavior {{ stop_signal }}",
description="Description {{ stop_signal }}",
behavior_instructions=["instruction {{ stop_signal }}"],
violation_rubrics=["rubric 1"],
)
],
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
assert "## Behavior test stop" in prompt
assert "Description test stop" in prompt
assert " * instruction test stop" in prompt
def test_get_llm_backed_user_simulator_prompt_blocks_unsafe_persona_templates(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description",
behavior_instructions=["instruction 1"],
violation_rubrics=["rubric 1"],
)
],
)
with pytest.raises(SecurityError):
get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
class TestIsValidUserSimulatorTemplate:
"""Test cases for is_valid_user_simulator_template."""
def test_valid_template(self):
template = "Hello {{ name }}"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is True
def test_invalid_syntax(self):
template = "Hello {{ name"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is False
def test_missing_parameter(self):
template = "Hello"
params = ["name"]
assert is_valid_user_simulator_template(template, params) is False
@@ -0,0 +1,239 @@
# 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.
import textwrap
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _get_latest_turn_user_simulator_quality_prompt_template
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import get_per_turn_user_simulator_quality_prompt
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
Default template
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
# Stop signal
{{stop_signal}}
""").strip()
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
Persona template
# Persona Description
{{persona.description}}
{% for b in persona.behaviors %}
## Criteria: {{ b.name | render_string_filter}}
{{ b.description | render_string_filter}}
Mark as FAIL if any of the following Violations occur:
{{ b.get_violation_rubrics_str() | render_string_filter}}
{% endfor %}
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
# Stop signal
{{stop_signal}}
""").strip()
class TestGetLatestTurnUserSimulatorQualityPrompt:
"""Test cases for get_latest_turn_user_simulator_quality_prompt."""
def test_get_get_latest_turn_user_simulator_quality_prompt_template_default(
self,
):
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
user_persona=None
)
assert prompt == _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
def test_get_latest_turn_user_simulator_quality_prompt_template_with_persona(
self,
):
"""Tests that the correct prompt is returned when a persona is provided."""
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
user_persona=persona
)
assert (
prompt
== _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
)
class TestGetPerTurnUserSimulatorQualityPrompt:
"""Test cases for get_per_turn_user_simulator_quality_prompt."""
def test_get_per_turn_user_simulator_quality_prompt_default(self, mocker):
"""Tests that the correct prompt is returned when no persona is provided."""
mocker.patch(
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE",
_MOCK_DEFAULT_TEMPLATE,
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=None,
)
expected_prompt = textwrap.dedent("""\
Default template
# Conversation Plan
plan
# Conversation History
history
# Generated User Response
response
# Stop signal
stop""").strip()
assert prompt == expected_prompt
def test_get_per_turn_user_simulator_quality_prompt_with_persona(
self, mocker
):
"""Tests that the correct prompt is returned when a persona is provided."""
mocker.patch(
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE",
_MOCK_PERSONA_TEMPLATE,
)
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
expected_prompt = textwrap.dedent("""\
Persona template
# Persona Description
Test persona description.
## Criteria: test_behavior
Test behavior description.
Mark as FAIL if any of the following Violations occur:
* violation1
# Conversation Plan
plan
# Conversation History
history
# Generated User Response
response
# Stop signal
stop""").strip()
assert prompt == expected_prompt
def test_get_per_turn_user_simulator_quality_prompt_renders_persona_templates_in_sandbox(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="criteria {{ stop_signal }}",
description="Test behavior {{ stop_signal }}.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation {{ stop_signal }}"],
)
],
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
assert "## Criteria: criteria stop" in prompt
assert "Test behavior stop." in prompt
assert " * violation stop" in prompt
def test_get_per_turn_user_simulator_quality_prompt_blocks_unsafe_persona_templates(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
with pytest.raises(SecurityError):
get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
@@ -0,0 +1,698 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import ConversationScenario
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import EvalStatus
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import LlmBackedUserSimulatorCriterion
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.llm_as_judge import AutoRaterScore
from google.adk.evaluation.llm_as_judge_utils import Label
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _format_conversation_history
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _parse_llm_response
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import PerTurnUserSimulatorQualityV1
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.models.llm_response import LlmResponse
from google.genai import types
from google.genai import types as genai_types
import pytest
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid_undefined_key": True
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "undefined label",
}
```""",
],
)
def test_parse_llm_response_label_not_found(response_text):
label = _parse_llm_response(response_text)
assert label == Label.NOT_FOUND
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": True
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "true"
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": True
}
],
"is_valid": "valid"
}
```""",
],
)
def test_parse_llm_response_label_valid(response_text):
label = _parse_llm_response(response_text)
assert label == Label.VALID
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": False
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "false",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "invalid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "almost",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially_valid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially valid",
}
```""",
"""```json
{
"criteria": [
{
"name": "TEST_NAME",
"reasoning": "test_resonining",
"passes": False
}
],
"is_valid": "partially",
}
```""",
],
)
def test_parse_llm_response_label_invalid(response_text):
label = _parse_llm_response(response_text)
assert label == Label.INVALID
def create_test_template() -> str:
return """This is a test template with stop signal: `{{stop_signal}}`.
# Conversation Plan
{{conversation_plan}}
# Conversation History
{{conversation_history}}
# Generated User Response
{{generated_user_response}}
""".strip()
def _create_test_evaluator(
threshold: float = 1.0, stop_signal: str = "test stop signal"
) -> PerTurnUserSimulatorQualityV1:
evaluator = PerTurnUserSimulatorQualityV1(
EvalMetric(
metric_name="test_per_turn_user_simulator_quality_v1",
threshold=threshold,
criterion=LlmBackedUserSimulatorCriterion(
threshold=threshold,
stop_signal=stop_signal,
judge_model_options=JudgeModelOptions(
judge_model="gemini-2.5-flash",
judge_model_config=genai_types.GenerateContentConfig(),
num_samples=3,
),
),
),
)
return evaluator
def _create_test_conversation_scenario(
conversation_plan: str = "test conversation plan",
starting_prompt: str = "test starting prompt",
user_persona: UserPersona = None,
) -> ConversationScenario:
"""Returns a ConversationScenario."""
return ConversationScenario(
starting_prompt=starting_prompt,
conversation_plan=conversation_plan,
user_persona=user_persona,
)
def _create_test_invocation(
invocation_id: str,
user_content: str = "user content",
model_content: str = "model content",
) -> Invocation:
return Invocation(
invocation_id=invocation_id,
user_content=genai_types.Content(
parts=[genai_types.Part(text=user_content)],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=model_content)],
role="model",
),
)
def _create_test_invocations(
conversation_history: list[str],
) -> list[Invocation]:
conversation_length = len(conversation_history)
assert conversation_length % 2 == 0
invocations = []
for i in range(conversation_length // 2):
user_message = conversation_history[2 * i]
model_message = conversation_history[2 * i + 1]
invocations.append(
_create_test_invocation(
"turn {i}", user_content=user_message, model_content=model_message
)
)
return invocations
def test_format_llm_prompt_raises_error_if_previous_invocations_is_none():
evaluator = _create_test_evaluator()
with pytest.raises(
ValueError, match="Previous invocations should have a set value"
):
evaluator._format_llm_prompt(
invocation=_create_test_invocation("1"),
conversation_scenario=_create_test_conversation_scenario(),
previous_invocations=None,
)
def test_format_llm_prompt_raises_error_if_conversation_scenario_is_none():
evaluator = _create_test_evaluator()
with pytest.raises(
ValueError, match="Conversation scenario should have a set value"
):
evaluator._format_llm_prompt(
invocation=_create_test_invocation("1"),
conversation_scenario=None,
previous_invocations=[],
)
def test_convert_llm_response_to_score_pass():
evaluator = _create_test_evaluator()
auto_rater_response = """```json
{
"is_valid": True,
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore(score=1.0)
def test_convert_llm_response_to_score_failure():
evaluator = _create_test_evaluator()
auto_rater_response = """```json
{
"is_valid": False,
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore(score=0.0)
def test_convert_llm_response_to_score_invalid_json():
evaluator = _create_test_evaluator()
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="invalid json")],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore()
def test_convert_llm_response_to_score_missing_key():
evaluator = _create_test_evaluator()
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="{}")],
role="model",
)
)
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
assert auto_rater_score == AutoRaterScore()
def test_aggregate_samples_not_evaluated():
evaluator = _create_test_evaluator()
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
aggregation = evaluator._aggregate_samples(samples)
assert aggregation == samples[0]
def test_aggregate_samples_pass():
evaluator = _create_test_evaluator()
# The majority of results should be positive.
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation_result = evaluator._aggregate_samples(samples)
assert aggregation_result.score == 1.0
assert aggregation_result.eval_status == EvalStatus.PASSED
def test_aggregate_samples_failure():
evaluator = _create_test_evaluator()
# The majority of results should be negative.
samples = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation_result = evaluator._aggregate_samples(samples)
assert aggregation_result.score == 0.0
assert aggregation_result.eval_status == EvalStatus.FAILED
def test_format_conversation_history_with_none_values():
"""Tests that _format_conversation_history handles None values."""
invocations = [
Invocation(
invocation_id="1",
user_content=types.Content(),
final_response=None,
)
]
formatted_history = _format_conversation_history(invocations)
assert formatted_history == ""
def test_format_conversation_history():
conversation_history = [
"first user prompt.",
"first agent response.",
"second user prompt.",
"second agent response.",
]
invocation_history = _create_test_invocations(conversation_history)
formatted_history = _format_conversation_history(invocation_history)
assert formatted_history == """user: first user prompt.
model: first agent response.
user: second user prompt.
model: second agent response."""
def test_evaluate_first_turn_pass():
evaluator = _create_test_evaluator(
threshold=0.8, stop_signal="test stop signal"
)
conversation_scenario = _create_test_conversation_scenario(
conversation_plan="plan",
starting_prompt="test starting prompt",
)
invocation = _create_test_invocation("1", user_content="test starting prompt")
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
assert result.score == 1.0
assert result.eval_status == EvalStatus.PASSED
def test_evaluate_first_turn_failure():
evaluator = _create_test_evaluator(
threshold=1.0, stop_signal="test stop signal"
)
conversation_scenario = _create_test_conversation_scenario(
conversation_plan="plan",
starting_prompt="test starting prompt",
)
invocation = _create_test_invocation("1", "wrong starting prompt")
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
assert result.score == 0.0
assert result.eval_status == EvalStatus.FAILED
def test_aggregate_conversation_results_all_pass_produces_pass():
evaluator = _create_test_evaluator()
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 1.0
assert aggregation.overall_eval_status == EvalStatus.PASSED
def test_aggregate_conversation_results_percentage_above_threshold_produces_pass():
evaluator = _create_test_evaluator(threshold=0.7)
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.75
assert aggregation.overall_eval_status == EvalStatus.PASSED
def test_aggregate_conversation_results_all_failures_produces_failure():
evaluator = _create_test_evaluator()
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.0
assert aggregation.overall_eval_status == EvalStatus.FAILED
def test_aggregate_conversation_percentage_below_threshold_produces_failure():
evaluator = _create_test_evaluator(threshold=1.0)
results = [
PerInvocationResult(
actual_invocation=_create_test_invocation("1"),
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("2"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("3"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=_create_test_invocation("4"),
score=1.0,
eval_status=EvalStatus.PASSED,
),
]
aggregation = evaluator._aggregate_conversation_results(results)
assert aggregation.overall_score == 0.75
assert aggregation.overall_eval_status == EvalStatus.FAILED
@pytest.mark.asyncio
async def test_evaluate_invocations_all_pass():
evaluator = _create_test_evaluator()
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
return AutoRaterScore(score=1.0)
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
starting_prompt = "first user prompt."
conversation_scenario = _create_test_conversation_scenario(
starting_prompt=starting_prompt
)
invocations = _create_test_invocations(
[starting_prompt, "model 1.", "user 2.", "model 2."]
)
result = await evaluator.evaluate_invocations(
actual_invocations=invocations,
expected_invocations=None,
conversation_scenario=conversation_scenario,
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert len(result.per_invocation_results) == 2
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[1].score == 1.0
@pytest.mark.asyncio
async def test_evaluate_invocations_none_judge_model_config():
"""Tests evaluation when judge_model_config is None."""
evaluator = PerTurnUserSimulatorQualityV1(
EvalMetric(
metric_name="test_per_turn_user_simulator_quality_v1",
threshold=1.0,
criterion=LlmBackedUserSimulatorCriterion(
threshold=1.0,
stop_signal="test stop signal",
judge_model_options=JudgeModelOptions(
judge_model="gemini-2.5-flash",
judge_model_config=None,
num_samples=1,
),
),
),
)
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
return AutoRaterScore(score=1.0)
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
starting_prompt = "first user prompt."
conversation_scenario = _create_test_conversation_scenario(
starting_prompt=starting_prompt
)
invocations = _create_test_invocations(
[starting_prompt, "model 1.", "user 2.", "model 2."]
)
result = await evaluator.evaluate_invocations(
actual_invocations=invocations,
expected_invocations=None,
conversation_scenario=conversation_scenario,
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
@@ -0,0 +1,20 @@
# 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.
from google.adk.evaluation.simulation.pre_built_personas import get_default_persona_registry
def test_get_default_persona_registry():
"""Tests that the default persona registry can be loaded."""
assert get_default_persona_registry() is not None
@@ -0,0 +1,54 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.simulation import static_user_simulator
from google.adk.evaluation.simulation import user_simulator
from google.genai import types
import pytest
class TestStaticUserSimulator:
"""Test cases for StaticUserSimulator."""
@pytest.mark.asyncio
async def test_get_next_user_message(self):
"""Tests that the provided messages are returned in order followed by the stop signal."""
conversation = [
Invocation(
invocation_id="inv1",
user_content=types.Content(parts=[types.Part(text="message 1")]),
),
Invocation(
invocation_id="inv2",
user_content=types.Content(parts=[types.Part(text="message 2")]),
),
]
simulator = static_user_simulator.StaticUserSimulator(
static_conversation=conversation
)
next_message_1 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.SUCCESS == next_message_1.status
assert "message 1" == next_message_1.user_message.parts[0].text
next_message_2 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.SUCCESS == next_message_2.status
assert "message 2" == next_message_2.user_message.parts[0].text
next_message_3 = await simulator.get_next_user_message(events=[])
assert user_simulator.Status.STOP_SIGNAL_DETECTED == next_message_3.status
assert next_message_3.user_message is None
@@ -0,0 +1,106 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
from google.adk.evaluation.simulation.user_simulator import NextUserMessage
from google.adk.evaluation.simulation.user_simulator import register_user_simulator
from google.adk.evaluation.simulation.user_simulator import Status
from google.adk.evaluation.simulation.user_simulator import UserSimulator
from google.genai.types import Content
from pydantic import Field
import pytest
from typing_extensions import Literal
def test_next_user_message_validation():
"""Tests post-init validation of NextUserMessage."""
with pytest.raises(
ValueError,
match=(
"A user_message should be provided if and only if the status is"
" SUCCESS"
),
):
NextUserMessage(status=Status.SUCCESS)
with pytest.raises(
ValueError,
match=(
"A user_message should be provided if and only if the status is"
" SUCCESS"
),
):
NextUserMessage(status=Status.TURN_LIMIT_REACHED, user_message=Content())
# these two should not cause exceptions
NextUserMessage(status=Status.SUCCESS, user_message=Content())
NextUserMessage(status=Status.TURN_LIMIT_REACHED)
# -----------------------------------------------------------------------------
# `register_user_simulator` -- the extension-point API in `user_simulator.py`.
# End-to-end dispatch through `UserSimulatorProvider` is covered separately in
# `test_user_simulator_provider.py`.
# -----------------------------------------------------------------------------
class _FakeConfig(BaseUserSimulatorConfig):
"""Test-only config subclass with a unique Literal discriminator."""
type: Literal["fake_sim"] = Field(default="fake_sim")
class _FakeSimulator(UserSimulator):
"""Test-only simulator; internals do not matter, only its type identity."""
def test_register_user_simulator_writes_to_shared_registry():
"""`register_user_simulator(config_type, simulator_type)` must write the
mapping into `_SIMULATOR_BY_CONFIG_TYPE` so that any consumer -- including
`UserSimulatorProvider` in another module -- can look it up.
"""
try:
register_user_simulator(_FakeConfig, _FakeSimulator)
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
is _FakeSimulator
)
finally:
# Clean up so we don't leak state into other tests.
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
def test_register_user_simulator_overwrites_existing_entry():
"""Re-registering the same config type must overwrite the previous entry.
This lets a test or an out-of-tree extension swap in an alternative
implementation for the same config type without having to unregister first.
"""
class _AlternativeFakeSimulator(UserSimulator):
pass
try:
register_user_simulator(_FakeConfig, _FakeSimulator)
register_user_simulator(_FakeConfig, _AlternativeFakeSimulator)
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
is _AlternativeFakeSimulator
)
finally:
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
@@ -0,0 +1,133 @@
# 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.
from __future__ import annotations
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from google.adk.evaluation.simulation.user_simulator_personas import UserPersonaRegistry
import pytest
class TestUserBehavior:
"""Test cases for UserBehavior."""
def test_create_user_behavior(self):
"""Tests UserBehavior creation."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1", "instruction2"],
violation_rubrics=["violation1", "violation2"],
)
assert behavior.name == "test_behavior"
assert behavior.description == "Test behavior description."
assert behavior.behavior_instructions == ["instruction1", "instruction2"]
assert behavior.violation_rubrics == ["violation1", "violation2"]
def test_get_behavior_instructions_str(self):
"""Tests get_behavior_instructions_str method."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1", "instruction2"],
violation_rubrics=[],
)
assert (
behavior.get_behavior_instructions_str()
== " * instruction1\n * instruction2"
)
def test_get_violation_rubrics_str(self):
"""Tests get_violation_rubrics_str method."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=[],
violation_rubrics=["violation1", "violation2"],
)
assert (
behavior.get_violation_rubrics_str() == " * violation1\n * violation2"
)
class TestUserPersona:
"""Test cases for UserPersona."""
def test_create_user_persona(self):
"""Tests UserPersona creation."""
behavior = UserBehavior(
name="test_behavior",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[behavior],
)
assert persona.id == "test_persona"
assert persona.description == "Test persona description."
assert persona.behaviors == [behavior]
class TestUserPersonaRegistry:
"""Test cases for UserPersonaRegistry."""
def test_register_and_get_persona(self):
"""Tests register_persona and get_persona methods."""
registry = UserPersonaRegistry()
persona = UserPersona(
id="test_persona", description="Test persona", behaviors=[]
)
registry.register_persona("persona1", persona)
assert registry.get_persona("persona1") == persona
def test_get_persona_not_found(self):
"""Tests get_persona for a non-existent persona."""
registry = UserPersonaRegistry()
with pytest.raises(NotFoundError, match="persona2 not found in registry."):
registry.get_persona("persona2")
def test_update_persona(self):
"""Tests updating an existing persona in the registry."""
registry = UserPersonaRegistry()
persona1 = UserPersona(
id="test_persona1", description="Test persona 1", behaviors=[]
)
persona2 = UserPersona(
id="test_persona2", description="Test persona 2", behaviors=[]
)
registry.register_persona("persona1", persona1)
assert registry.get_persona("persona1") == persona1
registry.register_persona("persona1", persona2)
assert registry.get_persona("persona1") == persona2
def test_get_registered_personas(self):
"""Tests get_registered_personas method."""
registry = UserPersonaRegistry()
persona1 = UserPersona(
id="test_persona1", description="Test persona 1", behaviors=[]
)
persona2 = UserPersona(
id="test_persona2", description="Test persona 2", behaviors=[]
)
registry.register_persona("persona1", persona1)
registry.register_persona("persona2", persona2)
registered_personas = registry.get_registered_personas()
assert len(registered_personas) == 2
assert persona1 in registered_personas
assert persona2 in registered_personas
@@ -0,0 +1,200 @@
# 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.
from __future__ import annotations
from google.adk.evaluation import conversation_scenarios
from google.adk.evaluation import eval_case
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
from google.adk.evaluation.simulation import user_simulator_provider
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from google.adk.evaluation.simulation.static_user_simulator import StaticUserSimulator
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
from google.genai import types
from pydantic import Field
import pytest
from typing_extensions import Literal
_TEST_CONVERSATION = [
eval_case.Invocation(
invocation_id='inv1',
user_content=types.Content(parts=[types.Part(text='Hello!')]),
),
]
_TEST_CONVERSATION_SCENARIO = conversation_scenarios.ConversationScenario(
starting_prompt='Hello!', conversation_plan='test plan'
)
class TestUserSimulatorProvider:
"""Test cases for the UserSimulatorProvider."""
def test_provide_static_user_simulator(self):
"""Tests the case when a StaticUserSimulator should be provided."""
provider = user_simulator_provider.UserSimulatorProvider()
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation=_TEST_CONVERSATION,
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, StaticUserSimulator)
assert simulator.static_conversation == _TEST_CONVERSATION
def test_provide_llm_backed_user_simulator(self, mocker):
"""Tests the case when a LlmBackedUserSimulator should be provided."""
mock_llm_registry = mocker.patch(
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
autospec=True,
)
mock_llm_registry.return_value.resolve.return_value = mocker.Mock()
# Test case 1: No config in provider.
provider = user_simulator_provider.UserSimulatorProvider()
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, LlmBackedUserSimulator)
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
# Test case 2: Config in provider.
llm_config = LlmBackedUserSimulatorConfig(
model='test_model',
)
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=llm_config
)
simulator = provider.provide(test_eval_case)
assert isinstance(simulator, LlmBackedUserSimulator)
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
assert simulator._config.model == 'test_model'
# ---------------------------------------------------------------------------
# Backward-compat + discriminator + registry
# ---------------------------------------------------------------------------
def test_init_accepts_bare_base_config_but_provide_raises(self):
"""A bare `BaseUserSimulatorConfig` is a valid *instance* of the base,
so `__init__` accepts it -- but the base has no registered simulator,
so `provide()` should raise a clear error pointing the caller at the
fix. Callers wanting the default should pass `None` instead.
"""
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=BaseUserSimulatorConfig()
)
# __init__ stores the bare base as-is; no silent up-conversion.
assert type(provider._user_simulator_config) is BaseUserSimulatorConfig
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
with pytest.raises(
ValueError,
match=(
r'No UserSimulator registered for config type'
r' `BaseUserSimulatorConfig`'
),
):
provider.provide(test_eval_case)
def test_init_rejects_non_config_argument(self):
"""Passing something that isn't a `BaseUserSimulatorConfig` should raise
a clear ValueError.
"""
with pytest.raises(
ValueError,
match=r'Expect config of type `.*BaseUserSimulatorConfig.*`\.',
):
user_simulator_provider.UserSimulatorProvider(
user_simulator_config='not a config' # type: ignore[arg-type]
)
# NOTE: The "both / neither of conversation, conversation_scenario"
# checks in `provide()` are defensive; `EvalCase` itself enforces the same
# invariant at construction time via a model_validator, so those branches
# in the provider are effectively unreachable and don't warrant a unit
# test at this layer.
def test_base_config_type_defaults_to_none(self):
"""The base `BaseUserSimulatorConfig.type` must default to `None` -- the
base class must not hard-code a specific subclass's discriminator value.
Concrete subclasses supply their own `Literal[...]` default.
"""
base = BaseUserSimulatorConfig()
assert base.type is None
def test_llm_backed_config_has_locked_type_literal(self):
"""The `type` discriminator on `LlmBackedUserSimulatorConfig` must be a
Literal locked to `"llm_backed"`, so future subclasses can dispatch
correctly via pydantic's discriminated union.
"""
config = LlmBackedUserSimulatorConfig()
assert config.type == 'llm_backed'
# Attempting to construct with a different `type` value must fail
# validation (Literal constraint).
with pytest.raises(Exception):
LlmBackedUserSimulatorConfig(type='something_else')
def test_llm_backed_user_simulator_registered_by_provider_module(self):
"""Importing `user_simulator_provider` must wire the built-in
`LlmBackedUserSimulator` into the shared registry. This is the "batteries
included" contract callers rely on: they can `UserSimulatorProvider()`
without ever touching `register_user_simulator(...)`. If the registration
line at the top of the provider module is removed, this test catches it
immediately -- otherwise dispatch would silently fall through to the
"unregistered config type" error path.
"""
assert (
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(
LlmBackedUserSimulatorConfig
)
is LlmBackedUserSimulator
)
def test_provide_raises_for_unregistered_config_type(self, mocker):
"""If the caller supplies a config subclass that no one has registered,
provide() must raise a clear error naming the offending type.
"""
mocker.patch(
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
autospec=True,
)
class _UnregisteredConfig(BaseUserSimulatorConfig):
type: Literal['unregistered'] = Field(default='unregistered')
provider = user_simulator_provider.UserSimulatorProvider(
user_simulator_config=_UnregisteredConfig()
)
test_eval_case = eval_case.EvalCase(
eval_id='test_eval_id',
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
)
with pytest.raises(
ValueError,
match=(
r'No UserSimulator registered for config type'
r' `_UnregisteredConfig`'
),
):
provider.provide(test_eval_case)
@@ -0,0 +1,52 @@
# 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.
from __future__ import annotations
from google.adk.evaluation._path_validation import validate_path_segment
import pytest
@pytest.mark.parametrize(
"value", ["eval_set_1", "my-app", "App Name 1", "résumé", "a.b.c"]
)
def test_validate_path_segment_accepts_valid_value(value):
validate_path_segment(value, "field")
def test_validate_path_segment_rejects_empty():
with pytest.raises(ValueError, match="must not be empty"):
validate_path_segment("", "field")
def test_validate_path_segment_rejects_null_byte():
with pytest.raises(ValueError, match="must not contain null bytes"):
validate_path_segment("foo\x00bar", "field")
@pytest.mark.parametrize("value", ["foo/bar", "foo\\bar", "/", "\\"])
def test_validate_path_segment_rejects_path_separators(value):
with pytest.raises(ValueError, match="must not contain path separators"):
validate_path_segment(value, "field")
@pytest.mark.parametrize("value", [".", ".."])
def test_validate_path_segment_rejects_traversal_segments(value):
with pytest.raises(ValueError, match="must not contain traversal segments"):
validate_path_segment(value, "field")
def test_validate_path_segment_includes_field_name_in_error():
with pytest.raises(ValueError, match="eval_set_id"):
validate_path_segment("", "eval_set_id")
@@ -0,0 +1,73 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.genai import types as genai_types
from pytest import raises
def test_get_developer_instructions_existing_agent():
agent_details = {
'agent1': AgentDetails(
name='agent1', instructions='instruction for agent1'
),
'agent2': AgentDetails(
name='agent2', instructions='instruction for agent2'
),
}
app_details = AppDetails(
agent_details=agent_details,
)
# Test for existing agent
instructions = app_details.get_developer_instructions('agent1')
assert instructions == 'instruction for agent1'
def test_get_developer_instructions_non_existing_Agent():
agent_details = {
'agent1': AgentDetails(
name='agent1', instructions='instruction for agent1'
),
'agent2': AgentDetails(
name='agent2', instructions='instruction for agent2'
),
}
app_details = AppDetails(
agent_details=agent_details,
)
# Test for existing agent
with raises(ValueError, match='`agent3` not found in the agentic system.'):
app_details.get_developer_instructions('agent3')
def test_get_tools_by_agent_name():
tool1 = genai_types.Tool(
function_declarations=[genai_types.FunctionDeclaration(name='tool1_func')]
)
agent_details = {
'agent1': AgentDetails(name='agent1', tool_declarations=[tool1]),
'agent2': AgentDetails(name='agent2', tool_declarations=[]),
}
app_details = AppDetails(
agent_details=agent_details,
)
tools = app_details.get_tools_by_agent_name()
expected_tools = {'agent1': [tool1], 'agent2': []}
assert tools == expected_tools
@@ -0,0 +1,110 @@
# 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.
from unittest import mock
from google.adk.evaluation.custom_metric_evaluator import _CustomMetricEvaluator
from google.adk.evaluation.custom_metric_evaluator import _get_metric_function
from google.adk.evaluation.eval_case import ConversationScenario
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.evaluator import EvaluationResult
import pytest
def my_sync_metric_function(
eval_metric: EvalMetric,
actual_invocations: list[Invocation],
expected_invocations: list[Invocation] | None,
conversation_scenario: ConversationScenario | None,
) -> EvaluationResult:
"""Sync metric function for testing."""
return EvaluationResult(overall_score=1.0)
async def my_async_metric_function(
eval_metric: EvalMetric,
actual_invocations: list[Invocation],
expected_invocations: list[Invocation] | None,
conversation_scenario: ConversationScenario | None,
) -> EvaluationResult:
"""Async metric function for testing."""
return EvaluationResult(overall_score=0.5)
@mock.patch("importlib.import_module")
def test_get_metric_function_success(mock_import_module):
"""Tests that _get_metric_function successfully returns a function."""
mock_module = mock.MagicMock()
mock_module.my_sync_metric_function = my_sync_metric_function
mock_import_module.return_value = mock_module
func = _get_metric_function(
"test_custom_metric_evaluator.my_sync_metric_function"
)
assert func == my_sync_metric_function
@mock.patch("importlib.import_module", side_effect=ImportError)
def test_get_metric_function_module_not_found(mock_import_module):
"""Tests that _get_metric_function raises ImportError for non-existent module."""
with pytest.raises(ImportError):
_get_metric_function("non_existent_module.my_sync_metric_function")
@mock.patch("importlib.import_module")
def test_get_metric_function_function_not_found(mock_import_module):
"""Tests that _get_metric_function raises ImportError for non-existent function."""
mock_import_module.return_value = object()
with pytest.raises(ImportError):
_get_metric_function(
"google.adk.tests.unittests.evaluation.test_custom_metric_evaluator.non_existent_function"
)
def test_get_metric_function_malformed_path():
"""Tests that _get_metric_function raises ImportError for malformed path."""
with pytest.raises(ImportError):
_get_metric_function("malformed_path")
@mock.patch(
"google.adk.evaluation.custom_metric_evaluator._get_metric_function",
return_value=my_sync_metric_function,
)
@pytest.mark.asyncio
async def test_custom_metric_evaluator_sync_function(mock_get_metric_function):
"""Tests that _CustomMetricEvaluator works with a sync metric function."""
eval_metric = EvalMetric(metric_name="sync_metric")
evaluator = _CustomMetricEvaluator(
eval_metric=eval_metric,
custom_function_path="google.adk.tests.unittests.evaluation.test_custom_metric_evaluator.my_sync_metric_function",
)
result = await evaluator.evaluate_invocations([], None)
assert result.overall_score == 1.0
@mock.patch(
"google.adk.evaluation.custom_metric_evaluator._get_metric_function",
return_value=my_async_metric_function,
)
@pytest.mark.asyncio
async def test_custom_metric_evaluator_async_function(mock_get_metric_function):
"""Tests that _CustomMetricEvaluator works with an async metric function."""
eval_metric = EvalMetric(metric_name="async_metric")
evaluator = _CustomMetricEvaluator(
eval_metric=eval_metric,
custom_function_path="google.adk.tests.unittests.evaluation.test_custom_metric_evaluator.my_async_metric_function",
)
result = await evaluator.evaluate_invocations([], None)
assert result.overall_score == 0.5
@@ -0,0 +1,311 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.conversation_scenarios import ConversationScenario
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.eval_case import get_all_tool_calls
from google.adk.evaluation.eval_case import get_all_tool_calls_with_responses
from google.adk.evaluation.eval_case import get_all_tool_responses
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_case import SessionInput
from google.genai import types as genai_types
import pytest
def test_eval_models_preserve_extra_metadata():
session_input = SessionInput(
app_name='app',
user_id='user',
eval_group='retrieval',
source='nightly',
)
assert session_input.model_extra == {
'eval_group': 'retrieval',
'source': 'nightly',
}
assert session_input.model_dump()['eval_group'] == 'retrieval'
eval_case = EvalCase(
eval_id='case_1',
conversation=[],
session_input=session_input,
owner='platform',
)
assert eval_case.model_extra == {'owner': 'platform'}
dumped = eval_case.model_dump()
assert dumped['owner'] == 'platform'
assert dumped['session_input']['source'] == 'nightly'
def test_get_all_tool_calls_with_none_input():
"""Tests that an empty list is returned when intermediate_data is None."""
assert get_all_tool_calls(None) == []
def test_get_all_tool_calls_with_intermediate_data_no_tools():
"""Tests IntermediateData with no tool calls."""
intermediate_data = IntermediateData(tool_uses=[])
assert get_all_tool_calls(intermediate_data) == []
def test_get_all_tool_calls_with_intermediate_data():
"""Tests that tool calls are correctly extracted from IntermediateData."""
tool_call1 = genai_types.FunctionCall(
name='search', args={'query': 'weather'}
)
tool_call2 = genai_types.FunctionCall(name='lookup', args={'id': '123'})
intermediate_data = IntermediateData(tool_uses=[tool_call1, tool_call2])
assert get_all_tool_calls(intermediate_data) == [tool_call1, tool_call2]
def test_get_all_tool_calls_with_empty_invocation_events():
"""Tests InvocationEvents with an empty list of invocation events."""
intermediate_data = InvocationEvents(invocation_events=[])
assert get_all_tool_calls(intermediate_data) == []
def test_get_all_tool_calls_with_invocation_events_no_tools():
"""Tests InvocationEvents containing events without any tool calls."""
invocation_event = InvocationEvent(
author='agent',
content=genai_types.Content(
parts=[genai_types.Part(text='Thinking...')], role='model'
),
)
intermediate_data = InvocationEvents(invocation_events=[invocation_event])
assert get_all_tool_calls(intermediate_data) == []
def test_get_all_tool_calls_with_invocation_events():
"""Tests that tool calls are correctly extracted from a InvocationSteps object."""
tool_call1 = genai_types.FunctionCall(
name='search', args={'query': 'weather'}
)
tool_call2 = genai_types.FunctionCall(name='lookup', args={'id': '123'})
invocation_event1 = InvocationEvent(
author='agent1',
content=genai_types.Content(
parts=[genai_types.Part(function_call=tool_call1)],
role='model',
),
)
invocation_event2 = InvocationEvent(
author='agent2',
content=genai_types.Content(
parts=[
genai_types.Part(text='Found something.'),
genai_types.Part(function_call=tool_call2),
],
role='model',
),
)
intermediate_data = InvocationEvents(
invocation_events=[invocation_event1, invocation_event2]
)
assert get_all_tool_calls(intermediate_data) == [tool_call1, tool_call2]
def test_get_all_tool_calls_with_unsupported_type():
"""Tests that a ValueError is raised for unsupported intermediate_data types."""
with pytest.raises(
ValueError, match='Unsupported type for intermediate_data'
):
get_all_tool_calls('this is not a valid type')
def test_get_all_tool_responses_with_none_input():
"""Tests that an empty list is returned when intermediate_data is None."""
assert get_all_tool_responses(None) == []
def test_get_all_tool_responses_with_empty_invocation_events():
"""Tests InvocationEvents with an empty list of events."""
intermediate_data = InvocationEvents(invocation_events=[])
assert get_all_tool_responses(intermediate_data) == []
def test_get_all_tool_responses_with_invocation_events_no_tools():
"""Tests InvocationEvents containing events without any tool responses."""
invocation_event = InvocationEvent(
author='agent',
content=genai_types.Content(
parts=[genai_types.Part(text='Thinking...')], role='model'
),
)
intermediate_data = InvocationEvents(invocation_events=[invocation_event])
assert get_all_tool_responses(intermediate_data) == []
def test_get_all_tool_responses_with_invocation_events():
"""Tests that tool responses are correctly extracted from a InvocationEvents object."""
tool_response1 = genai_types.FunctionResponse(
name='search', response={'result': 'weather is good'}
)
tool_response2 = genai_types.FunctionResponse(
name='lookup', response={'id': '123'}
)
invocation_event1 = InvocationEvent(
author='agent1',
content=genai_types.Content(
parts=[genai_types.Part(function_response=tool_response1)],
role='model',
),
)
invocation_event2 = InvocationEvent(
author='agent2',
content=genai_types.Content(
parts=[
genai_types.Part(text='Found something.'),
genai_types.Part(function_response=tool_response2),
],
role='model',
),
)
intermediate_data = InvocationEvents(
invocation_events=[invocation_event1, invocation_event2]
)
assert get_all_tool_responses(intermediate_data) == [
tool_response1,
tool_response2,
]
def test_get_all_tool_responses_with_unsupported_type():
"""Tests that a ValueError is raised for unsupported intermediate_data types."""
with pytest.raises(
ValueError, match='Unsupported type for intermediate_data'
):
get_all_tool_responses('this is not a valid type')
def test_get_all_tool_calls_with_responses_with_none_input():
"""Tests that an empty list is returned when intermediate_data is None."""
assert get_all_tool_calls_with_responses(None) == []
def test_get_all_tool_calls_with_responses_with_intermediate_data_no_tool_calls():
"""Tests get_all_tool_calls_with_responses with IntermediateData with no tool calls."""
# No tool calls
intermediate_data = IntermediateData(tool_uses=[], tool_responses=[])
assert get_all_tool_calls_with_responses(intermediate_data) == []
def test_get_all_tool_calls_with_responses_with_intermediate_data_with_tool_calls():
"""Tests get_all_tool_calls_with_responses with IntermediateData with tools."""
# With matching and non-matching tool calls
tool_call1 = genai_types.FunctionCall(
name='search', args={'query': 'weather'}, id='call1'
)
tool_response1 = genai_types.FunctionResponse(
name='search', response={'result': 'sunny'}, id='call1'
)
tool_call2 = genai_types.FunctionCall(
name='lookup', args={'id': '123'}, id='call2'
)
intermediate_data = IntermediateData(
tool_uses=[tool_call1, tool_call2], tool_responses=[tool_response1]
)
assert get_all_tool_calls_with_responses(intermediate_data) == [
(tool_call1, tool_response1),
(tool_call2, None),
]
def test_get_all_tool_calls_with_responses_with_steps_no_tool_calls():
"""Tests get_all_tool_calls_with_responses with Steps that don't have tool calls."""
# No tool calls
intermediate_data = InvocationEvents(invocation_events=[])
assert get_all_tool_calls_with_responses(intermediate_data) == []
def test_get_all_tool_calls_with_responses_with_invocation_events():
"""Tests get_all_tool_calls_with_responses with InvocationEvents."""
# No tools
intermediate_data = InvocationEvents(invocation_events=[])
assert get_all_tool_calls_with_responses(intermediate_data) == []
# With matching and non-matching tool calls
tool_call1 = genai_types.FunctionCall(
name='search', args={'query': 'weather'}, id='call1'
)
tool_response1 = genai_types.FunctionResponse(
name='search', response={'result': 'sunny'}, id='call1'
)
tool_call2 = genai_types.FunctionCall(
name='lookup', args={'id': '123'}, id='call2'
)
invocation_event1 = InvocationEvent(
author='agent',
content=genai_types.Content(
parts=[
genai_types.Part(function_call=tool_call1),
genai_types.Part(function_call=tool_call2),
],
role='model',
),
)
invocation_event2 = InvocationEvent(
author='tool',
content=genai_types.Content(
parts=[genai_types.Part(function_response=tool_response1)],
role='tool',
),
)
intermediate_data = InvocationEvents(
invocation_events=[invocation_event1, invocation_event2]
)
assert get_all_tool_calls_with_responses(intermediate_data) == [
(tool_call1, tool_response1),
(tool_call2, None),
]
def test_conversation_and_conversation_scenario_mutual_exclusion():
"""Tests the ensure_conversation_xor_conversation_scenario validator."""
test_conversation_scenario = ConversationScenario(
starting_prompt='', conversation_plan=''
)
with pytest.raises(
ValueError,
match=(
'Exactly one of conversation and conversation_scenario must be'
' provided in an EvalCase.'
),
):
EvalCase(eval_id='test_id')
with pytest.raises(
ValueError,
match=(
'Exactly one of conversation and conversation_scenario must be'
' provided in an EvalCase.'
),
):
EvalCase(
eval_id='test_id',
conversation=[],
conversation_scenario=test_conversation_scenario,
)
# these two should not cause exceptions
EvalCase(eval_id='test_id', conversation=[])
EvalCase(eval_id='test_id', conversation_scenario=test_conversation_scenario)
@@ -0,0 +1,266 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_config import _DEFAULT_EVAL_CONFIG
from google.adk.evaluation.eval_config import EvalConfig
from google.adk.evaluation.eval_config import get_eval_metrics_from_config
from google.adk.evaluation.eval_config import get_evaluation_criteria_or_default
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from pydantic import ValidationError
import pytest
def test_get_evaluation_criteria_or_default_returns_default():
assert get_evaluation_criteria_or_default("") == _DEFAULT_EVAL_CONFIG
def test_get_evaluation_criteria_or_default_reads_from_file(mocker):
mocker.patch("os.path.exists", return_value=True)
eval_config = EvalConfig(
criteria={"tool_trajectory_avg_score": 0.5, "response_match_score": 0.5}
)
mocker.patch(
"builtins.open", mocker.mock_open(read_data=eval_config.model_dump_json())
)
assert get_evaluation_criteria_or_default("dummy_path") == eval_config
def test_get_evaluation_criteria_or_default_returns_default_if_file_not_found(
mocker,
):
mocker.patch("os.path.exists", return_value=False)
assert (
get_evaluation_criteria_or_default("dummy_path") == _DEFAULT_EVAL_CONFIG
)
def test_get_eval_metrics_from_config():
rubric_1 = Rubric(
rubric_id="test-rubric",
rubric_content=RubricContent(text_property="test"),
)
eval_config = EvalConfig(
criteria={
"tool_trajectory_avg_score": 1.0,
"response_match_score": 0.8,
"final_response_match_v2": {
"threshold": 0.5,
"judge_model_options": {
"judge_model": "gemini-pro",
"num_samples": 1,
},
},
"rubric_based_final_response_quality_v1": {
"threshold": 0.9,
"judge_model_options": {
"judge_model": "gemini-ultra",
"num_samples": 1,
},
"rubrics": [rubric_1],
},
}
)
eval_metrics = get_eval_metrics_from_config(eval_config)
assert len(eval_metrics) == 4
assert eval_metrics[0].metric_name == "tool_trajectory_avg_score"
assert eval_metrics[0].threshold == 1.0
assert eval_metrics[0].criterion.threshold == 1.0
assert eval_metrics[1].metric_name == "response_match_score"
assert eval_metrics[1].threshold == 0.8
assert eval_metrics[1].criterion.threshold == 0.8
assert eval_metrics[2].metric_name == "final_response_match_v2"
assert eval_metrics[2].threshold == 0.5
assert eval_metrics[2].criterion.threshold == 0.5
assert (
eval_metrics[2].criterion.judge_model_options["judge_model"]
== "gemini-pro"
)
assert eval_metrics[3].metric_name == "rubric_based_final_response_quality_v1"
assert eval_metrics[3].threshold == 0.9
assert eval_metrics[3].criterion.threshold == 0.9
assert (
eval_metrics[3].criterion.judge_model_options["judge_model"]
== "gemini-ultra"
)
assert len(eval_metrics[3].criterion.rubrics) == 1
assert eval_metrics[3].criterion.rubrics[0] == rubric_1
def test_get_eval_metrics_from_config_with_custom_metrics():
eval_config = EvalConfig(
criteria={
"custom_metric_1": 1.0,
"custom_metric_2": {
"threshold": 0.5,
},
},
custom_metrics={
"custom_metric_1": {
"code_config": {"name": "path/to/custom/metric_1"},
},
"custom_metric_2": {
"code_config": {"name": "path/to/custom/metric_2"},
},
},
)
eval_metrics = get_eval_metrics_from_config(eval_config)
assert len(eval_metrics) == 2
assert eval_metrics[0].metric_name == "custom_metric_1"
assert eval_metrics[0].threshold == 1.0
assert eval_metrics[0].criterion.threshold == 1.0
assert eval_metrics[0].custom_function_path == "path/to/custom/metric_1"
assert eval_metrics[1].metric_name == "custom_metric_2"
assert eval_metrics[1].threshold == 0.5
assert eval_metrics[1].criterion.threshold == 0.5
assert eval_metrics[1].custom_function_path == "path/to/custom/metric_2"
def test_get_eval_metrics_from_config_empty_criteria():
eval_config = EvalConfig(criteria={})
eval_metrics = get_eval_metrics_from_config(eval_config)
assert not eval_metrics
# -----------------------------------------------------------------------------
# `user_simulator_config` discriminator + backward-compat coverage
# -----------------------------------------------------------------------------
def test_user_simulator_config_default_is_none():
"""A brand-new EvalConfig has no user simulator config by default."""
eval_config = EvalConfig()
assert eval_config.user_simulator_config is None
def test_user_simulator_config_json_with_explicit_type():
"""A JSON config that carries `type=llm_backed` should deserialize to the
concrete subclass, not just the base.
"""
payload = (
'{"criteria": {"tool_trajectory_avg_score": 1.0},'
' "userSimulatorConfig": {"type": "llm_backed",'
' "model": "my-model", "maxAllowedInvocations": 5}}'
)
eval_config = EvalConfig.model_validate_json(payload)
assert isinstance(
eval_config.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert eval_config.user_simulator_config.type == "llm_backed"
assert eval_config.user_simulator_config.model == "my-model"
assert eval_config.user_simulator_config.max_allowed_invocations == 5
def test_user_simulator_config_json_without_type_backward_compat():
"""Pre-discriminator JSON (no `type` field) must still deserialize into
`LlmBackedUserSimulatorConfig` -- this is the backward-compat contract.
"""
# Note the ABSENCE of `type`: this shape is what existing configs on disk
# look like today.
payload = (
'{"criteria": {"tool_trajectory_avg_score": 1.0},'
' "userSimulatorConfig": {"model": "legacy-model"}}'
)
eval_config = EvalConfig.model_validate_json(payload)
assert isinstance(
eval_config.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert eval_config.user_simulator_config.type == "llm_backed"
assert eval_config.user_simulator_config.model == "legacy-model"
def test_user_simulator_config_json_without_type_snake_case():
"""The default-type injector must handle snake_case JSON keys too, since
users may serialize with `by_alias=False`.
"""
payload = (
'{"criteria": {"tool_trajectory_avg_score": 1.0},'
' "user_simulator_config": {"model": "legacy-model-snake"}}'
)
eval_config = EvalConfig.model_validate_json(payload)
assert isinstance(
eval_config.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert eval_config.user_simulator_config.model == "legacy-model-snake"
def test_user_simulator_config_json_with_explicit_null_type():
"""`type: null` in JSON (the shape produced by a `BaseUserSimulatorConfig`
whose default `type=None` gets serialized) must be treated the same as a
missing `type` key: default to the legacy subclass.
"""
payload = (
'{"criteria": {},'
' "userSimulatorConfig": {"type": null, "model": "explicit-null"}}'
)
eval_config = EvalConfig.model_validate_json(payload)
assert isinstance(
eval_config.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert eval_config.user_simulator_config.type == "llm_backed"
assert eval_config.user_simulator_config.model == "explicit-null"
def test_user_simulator_config_json_with_unknown_type_raises():
"""An unknown discriminator value must fail validation loudly."""
payload = (
'{"criteria": {}, "userSimulatorConfig": {"type": "typo_type_name"}}'
)
with pytest.raises(ValidationError):
EvalConfig.model_validate_json(payload)
def test_user_simulator_config_round_trip_via_model_dump_json():
"""Serialize -> deserialize preserves the concrete subclass (and the
`type` tag survives the round-trip).
"""
original = EvalConfig(
user_simulator_config=LlmBackedUserSimulatorConfig(
model="round-trip-model"
)
)
restored = EvalConfig.model_validate_json(original.model_dump_json())
assert isinstance(
restored.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert restored.user_simulator_config.model == "round-trip-model"
assert restored.user_simulator_config.type == "llm_backed"
def test_user_simulator_config_python_construction():
"""Direct Python construction with a concrete subclass instance also
works -- the discriminator on `Field` doesn't interfere with that path.
"""
eval_config = EvalConfig(
user_simulator_config=LlmBackedUserSimulatorConfig(model="py-model"),
)
assert isinstance(
eval_config.user_simulator_config, LlmBackedUserSimulatorConfig
)
assert eval_config.user_simulator_config.model == "py-model"
@@ -0,0 +1,968 @@
# 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.
from __future__ import annotations
import asyncio
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.conversation_scenarios import ConversationScenario
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.eval_case import get_all_tool_calls
from google.adk.evaluation.eval_set import EvalSet
from google.adk.evaluation.evaluation_generator import _LiveSession
from google.adk.evaluation.evaluation_generator import EvaluationGenerator
from google.adk.evaluation.request_intercepter_plugin import _RequestIntercepterPlugin
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
from google.adk.evaluation.simulation.user_simulator import NextUserMessage
from google.adk.evaluation.simulation.user_simulator import Status as UserSimulatorStatus
from google.adk.evaluation.simulation.user_simulator import UserSimulator
from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions
from google.adk.models.llm_request import LlmRequest
from google.genai import types
import pytest
def _build_event(
author: str, parts: list[types.Part], invocation_id: str
) -> Event:
"""Builds an Event object with specified parts."""
return Event(
author=author,
content=types.Content(parts=parts),
invocation_id=invocation_id,
)
class TestConvertEventsToEvalInvocation:
"""Test cases for EvaluationGenerator.convert_events_to_eval_invocations method."""
def test_convert_events_to_eval_invocations_empty(
self,
):
"""Tests conversion with an empty list of events."""
invocations = EvaluationGenerator.convert_events_to_eval_invocations([])
assert invocations == []
def test_convert_single_turn_text_only(
self,
):
"""Tests a single turn with a text response."""
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent", [types.Part(text="Hi there!")], "inv1"),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
invocation = invocations[0]
assert invocation.invocation_id == "inv1"
assert invocation.user_content.parts[0].text == "Hello"
assert invocation.final_response.parts[0].text == "Hi there!"
assert len(invocation.intermediate_data.invocation_events) == 0
def test_convert_single_turn_tool_call(
self,
):
"""Tests a single turn with a tool call."""
events = [
_build_event("user", [types.Part(text="what is the weather?")], "inv1"),
_build_event(
"agent",
[
types.Part(
function_call=types.FunctionCall(
name="get_weather", args={}
)
)
],
"inv1",
),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
invocation = invocations[0]
assert invocation.user_content.parts[0].text == "what is the weather?"
assert invocation.final_response is None
events = invocation.intermediate_data.invocation_events
assert len(events) == 1
assert events[0].author == "agent"
assert events[0].content.parts[0].function_call.name == "get_weather"
def test_convert_single_turn_tool_and_text_response(
self,
):
"""Tests a single turn with a tool call and a final text response."""
events = [
_build_event("user", [types.Part(text="what is the weather?")], "inv1"),
_build_event(
"agent",
[
types.Part(
function_call=types.FunctionCall(
name="get_weather", args={}
)
)
],
"inv1",
),
_build_event("agent", [types.Part(text="It is sunny in SF.")], "inv1"),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
invocation = invocations[0]
assert invocation.final_response.parts[0].text == "It is sunny in SF."
events = invocation.intermediate_data.invocation_events
assert len(events) == 1
assert events[0].content.parts[0].function_call.name == "get_weather"
def test_multi_turn(
self,
):
"""Tests a conversation with multiple turns."""
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent", [types.Part(text="Hi there!")], "inv1"),
_build_event("user", [types.Part(text="How are you?")], "inv2"),
_build_event("agent", [types.Part(text="I am fine.")], "inv2"),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 2
assert invocations[0].user_content.parts[0].text == "Hello"
assert invocations[0].final_response.parts[0].text == "Hi there!"
assert invocations[1].user_content.parts[0].text == "How are you?"
assert invocations[1].final_response.parts[0].text == "I am fine."
def test_multi_agent(
self,
):
"""Tests a multi-agent scenario creating multiple steps."""
events = [
_build_event("user", [types.Part(text="Do something")], "inv1"),
_build_event(
"root_agent",
[
types.Part(
function_call=types.FunctionCall(name="tool1", args={})
)
],
"inv1",
),
_build_event(
"sub_agent_1",
[
types.Part(
function_call=types.FunctionCall(name="tool2", args={})
)
],
"inv1",
),
_build_event(
"sub_agent_1",
[
types.Part(
function_call=types.FunctionCall(name="tool3", args={})
),
types.Part(text="intermediate response"),
],
"inv1",
),
_build_event(
"sub_agent_2",
[
types.Part(
function_call=types.FunctionCall(name="tool4", args={})
)
],
"inv1",
),
_build_event("root_agent", [types.Part(text="All done.")], "inv1"),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
invocation = invocations[0]
assert invocation.final_response.parts[0].text == "All done."
events = invocation.intermediate_data.invocation_events
assert len(events) == 4
assert events[0].author == "root_agent"
assert events[1].author == "sub_agent_1"
assert events[2].author == "sub_agent_1"
assert events[3].author == "sub_agent_2"
def test_convert_multi_agent_final_responses(
self,
):
"""Tests that only the last final response is excluded from intermediate data."""
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent1", [types.Part(text="First response")], "inv1"),
_build_event("agent2", [types.Part(text="Second response")], "inv1"),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
invocation = invocations[0]
assert invocation.final_response.parts[0].text == "Second response"
intermediate_events = invocation.intermediate_data.invocation_events
# agent1 is included because it is not the final_event (which is agent2)
assert len(intermediate_events) == 1
assert intermediate_events[0].author == "agent1"
assert intermediate_events[0].content.parts[0].text == "First response"
class TestGetAppDetailsByInvocationId:
"""Test cases for EvaluationGenerator._get_app_details_by_invocation_id method."""
def test_get_app_details_by_invocation_id_empty(self, mocker):
"""Tests with an empty list of events."""
mock_request_intercepter = mocker.MagicMock(spec=_RequestIntercepterPlugin)
app_details = EvaluationGenerator._get_app_details_by_invocation_id(
[], mock_request_intercepter
)
assert app_details == {}
def test_get_app_details_by_invocation_id_no_model_requests(self, mocker):
"""Tests when request_intercepter returns no model requests."""
mock_request_intercepter = mocker.MagicMock(spec=_RequestIntercepterPlugin)
mock_request_intercepter.get_model_request.return_value = None
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent", [types.Part(text="Hi there!")], "inv1"),
]
app_details = EvaluationGenerator._get_app_details_by_invocation_id(
events, mock_request_intercepter
)
assert app_details == {"inv1": AppDetails(agent_details={})}
mock_request_intercepter.get_model_request.assert_called_once_with(
events[1]
)
def test_get_app_details_single_invocation_single_agent(self, mocker):
"""Tests a single invocation with one agent."""
mock_request_intercepter = mocker.MagicMock(spec=_RequestIntercepterPlugin)
mock_llm_request = LlmRequest(model="test")
mock_llm_request.config.system_instruction = "instruction1"
mock_llm_request.config.tools = [types.Tool()]
mock_request_intercepter.get_model_request.return_value = mock_llm_request
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent", [types.Part(text="Hi there!")], "inv1"),
]
app_details = EvaluationGenerator._get_app_details_by_invocation_id(
events, mock_request_intercepter
)
expected_app_details = {
"inv1": AppDetails(
agent_details={
"agent": AgentDetails(
name="agent",
instructions="instruction1",
tool_declarations=[types.Tool()],
)
}
)
}
assert app_details == expected_app_details
mock_request_intercepter.get_model_request.assert_called_once_with(
events[1]
)
def test_get_app_details_multiple_invocations_multiple_agents(self, mocker):
"""Tests multiple invocations with multiple agents."""
mock_request_intercepter = mocker.MagicMock(spec=_RequestIntercepterPlugin)
def get_model_request_side_effect(event):
mock_llm_request = LlmRequest(model="test")
if event.invocation_id == "inv1" and event.author == "agent1":
mock_llm_request.config.system_instruction = "instruction1"
mock_llm_request.config.tools = [
types.Tool(
function_declarations=[types.FunctionDeclaration(name="tool1")]
)
]
return mock_llm_request
if event.invocation_id == "inv2" and event.author == "agent2":
mock_llm_request.config.system_instruction = "instruction2"
return mock_llm_request
return None
mock_request_intercepter.get_model_request.side_effect = (
get_model_request_side_effect
)
events = [
_build_event("user", [types.Part(text="Hello")], "inv1"),
_build_event("agent1", [types.Part(text="Hi there!")], "inv1"),
_build_event("user", [types.Part(text="Hello again")], "inv2"),
_build_event("agent2", [types.Part(text="Hi again!")], "inv2"),
_build_event(
"agent1", [types.Part(text="Hi again from agent1")], "inv2"
), # no request
]
app_details = EvaluationGenerator._get_app_details_by_invocation_id(
events, mock_request_intercepter
)
expected_app_details = {
"inv1": AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1",
instructions="instruction1",
tool_declarations=[
types.Tool(
function_declarations=[
types.FunctionDeclaration(name="tool1")
]
)
],
)
}
),
"inv2": AppDetails(
agent_details={
"agent2": AgentDetails(
name="agent2",
instructions="instruction2",
tool_declarations=[],
)
}
),
}
assert app_details == expected_app_details
assert mock_request_intercepter.get_model_request.call_count == 3
class TestGenerateInferencesForSingleUserInvocation:
"""Test cases for EvaluationGenerator._generate_inferences_for_single_user_invocation method."""
@pytest.mark.asyncio
async def test_generate_inferences_with_mock_runner(self, mocker):
"""Tests inference generation with a mocked runner."""
runner = mocker.MagicMock()
agent_parts = [types.Part(text="Agent response")]
async def mock_run_async(*args, **kwargs):
yield _build_event(
author="agent",
parts=agent_parts,
invocation_id="inv1",
)
runner.run_async.return_value = mock_run_async()
user_content = types.Content(parts=[types.Part(text="User query")])
events = [
event
async for event in (
EvaluationGenerator._generate_inferences_for_single_user_invocation(
runner, "test_user", "test_session", user_content
)
)
]
assert len(events) == 2
assert events[0].author == "user"
assert events[0].content == user_content
assert events[0].invocation_id == "inv1"
assert events[1].author == "agent"
assert events[1].content.parts == agent_parts
runner.run_async.assert_called_once_with(
user_id="test_user",
session_id="test_session",
new_message=user_content,
)
class TestGenerateInferencesForSingleUserInvocationLive:
"""Test cases for EvaluationGenerator._generate_inferences_for_single_user_invocation_live method."""
@pytest.mark.asyncio
async def test_generate_inferences_live(self, mocker):
"""Tests live inference generation."""
mock_live_request_queue = mocker.MagicMock()
event_queue = asyncio.Queue()
turn_complete_event = asyncio.Event()
user_content = types.Content(parts=[types.Part(text="User query")])
invocation_id = "inv1"
agent_event = _build_event(
"agent", [types.Part(text="Agent response")], invocation_id
)
other_event = _build_event(
"agent", [types.Part(text="Other response")], "inv2"
)
gen = EvaluationGenerator._generate_inferences_for_single_user_invocation_live(
live_request_queue=mock_live_request_queue,
event_queue=event_queue,
user_message=user_content,
current_invocation_id=invocation_id,
turn_complete_event=turn_complete_event,
live_timeout_seconds=300,
)
# First yield should be the user message
first_event = await gen.__anext__()
assert first_event.author == "user"
assert first_event.content == user_content
assert first_event.invocation_id == invocation_id
# Mock turn_complete_event.wait to avoid blocking
turn_complete_event.wait = mocker.AsyncMock()
# Put events in queue BEFORE advancing
await event_queue.put(agent_event)
await event_queue.put(other_event)
# Now advance to get the next event
second_event = await gen.__anext__()
assert mock_live_request_queue.send_content.called
mock_live_request_queue.send_content.assert_called_once_with(user_content)
assert second_event == agent_event
# The generator should be exhausted now because other_event doesn't match invocation_id
with pytest.raises(StopAsyncIteration):
await gen.__anext__()
@pytest.mark.asyncio
async def test_generate_inferences_live_with_synthetic_events(self, mocker):
"""Tests live inference generation with synthetic events."""
mock_live_request_queue = mocker.MagicMock()
event_queue = asyncio.Queue()
turn_complete_event = asyncio.Event()
user_content = types.Content(parts=[types.Part(text="User query")])
invocation_id = "inv1"
transcription = types.Transcription(text="Partial transcription")
partial_event = Event(
author="agent",
content=types.Content(parts=[]),
invocation_id=invocation_id,
output_transcription=transcription,
partial=True,
)
gen = EvaluationGenerator._generate_inferences_for_single_user_invocation_live(
live_request_queue=mock_live_request_queue,
event_queue=event_queue,
user_message=user_content,
current_invocation_id=invocation_id,
turn_complete_event=turn_complete_event,
live_timeout_seconds=300,
agent_name="custom_agent_name",
)
# First yield should be the user message
first_event = await gen.__anext__()
assert first_event.author == "user"
assert first_event.content == user_content
assert first_event.invocation_id == invocation_id
# Mock turn_complete_event.wait to avoid blocking
turn_complete_event.wait = mocker.AsyncMock()
# Put the partial event in the queue
await event_queue.put(partial_event)
# Now advance
second_event = await gen.__anext__()
assert second_event == partial_event
# Next should be the synthetic event
third_event = await gen.__anext__()
assert third_event.author == "custom_agent_name"
assert third_event.invocation_id == invocation_id
assert third_event.content.role == "model"
assert third_event.content.parts[0].text == "Partial transcription"
# The generator should be exhausted now
with pytest.raises(StopAsyncIteration):
await gen.__anext__()
@pytest.fixture
def mock_runner(mocker):
"""Provides a mock Runner for testing."""
mock_runner_cls = mocker.patch(
"google.adk.evaluation.evaluation_generator.Runner"
)
mock_runner_instance = mocker.AsyncMock()
mock_runner_instance.__aenter__.return_value = mock_runner_instance
mock_runner_cls.return_value = mock_runner_instance
yield mock_runner_instance
@pytest.fixture
def mock_session_service(mocker):
"""Provides a mock InMemorySessionService for testing."""
mock_session_service_cls = mocker.patch(
"google.adk.evaluation.evaluation_generator.InMemorySessionService"
)
mock_session_service_instance = mocker.MagicMock()
mock_session_service_instance.create_session = mocker.AsyncMock()
mock_session_service_cls.return_value = mock_session_service_instance
yield mock_session_service_instance
class TestGenerateInferencesFromRootAgent:
"""Test cases for EvaluationGenerator._generate_inferences_from_root_agent method."""
@pytest.mark.asyncio
async def test_generates_inferences_with_user_simulator(
self, mocker, mock_runner, mock_session_service
):
"""Tests that inferences are generated by interacting with a user simulator."""
mock_agent = mocker.MagicMock()
mock_user_sim = mocker.MagicMock(spec=UserSimulator)
# Mock user simulator will produce one message, then stop.
async def get_next_user_message_side_effect(*args, **kwargs):
if mock_user_sim.get_next_user_message.call_count == 1:
return NextUserMessage(
status=UserSimulatorStatus.SUCCESS,
user_message=types.Content(parts=[types.Part(text="message 1")]),
)
return NextUserMessage(status=UserSimulatorStatus.STOP_SIGNAL_DETECTED)
mock_user_sim.get_next_user_message = mocker.AsyncMock(
side_effect=get_next_user_message_side_effect
)
mock_generate_inferences = mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._generate_inferences_for_single_user_invocation"
)
mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._get_app_details_by_invocation_id"
)
mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator.convert_events_to_eval_invocations"
)
# Each call to _generate_inferences_for_single_user_invocation will
# yield one user and one agent event.
async def mock_generate_inferences_side_effect(
runner, user_id, session_id, user_content
):
yield _build_event("user", user_content.parts, "inv1")
yield _build_event("agent", [types.Part(text="agent_response")], "inv1")
mock_generate_inferences.side_effect = mock_generate_inferences_side_effect
await EvaluationGenerator._generate_inferences_from_root_agent(
root_agent=mock_agent,
user_simulator=mock_user_sim,
)
# Check that user simulator was called until it stopped.
assert mock_user_sim.get_next_user_message.call_count == 2
# Check that we generated inferences for each user message.
assert mock_generate_inferences.call_count == 1
# Check the content of the user messages passed to inference generation
mock_generate_inferences.assert_called_once()
called_with_content = mock_generate_inferences.call_args.args[3]
assert called_with_content.parts[0].text == "message 1"
@pytest.mark.asyncio
async def test_generates_inferences_with_user_simulator_live(
self, mocker, mock_runner, mock_session_service
):
"""Tests that inferences are generated by interacting with a user simulator in live mode."""
mock_agent = mocker.MagicMock()
mock_user_sim = mocker.MagicMock(spec=UserSimulator)
# Mock user simulator will produce one message, then stop.
async def get_next_user_message_side_effect(*args, **kwargs):
if mock_user_sim.get_next_user_message.call_count == 1:
return NextUserMessage(
status=UserSimulatorStatus.SUCCESS,
user_message=types.Content(parts=[types.Part(text="message 1")]),
)
return NextUserMessage(status=UserSimulatorStatus.STOP_SIGNAL_DETECTED)
mock_user_sim.get_next_user_message = mocker.AsyncMock(
side_effect=get_next_user_message_side_effect
)
mock_generate_inferences_live = mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._generate_inferences_for_single_user_invocation_live"
)
mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._get_app_details_by_invocation_id"
)
mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator.convert_events_to_eval_invocations"
)
# Mock _LiveSession context manager
mock_live_session = mocker.MagicMock()
mock_live_session.__aenter__ = mocker.AsyncMock(
return_value=mock_live_session
)
mock_live_session.__aexit__ = mocker.AsyncMock(return_value=None)
mock_live_session.live_request_queue = mocker.MagicMock()
mock_live_session.event_queue = asyncio.Queue()
mock_live_session.turn_complete_event = asyncio.Event()
mock_live_session.live_finished = asyncio.Event()
mock_live_session_cls = mocker.patch(
"google.adk.evaluation.evaluation_generator._LiveSession",
return_value=mock_live_session,
)
# Each call to _generate_inferences_for_single_user_invocation_live will
# yield one user and one agent event.
async def mock_generate_inferences_live_side_effect(*args, **kwargs):
yield _build_event("user", [types.Part(text="message 1")], "inv1")
yield _build_event("agent", [types.Part(text="agent_response")], "inv1")
mock_generate_inferences_live.side_effect = (
mock_generate_inferences_live_side_effect
)
await EvaluationGenerator._generate_inferences_from_root_agent_live(
root_agent=mock_agent,
user_simulator=mock_user_sim,
live_timeout_seconds=600,
)
# Check that user simulator was called until it stopped.
assert mock_user_sim.get_next_user_message.call_count == 2
# Check that we generated inferences for each user message.
mock_generate_inferences_live.assert_called_once()
called_with_content = mock_generate_inferences_live.call_args.kwargs[
"user_message"
]
assert called_with_content.parts[0].text == "message 1"
assert (
mock_generate_inferences_live.call_args.kwargs["live_timeout_seconds"]
== 600
)
# Verify that the agent response was collected
mock_convert = EvaluationGenerator.convert_events_to_eval_invocations
mock_convert.assert_called_once()
events_passed = mock_convert.call_args.args[0]
agent_events = [e for e in events_passed if e.author == "agent"]
assert len(agent_events) == 1
assert agent_events[0].content.parts[0].text == "agent_response"
# Verify that the _LiveSession constructor was called
mock_live_session_cls.assert_called_once()
class TestGenerateResponses:
"""Test cases for EvaluationGenerator.generate_responses method."""
@pytest.mark.asyncio
async def test_generate_responses_passes_config_to_simulator_instance(
self, mocker
):
"""Tests that user_simulator_config reaches the actual UserSimulator instance when UserSimulatorProvider is not mocked."""
mock_process_query = mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._process_query",
new_callable=mocker.AsyncMock,
return_value=[],
)
user_simulator_config = LlmBackedUserSimulatorConfig(
model="gemini-2.5-flash",
max_allowed_invocations=5,
custom_instructions=(
"custom {{ stop_signal }} {{ conversation_plan }} {{"
" conversation_history }}"
),
)
eval_set = EvalSet(
eval_set_id="test_set",
eval_cases=[
EvalCase(
eval_id="case_0",
conversation_scenario=ConversationScenario(
starting_prompt="hello",
conversation_plan="test plan",
),
)
],
)
await EvaluationGenerator.generate_responses(
eval_set=eval_set,
agent_module_path="some.agent.module",
repeat_num=1,
user_simulator_config=user_simulator_config,
)
mock_process_query.assert_called_once()
user_simulator = mock_process_query.call_args.args[1]
assert isinstance(user_simulator, LlmBackedUserSimulator)
assert user_simulator._config.model == "gemini-2.5-flash"
assert user_simulator._config.max_allowed_invocations == 5
assert (
user_simulator._config.custom_instructions
== "custom {{ stop_signal }} {{ conversation_plan }} {{"
" conversation_history }}"
)
class TestLiveSessionCallbacks:
"""Unit tests verifying that _LiveSession manually triggers callbacks."""
@pytest.mark.asyncio
async def test_live_session_manually_triggers_callbacks(self, mocker):
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_request import LlmRequest
# 1. Setup mock runner, agent, and session
mock_runner = mocker.MagicMock()
mock_runner.session_service.append_event = mocker.AsyncMock()
mock_session = mocker.MagicMock()
mock_agent = mocker.MagicMock(spec=Agent)
mock_runner.agent = mock_agent
mock_runner._find_agent_to_run.return_value = mock_agent
mock_agent.name = "test_agent"
# Mock _llm_flow._preprocess_async to set dummy instruction
async def mock_preprocess_async(invocation_context, llm_request):
llm_request.config.system_instruction = "mock instruction"
return
yield # make it an async generator
mock_flow = mocker.MagicMock()
mock_flow._preprocess_async = mock_preprocess_async
mock_agent._llm_flow = mock_flow
# Mock run_live stream yielding one event
mock_event = Event(
author="agent",
content=types.Content(parts=[types.Part(text="Hello")]),
invocation_id="test_invocation_id",
)
async def mock_run_live(*args, **kwargs):
yield mock_event
mock_agent.run_live.return_value = mock_run_live()
# Mock plugin_manager on invocation context
mock_plugin_manager = mocker.MagicMock()
mock_plugin_manager.run_before_model_callback = mocker.AsyncMock()
mock_plugin_manager.run_after_model_callback = mocker.AsyncMock()
mock_runner._new_invocation_context_for_live.return_value.plugin_manager = (
mock_plugin_manager
)
mock_runner._new_invocation_context_for_live.return_value.agent = mock_agent
# 2. Instantiate and enter _LiveSession
live_session = _LiveSession(
runner=mock_runner,
session=mock_session,
user_id="test_user",
session_id="test_session",
)
# Directly run _consume_events as a coroutine for synchronous-style testing
await live_session._consume_events()
# 3. Assertions
mock_plugin_manager.run_before_model_callback.assert_called_once()
called_before_args = mock_plugin_manager.run_before_model_callback.call_args
assert isinstance(
called_before_args.kwargs["callback_context"], CallbackContext
)
assert isinstance(called_before_args.kwargs["llm_request"], LlmRequest)
assert (
called_before_args.kwargs["llm_request"].config.system_instruction
== "mock instruction"
)
mock_plugin_manager.run_after_model_callback.assert_called_once()
called_after_args = mock_plugin_manager.run_after_model_callback.call_args
assert isinstance(
called_after_args.kwargs["callback_context"], CallbackContext
)
assert isinstance(called_after_args.kwargs["llm_response"], Event)
assert called_after_args.kwargs["llm_response"] == mock_event
@pytest.mark.asyncio
async def test_live_session_manually_triggers_callbacks_with_tools(
self, mocker
):
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import Agent
from google.adk.models.llm_request import LlmRequest
# 1. Setup mock runner, agent, and session
mock_runner = mocker.MagicMock()
mock_runner.session_service.append_event = mocker.AsyncMock()
mock_session = mocker.MagicMock()
mock_agent = mocker.MagicMock(spec=Agent)
mock_runner.agent = mock_agent
mock_runner._find_agent_to_run.return_value = mock_agent
mock_agent.name = "test_agent"
# Set up a mock tool
mock_tool = mocker.MagicMock()
mock_tool.name = "get_weather"
mock_decl = types.FunctionDeclaration(
name="get_weather",
description="Get weather details",
)
mock_tool._get_declaration.return_value = mock_decl
# Mock _llm_flow._preprocess_async to set instruction and append tool
async def mock_preprocess_async(invocation_context, llm_request):
llm_request.config.system_instruction = "mock instruction"
llm_request.append_tools([mock_tool])
return
yield # make it an async generator
mock_flow = mocker.MagicMock()
mock_flow._preprocess_async = mock_preprocess_async
mock_agent._llm_flow = mock_flow
# Mock run_live stream yielding one event
mock_event = Event(
author="agent",
content=types.Content(parts=[types.Part(text="Hello")]),
invocation_id="test_invocation_id",
)
async def mock_run_live(*args, **kwargs):
yield mock_event
mock_agent.run_live.return_value = mock_run_live()
# Mock plugin_manager on invocation context
mock_plugin_manager = mocker.MagicMock()
mock_plugin_manager.run_before_model_callback = mocker.AsyncMock()
mock_plugin_manager.run_after_model_callback = mocker.AsyncMock()
mock_runner._new_invocation_context_for_live.return_value.plugin_manager = (
mock_plugin_manager
)
mock_runner._new_invocation_context_for_live.return_value.agent = mock_agent
# 2. Instantiate and enter _LiveSession
live_session = _LiveSession(
runner=mock_runner,
session=mock_session,
user_id="test_user",
session_id="test_session",
)
# Directly run _consume_events as a coroutine
await live_session._consume_events()
# 3. Assertions
mock_plugin_manager.run_before_model_callback.assert_called_once()
called_before_args = mock_plugin_manager.run_before_model_callback.call_args
assert isinstance(
called_before_args.kwargs["callback_context"], CallbackContext
)
llm_request = called_before_args.kwargs["llm_request"]
assert isinstance(llm_request, LlmRequest)
assert llm_request.config.system_instruction == "mock instruction"
# Assert that tool was correctly wrapped under types.Tool format
assert len(llm_request.config.tools) == 1
wrapped_tool = llm_request.config.tools[0]
assert isinstance(wrapped_tool, types.Tool)
assert len(wrapped_tool.function_declarations) == 1
assert wrapped_tool.function_declarations[0].name == "get_weather"
mock_plugin_manager.run_after_model_callback.assert_called_once()
called_after_args = mock_plugin_manager.run_after_model_callback.call_args
assert isinstance(
called_after_args.kwargs["callback_context"], CallbackContext
)
assert isinstance(called_after_args.kwargs["llm_response"], Event)
assert called_after_args.kwargs["llm_response"] == mock_event
def test_convert_events_preserves_tool_calls_when_skip_summarization():
"""Regression test for #5410.
When an event has skip_summarization=True, is_final_response() returns True
even if the event contains function calls. Previously such an event was
treated as final_event and excluded from invocation_events, causing
get_all_tool_calls() to return an empty list and tool_trajectory_avg_score
to always be 0.0 despite matching tool calls.
"""
events = [
Event(
invocation_id="inv1",
author="user",
content=types.Content(
parts=[types.Part(text="run a query")], role="user"
),
timestamp=1000.0,
),
Event(
invocation_id="inv1",
author="agent",
content=types.Content(
parts=[
types.Part(
function_call=types.FunctionCall(
id="call_01",
name="execute_sql",
args={"project_id": "my-proj", "query": "SELECT 1"},
)
)
]
),
actions=EventActions(skip_summarization=True),
),
]
invocations = EvaluationGenerator.convert_events_to_eval_invocations(events)
assert len(invocations) == 1
tool_calls = get_all_tool_calls(invocations[0].intermediate_data)
assert len(tool_calls) == 1
assert tool_calls[0].name == "execute_sql"
assert tool_calls[0].args == {"project_id": "my-proj", "query": "SELECT 1"}
@@ -0,0 +1,141 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.final_response_match_v1 import _calculate_rouge_1_scores
from google.adk.evaluation.final_response_match_v1 import RougeEvaluator
from google.genai import types as genai_types
import pytest
def _create_test_rouge_evaluator(threshold: float) -> RougeEvaluator:
return RougeEvaluator(
EvalMetric(metric_name="response_match_score", threshold=threshold)
)
def _create_test_invocations(
candidate: str, reference: str
) -> tuple[Invocation, Invocation]:
"""Returns tuple of (actual_invocation, expected_invocation)."""
return Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=candidate)]
),
), Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=reference)]
),
)
def test_calculate_rouge_1_scores_empty_candidate_and_reference():
candidate = ""
reference = ""
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores_empty_candidate():
candidate = ""
reference = "This is a test reference."
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores_empty_reference():
candidate = "This is a test candidate response."
reference = ""
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == 0
assert rouge_1_score.recall == 0
assert rouge_1_score.fmeasure == 0
def test_calculate_rouge_1_scores():
candidate = "This is a test candidate response."
reference = "This is a test reference."
rouge_1_score = _calculate_rouge_1_scores(candidate, reference)
assert rouge_1_score.precision == pytest.approx(2 / 3)
assert rouge_1_score.recall == pytest.approx(4 / 5)
assert rouge_1_score.fmeasure == pytest.approx(8 / 11)
@pytest.mark.parametrize(
"candidates, references, expected_score, expected_status",
[
(
["The quick brown fox jumps.", "hello world"],
["The quick brown fox jumps over the lazy dog.", "hello"],
0.69048, # (5/7 + 2/3) / 2
EvalStatus.FAILED,
),
(
["This is a test.", "Another test case."],
["This is a test.", "This is a different test."],
0.625, # (1 + 1/4) / 2
EvalStatus.FAILED,
),
(
["No matching words here.", "Second candidate."],
["Completely different text.", "Another reference."],
0.0, # (0 + 1/2) / 2
EvalStatus.FAILED,
),
(
["Same words", "Same words"],
["Same words", "Same words"],
1.0,
EvalStatus.PASSED,
),
],
)
def test_rouge_evaluator_multiple_invocations(
candidates: list[str],
references: list[str],
expected_score: float,
expected_status: EvalStatus,
):
rouge_evaluator = _create_test_rouge_evaluator(threshold=0.8)
actual_invocations = []
expected_invocations = []
for candidate, reference in zip(candidates, references):
actual_invocation, expected_invocation = _create_test_invocations(
candidate, reference
)
actual_invocations.append(actual_invocation)
expected_invocations.append(expected_invocation)
evaluation_result = rouge_evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == pytest.approx(
expected_score, rel=1e-3
)
assert evaluation_result.overall_eval_status == expected_status
@@ -0,0 +1,594 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import BaseCriterion
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import EvalStatus
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.final_response_match_v2 import _parse_critique
from google.adk.evaluation.final_response_match_v2 import FinalResponseMatchV2Evaluator
from google.adk.evaluation.llm_as_judge import AutoRaterScore
from google.adk.evaluation.llm_as_judge_utils import Label
from google.adk.models.llm_response import LlmResponse
from google.genai import types as genai_types
import pytest
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"is_the_agent_response_valid_or_invalid": "valid",
"reasoning": "The response is valid."
}
```""",
"""```json
{
"is_the_agent_response_valid": "undefined label",
}
```""",
],
)
def test_parse_critique_label_not_found(response_text):
label = _parse_critique(response_text)
assert label == Label.NOT_FOUND
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"is_the_agent_response_valid": "valid",
"reasoning": "The response is valid."
}
```""",
"""```json
{
"is_the_agent_response_valid": ["valid"],
"reasoning": "The response is valid."
}
```""",
"""```json
{
"is_the_agent_response_valid":\n [ "valid\n"],
"reasoning": "The response is valid."
}
```""",
],
)
def test_parse_critique(response_text):
label = _parse_critique(response_text)
assert label == Label.VALID
@pytest.mark.parametrize(
"response_text",
[
"""```json
{
"is_the_agent_response_invalid": "invalid",
"reasoning": "The response is invalid."
}
```""",
"""```json
{
"is_the_agent_response_invalid": ["invalid"],
"reasoning": "The response is invalid."
}
```""",
"""```json
{
"is_the_agent_response_invalid":\n [ "invalid\n"],
"reasoning": "The response is invalid."
}
```""",
],
)
def test_parse_critique_invalid(response_text):
label = _parse_critique(response_text)
assert label == Label.INVALID
def create_test_template() -> str:
return """
This is a test template.
{{
"User prompt": {prompt},
"Agent response": {response},
"Reference response": {golden_response},
}}
The answer should be a json alone which follows the json structure below:
{{
"is_the_agent_response_valid": [valid or invalid],
"reasoning":
}}
"""
def _create_test_evaluator_gemini(
threshold: float,
*,
include_intermediate_responses_in_final: bool = False,
) -> FinalResponseMatchV2Evaluator:
evaluator = FinalResponseMatchV2Evaluator(
EvalMetric(
metric_name="final_response_match_v2",
threshold=threshold,
criterion=BaseCriterion(
threshold=0.5,
include_intermediate_responses_in_final=(
include_intermediate_responses_in_final
),
),
),
)
evaluator._auto_rater_prompt_template = create_test_template()
return evaluator
def _create_test_invocations(
candidate: str, reference: str
) -> tuple[Invocation, Invocation]:
"""Returns tuple of (actual_invocation, expected_invocation)."""
actual_invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=candidate)],
role="model",
),
)
expected_invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=reference)],
role="model",
),
)
return actual_invocation, expected_invocation
def _add_intermediate_text(invocation: Invocation, text: str) -> Invocation:
invocation.intermediate_data = InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent",
content=genai_types.Content(
parts=[genai_types.Part(text=text)],
role="model",
),
),
]
)
return invocation
def test_format_auto_rater_prompt():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
prompt = evaluator.format_auto_rater_prompt(
actual_invocation, expected_invocation
)
assert prompt == """
This is a test template.
{
"User prompt": This is a test query.,
"Agent response": candidate text,
"Reference response": reference text,
}
The answer should be a json alone which follows the json structure below:
{
"is_the_agent_response_valid": [valid or invalid],
"reasoning":
}
"""
def test_format_auto_rater_prompt_uses_empty_text_for_missing_final_response():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
actual_invocation.final_response = None
expected_invocation.final_response = None
prompt = evaluator.format_auto_rater_prompt(
actual_invocation, expected_invocation
)
assert "None" not in prompt
assert '"Agent response": ,' in prompt
assert '"Reference response": ,' in prompt
def test_format_auto_rater_prompt_ignores_intermediate_by_default():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate final", "reference final"
)
_add_intermediate_text(actual_invocation, "candidate intro")
_add_intermediate_text(expected_invocation, "reference intro")
prompt = evaluator.format_auto_rater_prompt(
actual_invocation, expected_invocation
)
assert "candidate final" in prompt
assert "reference final" in prompt
assert "candidate intro" not in prompt
assert "reference intro" not in prompt
def test_format_auto_rater_prompt_includes_intermediate_when_enabled():
evaluator = _create_test_evaluator_gemini(
threshold=0.8, include_intermediate_responses_in_final=True
)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate final", "reference final"
)
_add_intermediate_text(actual_invocation, "candidate intro")
_add_intermediate_text(expected_invocation, "reference intro")
prompt = evaluator.format_auto_rater_prompt(
actual_invocation, expected_invocation
)
assert "candidate intro\ncandidate final" in prompt
assert "reference intro\nreference final" in prompt
def test_convert_auto_rater_response_to_score_valid():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
auto_rater_response = """```json
{
"is_the_agent_response_valid": "valid",
"reasoning": "The response is valid."
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
llm_response
)
assert auto_rater_score == AutoRaterScore(score=1.0)
def test_convert_auto_rater_response_to_score_invalid():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
auto_rater_response = """```json
{
"is_the_agent_response_valid": "invalid",
"reasoning": "The response is invalid."
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=auto_rater_response)],
role="model",
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
llm_response
)
assert auto_rater_score == AutoRaterScore(score=0.0)
def test_convert_auto_rater_response_to_score_invalid_json():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="invalid json")],
role="model",
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
llm_response
)
assert auto_rater_score == AutoRaterScore()
def test_convert_auto_rater_response_to_score_missing_key():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
llm_response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="{}")],
role="model",
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
llm_response
)
assert auto_rater_score == AutoRaterScore()
def test_aggregate_per_invocation_samples_none_evaluated():
evaluator = _create_test_evaluator_gemini(threshold=0.5)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
per_invocation_result_samples = [
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
assert (
evaluator.aggregate_per_invocation_samples(per_invocation_result_samples)
== per_invocation_result_samples[0]
)
def test_aggregate_per_invocation_samples_valid():
evaluator = _create_test_evaluator_gemini(threshold=0.5)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
per_invocation_result_samples = [
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
per_invocation_result = evaluator.aggregate_per_invocation_samples(
per_invocation_result_samples
)
assert per_invocation_result.score == 1.0
assert per_invocation_result.eval_status == EvalStatus.PASSED
def test_aggregate_per_invocation_samples_invalid():
evaluator = _create_test_evaluator_gemini(threshold=0.5)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
per_invocation_result_samples = [
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
per_invocation_result = evaluator.aggregate_per_invocation_samples(
per_invocation_result_samples
)
assert per_invocation_result.score == 0.0
assert per_invocation_result.eval_status == EvalStatus.FAILED
def test_aggregate_invocation_results():
evaluator = _create_test_evaluator_gemini(threshold=0.5)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
per_invocation_results = [
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=0.0,
eval_status=EvalStatus.FAILED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.PASSED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=100.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
aggregated_result = evaluator.aggregate_invocation_results(
per_invocation_results
)
# Only 4 / 8 invocations are evaluated, and 2 / 4 are valid.
assert aggregated_result.overall_score == 0.5
assert aggregated_result.overall_eval_status == EvalStatus.PASSED
def test_aggregate_invocation_results_none_evaluated():
evaluator = _create_test_evaluator_gemini(threshold=0.5)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
per_invocation_results = [
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=None,
eval_status=EvalStatus.NOT_EVALUATED,
),
PerInvocationResult(
actual_invocation=actual_invocation,
expected_invocation=expected_invocation,
score=1.0,
eval_status=EvalStatus.NOT_EVALUATED,
),
]
aggregated_result = evaluator.aggregate_invocation_results(
per_invocation_results
)
assert aggregated_result.overall_score is None
assert aggregated_result.overall_eval_status == EvalStatus.NOT_EVALUATED
assert aggregated_result.per_invocation_results == per_invocation_results
@@ -0,0 +1,220 @@
# 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.
import json
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation._eval_set_results_manager_utils import _sanitize_eval_set_result_name
from google.adk.evaluation._eval_set_results_manager_utils import create_eval_set_result
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetricResult
from google.adk.evaluation.eval_metrics import EvalMetricResultPerInvocation
from google.adk.evaluation.eval_result import EvalCaseResult
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.gcs_eval_set_results_manager import GcsEvalSetResultsManager
from google.genai import types as genai_types
import pytest
from .mock_gcs_utils import MockBucket
from .mock_gcs_utils import MockClient
def _get_test_eval_case_results():
# Create mock Invocation objects
actual_invocation_1 = Invocation(
invocation_id="actual_1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="input_1")]
),
)
expected_invocation_1 = Invocation(
invocation_id="expected_1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected_input_1")]
),
)
actual_invocation_2 = Invocation(
invocation_id="actual_2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="input_2")]
),
)
expected_invocation_2 = Invocation(
invocation_id="expected_2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected_input_2")]
),
)
eval_metric_result_1 = EvalMetricResult(
metric_name="metric",
threshold=0.8,
score=1.0,
eval_status=EvalStatus.PASSED,
)
eval_metric_result_2 = EvalMetricResult(
metric_name="metric",
threshold=0.8,
score=0.5,
eval_status=EvalStatus.FAILED,
)
eval_metric_result_per_invocation_1 = EvalMetricResultPerInvocation(
actual_invocation=actual_invocation_1,
expected_invocation=expected_invocation_1,
eval_metric_results=[eval_metric_result_1],
)
eval_metric_result_per_invocation_2 = EvalMetricResultPerInvocation(
actual_invocation=actual_invocation_2,
expected_invocation=expected_invocation_2,
eval_metric_results=[eval_metric_result_2],
)
return [
EvalCaseResult(
eval_set_id="eval_set",
eval_id="eval_case_1",
final_eval_status=EvalStatus.PASSED,
overall_eval_metric_results=[eval_metric_result_1],
eval_metric_result_per_invocation=[
eval_metric_result_per_invocation_1
],
session_id="session_1",
),
EvalCaseResult(
eval_set_id="eval_set",
eval_id="eval_case_2",
final_eval_status=EvalStatus.FAILED,
overall_eval_metric_results=[eval_metric_result_2],
eval_metric_result_per_invocation=[
eval_metric_result_per_invocation_2
],
session_id="session_2",
),
]
class TestGcsEvalSetResultsManager:
@pytest.fixture
def gcs_eval_set_results_manager(self, mocker):
mock_storage_client = MockClient()
bucket_name = "test_bucket"
mock_bucket = MockBucket(bucket_name)
mocker.patch.object(mock_storage_client, "bucket", return_value=mock_bucket)
mocker.patch(
"google.cloud.storage.Client", return_value=mock_storage_client
)
return GcsEvalSetResultsManager(bucket_name=bucket_name)
def test_save_eval_set_result(self, gcs_eval_set_results_manager, mocker):
mocker.patch("time.time", return_value=12345678)
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_results = _get_test_eval_case_results()
eval_set_result = create_eval_set_result(
app_name, eval_set_id, eval_case_results
)
blob_name = gcs_eval_set_results_manager._get_eval_set_result_blob_name(
app_name, eval_set_result.eval_set_result_id
)
mock_write_eval_set_result = mocker.patch.object(
gcs_eval_set_results_manager,
"_write_eval_set_result",
)
gcs_eval_set_results_manager.save_eval_set_result(
app_name, eval_set_id, eval_case_results
)
mock_write_eval_set_result.assert_called_once_with(
blob_name,
eval_set_result,
)
def test_get_eval_set_result_not_found(
self, gcs_eval_set_results_manager, mocker
):
mocker.patch("time.time", return_value=12345678)
app_name = "test_app"
with pytest.raises(NotFoundError) as e:
gcs_eval_set_results_manager.get_eval_set_result(
app_name, "non_existent_id"
)
def test_get_eval_set_result(self, gcs_eval_set_results_manager, mocker):
mocker.patch("time.time", return_value=12345678)
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_results = _get_test_eval_case_results()
gcs_eval_set_results_manager.save_eval_set_result(
app_name, eval_set_id, eval_case_results
)
eval_set_result = create_eval_set_result(
app_name, eval_set_id, eval_case_results
)
retrieved_eval_set_result = (
gcs_eval_set_results_manager.get_eval_set_result(
app_name, eval_set_result.eval_set_result_id
)
)
assert retrieved_eval_set_result == eval_set_result
def test_get_eval_set_result_double_encoded_legacy(
self, gcs_eval_set_results_manager, mocker
):
mocker.patch("time.time", return_value=12345678)
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_results = _get_test_eval_case_results()
eval_set_result = create_eval_set_result(
app_name, eval_set_id, eval_case_results
)
blob_name = gcs_eval_set_results_manager._get_eval_set_result_blob_name(
app_name, eval_set_result.eval_set_result_id
)
blob = gcs_eval_set_results_manager.bucket.blob(blob_name)
double_encoded_json = json.dumps(eval_set_result.model_dump_json())
blob.upload_from_string(
double_encoded_json, content_type="application/json"
)
retrieved_eval_set_result = (
gcs_eval_set_results_manager.get_eval_set_result(
app_name, eval_set_result.eval_set_result_id
)
)
assert retrieved_eval_set_result == eval_set_result
def test_list_eval_set_results(self, gcs_eval_set_results_manager, mocker):
mocker.patch("time.time", return_value=123)
app_name = "test_app"
eval_set_ids = ["test_eval_set_1", "test_eval_set_2", "test_eval_set_3"]
for eval_set_id in eval_set_ids:
eval_case_results = _get_test_eval_case_results()
gcs_eval_set_results_manager.save_eval_set_result(
app_name, eval_set_id, eval_case_results
)
retrieved_eval_set_result_ids = (
gcs_eval_set_results_manager.list_eval_set_results(app_name)
)
assert retrieved_eval_set_result_ids == [
"test_app_test_eval_set_1_123",
"test_app_test_eval_set_2_123",
"test_app_test_eval_set_3_123",
]
def test_list_eval_set_results_empty(self, gcs_eval_set_results_manager):
app_name = "test_app"
retrieved_eval_set_result_ids = (
gcs_eval_set_results_manager.list_eval_set_results(app_name)
)
assert retrieved_eval_set_result_ids == []
@@ -0,0 +1,421 @@
# 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.
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.eval_set import EvalSet
from google.adk.evaluation.gcs_eval_sets_manager import _EVAL_SET_FILE_EXTENSION
from google.adk.evaluation.gcs_eval_sets_manager import GcsEvalSetsManager
from google.cloud import exceptions as cloud_exceptions
import pytest
from .mock_gcs_utils import MockBlob
from .mock_gcs_utils import MockBucket
from .mock_gcs_utils import MockClient
class TestGcsEvalSetsManager:
"""Tests for GcsEvalSetsManager."""
@pytest.fixture
def gcs_eval_sets_manager(self, mocker):
mock_storage_client = MockClient()
bucket_name = "test_bucket"
mock_bucket = MockBucket(bucket_name)
mocker.patch.object(mock_storage_client, "bucket", return_value=mock_bucket)
mocker.patch(
"google.cloud.storage.Client", return_value=mock_storage_client
)
return GcsEvalSetsManager(bucket_name=bucket_name)
def test_gcs_eval_sets_manager_get_eval_set_success(
self, gcs_eval_sets_manager
):
app_name = "test_app"
eval_set_id = "test_eval_set"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mock_bucket = gcs_eval_sets_manager.bucket
mock_blob = mock_bucket.blob(
f"{app_name}/evals/eval_sets/{eval_set_id}{_EVAL_SET_FILE_EXTENSION}"
)
mock_blob.upload_from_string(mock_eval_set.model_dump_json())
eval_set = gcs_eval_sets_manager.get_eval_set(app_name, eval_set_id)
assert eval_set == mock_eval_set
def test_gcs_eval_sets_manager_get_eval_set_not_found(
self, gcs_eval_sets_manager
):
app_name = "test_app"
eval_set_id = "test_eval_set_not_exist"
eval_set = gcs_eval_sets_manager.get_eval_set(app_name, eval_set_id)
assert eval_set is None
def test_gcs_eval_sets_manager_create_eval_set_success(
self, gcs_eval_sets_manager, mocker
):
mocked_time = 12345678
mocker.patch("time.time", return_value=mocked_time)
app_name = "test_app"
eval_set_id = "test_eval_set"
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager,
"_write_eval_set_to_blob",
)
eval_set_blob_name = gcs_eval_sets_manager._get_eval_set_blob_name(
app_name, eval_set_id
)
created_eval_set = gcs_eval_sets_manager.create_eval_set(
app_name, eval_set_id
)
expected_eval_set = EvalSet(
eval_set_id=eval_set_id,
name=eval_set_id,
eval_cases=[],
creation_timestamp=mocked_time,
)
mock_write_eval_set_to_blob.assert_called_once_with(
eval_set_blob_name,
expected_eval_set,
)
assert created_eval_set == expected_eval_set
def test_gcs_eval_sets_manager_create_eval_set_invalid_id(
self, gcs_eval_sets_manager
):
app_name = "test_app"
eval_set_id = "invalid-id"
with pytest.raises(ValueError, match="Invalid Eval Set ID"):
gcs_eval_sets_manager.create_eval_set(app_name, eval_set_id)
def test_gcs_eval_sets_manager_list_eval_sets_success(
self, gcs_eval_sets_manager
):
app_name = "test_app"
mock_blob_1 = MockBlob(
f"test_app/evals/eval_sets/eval_set_1{_EVAL_SET_FILE_EXTENSION}"
)
mock_blob_2 = MockBlob(
f"test_app/evals/eval_sets/eval_set_2{_EVAL_SET_FILE_EXTENSION}"
)
mock_blob_3 = MockBlob("test_app/evals/eval_sets/not_an_eval_set.txt")
mock_bucket = gcs_eval_sets_manager.bucket
mock_bucket.blobs = {
mock_blob_1.name: mock_blob_1,
mock_blob_2.name: mock_blob_2,
mock_blob_3.name: mock_blob_3,
}
eval_sets = gcs_eval_sets_manager.list_eval_sets(app_name)
assert eval_sets == ["eval_set_1", "eval_set_2"]
def test_gcs_eval_sets_manager_list_eval_sets_fails(
self, gcs_eval_sets_manager, mocker
):
mocker.patch.object(
gcs_eval_sets_manager.bucket,
"list_blobs",
side_effect=cloud_exceptions.NotFound("not found"),
)
with pytest.raises(NotFoundError):
gcs_eval_sets_manager.list_eval_sets("test_app")
def test_gcs_eval_sets_manager_add_eval_case_success(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager, "_write_eval_set_to_blob"
)
eval_set_blob_name = gcs_eval_sets_manager._get_eval_set_blob_name(
app_name, eval_set_id
)
gcs_eval_sets_manager.add_eval_case(app_name, eval_set_id, mock_eval_case)
assert len(mock_eval_set.eval_cases) == 1
assert mock_eval_set.eval_cases[0] == mock_eval_case
mock_write_eval_set_to_blob.assert_called_once_with(
eval_set_blob_name, mock_eval_set
)
def test_gcs_eval_sets_manager_add_eval_case_eval_set_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=None
)
with pytest.raises(
NotFoundError, match="Eval set `test_eval_set` not found."
):
gcs_eval_sets_manager.add_eval_case(app_name, eval_set_id, mock_eval_case)
def test_gcs_eval_sets_manager_add_eval_case_eval_case_id_exists(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
with pytest.raises(
ValueError,
match=(
f"Eval id `{eval_case_id}` already exists in `{eval_set_id}` eval"
" set."
),
):
gcs_eval_sets_manager.add_eval_case(app_name, eval_set_id, mock_eval_case)
def test_gcs_eval_sets_manager_get_eval_case_success(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
eval_case = gcs_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case == mock_eval_case
def test_gcs_eval_sets_manager_get_eval_case_eval_set_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=None
)
eval_case = gcs_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case is None
def test_gcs_eval_sets_manager_get_eval_case_eval_case_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
eval_case = gcs_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case is None
def test_gcs_eval_sets_manager_update_eval_case_success(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(
eval_id=eval_case_id, conversation=[], creation_timestamp=456
)
updated_eval_case = EvalCase(
eval_id=eval_case_id, conversation=[], creation_timestamp=123
)
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_case", return_value=mock_eval_case
)
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager, "_write_eval_set_to_blob"
)
eval_set_blob_name = gcs_eval_sets_manager._get_eval_set_blob_name(
app_name, eval_set_id
)
gcs_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
assert len(mock_eval_set.eval_cases) == 1
assert mock_eval_set.eval_cases[0] == updated_eval_case
mock_write_eval_set_to_blob.assert_called_once_with(
eval_set_blob_name,
EvalSet(eval_set_id=eval_set_id, eval_cases=[updated_eval_case]),
)
def test_gcs_eval_sets_manager_update_eval_case_eval_set_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
updated_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_case", return_value=None
)
with pytest.raises(
NotFoundError,
match=f"Eval set `{eval_set_id}` not found.",
):
gcs_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
def test_gcs_eval_sets_manager_update_eval_case_eval_case_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
updated_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
with pytest.raises(
NotFoundError,
match=(
f"Eval case `{eval_case_id}` not found in eval set `{eval_set_id}`."
),
):
gcs_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
def test_gcs_eval_sets_manager_delete_eval_case_success(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mock_bucket = gcs_eval_sets_manager.bucket
mock_blob = mock_bucket.blob(
f"{app_name}/evals/eval_sets/{eval_set_id}{_EVAL_SET_FILE_EXTENSION}"
)
mock_blob.upload_from_string(mock_eval_set.model_dump_json())
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_case", return_value=mock_eval_case
)
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager, "_write_eval_set_to_blob"
)
eval_set_blob_name = gcs_eval_sets_manager._get_eval_set_blob_name(
app_name, eval_set_id
)
gcs_eval_sets_manager.delete_eval_case(app_name, eval_set_id, eval_case_id)
assert len(mock_eval_set.eval_cases) == 0
mock_write_eval_set_to_blob.assert_called_once_with(
eval_set_blob_name,
EvalSet(eval_set_id=eval_set_id, eval_cases=[]),
)
def test_gcs_eval_sets_manager_delete_eval_case_eval_set_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager, "_write_eval_set_to_blob"
)
with pytest.raises(
NotFoundError,
match=f"Eval set `{eval_set_id}` not found.",
):
gcs_eval_sets_manager.delete_eval_case(
app_name, eval_set_id, eval_case_id
)
mock_write_eval_set_to_blob.assert_not_called()
def test_gcs_eval_sets_manager_delete_eval_case_eval_case_not_found(
self, gcs_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_set", return_value=mock_eval_set
)
mocker.patch.object(
gcs_eval_sets_manager, "get_eval_case", return_value=None
)
mock_write_eval_set_to_blob = mocker.patch.object(
gcs_eval_sets_manager, "_write_eval_set_to_blob"
)
with pytest.raises(
NotFoundError,
match=(
f"Eval case `{eval_case_id}` not found in eval set `{eval_set_id}`."
),
):
gcs_eval_sets_manager.delete_eval_case(
app_name, eval_set_id, eval_case_id
)
mock_write_eval_set_to_blob.assert_not_called()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
# 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.
import time
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager
import pytest
@pytest.fixture
def app_name():
return "test_app"
@pytest.fixture
def manager():
return InMemoryEvalSetsManager()
@pytest.fixture
def eval_set_id():
return "test_eval_set"
@pytest.fixture
def eval_case_id():
return "test_eval_case"
def test_create_eval_set(manager, app_name, eval_set_id):
eval_set = manager.create_eval_set(app_name, eval_set_id)
assert eval_set is not None
assert eval_set.eval_set_id == eval_set_id
assert eval_set.eval_cases == []
def test_create_eval_set_already_exists(manager, app_name, eval_set_id):
manager.create_eval_set(app_name, eval_set_id)
with pytest.raises(ValueError):
manager.create_eval_set(app_name, eval_set_id)
def test_get_eval_set(manager, app_name, eval_set_id):
manager.create_eval_set(app_name, eval_set_id)
eval_set = manager.get_eval_set(app_name, eval_set_id)
assert eval_set is not None
assert eval_set.eval_set_id == eval_set_id
def test_get_eval_set_not_found(manager, app_name):
eval_set = manager.get_eval_set(app_name, "nonexistent_set")
assert eval_set is None
def test_get_eval_set_wrong_app(manager, app_name, eval_set_id):
manager.create_eval_set(app_name, eval_set_id)
eval_set = manager.get_eval_set("wrong_app", eval_set_id)
assert eval_set is None
def test_list_eval_sets(manager, app_name):
manager.create_eval_set(app_name, "set1")
manager.create_eval_set(app_name, "set2")
eval_sets = manager.list_eval_sets(app_name)
assert len(eval_sets) == 2
assert "set1" in eval_sets
assert "set2" in eval_sets
def test_list_eval_sets_wrong_app(manager, app_name):
manager.create_eval_set(app_name, "set1")
eval_sets = manager.list_eval_sets("wrong_app")
assert len(eval_sets) == 0
def test_add_eval_case(manager, app_name, eval_set_id, eval_case_id):
manager.create_eval_set(app_name, eval_set_id)
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
manager.add_eval_case(app_name, eval_set_id, eval_case)
retrieved_case = manager.get_eval_case(app_name, eval_set_id, eval_case_id)
assert retrieved_case is not None
assert retrieved_case.eval_id == eval_case_id
eval_set = manager.get_eval_set(app_name, eval_set_id)
assert len(eval_set.eval_cases) == 1
assert eval_set.eval_cases[0].eval_id == eval_case_id
def test_add_eval_case_set_not_found(
manager, app_name, eval_set_id, eval_case_id
):
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
with pytest.raises(NotFoundError):
manager.add_eval_case(app_name, eval_set_id, eval_case)
def test_add_eval_case_already_exists(
manager, app_name, eval_set_id, eval_case_id
):
manager.create_eval_set(app_name, eval_set_id)
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
manager.add_eval_case(app_name, eval_set_id, eval_case)
with pytest.raises(ValueError):
manager.add_eval_case(app_name, eval_set_id, eval_case)
def test_get_eval_case(manager, app_name, eval_set_id, eval_case_id):
manager.create_eval_set(app_name, eval_set_id)
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
manager.add_eval_case(app_name, eval_set_id, eval_case)
retrieved_case = manager.get_eval_case(app_name, eval_set_id, eval_case_id)
assert retrieved_case is not None
assert retrieved_case.eval_id == eval_case_id
def test_get_eval_case_not_found(manager, app_name, eval_set_id):
manager.create_eval_set(app_name, eval_set_id)
retrieved_case = manager.get_eval_case(
app_name, eval_set_id, "nonexistent_case"
)
assert retrieved_case is None
def test_get_eval_case_set_not_found(manager, app_name, eval_case_id):
retrieved_case = manager.get_eval_case(
app_name, "nonexistent_set", eval_case_id
)
assert retrieved_case is None
def test_update_eval_case(manager, app_name, eval_set_id, eval_case_id):
manager.create_eval_set(app_name, eval_set_id)
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
manager.add_eval_case(app_name, eval_set_id, eval_case)
updated_eval_case = EvalCase(
eval_id=eval_case_id, conversation=[], creation_timestamp=time.time()
)
manager.update_eval_case(app_name, eval_set_id, updated_eval_case)
retrieved_case = manager.get_eval_case(app_name, eval_set_id, eval_case_id)
assert retrieved_case is not None
assert retrieved_case.creation_timestamp != 0.0
assert (
retrieved_case.creation_timestamp == updated_eval_case.creation_timestamp
)
eval_set = manager.get_eval_set(app_name, eval_set_id)
assert len(eval_set.eval_cases) == 1
assert (
eval_set.eval_cases[0].creation_timestamp
== updated_eval_case.creation_timestamp
)
def test_update_eval_case_not_found(
manager, app_name, eval_set_id, eval_case_id
):
manager.create_eval_set(app_name, eval_set_id)
updated_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
with pytest.raises(NotFoundError):
manager.update_eval_case(app_name, eval_set_id, updated_eval_case)
def test_delete_eval_case(manager, app_name, eval_set_id, eval_case_id):
manager.create_eval_set(app_name, eval_set_id)
eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
manager.add_eval_case(app_name, eval_set_id, eval_case)
manager.delete_eval_case(app_name, eval_set_id, eval_case_id)
retrieved_case = manager.get_eval_case(app_name, eval_set_id, eval_case_id)
assert retrieved_case is None
eval_set = manager.get_eval_set(app_name, eval_set_id)
assert len(eval_set.eval_cases) == 0
def test_delete_eval_case_not_found(
manager, app_name, eval_set_id, eval_case_id
):
manager.create_eval_set(app_name, eval_set_id)
with pytest.raises(NotFoundError):
manager.delete_eval_case(app_name, eval_set_id, eval_case_id)
@@ -0,0 +1,239 @@
# 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.
from __future__ import annotations
from typing import Optional
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import LlmAsAJudgeCriterion
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.evaluator import EvaluationResult
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.llm_as_judge import AutoRaterScore
from google.adk.evaluation.llm_as_judge import LlmAsJudge
from google.adk.evaluation.llm_as_judge_utils import get_eval_status
from google.adk.evaluation.llm_as_judge_utils import get_text_from_content
from google.adk.models.llm_response import LlmResponse
from google.genai import types as genai_types
import pytest
class MockLlmAsJudge(LlmAsJudge):
def format_auto_rater_prompt(
self,
actual_invocation: Invocation,
expected_invocation: Optional[Invocation],
rubrics: Optional[list[Rubric]] = None,
) -> str:
return "formatted prompt"
def convert_auto_rater_response_to_score(
self,
llm_response: LlmResponse,
rubrics: Optional[list[Rubric]] = None,
) -> AutoRaterScore:
return AutoRaterScore(score=1.0)
def aggregate_per_invocation_samples(
self,
per_invocation_samples: list[PerInvocationResult],
) -> PerInvocationResult:
return per_invocation_samples[0]
def aggregate_invocation_results(
self, per_invocation_results: list[PerInvocationResult]
) -> EvaluationResult:
return EvaluationResult(
overall_score=1.0, overall_eval_status=EvalStatus.PASSED
)
@pytest.fixture
def mock_llm_as_judge():
return MockLlmAsJudge(
eval_metric=EvalMetric(
metric_name="test_metric",
threshold=0.5,
criterion=LlmAsAJudgeCriterion(
threshold=0.5,
judge_model_options=JudgeModelOptions(
judge_model="gemini-2.5-flash",
judge_model_config=genai_types.GenerateContentConfig(),
num_samples=3,
),
),
),
criterion_type=LlmAsAJudgeCriterion,
)
def test_get_text_from_content():
content = genai_types.Content(
parts=[
genai_types.Part(text="This is a test text."),
genai_types.Part(text="This is another test text."),
],
role="model",
)
assert (
get_text_from_content(content)
== "This is a test text.\nThis is another test text."
)
def test_get_eval_status():
assert get_eval_status(score=0.8, threshold=0.8) == EvalStatus.PASSED
assert get_eval_status(score=0.7, threshold=0.8) == EvalStatus.FAILED
assert get_eval_status(score=0.8, threshold=0.9) == EvalStatus.FAILED
assert get_eval_status(score=0.9, threshold=0.8) == EvalStatus.PASSED
assert get_eval_status(score=None, threshold=0.8) == EvalStatus.NOT_EVALUATED
def test_llm_as_judge_init_missing_criterion():
with pytest.raises(ValueError):
MockLlmAsJudge(
EvalMetric(metric_name="test_metric", threshold=0.8),
criterion_type=LlmAsAJudgeCriterion,
)
def test_llm_as_judge_init_unregistered_model():
with pytest.raises(ValueError):
MockLlmAsJudge(
EvalMetric(
metric_name="test_metric",
threshold=0.8,
criterion=LlmAsAJudgeCriterion(
threshold=0.5,
judge_model_options=JudgeModelOptions(
judge_model="unregistered_model",
judge_model_config=genai_types.GenerateContentConfig(),
num_samples=3,
),
),
),
criterion_type=LlmAsAJudgeCriterion,
)
@pytest.fixture
def mock_judge_model(mocker):
mock_judge_model = mocker.MagicMock()
async def mock_generate_content_async(llm_request):
yield LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="auto rater response")],
)
)
mock_judge_model.generate_content_async = mock_generate_content_async
return mock_judge_model
@pytest.mark.asyncio
async def test_evaluate_invocations_with_mock(
mock_llm_as_judge, mock_judge_model, mocker
):
mock_llm_as_judge._judge_model = mock_judge_model
mock_format_auto_rater_prompt = mocker.MagicMock(
wraps=mock_llm_as_judge.format_auto_rater_prompt
)
mock_llm_as_judge.format_auto_rater_prompt = mock_format_auto_rater_prompt
mock_convert_auto_rater_response_to_score = mocker.MagicMock(
wraps=mock_llm_as_judge.convert_auto_rater_response_to_score
)
mock_llm_as_judge.convert_auto_rater_response_to_score = (
mock_convert_auto_rater_response_to_score
)
mock_aggregate_per_invocation_samples = mocker.MagicMock(
wraps=mock_llm_as_judge.aggregate_per_invocation_samples
)
mock_llm_as_judge.aggregate_per_invocation_samples = (
mock_aggregate_per_invocation_samples
)
mock_aggregate_invocation_results = mocker.MagicMock(
wraps=mock_llm_as_judge.aggregate_invocation_results
)
mock_llm_as_judge.aggregate_invocation_results = (
mock_aggregate_invocation_results
)
actual_invocations = [
Invocation(
invocation_id="id1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="user content 1")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="final response 1")],
role="model",
),
),
Invocation(
invocation_id="id2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="user content 2")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="final response 2")],
role="model",
),
),
]
expected_invocations = [
Invocation(
invocation_id="id1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="user content 1")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="expected response 1")],
role="model",
),
),
Invocation(
invocation_id="id2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="user content 2")],
role="user",
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="expected response 2")],
role="model",
),
),
]
result = await mock_llm_as_judge.evaluate_invocations(
actual_invocations, expected_invocations
)
# Assertions
assert result.overall_score == 1.0
assert mock_llm_as_judge.format_auto_rater_prompt.call_count == 2
assert mock_llm_as_judge.convert_auto_rater_response_to_score.call_count == 6
assert mock_llm_as_judge.aggregate_invocation_results.call_count == 1
@@ -0,0 +1,364 @@
# 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.
from __future__ import annotations
import json
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_rubrics import RubricScore
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.llm_as_judge_utils import get_average_rubric_score
from google.adk.evaluation.llm_as_judge_utils import get_eval_status
from google.adk.evaluation.llm_as_judge_utils import get_text_from_content
from google.adk.evaluation.llm_as_judge_utils import get_tool_calls_and_responses_as_json_str
from google.adk.evaluation.llm_as_judge_utils import get_tool_declarations_as_json_str
from google.genai import types as genai_types
def test_get_text_from_content_with_none():
"""Tests get_text_from_content with None as input."""
assert get_text_from_content(None) is None
def test_get_text_from_content_with_content_and_none_parts():
"""Tests get_text_from_content with Content that has None for parts."""
content = genai_types.Content(parts=None)
assert get_text_from_content(content) is None
def test_get_text_from_content_with_empty_parts():
"""Tests get_text_from_content with an empty parts list."""
content = genai_types.Content(parts=[])
assert get_text_from_content(content) == None
def test_get_text_from_content_with_parts_but_no_text():
"""Tests get_text_from_content with parts that do not contain text."""
content = genai_types.Content(
parts=[
genai_types.Part(
function_call=genai_types.FunctionCall(name="test_func")
)
]
)
assert get_text_from_content(content) == ""
def test_get_text_from_content_with_single_text_part():
"""Tests get_text_from_content with a single text part."""
content = genai_types.Content(parts=[genai_types.Part(text="Hello")])
assert get_text_from_content(content) == "Hello"
def test_get_text_from_content_with_multiple_text_parts():
"""Tests get_text_from_content with multiple text parts."""
content = genai_types.Content(
parts=[genai_types.Part(text="Hello"), genai_types.Part(text="World")]
)
assert get_text_from_content(content) == "Hello\nWorld"
def test_get_text_from_content_with_mixed_parts():
"""Tests get_text_from_content with a mix of text and non-text parts."""
content = genai_types.Content(
parts=[
genai_types.Part(text="Hello"),
genai_types.Part(
function_call=genai_types.FunctionCall(name="test_func")
),
genai_types.Part(text="World"),
]
)
assert get_text_from_content(content) == "Hello\nWorld"
def test_get_text_from_content_with_invocation_include_intermediate_responses_in_final():
"""Tests get_text_from_content on an Invocation with and without the flag."""
intermediate_text = "Let me check."
final_response_text = "Done."
invocation = Invocation(
user_content=genai_types.Content(parts=[genai_types.Part(text="user")]),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent",
content=genai_types.Content(
parts=[genai_types.Part(text=intermediate_text)]
),
),
InvocationEvent(
author="tool",
content=genai_types.Content(
parts=[
genai_types.Part(
function_call=genai_types.FunctionCall(name="t")
)
]
),
),
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=final_response_text)]
),
)
# Flag off (default): only the final response text is returned.
assert get_text_from_content(invocation) == final_response_text
# Flag on: intermediate text is concatenated before the final response.
assert (
get_text_from_content(
invocation, include_intermediate_responses_in_final=True
)
== f"{intermediate_text}\n{final_response_text}"
)
def test_get_text_from_content_with_intermediate_data_full_response():
invocation = Invocation(
user_content=genai_types.Content(parts=[genai_types.Part(text="user")]),
intermediate_data=IntermediateData(
intermediate_responses=[
("agent", [genai_types.Part(text="legacy intro")]),
(
"tool",
[
genai_types.Part(
function_call=genai_types.FunctionCall(name="lookup")
)
],
),
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="final answer")]
),
)
assert get_text_from_content(invocation) == "final answer"
assert (
get_text_from_content(
invocation, include_intermediate_responses_in_final=True
)
== "legacy intro\nfinal answer"
)
def test_get_eval_status_with_none_score():
"""Tests get_eval_status returns NOT_EVALUATED for a None score."""
assert get_eval_status(score=None, threshold=0.5) == EvalStatus.NOT_EVALUATED
def test_get_eval_status_when_score_is_greater_than_threshold():
"""Tests get_eval_status returns PASSED when score > threshold."""
assert get_eval_status(score=0.8, threshold=0.5) == EvalStatus.PASSED
def test_get_eval_status_when_score_is_equal_to_threshold():
"""Tests get_eval_status returns PASSED when score == threshold."""
assert get_eval_status(score=0.5, threshold=0.5) == EvalStatus.PASSED
def test_get_eval_status_when_score_is_less_than_threshold():
"""Tests get_eval_status returns FAILED when score < threshold."""
assert get_eval_status(score=0.4, threshold=0.5) == EvalStatus.FAILED
def test_get_average_rubric_score_with_empty_list():
"""Tests get_average_rubric_score returns None for an empty list."""
assert get_average_rubric_score([]) is None
def test_get_average_rubric_score_with_all_none_scores():
"""Tests get_average_rubric_score returns None when all scores are None."""
rubric_scores = [
RubricScore(rubric_id="1", score=None),
RubricScore(rubric_id="2", score=None),
]
assert get_average_rubric_score(rubric_scores) is None
def test_get_average_rubric_score_with_single_score():
"""Tests get_average_rubric_score with a single valid score."""
rubric_scores = [RubricScore(rubric_id="1", score=0.8)]
assert get_average_rubric_score(rubric_scores) == 0.8
def test_get_average_rubric_score_with_multiple_scores():
"""Tests get_average_rubric_score with multiple valid scores."""
rubric_scores = [
RubricScore(rubric_id="1", score=0.8),
RubricScore(rubric_id="2", score=0.6),
]
assert get_average_rubric_score(rubric_scores) == 0.7
def test_get_average_rubric_score_with_mixed_scores():
"""Tests get_average_rubric_score with a mix of valid and None scores."""
rubric_scores = [
RubricScore(rubric_id="1", score=0.8),
RubricScore(rubric_id="2", score=None),
RubricScore(rubric_id="3", score=0.6),
]
assert get_average_rubric_score(rubric_scores) == 0.7
def test_get_tool_declarations_as_json_str_with_no_agents():
"""Tests get_tool_declarations_as_json_str with no agents."""
app_details = AppDetails(agent_details={})
expected_json = {"tool_declarations": {}}
actual_json_str = get_tool_declarations_as_json_str(app_details)
assert json.loads(actual_json_str) == expected_json
def test_get_tool_declarations_as_json_str_with_agent_no_tools():
"""Tests get_tool_declarations_as_json_str with an agent that has no tools."""
agent_details = {"agent1": AgentDetails(name="agent1", tool_declarations=[])}
app_details = AppDetails(agent_details=agent_details)
expected_json = {"tool_declarations": {"agent1": []}}
actual_json_str = get_tool_declarations_as_json_str(app_details)
assert json.loads(actual_json_str) == expected_json
def test_get_tool_declarations_as_json_str_with_agent_with_tools():
"""Tests get_tool_declarations_as_json_str with an agent that has tools."""
tool1 = genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="test_func", description="A test function."
)
]
)
agent_details = {
"agent1": AgentDetails(name="agent1", tool_declarations=[tool1])
}
app_details = AppDetails(agent_details=agent_details)
expected_json = {
"tool_declarations": {
"agent1": [{
"function_declarations": [{
"name": "test_func",
"description": "A test function.",
}]
}]
}
}
actual_json_str = get_tool_declarations_as_json_str(app_details)
assert json.loads(actual_json_str) == expected_json
def test_get_tool_declarations_as_json_str_with_multiple_agents():
"""Tests get_tool_declarations_as_json_str with multiple agents."""
tool1 = genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="test_func1", description="A test function 1."
)
]
)
agent_details = {
"agent1": AgentDetails(name="agent1", tool_declarations=[tool1]),
"agent2": AgentDetails(name="agent2", tool_declarations=[]),
}
app_details = AppDetails(agent_details=agent_details)
expected_json = {
"tool_declarations": {
"agent1": [{
"function_declarations": [{
"name": "test_func1",
"description": "A test function 1.",
}]
}],
"agent2": [],
}
}
actual_json_str = get_tool_declarations_as_json_str(app_details)
assert json.loads(actual_json_str) == expected_json
def test_get_tool_calls_and_responses_as_json_str_with_none():
"""Tests get_tool_calls_and_responses_as_json_str with None."""
assert (
get_tool_calls_and_responses_as_json_str(None)
== "No intermediate steps were taken."
)
def test_get_tool_calls_and_responses_as_json_str_with_intermediate_data_no_tools():
"""Tests get_tool_calls_and_responses_as_json_str with IntermediateData and no tools."""
intermediate_data = IntermediateData(tool_uses=[], tool_responses=[])
assert (
get_tool_calls_and_responses_as_json_str(intermediate_data)
== "No intermediate steps were taken."
)
intermediate_data = InvocationEvents(invocation_events=[])
assert (
get_tool_calls_and_responses_as_json_str(intermediate_data)
== "No intermediate steps were taken."
)
def test_get_tool_calls_and_responses_as_json_str_with_invocation_events_multiple_calls():
"""Tests get_tool_calls_and_responses_as_json_str with multiple calls in InvocationEvents."""
tool_call1 = genai_types.FunctionCall(name="func1", args={}, id="call1")
tool_call2 = genai_types.FunctionCall(name="func2", args={}, id="call2")
tool_response1 = genai_types.FunctionResponse(
name="func1", response={"status": "ok"}, id="call1"
)
invocation_event1 = InvocationEvent(
author="agent",
content=genai_types.Content(
parts=[
genai_types.Part(function_call=tool_call1),
genai_types.Part(function_call=tool_call2),
]
),
)
invocation_event2 = InvocationEvent(
author="tool",
content=genai_types.Content(
parts=[genai_types.Part(function_response=tool_response1)]
),
)
intermediate_data = InvocationEvents(
invocation_events=[invocation_event1, invocation_event2]
)
json_str = get_tool_calls_and_responses_as_json_str(intermediate_data)
expected_json = {
"tool_calls_and_response": [
{
"step": 0,
"tool_call": {"name": "func1", "args": {}, "id": "call1"},
"tool_response": {
"name": "func1",
"response": {"status": "ok"},
"id": "call1",
},
},
{
"step": 1,
"tool_call": {"name": "func2", "args": {}, "id": "call2"},
"tool_response": "None",
},
]
}
assert json.loads(json_str) == expected_json
@@ -0,0 +1,949 @@
# 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.
from __future__ import annotations
import asyncio
from typing import Optional
from google.adk.agents.llm_agent import LlmAgent
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.base_eval_service import EvaluateConfig
from google.adk.evaluation.base_eval_service import EvaluateRequest
from google.adk.evaluation.base_eval_service import InferenceConfig
from google.adk.evaluation.base_eval_service import InferenceRequest
from google.adk.evaluation.base_eval_service import InferenceResult
from google.adk.evaluation.base_eval_service import InferenceStatus
from google.adk.evaluation.conversation_scenarios import ConversationScenario
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import EvalMetricResult
from google.adk.evaluation.eval_metrics import Interval
from google.adk.evaluation.eval_metrics import MetricInfo
from google.adk.evaluation.eval_metrics import MetricValueInfo
from google.adk.evaluation.eval_result import EvalCaseResult
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.eval_set import EvalCase
from google.adk.evaluation.eval_set import EvalSet
from google.adk.evaluation.eval_set_results_manager import EvalSetResultsManager
from google.adk.evaluation.eval_sets_manager import EvalSetsManager
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.evaluator import EvaluationResult
from google.adk.evaluation.evaluator import Evaluator
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.local_eval_service import _add_rubrics_to_invocation
from google.adk.evaluation.local_eval_service import _copy_eval_case_rubrics_to_actual_invocations
from google.adk.evaluation.local_eval_service import _copy_invocation_rubrics_to_actual_invocations
from google.adk.evaluation.local_eval_service import LocalEvalService
from google.adk.evaluation.metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY
from google.adk.models.registry import LLMRegistry
from google.genai import types as genai_types
import pytest
from typing_extensions import override
@pytest.fixture
def mock_eval_sets_manager(mocker):
return mocker.create_autospec(EvalSetsManager)
@pytest.fixture
def dummy_agent():
llm = LLMRegistry.new_llm("gemini-pro")
return LlmAgent(name="test_agent", model=llm)
@pytest.fixture
def mock_eval_set_results_manager(mocker):
return mocker.create_autospec(EvalSetResultsManager)
@pytest.fixture
def eval_service(
dummy_agent, mock_eval_sets_manager, mock_eval_set_results_manager
):
DEFAULT_METRIC_EVALUATOR_REGISTRY.register_evaluator(
metric_info=FakeEvaluator.get_metric_info(), evaluator=FakeEvaluator
)
DEFAULT_METRIC_EVALUATOR_REGISTRY.register_evaluator(
metric_info=FakeSingleSidedEvaluator.get_metric_info(),
evaluator=FakeSingleSidedEvaluator,
)
return LocalEvalService(
root_agent=dummy_agent,
eval_sets_manager=mock_eval_sets_manager,
eval_set_results_manager=mock_eval_set_results_manager,
)
class FakeEvaluator(Evaluator):
def __init__(self, eval_metric: EvalMetric):
self._eval_metric = eval_metric
@staticmethod
def get_metric_info() -> MetricInfo:
return MetricInfo(
metric_name="fake_metric",
description="Fake metric description",
metric_value_info=MetricValueInfo(
interval=Interval(min_value=0.0, max_value=1.0)
),
)
@override
def evaluate_invocations(
self,
actual_invocations: list[Invocation],
expected_invocations: Optional[list[Invocation]] = None,
conversation_scenario: Optional[ConversationScenario] = None,
) -> EvaluationResult:
if expected_invocations is None:
raise ValueError("expected_invocations is required for this metric.")
per_invocation_results = []
for actual, expected in zip(actual_invocations, expected_invocations):
per_invocation_results.append(
PerInvocationResult(
actual_invocation=actual,
expected_invocation=expected,
score=0.9,
eval_status=EvalStatus.PASSED,
)
)
return EvaluationResult(
overall_score=0.9,
overall_eval_status=EvalStatus.PASSED,
per_invocation_results=per_invocation_results,
)
class FakeSingleSidedEvaluator(Evaluator):
def __init__(self, eval_metric: EvalMetric):
self._eval_metric = eval_metric
@staticmethod
def get_metric_info() -> MetricInfo:
return MetricInfo(
metric_name="fake_single_sided_metric",
description="Fake single sided metric description",
metric_value_info=MetricValueInfo(
interval=Interval(min_value=0.0, max_value=1.0)
),
)
@override
def evaluate_invocations(
self,
actual_invocations: list[Invocation],
expected_invocations: Optional[list[Invocation]] = None,
conversation_scenario: Optional[ConversationScenario] = None,
) -> EvaluationResult:
per_invocation_results = []
for actual in actual_invocations:
per_invocation_results.append(
PerInvocationResult(
actual_invocation=actual,
score=0.995,
eval_status=EvalStatus.PASSED,
)
)
return EvaluationResult(
overall_score=0.95,
overall_eval_status=EvalStatus.PASSED,
per_invocation_results=per_invocation_results,
)
@pytest.mark.asyncio
async def test_perform_inference_success(
eval_service,
dummy_agent,
mock_eval_sets_manager,
mocker,
):
eval_set = EvalSet(
eval_set_id="test_eval_set",
eval_cases=[
EvalCase(eval_id="case1", conversation=[], session_input=None),
EvalCase(eval_id="case2", conversation=[], session_input=None),
],
)
mock_eval_sets_manager.get_eval_set.return_value = eval_set
mock_inference_result = mocker.MagicMock()
eval_service._perform_inference_single_eval_item = mocker.AsyncMock(
return_value=mock_inference_result
)
inference_request = InferenceRequest(
app_name="test_app",
eval_set_id="test_eval_set",
inference_config=InferenceConfig(parallelism=2),
)
results = []
async for result in eval_service.perform_inference(inference_request):
results.append(result)
assert len(results) == 2
assert results[0] == mock_inference_result
assert results[1] == mock_inference_result
mock_eval_sets_manager.get_eval_set.assert_called_once_with(
app_name="test_app", eval_set_id="test_eval_set"
)
assert eval_service._perform_inference_single_eval_item.call_count == 2
@pytest.mark.asyncio
async def test_perform_inference_with_case_ids(
eval_service,
dummy_agent,
mock_eval_sets_manager,
mocker,
):
eval_set = EvalSet(
eval_set_id="test_eval_set",
eval_cases=[
EvalCase(eval_id="case1", conversation=[], session_input=None),
EvalCase(eval_id="case2", conversation=[], session_input=None),
EvalCase(eval_id="case3", conversation=[], session_input=None),
],
)
mock_eval_sets_manager.get_eval_set.return_value = eval_set
mock_inference_result = mocker.MagicMock()
eval_service._perform_inference_single_eval_item = mocker.AsyncMock(
return_value=mock_inference_result
)
inference_request = InferenceRequest(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_ids=["case1", "case3"],
inference_config=InferenceConfig(parallelism=1),
)
results = []
async for result in eval_service.perform_inference(inference_request):
results.append(result)
assert len(results) == 2
eval_service._perform_inference_single_eval_item.assert_any_call(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case=eval_set.eval_cases[0],
root_agent=dummy_agent,
use_live=False,
live_timeout_seconds=300,
)
eval_service._perform_inference_single_eval_item.assert_any_call(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case=eval_set.eval_cases[2],
root_agent=dummy_agent,
use_live=False,
live_timeout_seconds=300,
)
@pytest.mark.asyncio
async def test_perform_inference_with_use_live(
eval_service,
dummy_agent,
mock_eval_sets_manager,
mocker,
):
eval_set = EvalSet(
eval_set_id="test_eval_set",
eval_cases=[
EvalCase(eval_id="case1", conversation=[], session_input=None),
],
)
mock_eval_sets_manager.get_eval_set.return_value = eval_set
mock_inference_result = mocker.MagicMock()
eval_service._perform_inference_single_eval_item = mocker.AsyncMock(
return_value=mock_inference_result
)
inference_request = InferenceRequest(
app_name="test_app",
eval_set_id="test_eval_set",
inference_config=InferenceConfig(
parallelism=1, use_live=True, live_timeout_seconds=600
),
)
results = []
async for result in eval_service.perform_inference(inference_request):
results.append(result)
assert len(results) == 1
eval_service._perform_inference_single_eval_item.assert_called_once_with(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case=eval_set.eval_cases[0],
root_agent=dummy_agent,
use_live=True,
live_timeout_seconds=600,
)
@pytest.mark.asyncio
async def test_perform_inference_eval_set_not_found(
eval_service,
mock_eval_sets_manager,
):
mock_eval_sets_manager.get_eval_set.return_value = None
inference_request = InferenceRequest(
app_name="test_app",
eval_set_id="not_found_set",
inference_config=InferenceConfig(parallelism=1),
)
with pytest.raises(NotFoundError):
async for _ in eval_service.perform_inference(inference_request):
pass
@pytest.mark.asyncio
async def test_evaluate_success(
eval_service, mock_eval_sets_manager, mock_eval_set_results_manager, mocker
):
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="test user content.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="test final response.")]
),
)
inference_results = [
InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=[invocation.model_copy(deep=True)],
session_id="session1",
),
InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case2",
inferences=[invocation.model_copy(deep=True)],
session_id="session2",
),
]
eval_metric = EvalMetric(metric_name="fake_metric", threshold=0.5)
evaluate_request = EvaluateRequest(
inference_results=inference_results,
evaluate_config=EvaluateConfig(eval_metrics=[eval_metric], parallelism=2),
)
mock_eval_case = mocker.MagicMock(spec=EvalCase)
mock_eval_case.conversation = [invocation.model_copy(deep=True)]
mock_eval_case.conversation_scenario = None
mock_eval_case.session_input = None
mock_eval_sets_manager.get_eval_case.return_value = mock_eval_case
results = []
async for result in eval_service.evaluate(evaluate_request):
results.append(result)
assert len(results) == 2
assert isinstance(results[0], EvalCaseResult)
assert isinstance(results[1], EvalCaseResult)
assert mock_eval_sets_manager.get_eval_case.call_count == 2
assert mock_eval_set_results_manager.save_eval_set_result.call_count == 1
@pytest.mark.asyncio
async def test_evaluate_eval_case_not_found(
eval_service,
mock_eval_sets_manager,
):
inference_results = [
InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=[],
session_id="session1",
),
]
eval_metric = EvalMetric(metric_name="fake_metric", threshold=0.5)
evaluate_request = EvaluateRequest(
inference_results=inference_results,
evaluate_config=EvaluateConfig(eval_metrics=[eval_metric], parallelism=1),
)
mock_eval_sets_manager.get_eval_case.return_value = None
with pytest.raises(NotFoundError):
async for _ in eval_service.evaluate(evaluate_request):
pass
mock_eval_sets_manager.get_eval_case.assert_called_once()
@pytest.mark.asyncio
async def test_evaluate_single_inference_result(
eval_service, mock_eval_sets_manager, mock_eval_set_results_manager, mocker
):
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="test user content.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="test final response.")]
),
)
inference_result = InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=[
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
],
session_id="session1",
)
eval_metric = EvalMetric(metric_name="fake_metric", threshold=0.5)
evaluate_config = EvaluateConfig(eval_metrics=[eval_metric], parallelism=1)
mock_eval_case = mocker.MagicMock(spec=EvalCase)
mock_eval_case.conversation = [
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
]
mock_eval_case.conversation_scenario = None
mock_eval_case.session_input = None
mock_eval_sets_manager.get_eval_case.return_value = mock_eval_case
_, result = await eval_service._evaluate_single_inference_result(
inference_result=inference_result, evaluate_config=evaluate_config
)
assert isinstance(result, EvalCaseResult)
assert result.eval_id == "case1"
assert result.session_id == "session1"
assert len(result.overall_eval_metric_results) == 1
assert result.overall_eval_metric_results[0].metric_name == "fake_metric"
assert result.overall_eval_metric_results[0].score == 0.9
mock_eval_sets_manager.get_eval_case.assert_called_once_with(
app_name="test_app", eval_set_id="test_eval_set", eval_case_id="case1"
)
assert len(result.eval_metric_result_per_invocation) == 3
for i in range(3):
invocation_result = result.eval_metric_result_per_invocation[i]
assert invocation_result.actual_invocation == inference_result.inferences[i]
assert (
invocation_result.expected_invocation == mock_eval_case.conversation[i]
)
assert len(invocation_result.eval_metric_results) == 1
metric_result = invocation_result.eval_metric_results[0]
assert metric_result.metric_name == "fake_metric"
assert metric_result.score == 0.9
assert metric_result.eval_status == EvalStatus.PASSED
@pytest.mark.asyncio
async def test_evaluate_single_inference_result_failed_without_inferences(
eval_service, mock_eval_sets_manager, mocker
):
inference_result = InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=None,
session_id="session1",
status=InferenceStatus.FAILURE,
error_message="auth failed",
)
eval_metric = EvalMetric(metric_name="fake_metric", threshold=0.5)
evaluate_config = EvaluateConfig(eval_metrics=[eval_metric], parallelism=1)
mock_eval_case = mocker.MagicMock(spec=EvalCase)
mock_eval_case.conversation = []
mock_eval_case.conversation_scenario = None
mock_eval_case.session_input = None
mock_eval_sets_manager.get_eval_case.return_value = mock_eval_case
_, result = await eval_service._evaluate_single_inference_result(
inference_result=inference_result, evaluate_config=evaluate_config
)
assert result.eval_id == "case1"
assert result.session_id == "session1"
assert result.final_eval_status == EvalStatus.FAILED
assert result.overall_eval_metric_results == []
assert result.eval_metric_result_per_invocation == []
@pytest.mark.asyncio
async def test_evaluate_single_inference_result_for_conversation_scenario(
eval_service, mock_eval_sets_manager, mocker
):
"""To be removed once evaluation is implemented for conversation scenarios."""
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="test user content.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="test final response.")]
),
)
inference_result = InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=[
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
],
session_id="session1",
)
eval_metric = EvalMetric(
metric_name="fake_single_sided_metric", threshold=0.5
)
evaluate_config = EvaluateConfig(eval_metrics=[eval_metric], parallelism=1)
mock_eval_case = mocker.MagicMock(spec=EvalCase)
mock_eval_case.conversation = None
mock_eval_case.conversation_scenario = mocker.MagicMock()
mock_eval_case.session_input = None
mock_eval_sets_manager.get_eval_case.return_value = mock_eval_case
_, result = await eval_service._evaluate_single_inference_result(
inference_result=inference_result, evaluate_config=evaluate_config
)
assert isinstance(result, EvalCaseResult)
assert result.eval_id == "case1"
assert result.final_eval_status == EvalStatus.PASSED
assert len(result.overall_eval_metric_results) == 1
assert (
result.overall_eval_metric_results[0].metric_name
== "fake_single_sided_metric"
)
assert result.overall_eval_metric_results[0].score == 0.95
mock_eval_sets_manager.get_eval_case.assert_called_once_with(
app_name="test_app", eval_set_id="test_eval_set", eval_case_id="case1"
)
assert len(result.eval_metric_result_per_invocation) == 3
for i in range(3):
invocation_result = result.eval_metric_result_per_invocation[i]
assert invocation_result.actual_invocation == inference_result.inferences[i]
assert invocation_result.expected_invocation is None
assert len(invocation_result.eval_metric_results) == 1
metric_result = invocation_result.eval_metric_results[0]
assert metric_result.metric_name == "fake_single_sided_metric"
assert metric_result.score == 0.995
assert metric_result.eval_status == EvalStatus.PASSED
@pytest.mark.asyncio
async def test_evaluate_single_inference_result_for_conversation_scenario_with_unsupported_metric(
eval_service, mock_eval_sets_manager, mocker
):
"""To be removed once evaluation is implemented for conversation scenarios."""
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="test user content.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="test final response.")]
),
)
inference_result = InferenceResult(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case_id="case1",
inferences=[
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
invocation.model_copy(deep=True),
],
session_id="session1",
)
eval_metric = EvalMetric(metric_name="fake_metric", threshold=0.5)
evaluate_config = EvaluateConfig(eval_metrics=[eval_metric], parallelism=1)
mock_eval_case = mocker.MagicMock(spec=EvalCase)
mock_eval_case.eval_id = "case1"
mock_eval_case.conversation = None
mock_eval_case.conversation_scenario = mocker.MagicMock()
mock_eval_case.session_input = None
mock_eval_sets_manager.get_eval_case.return_value = mock_eval_case
_, result = await eval_service._evaluate_single_inference_result(
inference_result=inference_result, evaluate_config=evaluate_config
)
assert isinstance(result, EvalCaseResult)
assert result.eval_id == "case1"
assert result.final_eval_status == EvalStatus.NOT_EVALUATED
assert len(result.overall_eval_metric_results) == 1
assert result.overall_eval_metric_results[0].metric_name == "fake_metric"
assert result.overall_eval_metric_results[0].score is None
mock_eval_sets_manager.get_eval_case.assert_called_once_with(
app_name="test_app", eval_set_id="test_eval_set", eval_case_id="case1"
)
assert len(result.eval_metric_result_per_invocation) == 3
def test_generate_final_eval_status_doesn_t_throw_on(eval_service):
# How to fix if this test case fails?
# This test case has failed mainly because a new EvalStatus got added. You
# mostly need to update _generate_final_eval_status method to handle the new
# eval case.
# We go over all the possible values of EvalStatus one by one and expect
# the _generate_final_eval_status to handle it without throwing an exception.
for status in EvalStatus:
eval_metric_result = EvalMetricResult(
metric_name="metric1", threshold=0.5, eval_status=status
)
eval_service._generate_final_eval_status([eval_metric_result])
@pytest.mark.asyncio
async def test_mcp_stdio_agent_no_runtime_error(mocker):
"""Test that LocalEvalService can handle MCP stdio agents without RuntimeError.
This is a regression test for GitHub issue #2196:
"RuntimeError: Attempted to exit cancel scope in a different task than it was
entered in"
The fix ensures that Runner.close() is called to properly cleanup MCP
connections.
"""
import tempfile
from google.adk.evaluation.local_eval_service import LocalEvalService
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
from mcp import StdioServerParameters
# Mock LLM responses to avoid real API calls
from tests.unittests.testing_utils import MockModel
mock_responses = [
genai_types.Content(
parts=[genai_types.Part(text="Mocked response from test agent")]
)
]
mock_model = MockModel.create(responses=mock_responses)
# Create a test agent with MCP stdio toolset and mocked model
test_dir = tempfile.mkdtemp()
try:
agent = LlmAgent(
model=mock_model,
name="test_mcp_agent",
instruction="Test agent for MCP stdio regression test.",
tools=[
MCPToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(
command="npx",
args=[
"-y",
"@modelcontextprotocol/server-filesystem",
test_dir,
],
),
timeout=5,
),
tool_filter=["read_file", "list_directory"],
)
],
)
# Create a mock eval sets manager that returns an eval case
mock_eval_sets_manager = mocker.create_autospec(EvalSetsManager)
test_eval_case = EvalCase(
eval_id="test_mcp_case",
conversation=[
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="List directory contents")]
),
)
],
)
mock_eval_sets_manager.get_eval_case.return_value = test_eval_case
eval_set = EvalSet(
eval_set_id="test_set",
eval_cases=[test_eval_case],
)
mock_eval_sets_manager.get_eval_set.return_value = eval_set
# Create LocalEvalService with MCP agent
eval_service = LocalEvalService(
root_agent=agent,
eval_sets_manager=mock_eval_sets_manager,
)
# Create inference request to actually trigger the code path with the fix
inference_request = InferenceRequest(
app_name="test_app",
eval_set_id="test_set",
inference_config=InferenceConfig(parallelism=1),
)
# The main test: actually call perform_inference which will trigger
# _generate_inferences_from_root_agent where the fix is located
# Note: In Python 3.10 and 3.11, there may be asyncio.CancelledError during cleanup
# due to anyio cancel scope context violations when MCP toolsets are cleaned up
# via asyncio.wait_for() in different task contexts. Python 3.12+ enhanced task
# context management (Task.get_context(), improved context propagation) resolves this.
try:
results = []
async for result in eval_service.perform_inference(inference_request):
results.append(result)
# We should get at least one result since we mocked the LLM
break
# Test passes if we get here without the cancel scope RuntimeError
# With mocked model, we should get successful inference results
assert len(results) >= 1
except RuntimeError as e:
# If we get a RuntimeError about cancel scope, the fix isn't working
if "cancel scope" in str(e) and "different task" in str(e):
pytest.fail(f"MCP stdio RuntimeError regression detected: {e}")
else:
# Other RuntimeErrors might be acceptable
pass
except asyncio.CancelledError as e:
# In Python 3.10 and 3.11, anyio cancel scope context violations may manifest as CancelledError
# when MCP RequestResponder.__exit__() is called in a different task than __enter__()
if (
hasattr(e, "args")
and len(e.args) > 0
and "cancel scope" in str(e.args[0])
):
pytest.fail(f"MCP stdio cancel scope error regression detected: {e}")
else:
# Re-raise other CancelledErrors
raise
except Exception as e:
# Check if this is the specific cancel scope error we're testing for
if "cancel scope" in str(e) and "different task" in str(e):
pytest.fail(f"MCP stdio RuntimeError regression detected: {e}")
# Other exceptions are acceptable for this test
# The main goal is to ensure the test completes without the specific
# RuntimeError about cancel scopes. If we reach here, the fix is working.
finally:
# Cleanup
import shutil
shutil.rmtree(test_dir, ignore_errors=True)
def test_add_rubrics_to_invocation_initializes_rubrics_list():
invocation = Invocation(user_content=genai_types.Content())
rubric = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p1")
)
_add_rubrics_to_invocation(invocation, [rubric])
assert invocation.rubrics == [rubric]
def test_add_rubrics_to_invocation_adds_to_existing_list():
rubric1 = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p1")
)
rubric2 = Rubric(
rubric_id="r2", rubric_content=RubricContent(text_property="p2")
)
invocation = Invocation(user_content=genai_types.Content(), rubrics=[rubric1])
_add_rubrics_to_invocation(invocation, [rubric2])
assert invocation.rubrics == [rubric1, rubric2]
def test_add_rubrics_to_invocation_errors_on_duplicate_id():
rubric1 = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p1")
)
rubric2 = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p2")
)
invocation = Invocation(user_content=genai_types.Content(), rubrics=[rubric1])
with pytest.raises(ValueError):
_add_rubrics_to_invocation(invocation, [rubric2])
def test_copy_eval_case_rubrics_to_actual_invocations():
rubric1 = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p1")
)
eval_case = EvalCase(
eval_id="case1",
conversation=[
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected invocation 1.")]
)
),
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected invocation 2.")]
)
),
],
rubrics=[rubric1],
)
invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="actual invocation 1.")]
)
),
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="actual invocation 2.")]
)
),
]
_copy_eval_case_rubrics_to_actual_invocations(eval_case, invocations)
assert invocations[0].rubrics == [rubric1]
assert invocations[1].rubrics == [rubric1]
def test_copy_invocation_rubrics_to_actual_invocations():
rubric1 = Rubric(
rubric_id="r1", rubric_content=RubricContent(text_property="p1")
)
rubric2 = Rubric(
rubric_id="r2", rubric_content=RubricContent(text_property="p2")
)
expected = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected invocation 1.")]
),
rubrics=[rubric1],
),
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="expected invocation 2.")]
),
rubrics=[rubric2],
),
]
actual = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="actual invocation 1.")]
)
),
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="actual invocation 2.")]
)
),
]
_copy_invocation_rubrics_to_actual_invocations(expected, actual)
assert actual[0].rubrics == [rubric1]
assert actual[1].rubrics == [rubric2]
@pytest.mark.asyncio
async def test_perform_inference_single_eval_item_live(
eval_service, dummy_agent, mocker
):
eval_case = EvalCase(eval_id="case1", conversation=[], session_input=None)
mock_generate_live = mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._generate_inferences_from_root_agent_live"
)
mock_generate_live.return_value = []
eval_service._session_id_supplier = mocker.MagicMock(
return_value="test_session_id"
)
mock_user_sim = mocker.MagicMock()
eval_service._user_simulator_provider.provide = mocker.MagicMock(
return_value=mock_user_sim
)
await eval_service._perform_inference_single_eval_item(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case=eval_case,
root_agent=dummy_agent,
use_live=True,
live_timeout_seconds=600,
)
mock_generate_live.assert_called_once_with(
root_agent=dummy_agent,
user_simulator=mock_user_sim,
initial_session=None,
session_id="test_session_id",
session_service=eval_service._session_service,
artifact_service=eval_service._artifact_service,
memory_service=eval_service._memory_service,
live_timeout_seconds=600,
)
@pytest.mark.asyncio
async def test_perform_inference_single_eval_item_non_live(
eval_service, dummy_agent, mocker
):
eval_case = EvalCase(eval_id="case1", conversation=[], session_input=None)
mock_generate = mocker.patch(
"google.adk.evaluation.evaluation_generator.EvaluationGenerator._generate_inferences_from_root_agent"
)
mock_generate.return_value = []
eval_service._session_id_supplier = mocker.MagicMock(
return_value="test_session_id"
)
mock_user_sim = mocker.MagicMock()
eval_service._user_simulator_provider.provide = mocker.MagicMock(
return_value=mock_user_sim
)
await eval_service._perform_inference_single_eval_item(
app_name="test_app",
eval_set_id="test_eval_set",
eval_case=eval_case,
root_agent=dummy_agent,
use_live=False,
live_timeout_seconds=300,
)
mock_generate.assert_called_once_with(
root_agent=dummy_agent,
user_simulator=mock_user_sim,
initial_session=None,
session_id="test_session_id",
session_service=eval_service._session_service,
artifact_service=eval_service._artifact_service,
memory_service=eval_service._memory_service,
)
@@ -0,0 +1,206 @@
# 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.
from __future__ import annotations
import json
import os
import shutil
import tempfile
import time
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation._eval_set_results_manager_utils import _sanitize_eval_set_result_name
from google.adk.evaluation.eval_result import EvalCaseResult
from google.adk.evaluation.eval_result import EvalSetResult
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.local_eval_set_results_manager import _ADK_EVAL_HISTORY_DIR
from google.adk.evaluation.local_eval_set_results_manager import _EVAL_SET_RESULT_FILE_EXTENSION
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
import pytest
class TestLocalEvalSetResultsManager:
@pytest.fixture(autouse=True)
def setup(self):
self.temp_dir = tempfile.mkdtemp()
self.agents_dir = os.path.join(self.temp_dir, "agents")
os.makedirs(self.agents_dir)
self.manager = LocalEvalSetResultsManager(self.agents_dir)
self.app_name = "test_app"
self.eval_set_id = "test_eval_set"
self.eval_case_results = [
EvalCaseResult(
eval_set_file="test_file",
eval_set_id=self.eval_set_id,
eval_id="case1",
final_eval_status=EvalStatus.PASSED,
eval_metric_results=[],
overall_eval_metric_results=[],
eval_metric_result_per_invocation=[],
session_id="session1",
)
]
self.timestamp = time.time() # Store the timestamp
self.eval_set_result_id = (
self.app_name + "_" + self.eval_set_id + "_" + str(self.timestamp)
)
self.eval_set_result_name = _sanitize_eval_set_result_name(
self.eval_set_result_id
)
self.eval_set_result = EvalSetResult(
eval_set_result_id=self.eval_set_result_id,
eval_set_result_name=self.eval_set_result_name,
eval_set_id=self.eval_set_id,
eval_case_results=self.eval_case_results,
creation_timestamp=self.timestamp,
)
yield
shutil.rmtree(self.temp_dir)
def test_save_eval_set_result(self, mocker):
mock_time = mocker.patch("time.time")
mock_time.return_value = self.timestamp
self.manager.save_eval_set_result(
self.app_name, self.eval_set_id, self.eval_case_results
)
eval_history_dir = os.path.join(
self.agents_dir, self.app_name, _ADK_EVAL_HISTORY_DIR
)
expected_file_path = os.path.join(
eval_history_dir,
self.eval_set_result_name + _EVAL_SET_RESULT_FILE_EXTENSION,
)
assert os.path.exists(expected_file_path)
with open(expected_file_path, "r") as f:
actual_eval_set_result_data = json.load(f)
# Verify the file contains a proper JSON object (not double-encoded)
# Use mode='json' to serialize enums to their values for comparison
expected_eval_set_result_data = self.eval_set_result.model_dump(mode="json")
assert expected_eval_set_result_data == actual_eval_set_result_data
@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_save_eval_set_result_rejects_invalid_app_name(self, app_name):
with pytest.raises(ValueError):
self.manager.save_eval_set_result(
app_name, self.eval_set_id, self.eval_case_results
)
@pytest.mark.parametrize(
"eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_save_eval_set_result_rejects_invalid_eval_set_id(self, eval_set_id):
with pytest.raises(ValueError):
self.manager.save_eval_set_result(
self.app_name, eval_set_id, self.eval_case_results
)
def test_get_eval_set_result(self, mocker):
mock_time = mocker.patch("time.time")
mock_time.return_value = self.timestamp
self.manager.save_eval_set_result(
self.app_name, self.eval_set_id, self.eval_case_results
)
retrieved_result = self.manager.get_eval_set_result(
self.app_name, self.eval_set_result_name
)
assert retrieved_result == self.eval_set_result
@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_get_eval_set_result_rejects_invalid_app_name(self, app_name):
with pytest.raises(ValueError):
self.manager.get_eval_set_result(app_name, self.eval_set_result_name)
@pytest.mark.parametrize(
"eval_set_result_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_get_eval_set_result_rejects_invalid_eval_set_result_id(
self, eval_set_result_id
):
with pytest.raises(ValueError):
self.manager.get_eval_set_result(self.app_name, eval_set_result_id)
def test_get_eval_set_result_double_encoded_legacy(self):
eval_history_dir = os.path.join(
self.agents_dir, self.app_name, _ADK_EVAL_HISTORY_DIR
)
os.makedirs(eval_history_dir, exist_ok=True)
eval_set_result_file_path = os.path.join(
eval_history_dir,
self.eval_set_result_name + _EVAL_SET_RESULT_FILE_EXTENSION,
)
double_encoded_json = json.dumps(self.eval_set_result.model_dump_json())
with open(eval_set_result_file_path, "w", encoding="utf-8") as f:
f.write(double_encoded_json)
retrieved_result = self.manager.get_eval_set_result(
self.app_name, self.eval_set_result_name
)
assert retrieved_result == self.eval_set_result
def test_get_eval_set_result_not_found(self, mocker):
mock_time = mocker.patch("time.time")
mock_time.return_value = self.timestamp
with pytest.raises(NotFoundError) as e:
self.manager.get_eval_set_result(self.app_name, "non_existent_id")
def test_list_eval_set_results(self, mocker):
mock_time = mocker.patch("time.time")
mock_time.return_value = self.timestamp
# Save two eval set results for the same app
self.manager.save_eval_set_result(
self.app_name, self.eval_set_id, self.eval_case_results
)
timestamp2 = time.time() + 1
mock_time.return_value = timestamp2
eval_set_result_id2 = (
self.app_name + "_" + self.eval_set_id + "_" + str(timestamp2)
)
eval_set_result_name2 = _sanitize_eval_set_result_name(eval_set_result_id2)
eval_case_results2 = [
EvalCaseResult(
eval_set_file="test_file",
eval_set_id=self.eval_set_id,
eval_id="case2",
final_eval_status=EvalStatus.FAILED,
eval_metric_results=[],
overall_eval_metric_results=[],
eval_metric_result_per_invocation=[],
session_id="session2",
)
]
self.manager.save_eval_set_result(
self.app_name, self.eval_set_id, eval_case_results2
)
# Save one eval set result for a different app
app_name2 = "another_app"
timestamp3 = time.time() + 2
mock_time.return_value = timestamp3
self.manager.save_eval_set_result(
app_name2, self.eval_set_id, self.eval_case_results
)
results = self.manager.list_eval_set_results(self.app_name)
expected_result = [self.eval_set_result_name, eval_set_result_name2]
assert set(results) == set(expected_result)
def test_list_eval_set_results_empty(self):
# No eval set results saved for the app
results = self.manager.list_eval_set_results(self.app_name)
assert results == []
@@ -0,0 +1,774 @@
# 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.
from __future__ import annotations
import json
import os
import uuid
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.eval_case import EvalCase
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_set import EvalSet
from google.adk.evaluation.local_eval_sets_manager import _EVAL_SET_FILE_EXTENSION
from google.adk.evaluation.local_eval_sets_manager import convert_eval_set_to_pydantic_schema
from google.adk.evaluation.local_eval_sets_manager import load_eval_set_from_file
from google.adk.evaluation.local_eval_sets_manager import LocalEvalSetsManager
from google.genai import types as genai_types
from pydantic import ValidationError
import pytest
class TestConvertEvalSetToPydanticSchema:
"""Tests convert_eval_set_to_pydantic_schema method."""
def test_convert_eval_set_to_pydantic_schema_complete(self):
eval_set_id = "test_eval_set"
eval_set_in_json_format = [{
"name": "roll_17_sided_dice_twice",
"data": [
{
"query": "What can you do?",
"expected_tool_use": [],
"expected_intermediate_agent_responses": [],
"reference": (
"I can roll dice of different sizes and check if a number"
" is prime. I can also use multiple tools in parallel.\n"
),
},
{
"query": "Roll a 17 sided dice twice for me",
"expected_tool_use": [
{"tool_name": "roll_die", "tool_input": {"sides": 17}},
{"tool_name": "roll_die", "tool_input": {"sides": 17}},
],
"expected_intermediate_agent_responses": [
{"author": "agent1", "text": "thought1"}
],
"reference": (
"I have rolled a 17 sided die twice. The first roll was 13"
" and the second roll was 4.\n"
),
},
],
"initial_session": {
"state": {},
"app_name": "hello_world",
"user_id": "user",
},
}]
eval_set = convert_eval_set_to_pydantic_schema(
eval_set_id, eval_set_in_json_format
)
assert eval_set.eval_set_id == eval_set_id
assert len(eval_set.eval_cases) == 1
assert eval_set.eval_cases[0].eval_id == "roll_17_sided_dice_twice"
assert len(eval_set.eval_cases[0].conversation) == 2
assert eval_set.eval_cases[0].session_input.app_name == "hello_world"
assert (
len(eval_set.eval_cases[0].conversation[1].intermediate_data.tool_uses)
== 2
)
assert (
len(
eval_set.eval_cases[0]
.conversation[1]
.intermediate_data.intermediate_responses
)
== 1
)
def test_convert_eval_set_to_pydantic_schema_minimal(self):
eval_set_id = "test_eval_set"
eval_set_in_json_format = [{
"name": "minimal_case",
"data": [{"query": "Hello", "reference": "World"}],
}]
eval_set = convert_eval_set_to_pydantic_schema(
eval_set_id, eval_set_in_json_format
)
assert eval_set.eval_set_id == eval_set_id
assert len(eval_set.eval_cases) == 1
assert eval_set.eval_cases[0].eval_id == "minimal_case"
assert len(eval_set.eval_cases[0].conversation) == 1
assert (
eval_set.eval_cases[0].conversation[0].user_content.parts[0].text
== "Hello"
)
assert (
eval_set.eval_cases[0].conversation[0].final_response.parts[0].text
== "World"
)
def test_convert_eval_set_to_pydantic_schema_empty_tool_use_and_intermediate_responses(
self,
):
eval_set_id = "test_eval_set"
eval_set_in_json_format = [{
"name": "empty_lists",
"data": [{
"query": "Test",
"reference": "Test Ref",
"expected_tool_use": [],
"expected_intermediate_agent_responses": [],
}],
}]
eval_set = convert_eval_set_to_pydantic_schema(
eval_set_id, eval_set_in_json_format
)
assert eval_set.eval_set_id == eval_set_id
assert len(eval_set.eval_cases) == 1
assert (
len(eval_set.eval_cases[0].conversation[0].intermediate_data.tool_uses)
== 0
)
assert (
len(
eval_set.eval_cases[0]
.conversation[0]
.intermediate_data.intermediate_responses
)
== 0
)
def test_convert_eval_set_to_pydantic_schema_empty_initial_session(self):
eval_set_id = "test_eval_set"
eval_set_in_json_format = [{
"name": "empty_session",
"data": [{"query": "Test", "reference": "Test Ref"}],
"initial_session": {},
}]
eval_set = convert_eval_set_to_pydantic_schema(
eval_set_id, eval_set_in_json_format
)
assert eval_set.eval_set_id == eval_set_id
assert eval_set.eval_cases[0].session_input is None
def test_convert_eval_set_to_pydantic_schema_invalid_data(self):
# This test implicitly checks for potential validation errors during Pydantic
# object creation
eval_set_id = "test_eval_set"
eval_set_in_json_format = [{
"name": 123, # Invalid name type
"data": [{
"query": 456, # Invalid query type
"reference": 789, # Invalid reference type
"expected_tool_use": [{
"tool_name": 123,
"tool_input": 456,
}], # Invalid tool name and input
"expected_intermediate_agent_responses": [
{"author": 123, "text": 456} # Invalid author and text
],
}],
"initial_session": {
"state": "invalid", # Invalid state type
"app_name": 123, # Invalid app_name type
"user_id": 456, # Invalid user_id type
},
}]
with pytest.raises(ValidationError):
convert_eval_set_to_pydantic_schema(eval_set_id, eval_set_in_json_format)
class TestLoadEvalSetFromFile:
"""Tests for load_eval_set_from_file method."""
def test_load_eval_set_from_file_new_format(self, tmp_path):
# Create a dummy file with EvalSet in the new Pydantic JSON format
eval_set = EvalSet(
eval_set_id="new_format_eval_set",
eval_cases=[
EvalCase(
eval_id="new_format_case",
conversation=[
Invocation(
invocation_id=str(uuid.uuid4()),
user_content=genai_types.Content(
parts=[genai_types.Part(text="New Format Query")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="New Format Reference")
]
),
)
],
)
],
)
file_path = tmp_path / "new_format.json"
with open(file_path, "w", encoding="utf-8") as f:
f.write(eval_set.model_dump_json())
loaded_eval_set = load_eval_set_from_file(
str(file_path), "new_format_eval_set"
)
assert loaded_eval_set == eval_set
def test_load_eval_set_from_file_old_format(self, tmp_path, mocker):
mocked_time = 12345678
mocked_invocation_id = "15061953"
mocker.patch("time.time", return_value=mocked_time)
mocker.patch("uuid.uuid4", return_value=mocked_invocation_id)
# Create a dummy file with EvalSet in the old JSON format
old_format_json = [{
"name": "old_format_case",
"data": [
{"query": "Old Format Query", "reference": "Old Format Reference"}
],
}]
file_path = tmp_path / "old_format.json"
with open(file_path, "w", encoding="utf-8") as f:
json.dump(old_format_json, f)
loaded_eval_set = load_eval_set_from_file(
str(file_path), "old_format_eval_set"
)
expected_eval_set = EvalSet(
eval_set_id="old_format_eval_set",
name="old_format_eval_set",
creation_timestamp=mocked_time,
eval_cases=[
EvalCase(
eval_id="old_format_case",
creation_timestamp=mocked_time,
conversation=[
Invocation(
invocation_id=mocked_invocation_id,
user_content=genai_types.Content(
parts=[genai_types.Part(text="Old Format Query")],
role="user",
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="Old Format Reference")
],
role="model",
),
intermediate_data=IntermediateData(
tool_uses=[],
intermediate_responses=[],
),
creation_timestamp=mocked_time,
)
],
)
],
)
assert loaded_eval_set == expected_eval_set
def test_load_eval_set_from_file_nonexistent_file(self):
with pytest.raises(FileNotFoundError):
load_eval_set_from_file("nonexistent_file.json", "test_eval_set")
def test_load_eval_set_from_file_invalid_json(self, tmp_path):
# Create a dummy file with invalid JSON
file_path = tmp_path / "invalid.json"
with open(file_path, "w", encoding="utf-8") as f:
f.write("invalid json")
with pytest.raises(json.JSONDecodeError):
load_eval_set_from_file(str(file_path), "test_eval_set")
def test_load_eval_set_from_file_invalid_data(self, tmp_path, mocker):
# Create a dummy file with invalid data that fails both Pydantic validation
# and the old format conversion. We mock the
# convert_eval_set_to_pydantic_schema function to raise a ValueError
# so that we can assert that the exception is raised.
file_path = tmp_path / "invalid_data.json"
with open(file_path, "w", encoding="utf-8") as f:
f.write('{"invalid": "data"}')
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.convert_eval_set_to_pydantic_schema",
side_effect=ValueError(),
)
with pytest.raises(ValueError):
load_eval_set_from_file(str(file_path), "test_eval_set")
class TestLocalEvalSetsManager:
"""Tests for LocalEvalSetsManager."""
@pytest.fixture
def local_eval_sets_manager(tmp_path):
agents_dir = str(tmp_path)
return LocalEvalSetsManager(agents_dir=agents_dir)
def test_local_eval_sets_manager_get_eval_set_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.load_eval_set_from_file",
return_value=mock_eval_set,
)
mocker.patch("os.path.exists", return_value=True)
eval_set = local_eval_sets_manager.get_eval_set(app_name, eval_set_id)
assert eval_set == mock_eval_set
def test_local_eval_sets_manager_get_eval_set_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.load_eval_set_from_file",
side_effect=FileNotFoundError,
)
eval_set = local_eval_sets_manager.get_eval_set(app_name, eval_set_id)
assert eval_set is None
def test_local_eval_sets_manager_create_eval_set_success(
self, local_eval_sets_manager, mocker
):
mocked_time = 12345678
mocker.patch("time.time", return_value=mocked_time)
app_name = "test_app"
eval_set_id = "test_eval_set"
mocker.patch("os.path.exists", return_value=False)
mock_write_eval_set_to_path = mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager._write_eval_set_to_path"
)
eval_set_file_path = os.path.join(
local_eval_sets_manager._agents_dir,
app_name,
eval_set_id + _EVAL_SET_FILE_EXTENSION,
)
created_eval_set = local_eval_sets_manager.create_eval_set(
app_name, eval_set_id
)
expected_eval_set = EvalSet(
eval_set_id=eval_set_id,
name=eval_set_id,
eval_cases=[],
creation_timestamp=mocked_time,
)
mock_write_eval_set_to_path.assert_called_once_with(
eval_set_file_path,
expected_eval_set,
)
assert created_eval_set == expected_eval_set
def test_local_eval_sets_manager_create_eval_set_invalid_id(
self, local_eval_sets_manager
):
app_name = "test_app"
eval_set_id = "invalid-id"
with pytest.raises(ValueError, match="Invalid Eval Set ID"):
local_eval_sets_manager.create_eval_set(app_name, eval_set_id)
@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_local_eval_sets_manager_create_eval_set_rejects_invalid_app_name(
self, local_eval_sets_manager, app_name
):
with pytest.raises(ValueError):
local_eval_sets_manager.create_eval_set(app_name, "test_eval_set")
@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_local_eval_sets_manager_list_eval_sets_rejects_invalid_app_name(
self, local_eval_sets_manager, app_name
):
with pytest.raises(ValueError):
local_eval_sets_manager.list_eval_sets(app_name)
@pytest.mark.parametrize(
"eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_local_eval_sets_manager_get_eval_set_rejects_invalid_eval_set_id(
self, local_eval_sets_manager, eval_set_id
):
with pytest.raises(ValueError):
local_eval_sets_manager.get_eval_set("test_app", eval_set_id)
def test_local_eval_sets_manager_create_eval_set_already_exists(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "existing_eval_set_id"
mocker.patch("os.path.exists", return_value=True)
with pytest.raises(
ValueError,
match="EvalSet existing_eval_set_id already exists for app test_app.",
):
local_eval_sets_manager.create_eval_set(app_name, eval_set_id)
def test_local_eval_sets_manager_list_eval_sets_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
mock_listdir_return = [
"eval_set_1.evalset.json",
"eval_set_2.evalset.json",
"not_an_eval_set.txt",
]
mocker.patch("os.listdir", return_value=mock_listdir_return)
mocker.patch("os.path.join", return_value="dummy_path")
mocker.patch("os.path.basename", side_effect=lambda x: x)
eval_sets = local_eval_sets_manager.list_eval_sets(app_name)
assert eval_sets == ["eval_set_1", "eval_set_2"]
def test_local_eval_sets_manager_list_eval_sets_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
mocker.patch("os.listdir", side_effect=FileNotFoundError)
with pytest.raises(NotFoundError):
local_eval_sets_manager.list_eval_sets(app_name)
def test_local_eval_sets_manager_add_eval_case_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
mock_write_eval_set_to_path = mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager._write_eval_set_to_path"
)
local_eval_sets_manager.add_eval_case(app_name, eval_set_id, mock_eval_case)
assert len(mock_eval_set.eval_cases) == 1
assert mock_eval_set.eval_cases[0] == mock_eval_case
expected_eval_set_file_path = os.path.join(
local_eval_sets_manager._agents_dir,
app_name,
eval_set_id + _EVAL_SET_FILE_EXTENSION,
)
mock_eval_set.eval_cases.append(mock_eval_case)
mock_write_eval_set_to_path.assert_called_once_with(
expected_eval_set_file_path, mock_eval_set
)
def test_local_eval_sets_manager_add_eval_case_eval_set_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=None,
)
with pytest.raises(
NotFoundError, match="Eval set `test_eval_set` not found."
):
local_eval_sets_manager.add_eval_case(
app_name, eval_set_id, mock_eval_case
)
def test_local_eval_sets_manager_add_eval_case_eval_case_id_exists(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
with pytest.raises(
ValueError,
match=(
f"Eval id `{eval_case_id}` already exists in `{eval_set_id}` eval"
" set."
),
):
local_eval_sets_manager.add_eval_case(
app_name, eval_set_id, mock_eval_case
)
def test_local_eval_sets_manager_get_eval_case_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
eval_case = local_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case == mock_eval_case
def test_local_eval_sets_manager_get_eval_case_eval_set_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=None,
)
eval_case = local_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case is None
def test_local_eval_sets_manager_get_eval_case_eval_case_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
eval_case = local_eval_sets_manager.get_eval_case(
app_name, eval_set_id, eval_case_id
)
assert eval_case is None
def test_local_eval_sets_manager_update_eval_case_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(
eval_id=eval_case_id, conversation=[], creation_timestamp=456
)
updated_eval_case = EvalCase(
eval_id=eval_case_id, conversation=[], creation_timestamp=123
)
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=mock_eval_case,
)
mock_write_eval_set_to_path = mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager._write_eval_set_to_path"
)
local_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
assert len(mock_eval_set.eval_cases) == 1
assert mock_eval_set.eval_cases[0] == updated_eval_case
expected_eval_set_file_path = os.path.join(
local_eval_sets_manager._agents_dir,
app_name,
eval_set_id + _EVAL_SET_FILE_EXTENSION,
)
mock_write_eval_set_to_path.assert_called_once_with(
expected_eval_set_file_path,
EvalSet(eval_set_id=eval_set_id, eval_cases=[updated_eval_case]),
)
def test_local_eval_sets_manager_update_eval_case_eval_set_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
updated_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=None,
)
with pytest.raises(
NotFoundError,
match=f"Eval set `{eval_set_id}` not found.",
):
local_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
def test_local_eval_sets_manager_update_eval_case_eval_case_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
updated_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=None,
)
with pytest.raises(
NotFoundError,
match=(
f"Eval case `{eval_case_id}` not found in eval set `{eval_set_id}`."
),
):
local_eval_sets_manager.update_eval_case(
app_name, eval_set_id, updated_eval_case
)
def test_local_eval_sets_manager_delete_eval_case_success(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_case = EvalCase(eval_id=eval_case_id, conversation=[])
mock_eval_set = EvalSet(
eval_set_id=eval_set_id, eval_cases=[mock_eval_case]
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=mock_eval_case,
)
mock_write_eval_set_to_path = mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager._write_eval_set_to_path"
)
local_eval_sets_manager.delete_eval_case(
app_name, eval_set_id, eval_case_id
)
assert len(mock_eval_set.eval_cases) == 0
expected_eval_set_file_path = os.path.join(
local_eval_sets_manager._agents_dir,
app_name,
eval_set_id + _EVAL_SET_FILE_EXTENSION,
)
mock_write_eval_set_to_path.assert_called_once_with(
expected_eval_set_file_path,
EvalSet(eval_set_id=eval_set_id, eval_cases=[]),
)
def test_local_eval_sets_manager_delete_eval_case_eval_set_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=None,
)
mock_write_eval_set_to_path = mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager._write_eval_set_to_path"
)
with pytest.raises(
NotFoundError,
match=f"Eval set `{eval_set_id}` not found.",
):
local_eval_sets_manager.delete_eval_case(
app_name, eval_set_id, eval_case_id
)
mock_write_eval_set_to_path.assert_not_called()
def test_local_eval_sets_manager_delete_eval_case_eval_case_not_found(
self, local_eval_sets_manager, mocker
):
app_name = "test_app"
eval_set_id = "test_eval_set"
eval_case_id = "test_eval_case"
mock_eval_set = EvalSet(eval_set_id=eval_set_id, eval_cases=[])
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_set",
return_value=mock_eval_set,
)
mocker.patch(
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager.get_eval_case",
return_value=None,
)
with pytest.raises(
NotFoundError,
match=(
f"Eval case `{eval_case_id}` not found in eval set `{eval_set_id}`."
),
):
local_eval_sets_manager.delete_eval_case(
app_name, eval_set_id, eval_case_id
)
@@ -0,0 +1,222 @@
# 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.
from __future__ import annotations
from google.adk.errors.not_found_error import NotFoundError
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import Interval
from google.adk.evaluation.eval_metrics import MetricInfo
from google.adk.evaluation.eval_metrics import MetricValueInfo
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.evaluator import Evaluator
from google.adk.evaluation.metric_evaluator_registry import FinalResponseMatchV2EvaluatorMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import HallucinationsV1EvaluatorMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import MetricEvaluatorRegistry
from google.adk.evaluation.metric_evaluator_registry import PerTurnUserSimulatorQualityV1MetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import ResponseEvaluatorMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import RubricBasedFinalResponseQualityV1EvaluatorMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import RubricBasedMultiTurnTrajectoryMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import RubricBasedToolUseV1EvaluatorMetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import SafetyEvaluatorV1MetricInfoProvider
from google.adk.evaluation.metric_evaluator_registry import TrajectoryEvaluatorMetricInfoProvider
import pytest
_DUMMY_METRIC_NAME = "dummy_metric_name"
_DUMMY_METRIC_INFO = MetricInfo(
metric_name=_DUMMY_METRIC_NAME,
description="Dummy metric description",
metric_value_info=MetricValueInfo(
interval=Interval(min_value=0.0, max_value=1.0)
),
)
_ANOTHER_DUMMY_METRIC_INFO = MetricInfo(
metric_name=_DUMMY_METRIC_NAME,
description="Another dummy metric description",
metric_value_info=MetricValueInfo(
interval=Interval(min_value=0.0, max_value=1.0)
),
)
class DummyEvaluator(Evaluator):
def __init__(self, eval_metric: EvalMetric):
self._eval_metric = eval_metric
def evaluate_invocations(self, actual_invocations, expected_invocations):
return "dummy_result"
class AnotherDummyEvaluator(Evaluator):
def __init__(self, eval_metric: EvalMetric):
self._eval_metric = eval_metric
def evaluate_invocations(self, actual_invocations, expected_invocations):
return "another_dummy_result"
class TestMetricEvaluatorRegistry:
"""Test cases for MetricEvaluatorRegistry."""
@pytest.fixture
def registry(self):
return MetricEvaluatorRegistry()
def test_register_evaluator(self, registry):
registry.register_evaluator(
_DUMMY_METRIC_INFO,
DummyEvaluator,
)
assert _DUMMY_METRIC_NAME in registry._registry
assert registry._registry[_DUMMY_METRIC_NAME] == (
DummyEvaluator,
_DUMMY_METRIC_INFO,
)
def test_register_evaluator_updates_existing(self, registry):
registry.register_evaluator(
_DUMMY_METRIC_INFO,
DummyEvaluator,
)
assert registry._registry[_DUMMY_METRIC_NAME] == (
DummyEvaluator,
_DUMMY_METRIC_INFO,
)
registry.register_evaluator(
_ANOTHER_DUMMY_METRIC_INFO, AnotherDummyEvaluator
)
assert registry._registry[_DUMMY_METRIC_NAME] == (
AnotherDummyEvaluator,
_ANOTHER_DUMMY_METRIC_INFO,
)
def test_get_evaluator(self, registry):
registry.register_evaluator(
_DUMMY_METRIC_INFO,
DummyEvaluator,
)
eval_metric = EvalMetric(metric_name=_DUMMY_METRIC_NAME, threshold=0.5)
evaluator = registry.get_evaluator(eval_metric)
assert isinstance(evaluator, DummyEvaluator)
def test_get_evaluator_not_found(self, registry):
eval_metric = EvalMetric(metric_name="non_existent_metric", threshold=0.5)
with pytest.raises(NotFoundError):
registry.get_evaluator(eval_metric)
class TestMetricInfoProviders:
"""Test cases for MetricInfoProviders."""
def test_trajectory_evaluator_metric_info_provider(self):
metric_info = TrajectoryEvaluatorMetricInfoProvider().get_metric_info()
assert (
metric_info.metric_name
== PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_response_evaluator_metric_info_provider_eval_score(self):
metric_info = ResponseEvaluatorMetricInfoProvider(
PrebuiltMetrics.RESPONSE_EVALUATION_SCORE.value
).get_metric_info()
assert (
metric_info.metric_name
== PrebuiltMetrics.RESPONSE_EVALUATION_SCORE.value
)
assert metric_info.metric_value_info.interval.min_value == 1.0
assert metric_info.metric_value_info.interval.max_value == 5.0
def test_response_evaluator_metric_info_provider_match_score(self):
metric_info = ResponseEvaluatorMetricInfoProvider(
PrebuiltMetrics.RESPONSE_MATCH_SCORE.value
).get_metric_info()
assert metric_info.metric_name == PrebuiltMetrics.RESPONSE_MATCH_SCORE.value
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_safety_evaluator_v1_metric_info_provider(self):
metric_info = SafetyEvaluatorV1MetricInfoProvider().get_metric_info()
assert metric_info.metric_name == PrebuiltMetrics.SAFETY_V1.value
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_final_response_match_v2_evaluator_metric_info_provider(self):
metric_info = (
FinalResponseMatchV2EvaluatorMetricInfoProvider().get_metric_info()
)
assert (
metric_info.metric_name == PrebuiltMetrics.FINAL_RESPONSE_MATCH_V2.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_rubric_based_final_response_quality_v1_evaluator_metric_info_provider(
self,
):
metric_info = (
RubricBasedFinalResponseQualityV1EvaluatorMetricInfoProvider().get_metric_info()
)
assert (
metric_info.metric_name
== PrebuiltMetrics.RUBRIC_BASED_FINAL_RESPONSE_QUALITY_V1.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_hallucinations_v1_evaluator_metric_info_provider(self):
metric_info = (
HallucinationsV1EvaluatorMetricInfoProvider().get_metric_info()
)
assert metric_info.metric_name == PrebuiltMetrics.HALLUCINATIONS_V1.value
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_rubric_based_tool_use_v1_evaluator_metric_info_provider(self):
metric_info = (
RubricBasedToolUseV1EvaluatorMetricInfoProvider().get_metric_info()
)
assert (
metric_info.metric_name
== PrebuiltMetrics.RUBRIC_BASED_TOOL_USE_QUALITY_V1.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_per_turn_user_simulator_quality_v1_metric_info_provider(self):
metric_info = (
PerTurnUserSimulatorQualityV1MetricInfoProvider().get_metric_info()
)
assert (
metric_info.metric_name
== PrebuiltMetrics.PER_TURN_USER_SIMULATOR_QUALITY_V1.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
def test_rubric_based_multi_turn_trajectory_metric_info_provider(self):
metric_info = (
RubricBasedMultiTurnTrajectoryMetricInfoProvider().get_metric_info()
)
assert (
metric_info.metric_name
== PrebuiltMetrics.RUBRIC_BASED_MULTI_TURN_TRAJECTORY_QUALITY_V1.value
)
assert metric_info.metric_value_info.interval.min_value == 0.0
assert metric_info.metric_value_info.interval.max_value == 1.0
@@ -0,0 +1,106 @@
# 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 the Multi Turn Task Success Evaluator."""
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.multi_turn_task_success_evaluator import MultiTurnTaskSuccessV1Evaluator
from google.genai import types as genai_types
vertexai_types = vertexai.types
class TestMultiTurnTaskSuccessV1Evaluator:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_metric_passed(self, mocker):
"""Test evaluate_invocations function for multi-turn task success metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
Invocation(
invocation_id="inv2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q2")]
),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent1",
content=genai_types.Content(
parts=[genai_types.Part(text="intermediate")]
),
)
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r2")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
]
evaluator = MultiTurnTaskSuccessV1Evaluator(
eval_metric=EvalMetric(
threshold=0.8, metric_name="multi_turn_task_success"
)
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations,
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.RubricMetric.MULTI_TURN_TASK_SUCCESS.name
]
@@ -0,0 +1,106 @@
# 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 the Multi Turn Tool Use Quality Evaluator."""
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.multi_turn_tool_use_quality_evaluator import MultiTurnToolUseQualityV1Evaluator
from google.genai import types as genai_types
vertexai_types = vertexai.types
class TestMultiTurnToolUseQualityV1Evaluator:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_metric_passed(self, mocker):
"""Test evaluate_invocations function for multi-turn tool use quality metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
Invocation(
invocation_id="inv2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q2")]
),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent1",
content=genai_types.Content(
parts=[genai_types.Part(text="intermediate")]
),
)
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r2")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
]
evaluator = MultiTurnToolUseQualityV1Evaluator(
eval_metric=EvalMetric(
threshold=0.8, metric_name="multi_turn_tool_use_quality"
)
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations,
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.RubricMetric.MULTI_TURN_TOOL_USE_QUALITY.name
]
@@ -0,0 +1,106 @@
# 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 the Multi Turn Trajectory Quality Evaluator."""
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.multi_turn_trajectory_quality_evaluator import MultiTurnTrajectoryQualityV1Evaluator
from google.genai import types as genai_types
vertexai_types = vertexai.types
class TestMultiTurnTrajectoryQualityV1Evaluator:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_metric_passed(self, mocker):
"""Test evaluate_invocations function for multi-turn trajectory quality metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
Invocation(
invocation_id="inv2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q2")]
),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent1",
content=genai_types.Content(
parts=[genai_types.Part(text="intermediate")]
),
)
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r2")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
]
evaluator = MultiTurnTrajectoryQualityV1Evaluator(
eval_metric=EvalMetric(
threshold=0.8, metric_name="multi_turn_trajectory_quality"
)
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations,
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.RubricMetric.MULTI_TURN_TRAJECTORY_QUALITY.name
]
@@ -0,0 +1,72 @@
# 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.
from __future__ import annotations
from google.adk.agents.callback_context import CallbackContext
from google.adk.evaluation.request_intercepter_plugin import _LLM_REQUEST_ID_KEY
from google.adk.evaluation.request_intercepter_plugin import _RequestIntercepterPlugin
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types
class TestRequestIntercepterPlugin:
async def test_intercept_request_and_response(self, mocker):
plugin = _RequestIntercepterPlugin(name="test_plugin")
llm_request = LlmRequest(
model="test_model",
contents=[
types.Content(
role="user",
parts=[types.Part(text="hello")],
)
],
)
mock_invocation_context = mocker.MagicMock()
mock_invocation_context.session.state = {}
mock_invocation_context._state_schema = None
callback_context = CallbackContext(mock_invocation_context)
llm_response = LlmResponse()
# Test before_model_callback
await plugin.before_model_callback(
callback_context=callback_context, llm_request=llm_request
)
assert _LLM_REQUEST_ID_KEY in callback_context.state
request_id = callback_context.state[_LLM_REQUEST_ID_KEY]
assert isinstance(request_id, str)
# Test after_model_callback
await plugin.after_model_callback(
callback_context=callback_context, llm_response=llm_response
)
assert llm_response.custom_metadata is not None
assert _LLM_REQUEST_ID_KEY in llm_response.custom_metadata
assert llm_response.custom_metadata[_LLM_REQUEST_ID_KEY] == request_id
# Test get_model_request
retrieved_request = plugin.get_model_request(llm_response)
assert retrieved_request == llm_request
def test_get_model_request_not_found(self):
plugin = _RequestIntercepterPlugin(name="test_plugin")
llm_response = LlmResponse()
assert plugin.get_model_request(llm_response) is None
llm_response_with_metadata = LlmResponse(
custom_metadata={_LLM_REQUEST_ID_KEY: "non_existent_id"}
)
assert plugin.get_model_request(llm_response_with_metadata) is None
@@ -0,0 +1,120 @@
# 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.
from __future__ import annotations
"""Tests for the Response Evaluator."""
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.response_evaluator import ResponseEvaluator
from google.genai import types as genai_types
import pytest
vertexai_types = vertexai.types
class TestResponseEvaluator:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_rouge_metric(self, mocker):
"""Test evaluate_invocations function for Rouge metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = ResponseEvaluator(
threshold=0.8, metric_name="response_match_score"
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == pytest.approx(8 / 11)
# ROUGE-1 F1 is approx. 0.73 < 0.8 threshold, so eval status is FAILED.
assert evaluation_result.overall_eval_status == EvalStatus.FAILED
mock_perform_eval.assert_not_called() # Ensure _perform_eval was not called
def test_evaluate_invocations_coherence_metric_passed(self, mocker):
"""Test evaluate_invocations function for Coherence metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = ResponseEvaluator(
threshold=0.8, metric_name="response_evaluation_score"
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.COHERENCE.name
]
@@ -0,0 +1,78 @@
# 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.
from google.adk.agents.callback_context import CallbackContext
from google.adk.evaluation import _retry_options_utils
from google.adk.models.llm_request import LlmRequest
from google.genai import types
import pytest
def test_add_retry_options_with_default_request():
request = LlmRequest()
_retry_options_utils.add_default_retry_options_if_not_present(request)
assert request.config.http_options is not None
assert (
request.config.http_options.retry_options
== _retry_options_utils._DEFAULT_HTTP_RETRY_OPTIONS
)
def test_add_retry_options_when_retry_options_is_none():
request = LlmRequest()
request.config.http_options = types.HttpOptions(retry_options=None)
_retry_options_utils.add_default_retry_options_if_not_present(request)
assert (
request.config.http_options.retry_options
== _retry_options_utils._DEFAULT_HTTP_RETRY_OPTIONS
)
def test_add_retry_options_does_not_override_existing_options():
my_retry_options = types.HttpRetryOptions(attempts=1)
request = LlmRequest()
request.config.http_options = types.HttpOptions(
retry_options=my_retry_options
)
_retry_options_utils.add_default_retry_options_if_not_present(request)
assert request.config.http_options.retry_options == my_retry_options
def test_add_retry_options_when_config_is_none():
request = LlmRequest()
request.config = None
_retry_options_utils.add_default_retry_options_if_not_present(request)
assert request.config is not None
assert request.config.http_options is not None
assert (
request.config.http_options.retry_options
== _retry_options_utils._DEFAULT_HTTP_RETRY_OPTIONS
)
@pytest.mark.asyncio
async def test_ensure_retry_options_plugin(mocker):
request = LlmRequest()
plugin = _retry_options_utils.EnsureRetryOptionsPlugin(name="test_plugin")
mock_invocation_context = mocker.MagicMock()
mock_invocation_context.session.state = {}
callback_context = CallbackContext(mock_invocation_context)
await plugin.before_model_callback(
callback_context=callback_context, llm_request=request
)
assert request.config.http_options is not None
assert (
request.config.http_options.retry_options
== _retry_options_utils._DEFAULT_HTTP_RETRY_OPTIONS
)
@@ -0,0 +1,697 @@
# 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.
from __future__ import annotations
import logging
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.eval_metrics import RubricsBasedCriterion
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.eval_rubrics import RubricScore
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.llm_as_judge_utils import get_average_rubric_score
from google.adk.evaluation.rubric_based_evaluator import DefaultAutoRaterResponseParser
from google.adk.evaluation.rubric_based_evaluator import MajorityVotePerInvocationResultsAggregator
from google.adk.evaluation.rubric_based_evaluator import MeanInvocationResultsSummarizer
from google.adk.evaluation.rubric_based_evaluator import RubricBasedEvaluator
from google.adk.models.llm_response import LlmResponse
from google.genai import types as genai_types
import pytest
class FakeRubricBasedEvaluator(RubricBasedEvaluator):
"""A fake implementation of RubricBasedEvaluator intended for testing."""
def __init__(
self,
eval_metric: EvalMetric,
rubric_type: str | None = None,
):
super().__init__(
eval_metric,
criterion_type=RubricsBasedCriterion,
rubric_type=rubric_type,
)
def format_auto_rater_prompt(
self, actual: Invocation, expected: Invocation
) -> str:
return "fake response"
def _create_per_invocation_result(
rubric_scores: list[RubricScore],
) -> PerInvocationResult:
"""Helper to create a PerInvocationResult."""
return PerInvocationResult(
actual_invocation=Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="part_1")]
)
),
expected_invocation=Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="part_2")]
)
),
score=get_average_rubric_score(rubric_scores),
rubric_scores=rubric_scores,
eval_status=EvalStatus.NOT_EVALUATED,
)
class TestDefaultAutoRaterResponseParser:
"""Test cases for DefaultAutoRaterResponseParser."""
def test_parse_auto_rater_response_with_empty_string(self):
"""Tests _parse_auto_rater_response with an empty string."""
assert DefaultAutoRaterResponseParser().parse("") == []
def test_parse_auto_rater_response_with_malformed_string(self):
"""Tests _parse_auto_rater_response with a malformed string."""
response = "This is just some random text without the expected format."
assert DefaultAutoRaterResponseParser().parse(response) == []
def test_parse_auto_rater_response_with_single_yes_verdict(self):
"""Tests _parse_auto_rater_response with a single 'yes' verdict."""
response = """
Property: Is the response good?
Rationale: It was good.
Verdict: yes
"""
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 1
assert parsed[0].property_text == "Is the response good?"
assert parsed[0].rationale == "It was good."
assert parsed[0].score == 1.0
def test_parse_auto_rater_response_with_single_no_verdict(self):
"""Tests _parse_auto_rater_response with a single 'no' verdict."""
response = """
Property: Is the response bad?
Rationale: It was bad.
Verdict: no
"""
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 1
assert parsed[0].property_text == "Is the response bad?"
assert parsed[0].rationale == "It was bad."
assert parsed[0].score == 0.0
def test_parse_auto_rater_response_with_invalid_verdict(self):
"""Tests _parse_auto_rater_response with an invalid verdict."""
response = """
Property: Is it unclear?
Rationale: I cannot tell.
Verdict: maybe
"""
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 1
assert parsed[0].property_text == "Is it unclear?"
assert parsed[0].rationale == "I cannot tell."
assert parsed[0].score is None
def test_parse_auto_rater_response_with_multiple_verdicts(self):
"""Tests _parse_auto_rater_response with multiple verdicts."""
response = """
Property: Is the response good?
Rationale: It was good.
Verdict: yes
Property: Is the response bad?
Rationale: It was not bad.
Verdict: no
"""
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 2
assert parsed[0].property_text == "Is the response good?"
assert parsed[0].rationale == "It was good."
assert parsed[0].score == 1.0
assert parsed[1].property_text == "Is the response bad?"
assert parsed[1].rationale == "It was not bad."
assert parsed[1].score == 0.0
def test_parse_auto_rater_response_with_incomplete_entry(self):
"""Tests _parse_auto_rater_response with an incomplete entry."""
response = """
Property: Is the response good?
Rationale: It was good.
Verdict: yes
Property: Is the response bad?
Rationale: It was not bad.
""" # Missing Verdict
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 1 # zip will only create one item
assert parsed[0].property_text == "Is the response good?"
def test_parse_auto_rater_response_with_case_insensitive_verdict(self):
"""Tests _parse_auto_rater_response is case-insensitive for verdicts."""
response = """
Property: Is the response good?
Rationale: It was good.
Verdict: Yes
Property: Is the response bad?
Rationale: It was bad.
Verdict: NO
"""
parsed = DefaultAutoRaterResponseParser().parse(response)
assert len(parsed) == 2
assert parsed[0].score == 1.0
assert parsed[1].score == 0.0
class TestMajorityVotePerInvocationResultsAggregator:
def test_aggregate_per_invocation_samples_with_no_rubric_scores(
self,
):
"""Tests aggregation when samples have no rubric scores."""
samples = [
_create_per_invocation_result([]),
_create_per_invocation_result([]),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score is None
assert result.rubric_scores == []
def test_aggregate_per_invocation_samples_with_majority_positive(
self,
):
"""Tests aggregation with a majority of positive scores."""
samples = [
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score == 1.0
assert len(result.rubric_scores) == 1
assert result.rubric_scores[0].rubric_id == "1"
assert result.rubric_scores[0].score == 1.0
def test_aggregate_per_invocation_samples_with_majority_negative(
self,
):
"""Tests aggregation with a majority of negative scores."""
samples = [
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score == 0.0
assert len(result.rubric_scores) == 1
assert result.rubric_scores[0].rubric_id == "1"
assert result.rubric_scores[0].score == 0.0
def test_aggregate_per_invocation_samples_with_tie_verdicts(
self,
):
"""Tests aggregation with a tie, where negative should win."""
samples = [
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score == 0.0
assert len(result.rubric_scores) == 1
assert result.rubric_scores[0].rubric_id == "1"
assert result.rubric_scores[0].score == 0.0
def test_aggregate_per_invocation_samples_with_all_none_scores(
self,
):
"""Tests aggregation when all samples have a score of None."""
samples = [
_create_per_invocation_result(
[RubricScore(rubric_id="1", score=None, rationale="r1")]
),
_create_per_invocation_result(
[RubricScore(rubric_id="1", score=None, rationale="r2")]
),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score is None
assert len(result.rubric_scores) == 1
assert result.rubric_scores[0].rubric_id == "1"
assert result.rubric_scores[0].score is None
assert result.rubric_scores[0].rationale == "r1"
def test_aggregate_per_invocation_samples_with_multiple_rubrics(
self,
):
"""Tests aggregation with multiple rubrics."""
samples = [
_create_per_invocation_result([
RubricScore(rubric_id="1", score=1.0),
RubricScore(rubric_id="2", score=0.0),
]),
_create_per_invocation_result([
RubricScore(rubric_id="1", score=1.0),
RubricScore(rubric_id="2", score=0.0),
]),
_create_per_invocation_result([
RubricScore(rubric_id="1", score=0.0),
RubricScore(rubric_id="2", score=1.0),
]),
]
result = MajorityVotePerInvocationResultsAggregator().aggregate(
samples, threshold=0.5
)
assert result.score == 0.5
assert len(result.rubric_scores) == 2
rubric1_score = next(
(s for s in result.rubric_scores if s.rubric_id == "1"), None
)
rubric2_score = next(
(s for s in result.rubric_scores if s.rubric_id == "2"), None
)
assert rubric1_score is not None
assert rubric1_score.score == 1.0
assert rubric2_score is not None
assert rubric2_score.score == 0.0
class TestMeanInvocationResultsSummarizer:
"""Test cases for MeanInvocationResultsSummarizer."""
def test_summarize_with_empty_list(
self,
):
"""Tests aggregate_invocation_results with an empty list."""
result = MeanInvocationResultsSummarizer().summarize([], threshold=0.5)
assert result.overall_score is None
assert result.overall_rubric_scores == []
assert result.per_invocation_results == []
def test_summarize_with_no_rubric_scores(
self,
):
"""Tests aggregate_invocation_results with samples that have no rubric scores."""
invocations = [
_create_per_invocation_result([]),
_create_per_invocation_result([]),
]
result = MeanInvocationResultsSummarizer().summarize(
invocations, threshold=0.5
)
assert result.overall_score is None
assert result.overall_rubric_scores == []
assert result.per_invocation_results == invocations
def test_summarize_with_single_invocation(
self,
):
"""Tests aggregate_invocation_results with a single invocation result."""
invocations = [
_create_per_invocation_result([
RubricScore(rubric_id="1", score=1.0),
RubricScore(rubric_id="2", score=0.0),
])
]
result = MeanInvocationResultsSummarizer().summarize(
invocations, threshold=0.5
)
assert result.overall_score == 0.5
assert len(result.overall_rubric_scores) == 2
rubric1_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "1"
)
rubric2_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "2"
)
assert rubric1_score.score == 1.0
assert rubric2_score.score == 0.0
def test_summarize_with_multiple_invocations_single_rubric(
self,
):
"""Tests aggregate_invocation_results with multiple invocations for a single rubric."""
invocations = [
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]),
_create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]),
]
result = MeanInvocationResultsSummarizer().summarize(
invocations, threshold=0.5
)
assert result.overall_score == pytest.approx(2 / 3)
assert len(result.overall_rubric_scores) == 1
assert result.overall_rubric_scores[0].rubric_id == "1"
assert result.overall_rubric_scores[0].score == pytest.approx(2 / 3)
def test_summarize_with_multiple_invocations_and_rubrics(
self,
):
"""Tests aggregate_invocation_results with multiple invocations and rubrics."""
invocations = [
_create_per_invocation_result([
RubricScore(rubric_id="1", score=1.0),
RubricScore(rubric_id="2", score=0.0),
]),
_create_per_invocation_result([
RubricScore(rubric_id="1", score=0.0),
RubricScore(rubric_id="2", score=1.0),
]),
]
result = MeanInvocationResultsSummarizer().summarize(
invocations, threshold=0.5
)
assert result.overall_score == 0.5
assert len(result.overall_rubric_scores) == 2
rubric1_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "1"
)
rubric2_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "2"
)
assert rubric1_score.score == 0.5
assert rubric2_score.score == 0.5
def test_summarize_with_none_scores(
self,
):
"""Tests aggregate_invocation_results with some None scores."""
invocations = [
_create_per_invocation_result([
RubricScore(rubric_id="1", score=1.0),
RubricScore(rubric_id="2", score=None),
]),
_create_per_invocation_result([
RubricScore(rubric_id="1", score=0.0),
RubricScore(rubric_id="2", score=1.0),
]),
]
result = MeanInvocationResultsSummarizer().summarize(
invocations, threshold=0.5
)
assert result.overall_score == pytest.approx(2 / 3)
assert len(result.overall_rubric_scores) == 2
rubric1_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "1"
)
rubric2_score = next(
s for s in result.overall_rubric_scores if s.rubric_id == "2"
)
assert rubric1_score.score == 0.5
assert rubric2_score.score == 1.0
class TestRubricBasedEvaluator:
"""Tests for RubricBasedEvaluator."""
@pytest.fixture
def evaluator(self) -> FakeRubricBasedEvaluator:
"""Returns a RubricBasedFinalResponseQualityV1Evaluator."""
rubrics = [
Rubric(
rubric_id="1",
rubric_content=RubricContent(text_property="Is the response good?"),
),
Rubric(
rubric_id="2",
rubric_content=RubricContent(text_property="Is the response bad?"),
),
]
judge_model_options = JudgeModelOptions(
judge_model_config=None,
num_samples=3,
)
criterion = RubricsBasedCriterion(
threshold=0.5, rubrics=rubrics, judge_model_options=judge_model_options
)
metric = EvalMetric(
metric_name=PrebuiltMetrics.RUBRIC_BASED_FINAL_RESPONSE_QUALITY_V1.value,
threshold=0.5,
criterion=criterion,
)
return FakeRubricBasedEvaluator(metric)
def test_convert_auto_rater_response_to_score_with_empty_response(
self,
evaluator: RubricBasedEvaluator,
):
"""Tests convert_auto_rater_response_to_score with an empty response."""
evaluator.create_effective_rubrics_list(None)
response = LlmResponse(
content=genai_types.Content(parts=[genai_types.Part(text="")])
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(response)
assert auto_rater_score.score is None
assert auto_rater_score.rubric_scores == []
def test_convert_auto_rater_response_to_score_with_malformed_response(
self,
evaluator: RubricBasedEvaluator,
):
"""Tests convert_auto_rater_response_to_score with a malformed response."""
evaluator.create_effective_rubrics_list(None)
response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="This is not a valid format.")]
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(response)
assert auto_rater_score.score is None
assert auto_rater_score.rubric_scores == []
def test_convert_auto_rater_response_to_score_with_none_content(
self,
evaluator: RubricBasedEvaluator,
caplog: pytest.LogCaptureFixture,
):
"""An empty auto-rater response is scored as empty, not crashed on."""
evaluator.create_effective_rubrics_list(None)
response = LlmResponse(content=None)
with caplog.at_level(logging.WARNING):
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
response
)
assert auto_rater_score.score is None
assert auto_rater_score.rubric_scores == []
assert "empty response" in caplog.text
def test_convert_auto_rater_response_to_score_warns_on_unparseable(
self,
evaluator: RubricBasedEvaluator,
caplog: pytest.LogCaptureFixture,
):
"""Auto-rater output that misses the expected format logs a diagnostic."""
evaluator.create_effective_rubrics_list(None)
response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text="**Verdict**: Yes")]
)
)
with caplog.at_level(logging.WARNING):
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
response
)
assert auto_rater_score.rubric_scores == []
assert "did not match the expected" in caplog.text
def test_convert_auto_rater_response_to_score_with_mixed_verdicts(
self,
evaluator: RubricBasedEvaluator,
):
"""Tests convert_auto_rater_response_to_score with mixed verdicts."""
evaluator.create_effective_rubrics_list(None)
response_text = """
Property: Is the response good?
Rationale: It was good.
Verdict: yes
Property: Is the response bad?
Rationale: It was bad.
Verdict: no
"""
response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=response_text)]
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(response)
assert auto_rater_score.score == 0.5
assert len(auto_rater_score.rubric_scores) == 2
assert auto_rater_score.rubric_scores[0].score == 1.0
assert auto_rater_score.rubric_scores[1].score == 0.0
def test_convert_auto_rater_response_to_score_with_invalid_verdict(
self,
evaluator: RubricBasedEvaluator,
):
"""Tests convert_auto_rater_response_to_score with an invalid verdict."""
evaluator.create_effective_rubrics_list(None)
response_text = """
Property: Is the response good?
Rationale: It was good.
Verdict: yes
Property: Is the response bad?
Rationale: I cannot tell.
Verdict: invalid
"""
response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=response_text)]
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(response)
assert auto_rater_score.score == 1.0
assert len(auto_rater_score.rubric_scores) == 2
assert auto_rater_score.rubric_scores[0].score == 1.0
assert auto_rater_score.rubric_scores[1].score is None
def test_convert_auto_rater_response_to_score_with_unknown_property(
self,
evaluator: RubricBasedEvaluator,
):
"""Tests convert_auto_rater_response_to_score with an unknown property."""
evaluator.create_effective_rubrics_list(None)
response_text = """
Property: Is the response amazing?
Rationale: It was amazing.
Verdict: yes
"""
response = LlmResponse(
content=genai_types.Content(
parts=[genai_types.Part(text=response_text)]
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(response)
assert auto_rater_score.score is None
assert auto_rater_score.rubric_scores == []
def test_create_effective_rubrics_list_with_invocation_rubrics(
self, evaluator: RubricBasedEvaluator
):
invocation_rubrics = [
Rubric(
rubric_id="3",
rubric_content=RubricContent(text_property="Invocation rubric"),
)
]
evaluator.create_effective_rubrics_list(invocation_rubrics)
effective_rubrics = evaluator.get_effective_rubrics_list()
assert len(effective_rubrics) == 3
assert {r.rubric_id for r in effective_rubrics} == {"1", "2", "3"}
def test_create_effective_rubrics_list_with_duplicate_invocation_rubric_id(
self, evaluator: RubricBasedEvaluator
):
invocation_rubrics = [
Rubric(
rubric_id="1",
rubric_content=RubricContent(text_property="Invocation rubric"),
)
]
with pytest.raises(
ValueError, match="Rubric with rubric_id '1' already exists."
):
evaluator.create_effective_rubrics_list(invocation_rubrics)
def test_create_effective_rubrics_list_with_no_invocation_rubrics(
self, evaluator: RubricBasedEvaluator
):
evaluator.create_effective_rubrics_list(None)
effective_rubrics = evaluator.get_effective_rubrics_list()
assert len(effective_rubrics) == 2
assert {r.rubric_id for r in effective_rubrics} == {"1", "2"}
def test_get_effective_rubrics_list_before_creation_raises_error(
self, evaluator: RubricBasedEvaluator
):
with pytest.raises(
ValueError, match="Effective rubrics list not initialized."
):
evaluator.get_effective_rubrics_list()
def test_create_effective_rubrics_list_multiple_calls(
self, evaluator: RubricBasedEvaluator
):
invocation_rubrics1 = [
Rubric(
rubric_id="3",
rubric_content=RubricContent(text_property="Invocation rubric 1"),
)
]
evaluator.create_effective_rubrics_list(invocation_rubrics1)
effective_rubrics1 = evaluator.get_effective_rubrics_list()
assert len(effective_rubrics1) == 3
assert {r.rubric_id for r in effective_rubrics1} == {"1", "2", "3"}
invocation_rubrics2 = [
Rubric(
rubric_id="4",
rubric_content=RubricContent(text_property="Invocation rubric 2"),
)
]
evaluator.create_effective_rubrics_list(invocation_rubrics2)
effective_rubrics2 = evaluator.get_effective_rubrics_list()
assert len(effective_rubrics2) == 3
assert {r.rubric_id for r in effective_rubrics2} == {"1", "2", "4"}
def test_create_effective_rubrics_filters_by_rubric_type(
self, evaluator: RubricBasedEvaluator
):
evaluator_with_type = FakeRubricBasedEvaluator(
evaluator._eval_metric, rubric_type="TEST_TYPE"
)
invocation_rubrics = [
Rubric(
rubric_id="test_type_rubric",
rubric_content=RubricContent(text_property="Invocation rubric 1"),
type="TEST_TYPE",
),
Rubric(
rubric_id="other_type_rubric",
rubric_content=RubricContent(text_property="Invocation rubric 2"),
type="OTHER_TYPE",
),
]
evaluator_with_type.create_effective_rubrics_list(invocation_rubrics)
effective_rubrics = evaluator_with_type.get_effective_rubrics_list()
assert len(effective_rubrics) == 3
assert {r.rubric_id for r in effective_rubrics} == {
"1",
"2",
"test_type_rubric",
}
@@ -0,0 +1,224 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.eval_metrics import RubricsBasedCriterion
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.eval_rubrics import RubricScore
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.evaluator import PerInvocationResult
from google.adk.evaluation.llm_as_judge_utils import get_average_rubric_score
from google.adk.evaluation.rubric_based_final_response_quality_v1 import RubricBasedFinalResponseQualityV1Evaluator
from google.genai import types as genai_types
import pytest
@pytest.fixture
def evaluator() -> RubricBasedFinalResponseQualityV1Evaluator:
"""Returns a RubricBasedFinalResponseQualityV1Evaluator."""
rubrics = [
Rubric(
rubric_id="1",
rubric_content=RubricContent(text_property="Is the response good?"),
),
Rubric(
rubric_id="2",
rubric_content=RubricContent(text_property="Is the response bad?"),
),
]
judge_model_options = JudgeModelOptions(
judge_model_config=None,
num_samples=3,
)
criterion = RubricsBasedCriterion(
threshold=0.5, rubrics=rubrics, judge_model_options=judge_model_options
)
metric = EvalMetric(
metric_name=PrebuiltMetrics.RUBRIC_BASED_FINAL_RESPONSE_QUALITY_V1.value,
threshold=0.5,
criterion=criterion,
)
return RubricBasedFinalResponseQualityV1Evaluator(metric)
def _create_per_invocation_result(
rubric_scores: list[RubricScore],
) -> PerInvocationResult:
"""Helper to create a PerInvocationResult."""
return PerInvocationResult(
actual_invocation=Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="part_1")]
)
),
expected_invocation=Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="part_2")]
)
),
score=get_average_rubric_score(rubric_scores),
rubric_scores=rubric_scores,
eval_status=EvalStatus.NOT_EVALUATED,
)
def test_format_auto_rater_prompt_with_basic_invocation(
evaluator: RubricBasedFinalResponseQualityV1Evaluator,
):
"""Tests format_auto_rater_prompt with a basic invocation."""
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Final agent response.")]
),
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "User input here." in prompt
assert "Final agent response." in prompt
assert "Is the response good?" in prompt
assert "Is the response bad?" in prompt
assert "<developer_instructions>\n \n </developer_instructions>" in prompt
assert (
"<available_tools>\n Agent has no tools.\n </available_tools>" in prompt
)
assert (
"<response_steps>\n No intermediate steps were taken.\n "
" </response_steps>"
) in prompt
def test_format_auto_rater_prompt_with_app_details(
evaluator: RubricBasedFinalResponseQualityV1Evaluator,
):
"""Tests format_auto_rater_prompt with app_details in invocation."""
tool = genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="test_func", description="A test function."
)
]
)
app_details = AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1",
instructions="This is an agent instruction.",
tool_declarations=[tool],
)
},
)
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Final agent response.")]
),
app_details=app_details,
intermediate_data=InvocationEvents(
invocation_events=[InvocationEvent(author="agent1", content=None)]
),
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "This is an agent instruction." in prompt
assert '"name": "test_func"' in prompt
assert '"description": "A test function."' in prompt
def test_format_auto_rater_prompt_with_intermediate_data(
evaluator: RubricBasedFinalResponseQualityV1Evaluator,
):
"""Tests format_auto_rater_prompt with intermediate_data in invocation."""
tool_call = genai_types.FunctionCall(
name="test_func", args={"arg1": "val1"}, id="call1"
)
tool_response = genai_types.FunctionResponse(
name="test_func", response={"result": "ok"}, id="call1"
)
intermediate_data = IntermediateData(
tool_uses=[tool_call], tool_responses=[tool_response]
)
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Final agent response.")]
),
intermediate_data=intermediate_data,
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert '"step": 0' in prompt
assert '"tool_call":' in prompt
assert '"name": "test_func"' in prompt
assert '"tool_response":' in prompt
assert '"result": "ok"' in prompt
def test_format_auto_rater_prompt_with_app_details_no_tools(
evaluator: RubricBasedFinalResponseQualityV1Evaluator,
):
"""Tests format_auto_rater_prompt with app_details but no tools."""
app_details = AppDetails(
agent_details={
"agent1": AgentDetails(name="agent1", tool_declarations=[])
},
)
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Final agent response.")]
),
app_details=app_details,
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert '"tool_declarations": {\n "agent1": []\n }' in prompt
def test_format_auto_rater_prompt_with_intermediate_data_no_tools(
evaluator: RubricBasedFinalResponseQualityV1Evaluator,
):
"""Tests format_auto_rater_prompt with intermediate_data but no tool calls."""
intermediate_data = IntermediateData(tool_uses=[], tool_responses=[])
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Final agent response.")]
),
intermediate_data=intermediate_data,
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "No intermediate steps were taken." in prompt
@@ -0,0 +1,298 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.eval_metrics import RubricsBasedCriterion
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.rubric_based_multi_turn_trajectory_evaluator import RubricBasedMultiTurnTrajectoryEvaluator
from google.genai import types as genai_types
import pytest
_RUBRICS = [
Rubric(
rubric_id="1",
rubric_content=RubricContent(
text_property="The agent uses the correct tool."
),
type="TOOL_USAGE",
),
Rubric(
rubric_id="2",
rubric_content=RubricContent(
text_property="The agent fulfills the user intent."
),
type="FULFILL_USER_INTENT",
),
]
def _make_evaluator(
rubrics: list[Rubric] | None = None,
) -> RubricBasedMultiTurnTrajectoryEvaluator:
"""Helper to build an evaluator with the given rubrics."""
rubrics = rubrics or _RUBRICS
criterion = RubricsBasedCriterion(
threshold=0.5,
rubrics=rubrics,
judge_model_options=JudgeModelOptions(
judge_model_config=None,
num_samples=3,
),
)
metric = EvalMetric(
metric_name=PrebuiltMetrics.RUBRIC_BASED_MULTI_TURN_TRAJECTORY_QUALITY_V1.value,
threshold=0.5,
criterion=criterion,
)
return RubricBasedMultiTurnTrajectoryEvaluator(metric)
def _make_invocation(
user_text: str,
agent_text: str | None = None,
invocation_id: str = "",
rubrics: list[Rubric] | None = None,
app_details: AppDetails | None = None,
intermediate_data: InvocationEvents | None = None,
) -> Invocation:
"""Helper to build an Invocation."""
return Invocation(
invocation_id=invocation_id,
user_content=genai_types.Content(
parts=[genai_types.Part(text=user_text)]
),
final_response=(
genai_types.Content(parts=[genai_types.Part(text=agent_text)])
if agent_text
else None
),
rubrics=rubrics,
app_details=app_details,
intermediate_data=intermediate_data,
)
class TestFormatAutoRaterPrompt:
"""Tests for format_auto_rater_prompt."""
def test_basic_dialogue_and_rubrics_in_prompt(self):
"""Tests that user dialogue and rubrics appear in the generated prompt."""
evaluator = _make_evaluator()
invocation = _make_invocation(
user_text="What is the balance?",
agent_text="Your balance is $100.",
rubrics=_RUBRICS,
)
# Simulate evaluate_invocations dialogue assembly by setting internal state.
evaluator._formatted_dialogue = "USER TURN 1: What is the balance?"
evaluator._formatted_instructions = ""
evaluator._formatted_tools = ""
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "USER TURN 1: What is the balance?" in prompt
assert "The agent uses the correct tool." in prompt
assert "The agent fulfills the user intent." in prompt
assert "TOOL_USAGE" in prompt
assert "FULFILL_USER_INTENT" in prompt
def test_prompt_includes_agent_instructions_and_tools(self):
"""Tests that agent instructions and tools are inserted into the prompt."""
evaluator = _make_evaluator()
invocation = _make_invocation(
user_text="Transfer funds",
rubrics=_RUBRICS,
)
evaluator._formatted_dialogue = "USER TURN 1: Transfer funds"
evaluator._formatted_instructions = (
"Agent banking_agent Instructions:\nYou are a banking assistant."
)
evaluator._formatted_tools = (
"Agent: banking_agent\n- transfer_funds: Transfer money between"
" accounts."
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "You are a banking assistant." in prompt
assert "transfer_funds" in prompt
class TestDialogueAssembly:
"""Tests for the dialogue assembly logic in evaluate_invocations.
These test the internal dialogue construction by calling evaluate_invocations
and inspecting self._formatted_dialogue.
"""
@pytest.fixture
def evaluator(self):
return _make_evaluator()
@pytest.mark.asyncio
async def test_single_turn_user_and_agent(self, evaluator):
"""Tests that a single turn assembles user and agent dialogue."""
invocations = [
_make_invocation(
user_text="Hello",
agent_text="Hi there!",
invocation_id="agent1",
rubrics=_RUBRICS,
),
]
# We need to mock the super().evaluate_invocations call since it calls
# the LLM. Instead, we just test the dialogue assembly part directly.
evaluator._formatted_dialogue = None
# Manually run the dialogue assembly portion
evaluator._assemble_dialogue_history(invocations)
assert "USER TURN 1: Hello" in evaluator._formatted_dialogue
assert "AGENT (agent) TURN 1: Hi there!" in evaluator._formatted_dialogue
@pytest.mark.asyncio
async def test_multi_turn_dialogue(self, evaluator):
"""Tests dialogue assembly across multiple turns."""
invocations = [
_make_invocation(
user_text="Check my balance",
agent_text="Your balance is $100.",
invocation_id="agent1",
rubrics=_RUBRICS,
),
_make_invocation(
user_text="Transfer $50",
agent_text="Transfer complete.",
invocation_id="agent1",
rubrics=_RUBRICS,
),
]
evaluator._assemble_dialogue_history(invocations)
assert "USER TURN 1: Check my balance" in evaluator._formatted_dialogue
assert (
"AGENT (agent) TURN 1: Your balance is $100."
in evaluator._formatted_dialogue
)
assert "USER TURN 2: Transfer $50" in evaluator._formatted_dialogue
assert (
"AGENT (agent) TURN 2: Transfer complete."
in evaluator._formatted_dialogue
)
@pytest.mark.asyncio
async def test_intermediate_events_with_function_calls(self, evaluator):
"""Tests that intermediate function calls and responses appear in dialogue."""
tool_call_part = genai_types.Part(
function_call=genai_types.FunctionCall(
name="get_balance", args={"account_id": "123"}
)
)
tool_response_part = genai_types.Part(
function_response=genai_types.FunctionResponse(
name="get_balance", response={"balance": 100}
)
)
intermediate_data = InvocationEvents(
invocation_events=[
InvocationEvent(
author="banking_agent",
content=genai_types.Content(parts=[tool_call_part]),
),
InvocationEvent(
author="banking_agent",
content=genai_types.Content(parts=[tool_response_part]),
),
]
)
invocations = [
_make_invocation(
user_text="What is my balance?",
agent_text="Your balance is $100.",
invocation_id="banking_agent",
rubrics=_RUBRICS,
intermediate_data=intermediate_data,
),
]
evaluator._assemble_dialogue_history(invocations)
assert "get_balance" in evaluator._formatted_dialogue
assert '"account_id": "123"' in evaluator._formatted_dialogue
assert '"balance": 100' in evaluator._formatted_dialogue
@pytest.mark.asyncio
async def test_app_details_instructions_and_tools(self, evaluator):
"""Tests that app_details instructions and tools are captured."""
tool = genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="transfer_funds",
description="Transfer money between accounts.",
)
]
)
app_details = AppDetails(
agent_details={
"banking_agent": AgentDetails(
name="banking_agent",
instructions="You are a banking assistant.",
tool_declarations=[tool],
)
},
)
invocations = [
_make_invocation(
user_text="Transfer $50",
agent_text="Done.",
invocation_id="banking_agent",
rubrics=_RUBRICS,
app_details=app_details,
),
]
evaluator._assemble_dialogue_history(invocations)
assert "You are a banking assistant." in evaluator._formatted_instructions
assert "transfer_funds" in evaluator._formatted_tools
assert "Transfer money between accounts." in evaluator._formatted_tools
@pytest.mark.asyncio
async def test_invocation_without_user_content(self, evaluator):
"""Tests that invocations with no user text parts are handled gracefully."""
invocations = [
Invocation(
user_content=genai_types.Content(parts=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="Agent response.")]
),
invocation_id="agent1",
rubrics=_RUBRICS,
),
]
evaluator._assemble_dialogue_history(invocations)
# No user turn should appear, but agent turn should
assert "USER TURN" not in evaluator._formatted_dialogue
assert (
"AGENT (agent) TURN 1: Agent response." in evaluator._formatted_dialogue
)
@@ -0,0 +1,138 @@
# 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.
from __future__ import annotations
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import JudgeModelOptions
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.eval_metrics import RubricsBasedCriterion
from google.adk.evaluation.eval_rubrics import Rubric
from google.adk.evaluation.eval_rubrics import RubricContent
from google.adk.evaluation.rubric_based_tool_use_quality_v1 import RubricBasedToolUseV1Evaluator
from google.genai import types as genai_types
import pytest
@pytest.fixture
def evaluator() -> RubricBasedToolUseV1Evaluator:
"""Returns a RubricBasedToolUseV1Evaluator."""
rubrics = [
Rubric(
rubric_id="1",
rubric_content=RubricContent(
text_property="Did the agent use the correct tool?"
),
),
Rubric(
rubric_id="2",
rubric_content=RubricContent(
text_property="Were the tool parameters correct?"
),
),
]
judge_model_options = JudgeModelOptions(
judge_model_config=None,
num_samples=3,
)
criterion = RubricsBasedCriterion(
threshold=0.5, rubrics=rubrics, judge_model_options=judge_model_options
)
metric = EvalMetric(
metric_name=PrebuiltMetrics.RUBRIC_BASED_TOOL_USE_QUALITY_V1.value,
threshold=0.5,
criterion=criterion,
)
return RubricBasedToolUseV1Evaluator(metric)
def test_format_auto_rater_prompt_with_basic_invocation(
evaluator: RubricBasedToolUseV1Evaluator,
):
"""Tests format_auto_rater_prompt with a basic invocation."""
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert "User input here." in prompt
assert "Did the agent use the correct tool?" in prompt
assert "Were the tool parameters correct?" in prompt
assert "<available_tools>\nAgent has no tools.\n</available_tools>" in prompt
assert "<response>\nNo intermediate steps were taken.\n</response>" in prompt
def test_format_auto_rater_prompt_with_app_details(
evaluator: RubricBasedToolUseV1Evaluator,
):
"""Tests format_auto_rater_prompt with app_details in invocation."""
tool = genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="test_func", description="A test function."
)
]
)
app_details = AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1",
tool_declarations=[tool],
)
},
)
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
app_details=app_details,
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert '"name": "test_func"' in prompt
assert '"description": "A test function."' in prompt
def test_format_auto_rater_prompt_with_intermediate_data(
evaluator: RubricBasedToolUseV1Evaluator,
):
"""Tests format_auto_rater_prompt with intermediate_data in invocation."""
tool_call = genai_types.FunctionCall(
name="test_func", args={"arg1": "val1"}, id="call1"
)
tool_response = genai_types.FunctionResponse(
name="test_func", response={"result": "ok"}, id="call1"
)
intermediate_data = IntermediateData(
tool_uses=[tool_call], tool_responses=[tool_response]
)
invocation = Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
),
intermediate_data=intermediate_data,
)
prompt = evaluator.format_auto_rater_prompt(invocation, None)
assert '"step": 0' in prompt
assert '"tool_call":' in prompt
assert '"name": "test_func"' in prompt
assert '"tool_response":' in prompt
assert '"result": "ok"' in prompt
@@ -0,0 +1,78 @@
# 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 the Response Evaluator."""
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.safety_evaluator import SafetyEvaluatorV1
from google.genai import types as genai_types
vertexai_types = vertexai.types
class TestSafetyEvaluatorV1:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_coherence_metric_passed(self, mocker):
"""Test evaluate_invocations function for Coherence metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = SafetyEvaluatorV1(
eval_metric=EvalMetric(threshold=0.8, metric_name="safety")
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.SAFETY.name
]
@@ -0,0 +1,525 @@
# 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.
"""Testings for the Trajectory Evaluator."""
from google.adk.evaluation.eval_case import IntermediateData
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.eval_metrics import EvalMetric
from google.adk.evaluation.eval_metrics import PrebuiltMetrics
from google.adk.evaluation.eval_metrics import ToolTrajectoryCriterion
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.trajectory_evaluator import TrajectoryEvaluator
from google.genai import types as genai_types
from pydantic import ValidationError
import pytest
_USER_CONTENT = genai_types.Content(
parts=[genai_types.Part(text="User input here.")]
)
def test_tool_trajectory_criterion_accepts_string_match_type():
criterion = ToolTrajectoryCriterion(threshold=0.5, match_type="in_order")
assert criterion.match_type == ToolTrajectoryCriterion.MatchType.IN_ORDER
@pytest.mark.parametrize(
("match_type", "expected"),
[
("exact", ToolTrajectoryCriterion.MatchType.EXACT),
("EXACT", ToolTrajectoryCriterion.MatchType.EXACT),
(" exact ", ToolTrajectoryCriterion.MatchType.EXACT),
("in order", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("IN ORDER", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("In OrDeR", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("in-order", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("IN-ORDER", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("in_order", ToolTrajectoryCriterion.MatchType.IN_ORDER),
("any order", ToolTrajectoryCriterion.MatchType.ANY_ORDER),
("ANY ORDER", ToolTrajectoryCriterion.MatchType.ANY_ORDER),
("any-order", ToolTrajectoryCriterion.MatchType.ANY_ORDER),
("ANY-ORDER", ToolTrajectoryCriterion.MatchType.ANY_ORDER),
("any_order", ToolTrajectoryCriterion.MatchType.ANY_ORDER),
],
)
def test_tool_trajectory_criterion_normalizes_string_match_type(
match_type: str, expected: ToolTrajectoryCriterion.MatchType
):
criterion = ToolTrajectoryCriterion(threshold=0.5, match_type=match_type)
assert criterion.match_type == expected
def test_tool_trajectory_criterion_rejects_unknown_string_match_type():
with pytest.raises(ValidationError):
ToolTrajectoryCriterion(threshold=0.5, match_type="random string")
def test_trajectory_evaluator_accepts_string_match_type_from_eval_metric_dict():
eval_metric = EvalMetric(
threshold=0.5,
metric_name=PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value,
criterion={
"threshold": 0.5,
"match_type": "ANY_ORDER",
},
)
evaluator = TrajectoryEvaluator(eval_metric=eval_metric)
tool_call1 = genai_types.FunctionCall(name="test_func1", args={})
tool_call2 = genai_types.FunctionCall(name="test_func2", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1, tool_call2]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call2, tool_call1]),
)
result = evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 1.0
@pytest.fixture
def evaluator() -> TrajectoryEvaluator:
"""Returns a TrajectoryEvaluator."""
return TrajectoryEvaluator(
eval_metric=EvalMetric(
threshold=0.5,
metric_name=PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value,
criterion=ToolTrajectoryCriterion(
threshold=0.5,
match_type=ToolTrajectoryCriterion.MatchType.EXACT,
),
)
)
def test_evaluate_invocations_equal_tool_calls(evaluator: TrajectoryEvaluator):
"""Tests evaluate_invocations with equal tool calls."""
tool_call = genai_types.FunctionCall(name="test_func", args={"arg1": "val1"})
intermediate_data = IntermediateData(tool_uses=[tool_call])
invocation = Invocation(
user_content=_USER_CONTENT, intermediate_data=intermediate_data
)
result = evaluator.evaluate_invocations([invocation], [invocation])
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert len(result.per_invocation_results) == 1
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
def test_evaluate_invocations_different_tool_call_names(
evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with different tool call names."""
tool_call1 = genai_types.FunctionCall(
name="test_func1", args={"arg1": "val1"}
)
tool_call2 = genai_types.FunctionCall(
name="test_func2", args={"arg1": "val1"}
)
invocation1 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
invocation2 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call2]),
)
result = evaluator.evaluate_invocations([invocation1], [invocation2])
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_different_tool_call_args(
evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with different tool call args."""
tool_call1 = genai_types.FunctionCall(name="test_func", args={"arg1": "val1"})
tool_call2 = genai_types.FunctionCall(name="test_func", args={"arg1": "val2"})
invocation1 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
invocation2 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call2]),
)
result = evaluator.evaluate_invocations([invocation1], [invocation2])
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_different_number_of_tool_calls(
evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with different number of tool calls."""
tool_call1 = genai_types.FunctionCall(name="test_func", args={"arg1": "val1"})
tool_call2 = genai_types.FunctionCall(name="test_func", args={"arg1": "val1"})
invocation1 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
invocation2 = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1, tool_call2]),
)
result = evaluator.evaluate_invocations([invocation1], [invocation2])
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_no_tool_calls(evaluator: TrajectoryEvaluator):
"""Tests evaluate_invocations with no tool calls."""
invocation = Invocation(
user_content=_USER_CONTENT, intermediate_data=IntermediateData()
)
result = evaluator.evaluate_invocations([invocation], [invocation])
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
def test_evaluate_invocations_multiple_invocations(
evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with multiple invocations."""
tool_call1 = genai_types.FunctionCall(
name="test_func1", args={"arg1": "val1"}
)
tool_call2 = genai_types.FunctionCall(
name="test_func2", args={"arg1": "val1"}
)
inv1_actual = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
inv1_expected = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
inv2_actual = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call1]),
)
inv2_expected = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[tool_call2]),
)
result = evaluator.evaluate_invocations(
[inv1_actual, inv2_actual], [inv1_expected, inv2_expected]
)
assert result.overall_score == 0.5
assert result.overall_eval_status == EvalStatus.PASSED
assert len(result.per_invocation_results) == 2
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
assert result.per_invocation_results[1].score == 0.0
assert result.per_invocation_results[1].eval_status == EvalStatus.FAILED
@pytest.fixture
def in_order_evaluator() -> TrajectoryEvaluator:
"""Returns a TrajectoryEvaluator for IN_ORDER match."""
return TrajectoryEvaluator(
eval_metric=EvalMetric(
threshold=0.5,
metric_name=PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value,
criterion=ToolTrajectoryCriterion(
threshold=0.5,
match_type=ToolTrajectoryCriterion.MatchType.IN_ORDER,
),
)
)
def test_evaluate_invocations_in_order_match_with_extra_tool_calls(
in_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with IN_ORDER match type and extra tool calls."""
t1 = genai_types.FunctionCall(name="t1", args={})
t1_1 = genai_types.FunctionCall(name="t1_1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t2_1 = genai_types.FunctionCall(name="t2_1", args={})
t3 = genai_types.FunctionCall(name="t3", args={})
t3_1 = genai_types.FunctionCall(name="t3_1", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(
tool_uses=[t1, t1_1, t2, t2_1, t3, t3_1]
),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t3]),
)
result = in_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
def test_evaluate_invocations_in_order_match_fails_with_missing_tool_call(
in_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with IN_ORDER match type and missing tool call."""
t1 = genai_types.FunctionCall(name="t1", args={})
t1_1 = genai_types.FunctionCall(name="t1_1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t2_1 = genai_types.FunctionCall(name="t2_1", args={})
t3_1 = genai_types.FunctionCall(name="t3_1", args={})
t4 = genai_types.FunctionCall(name="t4", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t1_1, t2, t2_1, t3_1]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t4]),
)
result = in_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_in_order_match_fails_with_wrong_order(
in_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with IN_ORDER match type and wrong order."""
t1 = genai_types.FunctionCall(name="t1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t3 = genai_types.FunctionCall(name="t3", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t3, t2]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t3]),
)
result = in_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
@pytest.fixture
def any_order_evaluator() -> TrajectoryEvaluator:
"""Returns a TrajectoryEvaluator for ANY_ORDER match."""
return TrajectoryEvaluator(
eval_metric=EvalMetric(
threshold=0.5,
metric_name=PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value,
criterion=ToolTrajectoryCriterion(
threshold=0.5,
match_type=ToolTrajectoryCriterion.MatchType.ANY_ORDER,
),
)
)
def test_evaluate_invocations_any_order_match_with_extra_tool_calls_different_order(
any_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with ANY_ORDER match type and extra tool calls."""
t1 = genai_types.FunctionCall(name="t1", args={})
t1_1 = genai_types.FunctionCall(name="t1_1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t2_1 = genai_types.FunctionCall(name="t2_1", args={})
t3 = genai_types.FunctionCall(name="t3", args={})
t3_1 = genai_types.FunctionCall(name="t3_1", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(
tool_uses=[t2, t2_1, t1, t1_1, t3, t3_1]
),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t3]),
)
result = any_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
def test_evaluate_invocations_any_order_match_fails_with_missing_tool_call(
any_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with ANY_ORDER match type and missing tool call."""
t1 = genai_types.FunctionCall(name="t1", args={})
t1_1 = genai_types.FunctionCall(name="t1_1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t2_1 = genai_types.FunctionCall(name="t2_1", args={})
t3_1 = genai_types.FunctionCall(name="t3_1", args={})
t4 = genai_types.FunctionCall(name="t4", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t1_1, t2, t2_1, t3_1]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t4]),
)
result = any_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_any_order_match_with_duplicates(
any_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with ANY_ORDER match type with duplicates."""
t1 = genai_types.FunctionCall(name="t1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t3 = genai_types.FunctionCall(name="t3", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t3, t1]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t1]),
)
result = any_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
assert result.per_invocation_results[0].score == 1.0
assert result.per_invocation_results[0].eval_status == EvalStatus.PASSED
def test_evaluate_invocations_any_order_match_fails_with_duplicates_missing(
any_order_evaluator: TrajectoryEvaluator,
):
"""Tests evaluate_invocations with ANY_ORDER match type with missing duplicates."""
t1 = genai_types.FunctionCall(name="t1", args={})
t2 = genai_types.FunctionCall(name="t2", args={})
t3 = genai_types.FunctionCall(name="t3", args={})
actual_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t3]),
)
expected_invocation = Invocation(
user_content=_USER_CONTENT,
intermediate_data=IntermediateData(tool_uses=[t1, t2, t1]),
)
result = any_order_evaluator.evaluate_invocations(
[actual_invocation], [expected_invocation]
)
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
assert result.per_invocation_results[0].score == 0.0
assert result.per_invocation_results[0].eval_status == EvalStatus.FAILED
def test_evaluate_invocations_no_invocations(evaluator: TrajectoryEvaluator):
"""Tests evaluate_invocations with no invocations."""
result = evaluator.evaluate_invocations([], [])
assert result.overall_score is None
assert result.overall_eval_status == EvalStatus.NOT_EVALUATED
assert not result.per_invocation_results
def _make_invocation_events(
*tool_calls: genai_types.FunctionCall,
) -> Invocation:
"""Returns an Invocation using InvocationEvents intermediate_data format."""
return Invocation(
user_content=_USER_CONTENT,
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent",
content=genai_types.Content(
parts=[genai_types.Part(function_call=tc)]
),
)
for tc in tool_calls
]
),
)
def test_evaluate_invocations_invocation_events_format_exact_match(
evaluator: TrajectoryEvaluator,
):
"""InvocationEvents intermediate_data format should score 1.0 on exact match.
Regression test for #5410: tool_trajectory_avg_score returned 0.0 even when
tool name and args were identical because function-call events with
skip_summarization=True were incorrectly excluded from invocation_events.
"""
tool_call = genai_types.FunctionCall(
id="toolu_01", name="execute_sql", args={"query": "SELECT 1"}
)
expected_tool_call = genai_types.FunctionCall(
name="execute_sql", args={"query": "SELECT 1"}
)
actual = _make_invocation_events(tool_call)
expected = _make_invocation_events(expected_tool_call)
result = evaluator.evaluate_invocations([actual], [expected])
assert result.overall_score == 1.0
assert result.overall_eval_status == EvalStatus.PASSED
def test_evaluate_invocations_invocation_events_format_mismatch(
evaluator: TrajectoryEvaluator,
):
"""InvocationEvents format should score 0.0 when tool calls differ."""
actual = _make_invocation_events(
genai_types.FunctionCall(name="tool_a", args={"x": "1"})
)
expected = _make_invocation_events(
genai_types.FunctionCall(name="tool_b", args={"x": "1"})
)
result = evaluator.evaluate_invocations([actual], [expected])
assert result.overall_score == 0.0
assert result.overall_eval_status == EvalStatus.FAILED
@@ -0,0 +1,594 @@
# 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.
from __future__ import annotations
"""Tests for the Response Evaluator."""
import math
import os
import random
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation.app_details import AgentDetails
from google.adk.evaluation.app_details import AppDetails
from google.adk.evaluation.eval_case import Invocation
from google.adk.evaluation.eval_case import InvocationEvent
from google.adk.evaluation.eval_case import InvocationEvents
from google.adk.evaluation.evaluator import EvalStatus
from google.adk.evaluation.vertex_ai_eval_facade import _MultiTurnVertexiAiEvalFacade
from google.adk.evaluation.vertex_ai_eval_facade import _SingleTurnVertexAiEvalFacade
from google.adk.evaluation.vertex_ai_eval_facade import _VertexAiEvalFacade
from google.genai import types as genai_types
import pandas as pd
import pytest
vertexai_types = vertexai.types
class TestSingleTurnVertexAiEvalFacade:
"""A class to help organize "patch" that are applicable to all tests."""
def test_evaluate_invocations_metric_passed(self, mocker):
"""Test evaluate_invocations function for a metric."""
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = _SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.COHERENCE.name
]
def test_evaluate_invocations_metric_failed(self, mocker):
"""Test evaluate_invocations function for a metric."""
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = _SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.7)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == 0.7
assert evaluation_result.overall_eval_status == EvalStatus.FAILED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.COHERENCE.name
]
@pytest.mark.parametrize(
"summary_metric_with_no_score",
[
([]),
([vertexai_types.AggregatedMetricResult(mean_score=float("nan"))]),
([vertexai_types.AggregatedMetricResult(mean_score=None)]),
([vertexai_types.AggregatedMetricResult(mean_score=math.nan)]),
],
)
def test_evaluate_invocations_metric_no_score(
self, mocker, summary_metric_with_no_score
):
"""Test evaluate_invocations function for a metric."""
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[
genai_types.Part(text="This is a test candidate response.")
]
),
)
]
expected_invocations = [
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text="This is a test query.")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="This is a test reference.")]
),
)
]
evaluator = _SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=summary_metric_with_no_score,
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score is None
assert evaluation_result.overall_eval_status == EvalStatus.NOT_EVALUATED
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
# Compare the names of the metrics.
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.COHERENCE.name
]
def test_evaluate_invocations_metric_multiple_invocations(self, mocker):
"""Test evaluate_invocations function for a metric with multiple invocations."""
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
num_invocations = 6
actual_invocations = []
expected_invocations = []
mock_eval_results = []
random.seed(61553)
scores = [random.random() for _ in range(num_invocations)]
for i in range(num_invocations):
actual_invocations.append(
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text=f"Query {i+1}")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=f"Response {i+1}")]
),
)
)
expected_invocations.append(
Invocation(
user_content=genai_types.Content(
parts=[genai_types.Part(text=f"Query {i+1}")]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text=f"Reference {i+1}")]
),
)
)
mock_eval_results.append(
vertexai_types.EvaluationResult(
summary_metrics=[
vertexai_types.AggregatedMetricResult(mean_score=scores[i])
],
eval_case_results=[],
)
)
evaluator = _SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
# Mock the return value of _perform_eval
mock_perform_eval.side_effect = mock_eval_results
evaluation_result = evaluator.evaluate_invocations(
actual_invocations, expected_invocations
)
assert evaluation_result.overall_score == pytest.approx(
sum(scores) / num_invocations
)
assert evaluation_result.overall_eval_status == EvalStatus.FAILED
assert mock_perform_eval.call_count == num_invocations
class TestVertexAiEvalFacade:
"""A class to help organize "patch" that are applicable to all tests."""
def test_constructor_with_api_key(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_API_KEY": "test_api_key"}, clear=True
)
mock_client_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.Client"
)
_SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
mock_client_cls.assert_called_once_with(api_key="test_api_key")
def test_constructor_with_project_and_location(self, mocker):
mocker.patch.dict(
os.environ,
{
"GOOGLE_CLOUD_PROJECT": "test_project",
"GOOGLE_CLOUD_LOCATION": "test_location",
},
clear=True,
)
mock_client_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.Client"
)
_SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
mock_client_cls.assert_called_once_with(
project="test_project", location="test_location"
)
def test_constructor_with_project_only_raises_error(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_CLOUD_PROJECT": "test_project"}, clear=True
)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(ValueError, match="Missing location."):
_SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
def test_constructor_with_location_only_raises_error(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_CLOUD_LOCATION": "test_location"}, clear=True
)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(ValueError, match="Missing project id."):
_SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
def test_constructor_with_no_env_vars_raises_error(self, mocker):
mocker.patch.dict(os.environ, {}, clear=True)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(
ValueError,
match=(
"Either API Key or Google cloud Project id and location should be"
" specified."
),
):
_SingleTurnVertexAiEvalFacade(
threshold=0.8, metric_name=vertexai_types.PrebuiltMetric.COHERENCE
)
class TestMultiTurnVertexAiEvalFacade:
"""Tests for _MultiTurnVertexiAiEvalFacade."""
def test_map_agent_details_to_agent_config(self):
tool_declarations = [
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name="tool_1",
description="this is tool 1",
)
]
)
]
agent_details = AgentDetails(
name="test_agent",
instructions="test_instructions",
tool_declarations=tool_declarations,
)
agent_config = (
_MultiTurnVertexiAiEvalFacade._map_agent_details_to_agent_config(
agent_details
)
)
assert agent_config.agent_id == "test_agent"
assert agent_config.instruction == "test_instructions"
assert agent_config.tools == tool_declarations
def test_get_agent_details(self):
invocations = [
Invocation(
user_content=genai_types.Content(),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
),
"agent2": AgentDetails(
name="agent2", instructions="instructions2"
),
}
),
),
Invocation(
user_content=genai_types.Content(),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
),
"agent3": AgentDetails(
name="agent3", instructions="instructions3"
),
}
),
),
]
agent_configs = _MultiTurnVertexiAiEvalFacade._get_agent_details(
invocations
)
assert len(agent_configs) == 3
assert "agent1" in agent_configs
assert "agent2" in agent_configs
assert "agent3" in agent_configs
assert agent_configs["agent1"].instruction == "instructions1"
assert agent_configs["agent2"].instruction == "instructions2"
assert agent_configs["agent3"].instruction == "instructions3"
def test_map_invocation_event_to_agent_event(self):
invocation_event = InvocationEvent(
author="test_author",
content=genai_types.Content(
parts=[genai_types.Part(text="test_content")]
),
)
agent_event = (
_MultiTurnVertexiAiEvalFacade._map_inovcation_event_to_agent_event(
invocation_event
)
)
assert agent_event.author == "test_author"
assert agent_event.content.parts[0].text == "test_content"
def test_map_invocation_turn(self):
invocation = Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="user query")]
),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent1",
content=genai_types.Content(
parts=[genai_types.Part(text="intermediate content")]
),
)
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="final response")]
),
)
conversation_turn = _MultiTurnVertexiAiEvalFacade._map_invocation_turn(
0, invocation
)
assert conversation_turn.turn_index == 0
assert conversation_turn.turn_id == "inv1"
assert len(conversation_turn.events) == 3
assert conversation_turn.events[0].author == "user"
assert conversation_turn.events[0].content.parts[0].text == "user query"
assert conversation_turn.events[1].author == "agent1"
assert (
conversation_turn.events[1].content.parts[0].text
== "intermediate content"
)
assert conversation_turn.events[2].author == "agent"
assert conversation_turn.events[2].content.parts[0].text == "final response"
def test_get_turns(self):
invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
),
Invocation(
invocation_id="inv2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q2")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r2")]
),
),
]
turns = _MultiTurnVertexiAiEvalFacade._get_turns(invocations)
assert len(turns) == 2
assert turns[0].turn_id == "inv1"
assert turns[1].turn_id == "inv2"
def test_get_agent_data(self):
invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
)
]
agent_data = _MultiTurnVertexiAiEvalFacade._get_agent_data(invocations)
assert "agent1" in agent_data.agents
assert len(agent_data.turns) == 1
def test_evaluate_invocations_multi_turn_metric_passed(self, mocker):
"""Test evaluate_invocations function for a multi-turn metric."""
mock_perform_eval = mocker.patch(
"google.adk.evaluation.vertex_ai_eval_facade._VertexAiEvalFacade._perform_eval"
)
actual_invocations = [
Invocation(
invocation_id="inv1",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q1")]
),
intermediate_data=InvocationEvents(invocation_events=[]),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r1")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
Invocation(
invocation_id="inv2",
user_content=genai_types.Content(
parts=[genai_types.Part(text="q2")]
),
intermediate_data=InvocationEvents(
invocation_events=[
InvocationEvent(
author="agent1",
content=genai_types.Content(
parts=[genai_types.Part(text="intermediate")]
),
)
]
),
final_response=genai_types.Content(
parts=[genai_types.Part(text="r2")]
),
app_details=AppDetails(
agent_details={
"agent1": AgentDetails(
name="agent1", instructions="instructions1"
)
}
),
),
]
evaluator = _MultiTurnVertexiAiEvalFacade(
threshold=0.8,
metric_name=vertexai_types.PrebuiltMetric.CONVERSATIONAL_COHERENCE,
)
# Mock the return value of _perform_eval
mock_perform_eval.return_value = vertexai_types.EvaluationResult(
summary_metrics=[vertexai_types.AggregatedMetricResult(mean_score=0.9)],
eval_case_results=[],
)
evaluation_result = evaluator.evaluate_invocations(actual_invocations)
assert evaluation_result.overall_score == 0.9
assert evaluation_result.overall_eval_status == EvalStatus.PASSED
assert len(evaluation_result.per_invocation_results) == 2
assert (
evaluation_result.per_invocation_results[0].eval_status
== EvalStatus.NOT_EVALUATED
)
assert (
evaluation_result.per_invocation_results[1].eval_status
== EvalStatus.PASSED
)
mock_perform_eval.assert_called_once()
_, mock_kwargs = mock_perform_eval.call_args
assert [m.name for m in mock_kwargs["metrics"]] == [
vertexai_types.PrebuiltMetric.CONVERSATIONAL_COHERENCE.name
]
dataset = mock_kwargs["dataset"]
assert len(dataset.eval_cases) == 1
agent_data = dataset.eval_cases[0].agent_data
assert "agent1" in agent_data.agents
assert len(agent_data.turns) == 2
assert agent_data.turns[0].turn_id == "inv1"
assert agent_data.turns[1].turn_id == "inv2"
assert len(agent_data.turns[1].events) == 3 # user, intermediate, agent
@@ -0,0 +1,155 @@
# 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 the Vertex AI Scenario Generation Facade."""
from __future__ import annotations
import os
from google.adk.agents.base_agent import BaseAgent
from google.adk.dependencies.vertexai import vertexai
from google.adk.evaluation._vertex_ai_scenario_generation_facade import ScenarioGenerator
from google.adk.evaluation.conversation_scenarios import ConversationGenerationConfig
import pytest
vertexai_types = vertexai.types
class TestScenarioGenerator:
"""Unit tests for ScenarioGenerator."""
def test_constructor_with_api_key(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_API_KEY": "test_api_key"}, clear=True
)
mock_client_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.Client"
)
ScenarioGenerator()
mock_client_cls.assert_called_once_with(api_key="test_api_key")
def test_constructor_with_project_and_location(self, mocker):
"""Test constructor with project and location in env."""
mocker.patch.dict(
os.environ,
{
"GOOGLE_CLOUD_PROJECT": "test_project",
"GOOGLE_CLOUD_LOCATION": "test_location",
},
clear=True,
)
mock_client_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.Client"
)
ScenarioGenerator()
mock_client_cls.assert_called_once_with(
project="test_project", location="test_location"
)
def test_constructor_with_project_only_raises_error(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_CLOUD_PROJECT": "test_project"}, clear=True
)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(ValueError, match="Missing location."):
ScenarioGenerator()
def test_constructor_with_location_only_raises_error(self, mocker):
mocker.patch.dict(
os.environ, {"GOOGLE_CLOUD_LOCATION": "test_location"}, clear=True
)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(ValueError, match="Missing project id."):
ScenarioGenerator()
def test_constructor_with_no_env_vars_raises_error(self, mocker):
mocker.patch.dict(os.environ, {}, clear=True)
mocker.patch("google.adk.dependencies.vertexai.vertexai.Client")
with pytest.raises(
ValueError,
match=(
"Either API Key or Google cloud Project id and location should be"
" specified."
),
):
ScenarioGenerator()
def test_generate_scenarios(self, mocker):
"""Test scenario generation with mocked components."""
mocker.patch.dict(
os.environ, {"GOOGLE_API_KEY": "test_api_key"}, clear=True
)
mock_client_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.Client"
)
mock_client = mock_client_cls.return_value
# I need to mock AgentInfo.load_from_agent(agent=agent)
mock_agent_info_cls = mocker.patch(
"google.adk.dependencies.vertexai.vertexai.types.evals.AgentInfo"
)
mock_agent_info_cls.load_from_agent.return_value = "mock_agent_info"
mock_generate = mocker.patch.object(
mock_client.evals, "generate_conversation_scenarios"
)
mock_eval_cases = [
mocker.Mock(
user_scenario=mocker.Mock(
starting_prompt="Hello", conversation_plan="Say hello"
)
),
mocker.Mock(user_scenario=None), # testing handling of None
mocker.Mock(
user_scenario=mocker.Mock(
starting_prompt="Bye", conversation_plan="Say bye"
)
),
]
mock_generate.return_value = mocker.Mock(eval_cases=mock_eval_cases)
generator = ScenarioGenerator()
mock_agent = mocker.Mock(spec=BaseAgent)
config = ConversationGenerationConfig(
count=2,
generation_instruction="Test generation",
model_name="gemini-2.5-flash",
)
scenarios = generator.generate_scenarios(mock_agent, config)
assert len(scenarios) == 2
assert scenarios[0].starting_prompt == "Hello"
assert scenarios[0].conversation_plan == "Say hello"
assert scenarios[1].starting_prompt == "Bye"
assert scenarios[1].conversation_plan == "Say bye"
mock_agent_info_cls.load_from_agent.assert_called_once_with(
agent=mock_agent
)
mock_generate.assert_called_once()
_, kwargs = mock_generate.call_args
assert kwargs["agent_info"] == "mock_agent_info"
passed_config = kwargs["config"]
assert passed_config.count == 2
assert passed_config.generation_instruction == "Test generation"