mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Details: 1. Data model for storing App Details (the agentic system) As we move towards LLM as Judge metrics, we see that some of these metrics need information about the Agentic system that was used for inferencing. We add a data model to capture that. 2. Data model for Steps We refine the concept of intermediate data. Previously it stored data in the form of a multiple lists, thereby losing out on the chronological information. This information is needed for some of the metrics. So we refine the concept of intermediate data as series of logical steps that an Agent Take. PiperOrigin-RevId: 811122784
507 lines
17 KiB
Python
507 lines
17 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.
|
|
|
|
import asyncio
|
|
import sys
|
|
from unittest import mock
|
|
|
|
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.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_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 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
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_eval_sets_manager():
|
|
return mock.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():
|
|
return mock.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
|
|
)
|
|
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)
|
|
),
|
|
)
|
|
|
|
def evaluate_invocations(
|
|
self,
|
|
actual_invocations: list[Invocation],
|
|
expected_invocations: list[Invocation],
|
|
):
|
|
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,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_perform_inference_success(
|
|
eval_service,
|
|
dummy_agent,
|
|
mock_eval_sets_manager,
|
|
):
|
|
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 = mock.MagicMock()
|
|
eval_service._perform_inference_sigle_eval_item = mock.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_sigle_eval_item.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_perform_inference_with_case_ids(
|
|
eval_service,
|
|
dummy_agent,
|
|
mock_eval_sets_manager,
|
|
):
|
|
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 = mock.MagicMock()
|
|
eval_service._perform_inference_sigle_eval_item = mock.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_sigle_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,
|
|
)
|
|
eval_service._perform_inference_sigle_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,
|
|
)
|
|
|
|
|
|
@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
|
|
):
|
|
inference_results = [
|
|
InferenceResult(
|
|
app_name="test_app",
|
|
eval_set_id="test_eval_set",
|
|
eval_case_id="case1",
|
|
inferences=[],
|
|
session_id="session1",
|
|
),
|
|
InferenceResult(
|
|
app_name="test_app",
|
|
eval_set_id="test_eval_set",
|
|
eval_case_id="case2",
|
|
inferences=[],
|
|
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 = mock.MagicMock(spec=EvalCase)
|
|
mock_eval_case.conversation = []
|
|
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 == 2
|
|
|
|
|
|
@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
|
|
):
|
|
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 = mock.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.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
|
|
|
|
|
|
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 exeception.
|
|
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
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 10), reason="MCP tool requires Python 3.10+"
|
|
)
|
|
async def test_mcp_stdio_agent_no_runtime_error():
|
|
"""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 = mock.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)
|