# 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__ importannotationsimportjsonimportloggingimportosfromosimportpathfromtypingimportAnyfromtypingimportDictfromtypingimportListfromtypingimportOptionalfromtypingimportUnionimportuuidfromgoogle.genaiimporttypesasgenai_typesfrompydanticimportValidationErrorfrom .constantsimportMISSING_EVAL_DEPENDENCIES_MESSAGEfrom .eval_caseimportIntermediateDatafrom .eval_metricsimportEvalMetricfrom .eval_setimportEvalSetfrom .evaluatorimportEvalStatusfrom .evaluatorimportEvaluationResultfrom .evaluatorimportEvaluatorfrom .local_eval_sets_managerimportconvert_eval_set_to_pydanctic_schemalogger=logging.getLogger("google_adk."+__name__)
# Constants for default runs and evaluation criteriaNUM_RUNS=2TOOL_TRAJECTORY_SCORE_KEY="tool_trajectory_avg_score"# This evaluation is not very stable.# This is always optional unless explicitly specified.RESPONSE_EVALUATION_SCORE_KEY="response_evaluation_score"RESPONSE_MATCH_SCORE_KEY="response_match_score"SAFETY_V1_KEY="safety_v1"ALLOWED_CRITERIA= [
TOOL_TRAJECTORY_SCORE_KEY,
RESPONSE_EVALUATION_SCORE_KEY,
RESPONSE_MATCH_SCORE_KEY,
SAFETY_V1_KEY,
]
QUERY_COLUMN="query"REFERENCE_COLUMN="reference"EXPECTED_TOOL_USE_COLUMN="expected_tool_use"DEFAULT_CRITERIA= {
TOOL_TRAJECTORY_SCORE_KEY: 1.0, # 1-point scale; 1.0 is perfect.RESPONSE_MATCH_SCORE_KEY: 0.8, # Rouge-1 text match; 0.8 is default.
}
defload_json(file_path: str) ->Union[Dict, List]:
withopen(file_path, "r") asf:
returnjson.load(f)
classAgentEvaluator:
"""An evaluator for Agents, mainly intended for helping with test cases."""@staticmethoddeffind_config_for_test_file(test_file: str):
"""Find the test_config.json file in the same folder as the test file."""test_folder=os.path.dirname(test_file)
config_path=os.path.join(test_folder, "test_config.json")
ifos.path.exists(config_path):
config_data=load_json(config_path)
if"criteria"inconfig_dataandisinstance(
config_data["criteria"], dict
):
returnconfig_data["criteria"]
else:
raiseValueError(
f"Invalid format for test_config.json at {config_path}. Expected a"" 'criteria' dictionary."
)
returnDEFAULT_CRITERIA@staticmethodasyncdefevaluate_eval_set(
agent_module: str,
eval_set: EvalSet,
criteria: dict[str, float],
num_runs=NUM_RUNS,
agent_name=None,
print_detailed_results: bool=True,
output_dir: Optional[str] =None,
):
"""Evaluates an agent using the given EvalSet. Args: agent_module: The path to python module that contains the definition of the agent. There is convention in place here, where the code is going to look for 'root_agent' in the loaded module. eval_set: The eval set. criteria: Evauation criterias, a dictionary of metric names to their respective thresholds. num_runs: Number of times all entries in the eval dataset should be assessed. agent_name: The name of the agent. print_detailed_results: Whether to print detailed results for each metric evaluation. output_dir: The directory to save the evaluation results to. """try:
from .evaluation_generatorimportEvaluationGeneratorexceptModuleNotFoundErrorase:
raiseModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) fromeeval_case_responses_list=awaitEvaluationGenerator.generate_responses(
eval_set=eval_set,
agent_module_path=agent_module,
repeat_num=num_runs,
agent_name=agent_name,
)
failures= []
run_id=str(uuid.uuid4())
foreval_case_responsesineval_case_responses_list:
actual_invocations= [
invocationforinvocationsineval_case_responses.responsesforinvocationininvocations
]
expected_invocations= (
eval_case_responses.eval_case.conversation*num_runs
)
formetric_name, thresholdincriteria.items():
metric_evaluator=AgentEvaluator._get_metric_evaluator(
metric_name=metric_name, threshold=threshold
)
evaluation_result: EvaluationResult= (
metric_evaluator.evaluate_invocations(
actual_invocations=actual_invocations,
expected_invocations=expected_invocations,
)
)
ifprint_detailed_results:
AgentEvaluator._print_details(
evaluation_result=evaluation_result,
metric_name=metric_name,
threshold=threshold,
output_dir=output_dir,
run_id=run_id,
)
# Gather all the failures.ifevaluation_result.overall_eval_status!=EvalStatus.PASSED:
failures.append(
f"{metric_name} for {agent_module} Failed. Expected {threshold},"f" but got {evaluation_result.overall_score}."
)
assertnotfailures, (
"Following are all the test failures. If you looking to get more"" details on the failures, then please re-run this test with"" `print_details` set to `True`.\n{}".format("\n".join(failures))
)
@staticmethodasyncdefevaluate(
agent_module: str,
eval_dataset_file_path_or_dir: str,
num_runs: int=NUM_RUNS,
agent_name: Optional[str] =None,
initial_session_file: Optional[str] =None,
output_dir: Optional[str] =None,
):
"""Evaluates an Agent given eval data. Args: agent_module: The path to python module that contains the definition of the agent. There is convention in place here, where the code is going to look for 'root_agent' in the loaded module. eval_dataset_file_path_or_dir: The eval data set. This can be either a string representing full path to the file containing eval dataset, or a directory that is recursively explored for all files that have a `.test.json` suffix. num_runs: Number of times all entries in the eval dataset should be assessed. agent_name: The name of the agent. initial_session_file: File that contains initial session state that is needed by all the evals in the eval dataset. output_dir: The directory to save the evaluation results to. """test_files= []
ifisinstance(eval_dataset_file_path_or_dir, str) andos.path.isdir(
eval_dataset_file_path_or_dir
):
forroot, _, filesinos.walk(eval_dataset_file_path_or_dir):
forfileinfiles:
iffile.endswith(".test.json"):
test_files.append(path.join(root, file))
else:
test_files= [eval_dataset_file_path_or_dir]
initial_session=AgentEvaluator._get_initial_session(initial_session_file)
fortest_fileintest_files:
criteria=AgentEvaluator.find_config_for_test_file(test_file)
eval_set=AgentEvaluator._load_eval_set_from_file(
test_file, criteria, initial_session
)
awaitAgentEvaluator.evaluate_eval_set(
agent_module=agent_module,
eval_set=eval_set,
criteria=criteria,
num_runs=num_runs,
agent_name=agent_name,
output_dir=output_dir,
)
@staticmethoddefmigrate_eval_data_to_new_schema(
old_eval_data_file: str,
new_eval_data_file: str,
initial_session_file: Optional[str] =None,
):
"""A utility for migrating eval data to new schema backed by EvalSet."""ifnotold_eval_data_fileornotnew_eval_data_file:
raiseValueError(
"One of old_eval_data_file or new_eval_data_file is empty."
)
criteria=AgentEvaluator.find_config_for_test_file(old_eval_data_file)
initial_session=AgentEvaluator._get_initial_session(initial_session_file)
eval_set=AgentEvaluator._get_eval_set_from_old_format(
old_eval_data_file, criteria, initial_session
)
withopen(new_eval_data_file, "w") asf:
f.write(eval_set.model_dump_json(indent=2))
@staticmethoddef_load_eval_set_from_file(
eval_set_file: str,
criteria: dict[str, float],
initial_session: dict[str, Any],
) ->EvalSet:
"""Loads an EvalSet from the given file."""ifos.path.isfile(eval_set_file):
withopen(eval_set_file, "r", encoding="utf-8") asf:
content=f.read()
try:
eval_set=EvalSet.model_validate_json(content)
assertlen(initial_session) ==0, (
"Intial session should be specified as a part of EvalSet file."" Explicit initial session is only needed, when specifying data in"" the older schema."
)
returneval_setexceptValidationError:
# We assume that the eval data was specified in the old formatlogger.warning(
f"Contents of {eval_set_file} appear to be in older format.To avoid"" this warning, please update your test files to contain data in"" EvalSet schema. You can use `migrate_eval_data_to_new_schema`"" for migrating your old test files."
)
# If we are here, the data must be specified in the older format.returnAgentEvaluator._get_eval_set_from_old_format(
eval_set_file, criteria, initial_session
)
@staticmethoddef_get_eval_set_from_old_format(
eval_set_file: str,
criteria: dict[str, float],
initial_session: dict[str, Any],
) ->EvalSet:
data=AgentEvaluator._load_dataset(eval_set_file)[0]
AgentEvaluator._validate_input([data], criteria)
eval_data= {
"name": eval_set_file,
"data": data,
"initial_session": initial_session,
}
returnconvert_eval_set_to_pydanctic_schema(
eval_set_id=str(uuid.uuid4()), eval_set_in_json_format=[eval_data]
)
@staticmethoddef_get_initial_session(initial_session_file: Optional[str] =None):
initial_session= {}
ifinitial_session_file:
withopen(initial_session_file, "r") asf:
initial_session=json.loads(f.read())
returninitial_session@staticmethoddef_load_dataset(
input_data: Union[str, List[str], List[Dict], List[List[Dict]]],
) ->List[List[Dict]]:
defload_json_file(file_path: str) ->List[Dict]:
data=load_json(file_path)
ifnotisinstance(data, list) ornotall(
isinstance(d, dict) fordindata
):
raiseValueError(f"{file_path} must contain a list of dictionaries.")
returndataifisinstance(input_data, str):
ifos.path.isdir(input_data):
test_files= []
forroot, _, filesinos.walk(input_data):
forfileinfiles:
iffile.endswith(".test.json"):
test_files.append(os.path.join(root, file))
return [load_json_file(f) forfintest_files]
elifos.path.isfile(input_data):
return [load_json_file(input_data)]
else:
raiseValueError(f"Input path {input_data} is invalid.")
elifisinstance(input_data, list):
ifall(isinstance(i, str) andos.path.isfile(i) foriininput_data):
return [load_json_file(i) foriininput_data]
raiseTypeError("Input list must contain valid file paths.")
raiseTypeError("Invalid input type for dataset loading.")
@staticmethoddef_validate_input(eval_dataset, criteria):
"""Validates that the evaluation criteria align with the provided dataset. For efficiency, we only use first row to validate input. """ifnoteval_dataset:
raiseValueError("The evaluation dataset is None or empty.")
forkeyincriteria:
ifkeynotinALLOWED_CRITERIA:
raiseValueError(
f"Invalid criteria key: {key}. Expected one of {ALLOWED_CRITERIA}."
)
ifnoteval_dataset:
raiseValueError("The evaluation dataset is empty.")
sample=eval_dataset[0]
first_query=sample[0]
ifnotisinstance(sample, list) andnotisinstance(first_query, dict):
raiseValueError(
"Each evaluation dataset sample must be list of dictionary. But it's"f" {eval_dataset}"
)
ifTOOL_TRAJECTORY_SCORE_KEYincriteria:
if (
QUERY_COLUMNnotinfirst_queryorEXPECTED_TOOL_USE_COLUMNnotinfirst_query
):
raiseValueError(
f"Samples for {TOOL_TRAJECTORY_SCORE_KEY} must include"f" '{QUERY_COLUMN}' and '{EXPECTED_TOOL_USE_COLUMN}' keys. The"f" sample is {sample}."
)
ifRESPONSE_EVALUATION_SCORE_KEYincriteria:
ifQUERY_COLUMNnotinfirst_query:
raiseValueError(
f"Samples for {RESPONSE_EVALUATION_SCORE_KEY} must include"f" '{QUERY_COLUMN}' key. The sample is {sample}."
)
ifRESPONSE_MATCH_SCORE_KEYincriteria:
ifQUERY_COLUMNnotinfirst_queryorREFERENCE_COLUMNnotinfirst_query:
raiseValueError(
f"Samples for {RESPONSE_MATCH_SCORE_KEY} must include"f" '{QUERY_COLUMN}' and '{REFERENCE_COLUMN}' keys. The sample is"f" {sample}."
)
@staticmethoddef_get_metric_evaluator(metric_name: str, threshold: float) ->Evaluator:
try:
from .response_evaluatorimportResponseEvaluatorfrom .safety_evaluatorimportSafetyEvaluatorV1from .trajectory_evaluatorimportTrajectoryEvaluatorexceptModuleNotFoundErrorase:
raiseModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) fromeifmetric_name==TOOL_TRAJECTORY_SCORE_KEY:
returnTrajectoryEvaluator(threshold=threshold)
elif (
metric_name==RESPONSE_MATCH_SCORE_KEYormetric_name==RESPONSE_EVALUATION_SCORE_KEY
):
returnResponseEvaluator(threshold=threshold, metric_name=metric_name)
elifmetric_name==SAFETY_V1_KEY:
returnSafetyEvaluatorV1(
eval_metric=EvalMetric(threshold=threshold, metric_name=metric_name)
)
raiseValueError(f"Unsupported eval metric: {metric_name}")
@staticmethoddef_print_details(
evaluation_result: EvaluationResult,
metric_name: str,
threshold: float,
output_dir: Optional[str] =None,
run_id: Optional[str] =None,
):
try:
importpandasaspdfromtabulateimporttabulateexceptModuleNotFoundErrorase:
raiseModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) fromeprint(
f"Summary: `{evaluation_result.overall_eval_status}` for Metric:"f" `{metric_name}`. Expected threshold: `{threshold}`, actual value:"f" `{evaluation_result.overall_score}`."
)
data= []
forper_invocation_resultinevaluation_result.per_invocation_results:
data.append({
"eval_status": per_invocation_result.eval_status,
"score": per_invocation_result.score,
"threshold": threshold,
"prompt": AgentEvaluator._convert_content_to_text(
per_invocation_result.expected_invocation.user_content
),
"expected_response": AgentEvaluator._convert_content_to_text(
per_invocation_result.expected_invocation.final_response
),
"actual_response": AgentEvaluator._convert_content_to_text(
per_invocation_result.actual_invocation.final_response
),
"expected_tool_calls": AgentEvaluator._convert_tool_calls_to_text(
per_invocation_result.expected_invocation.intermediate_data
),
"actual_tool_calls": AgentEvaluator._convert_tool_calls_to_text(
per_invocation_result.actual_invocation.intermediate_data
),
})
df=pd.DataFrame(data)
print(tabulate(df, headers="keys", tablefmt="grid"))
print("\n\n") # Few empty lines for visual clarityifoutput_dirandrun_id:
ifnotos.path.exists(output_dir):
os.makedirs(output_dir)
file_path=os.path.join(output_dir, f"eval_results_{run_id}.csv")
file_exists=os.path.isfile(file_path)
df.to_csv(file_path, mode="a", header=notfile_exists, index=False)
@staticmethoddef_convert_content_to_text(content: Optional[genai_types.Content]) ->str:
ifcontentandcontent.parts:
return"\n".join([p.textforpincontent.partsifp.text])
return""@staticmethoddef_convert_tool_calls_to_text(
intermediate_data: Optional[IntermediateData],
) ->str:
ifintermediate_dataandintermediate_data.tool_uses:
return"\n".join([str(t) fortinintermediate_data.tool_uses])
return""
Suggested change to allow saving eval results from pytest to a file