mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Details: - Adds the `StaticUserSimulator` which implements the current functionality of supplying a fixed set of user prompts for an EvalCase. - Adds the `UserSimulatorProvider` which determines the type of user simulator required for an EvalCase (StaticUserSimulator or LlmBackedUserSimulator). - Integrates the UserSimulatorProvider and UserSimulator into the CLI and evaluation infrastructure. - Updates and adds unit tests for the new functionality. - Miscellaneous updates to lay groundwork for a full implementation of the LlmBackedUserSimulator in the future. PiperOrigin-RevId: 822198401
458 lines
16 KiB
Python
458 lines
16 KiB
Python
# Copyright 2025 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.evaluation_generator import EvaluationGenerator
|
|
from google.adk.evaluation.request_intercepter_plugin import _RequestIntercepterPlugin
|
|
from google.adk.evaluation.user_simulator import NextUserMessage
|
|
from google.adk.evaluation.user_simulator import Status as UserSimulatorStatus
|
|
from google.adk.evaluation.user_simulator import UserSimulator
|
|
from google.adk.events.event import Event
|
|
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"
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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"
|