diff --git a/src/google/adk/evaluation/agent_evaluator.py b/src/google/adk/evaluation/agent_evaluator.py index f52a367950..bdf88fdb16 100644 --- a/src/google/adk/evaluation/agent_evaluator.py +++ b/src/google/adk/evaluation/agent_evaluator.py @@ -78,7 +78,7 @@ def load_json(file_path: str) -> Union[Dict, List]: - with open(file_path, "r") as f: + with open(file_path, "r", encoding="utf-8") as f: return json.load(f) @@ -266,7 +266,7 @@ def migrate_eval_data_to_new_schema( old_eval_data_file, eval_config, initial_session ) - with open(new_eval_data_file, "w") as f: + with open(new_eval_data_file, "w", encoding="utf-8") as f: f.write(eval_set.model_dump_json(indent=2)) @staticmethod @@ -323,7 +323,7 @@ def _get_eval_set_from_old_format( def _get_initial_session(initial_session_file: Optional[str] = None): initial_session = {} if initial_session_file: - with open(initial_session_file, "r") as f: + with open(initial_session_file, "r", encoding="utf-8") as f: initial_session = json.loads(f.read()) return initial_session diff --git a/src/google/adk/evaluation/evaluation_generator.py b/src/google/adk/evaluation/evaluation_generator.py index e7f88634d1..165ea7f09f 100644 --- a/src/google/adk/evaluation/evaluation_generator.py +++ b/src/google/adk/evaluation/evaluation_generator.py @@ -315,7 +315,7 @@ def generate_responses_from_session(session_path, eval_dataset): """ results = [] - with open(session_path, "r") as f: + with open(session_path, "r", encoding="utf-8") as f: session_data = Session.model_validate_json(f.read()) logger.info("Loaded session %s", session_path) diff --git a/tests/unittests/evaluation/test_agent_evaluator.py b/tests/unittests/evaluation/test_agent_evaluator.py new file mode 100644 index 0000000000..e9ff7aba1a --- /dev/null +++ b/tests/unittests/evaluation/test_agent_evaluator.py @@ -0,0 +1,113 @@ +# 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 builtins +import json + +from google.adk.evaluation import agent_evaluator as agent_evaluator_module +from google.adk.evaluation.agent_evaluator import AgentEvaluator +from google.adk.evaluation.agent_evaluator import load_json + +# Non-ASCII payload (emoji, CJK, accented characters) that only survives a +# UTF-8 round-trip. Written to eval-data files by the eval subsystem's Pydantic +# `model_dump_json` (which does not escape non-ASCII). +_NON_ASCII_TEXT = "\U0001f600 \u4f60\u597d caf\u00e9" + +_real_open = builtins.open + + +def _non_utf8_default_open(file, mode="r", *args, **kwargs): + """Emulates a platform whose default text encoding is not UTF-8. + + On such platforms (for example Windows, where the default is cp1252), + `open()` calls that omit `encoding=` inherit that non-UTF-8 default. This + wrapper reproduces that behaviour on any platform by falling back to ASCII + when a text-mode open does not specify an encoding, so a missing + `encoding="utf-8"` argument raises instead of silently depending on the + host locale. + """ + if "b" not in mode and "encoding" not in kwargs: + kwargs["encoding"] = "ascii" + return _real_open(file, mode, *args, **kwargs) + + +def test_load_json_reads_non_ascii_with_non_utf8_default(tmp_path, mocker): + """`load_json` must decode eval data as UTF-8 regardless of platform locale.""" + file_path = tmp_path / "eval.json" + file_path.write_text( + json.dumps([{"query": _NON_ASCII_TEXT}], ensure_ascii=False), + encoding="utf-8", + ) + + mocker.patch.object( + agent_evaluator_module, "open", _non_utf8_default_open, create=True + ) + + assert load_json(str(file_path)) == [{"query": _NON_ASCII_TEXT}] + + +def test_get_initial_session_reads_non_ascii_with_non_utf8_default( + tmp_path, mocker +): + """`_get_initial_session` must decode the session file as UTF-8.""" + session_file = tmp_path / "initial_session.json" + session_file.write_text( + json.dumps({"state": {"city": _NON_ASCII_TEXT}}, ensure_ascii=False), + encoding="utf-8", + ) + + mocker.patch.object( + agent_evaluator_module, "open", _non_utf8_default_open, create=True + ) + + initial_session = AgentEvaluator._get_initial_session(str(session_file)) + + assert initial_session == {"state": {"city": _NON_ASCII_TEXT}} + + +def test_migrate_eval_data_round_trips_non_ascii_with_non_utf8_default( + tmp_path, mocker +): + """Migration must read the old file and write the new file as UTF-8. + + This exercises both the read (`load_json`) and the write + (`model_dump_json`) of eval data, which must stay UTF-8 consistent so that + datasets containing non-ASCII characters survive migration on any platform. + """ + old_eval_data_file = tmp_path / "old_format.test.json" + old_eval_data_file.write_text( + json.dumps( + [{ + "query": _NON_ASCII_TEXT, + "reference": _NON_ASCII_TEXT, + "expected_tool_use": [], + }], + ensure_ascii=False, + ), + encoding="utf-8", + ) + new_eval_data_file = tmp_path / "new_format.json" + + mocker.patch.object( + agent_evaluator_module, "open", _non_utf8_default_open, create=True + ) + + AgentEvaluator.migrate_eval_data_to_new_schema( + str(old_eval_data_file), str(new_eval_data_file) + ) + + migrated = json.loads(new_eval_data_file.read_text(encoding="utf-8")) + assert _NON_ASCII_TEXT in json.dumps(migrated, ensure_ascii=False) diff --git a/tests/unittests/evaluation/test_evaluation_generator.py b/tests/unittests/evaluation/test_evaluation_generator.py index ea6364cad3..4e03016833 100644 --- a/tests/unittests/evaluation/test_evaluation_generator.py +++ b/tests/unittests/evaluation/test_evaluation_generator.py @@ -15,7 +15,9 @@ from __future__ import annotations import asyncio +import builtins +from google.adk.evaluation import evaluation_generator as evaluation_generator_module from google.adk.evaluation.app_details import AgentDetails from google.adk.evaluation.app_details import AppDetails from google.adk.evaluation.conversation_scenarios import ConversationScenario @@ -33,6 +35,7 @@ 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.adk.sessions.session import Session from google.genai import types import pytest @@ -966,3 +969,65 @@ def test_convert_events_preserves_tool_calls_when_skip_summarization(): assert len(tool_calls) == 1 assert tool_calls[0].name == "execute_sql" assert tool_calls[0].args == {"project_id": "my-proj", "query": "SELECT 1"} + + +_real_open = builtins.open + + +def _non_utf8_default_open(file, mode="r", *args, **kwargs): + """Emulates a platform whose default text encoding is not UTF-8. + + Falls back to ASCII when a text-mode open does not specify an encoding, so a + missing `encoding="utf-8"` argument raises instead of silently depending on + the host locale (for example cp1252 on Windows). + """ + if "b" not in mode and "encoding" not in kwargs: + kwargs["encoding"] = "ascii" + return _real_open(file, mode, *args, **kwargs) + + +def test_generate_responses_from_session_reads_non_ascii_with_non_utf8_default( + tmp_path, mocker +): + """The session file must be read as UTF-8 regardless of platform locale. + + Session files serialized via `model_dump_json` contain raw (unescaped) + non-ASCII characters, so reading them without an explicit UTF-8 encoding + fails on platforms whose default encoding is not UTF-8. + """ + non_ascii_text = "\U0001f600 \u4f60\u597d caf\u00e9" + session = Session( + id="s1", + app_name="app", + user_id="u1", + events=[ + Event( + author="user", + invocation_id="inv1", + content=types.Content( + role="user", parts=[types.Part(text=non_ascii_text)] + ), + ), + Event( + author="agent", + invocation_id="inv1", + content=types.Content( + role="model", + parts=[types.Part(text="response " + non_ascii_text)], + ), + ), + ], + ) + session_path = tmp_path / "session.json" + session_path.write_text(session.model_dump_json(), encoding="utf-8") + + mocker.patch.object( + evaluation_generator_module, "open", _non_utf8_default_open, create=True + ) + + results = EvaluationGenerator.generate_responses_from_session( + str(session_path), [[{"query": non_ascii_text}]] + ) + + assert results[0][0]["query"] == non_ascii_text + assert results[0][0]["response"] == "response " + non_ascii_text