Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/google/adk/evaluation/agent_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/evaluation/evaluation_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
113 changes: 113 additions & 0 deletions tests/unittests/evaluation/test_agent_evaluator.py
Original file line number Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions tests/unittests/evaluation/test_evaluation_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Loading