Skip to content

Commit 42ca225

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: Support creating evaluation runs from pre-existing Interactions API data
PiperOrigin-RevId: 944601748
1 parent 86c1a35 commit 42ca225

3 files changed

Lines changed: 355 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,15 @@ def _resolve_dataset(
433433
) -> types.EvaluationRunDataSource:
434434
"""Resolves dataset for the evaluation run."""
435435
if isinstance(dataset, types.EvaluationDataset):
436+
# Resolve EvalCases with interactions_data_source by fetching
437+
# each interaction and converting it to agent_data, then flowing
438+
# through the normal DataFrame/GCS pipeline.
439+
if dataset.eval_cases and _has_interactions_data_source(dataset.eval_cases):
440+
resolved_cases = _resolve_interactions_to_eval_cases(
441+
api_client, dataset.eval_cases
442+
)
443+
dataset = types.EvaluationDataset(eval_cases=resolved_cases)
444+
436445
candidate_name = _get_candidate_name(dataset, parsed_agent_info)
437446
eval_df = dataset.eval_dataset_df
438447
if eval_df is None and dataset.eval_cases:
@@ -923,6 +932,92 @@ def _fetch_agent_config_dict(
923932
)
924933

925934

935+
def _has_interactions_data_source(
936+
eval_cases: list[types.EvalCase],
937+
) -> bool:
938+
"""Returns True if any EvalCase has interactions_data_source set."""
939+
return any(case.interactions_data_source is not None for case in eval_cases)
940+
941+
942+
def _resolve_interactions_to_eval_cases(
943+
api_client: BaseApiClient,
944+
eval_cases: list[types.EvalCase],
945+
) -> list[types.EvalCase]:
946+
"""Resolves EvalCases with interactions_data_source to agent_data.
947+
948+
For each EvalCase that has interactions_data_source set, fetches the
949+
Interaction via the SDK's interactions.get() API, converts the steps
950+
to AgentData, and returns a new EvalCase with agent_data populated.
951+
952+
Args:
953+
api_client: The API client (must have an interactions module).
954+
eval_cases: EvalCases with interactions_data_source set.
955+
956+
Returns:
957+
New list of EvalCases with agent_data populated from resolved
958+
interactions.
959+
960+
Raises:
961+
ValueError: If eval_cases have missing interaction references.
962+
"""
963+
# Validate all cases up front before making any API calls.
964+
for case in eval_cases:
965+
ids = case.interactions_data_source
966+
if ids is None:
967+
raise ValueError(
968+
"All eval_cases must have interactions_data_source set when"
969+
" using interaction resolution. Found a case without it. Do"
970+
" not mix interaction-based and prompt-based eval cases."
971+
)
972+
if not ids.interaction:
973+
raise ValueError(
974+
"interactions_data_source.interaction is required. Each"
975+
" EvalCase must reference an existing Interaction resource."
976+
)
977+
978+
resolved_cases = []
979+
980+
for case in eval_cases:
981+
ids = case.interactions_data_source
982+
983+
# Extract the interaction short ID from the resource name.
984+
# Handles both full resource names (projects/.../interactions/{id})
985+
# and bare IDs by always taking the last path component.
986+
interaction_id = ids.interaction.split("/")[-1]
987+
988+
logger.info("Fetching interaction: %s", ids.interaction)
989+
path = f"interactions/{interaction_id}"
990+
response = api_client.request("get", path, {}, None)
991+
interaction_dict = {} if not response.body else json.loads(response.body)
992+
993+
agent_data = _interaction_dict_to_agent_data(interaction_dict)
994+
995+
# Best-effort: fetch the agent config (instruction, tools,
996+
# description) from the Agent API so the display can render
997+
# the System Topology section.
998+
gemini_cfg = ids.gemini_agent_config
999+
agent_name = gemini_cfg.gemini_agent if gemini_cfg else None
1000+
agent_config = _fetch_agent_config_dict(api_client, agent_name or "")
1001+
agent_data.agents = {agent_config.agent_id: agent_config}
1002+
1003+
# Merge consecutive text events and parts so multi-paragraph
1004+
# responses render as a single block in the trace display.
1005+
_merge_text_parts_in_agent_data(agent_data)
1006+
1007+
# Preserve all original EvalCase fields; only update agent_data
1008+
# and clear the now-resolved interactions_data_source.
1009+
resolved_cases.append(
1010+
case.model_copy(
1011+
update={
1012+
"agent_data": agent_data,
1013+
"interactions_data_source": None,
1014+
}
1015+
)
1016+
)
1017+
1018+
return resolved_cases
1019+
1020+
9261021
def _add_evaluation_run_labels(
9271022
labels: Optional[dict[str, str]] = None,
9281023
agent: Optional[str] = None,

tests/unit/agentplatform/genai/replays/test_create_evaluation_run.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,42 @@ def test_create_eval_run_with_metric_resource_name(mock_uuid4, client):
709709
# == INPUT_DF_WITH_CONTEXT_AND_HISTORY.iloc[i]["response"]
710710
# )
711711
# assert evaluation_run.error is None
712+
@mock.patch("uuid.uuid4")
713+
def test_create_eval_run_with_interactions_data_source(mock_uuid4, client):
714+
"""Tests create_evaluation_run() with EvalCases using interactions_data_source.
715+
716+
The SDK resolves each interaction client-side (GET interactions/{id} and
717+
GET agents/{id}) before uploading the EvalCases to the evaluation pipeline.
718+
"""
719+
mock_uuid4.return_value = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
720+
client._api_client._http_options.api_version = "v1beta1"
721+
interaction_id = "ChA2YzllYzk0MjY1NjZjODM5EAgaATAqBG1haW4"
722+
gemini_agent = (
723+
"projects/model-evaluation-dev/locations/global/agents/test-agent-eval"
724+
)
725+
eval_case = types.EvalCase(
726+
interactions_data_source=types.InteractionsDataSource(
727+
interaction=(
728+
f"projects/model-evaluation-dev/locations/global"
729+
f"/interactions/{interaction_id}"
730+
),
731+
gemini_agent_config=types.GeminiAgentConfig(
732+
gemini_agent=gemini_agent,
733+
),
734+
)
735+
)
736+
evaluation_run = client.evals.create_evaluation_run(
737+
name="test_interactions_data_source",
738+
display_name="test_interactions_data_source",
739+
dataset=types.EvaluationDataset(eval_cases=[eval_case]),
740+
dest=GCS_DEST,
741+
metrics=[GENERAL_QUALITY_METRIC],
742+
)
743+
assert isinstance(evaluation_run, types.EvaluationRun)
744+
assert evaluation_run.state == types.EvaluationRunState.PENDING
745+
assert evaluation_run.error is None
746+
747+
712748
def test_create_eval_run_with_red_teaming_config(client):
713749
"""Tests that create_evaluation_run() with red_teaming_config sends analysisConfigs."""
714750
evaluation_run = client.evals.create_evaluation_run(

tests/unit/agentplatform/genai/test_evals.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9563,6 +9563,230 @@ def test_resolve_dataset_preserves_conversation_history(
95639563
assert "conversation_history" in ptd_values
95649564

95659565

9566+
class TestResolveDatasetWithInteractions:
9567+
"""Tests for resolving interactions_data_source in _resolve_dataset."""
9568+
9569+
def setup_method(self):
9570+
self.mock_api_client = mock.Mock()
9571+
self.mock_api_client.project = "test-project"
9572+
self.mock_api_client.location = "us-central1"
9573+
9574+
def test_has_interactions_data_source_true(self):
9575+
cases = [
9576+
agentplatform_genai_types.EvalCase(
9577+
interactions_data_source=agentplatform_genai_types.InteractionsDataSource(
9578+
gemini_agent_config=agentplatform_genai_types.GeminiAgentConfig(
9579+
gemini_agent="projects/p/locations/l/agents/a"
9580+
),
9581+
interaction="projects/p/locations/l/interactions/i1",
9582+
)
9583+
)
9584+
]
9585+
assert _evals_common._has_interactions_data_source(cases)
9586+
9587+
def test_has_interactions_data_source_false(self):
9588+
cases = [
9589+
agentplatform_genai_types.EvalCase(
9590+
prompt=genai_types.Content(
9591+
parts=[genai_types.Part(text="test")]
9592+
),
9593+
)
9594+
]
9595+
assert not _evals_common._has_interactions_data_source(cases)
9596+
9597+
def test_resolve_rejects_mixed_cases(self):
9598+
"""Mixing interaction-based and prompt-based cases raises ValueError."""
9599+
cases = [
9600+
agentplatform_genai_types.EvalCase(
9601+
interactions_data_source=agentplatform_genai_types.InteractionsDataSource(
9602+
interaction="projects/p/locations/l/interactions/i1",
9603+
)
9604+
),
9605+
agentplatform_genai_types.EvalCase(
9606+
prompt=genai_types.Content(
9607+
parts=[genai_types.Part(text="test")]
9608+
),
9609+
),
9610+
]
9611+
with pytest.raises(ValueError, match="interactions_data_source"):
9612+
_evals_common._resolve_interactions_to_eval_cases(
9613+
self.mock_api_client, cases
9614+
)
9615+
9616+
def test_resolve_rejects_missing_interaction(self):
9617+
"""EvalCase with interactions_data_source but no interaction raises."""
9618+
cases = [
9619+
agentplatform_genai_types.EvalCase(
9620+
interactions_data_source=agentplatform_genai_types.InteractionsDataSource(
9621+
gemini_agent_config=agentplatform_genai_types.GeminiAgentConfig(
9622+
gemini_agent="projects/p/locations/l/agents/a"
9623+
),
9624+
)
9625+
),
9626+
]
9627+
with pytest.raises(ValueError, match="interaction is required"):
9628+
_evals_common._resolve_interactions_to_eval_cases(
9629+
self.mock_api_client, cases
9630+
)
9631+
9632+
def test_interaction_dict_to_agent_data_text_conversation(self):
9633+
"""Converts user_input + model_output steps to agent_data."""
9634+
interaction_dict = {
9635+
"status": "completed",
9636+
"steps": [
9637+
{
9638+
"type": "user_input",
9639+
"content": [{"type": "text", "text": "Hello agent"}],
9640+
},
9641+
{
9642+
"type": "model_output",
9643+
"content": [
9644+
{"type": "text", "text": "Hello! How can I help?"}
9645+
],
9646+
},
9647+
]
9648+
}
9649+
9650+
result = _evals_common._interaction_dict_to_agent_data(
9651+
interaction_dict
9652+
)
9653+
9654+
assert len(result.turns) == 1
9655+
events = result.turns[0].events
9656+
assert len(events) == 2
9657+
assert events[0].author == "user"
9658+
assert events[0].content.parts[0].text == "Hello agent"
9659+
assert events[1].author == "agent"
9660+
assert events[1].content.parts[0].text == "Hello! How can I help?"
9661+
9662+
def test_interaction_dict_to_agent_data_with_tool_calls(self):
9663+
"""Converts function_call + function_result steps."""
9664+
interaction_dict = {
9665+
"status": "completed",
9666+
"steps": [
9667+
{
9668+
"type": "function_call",
9669+
"name": "get_weather",
9670+
"arguments": {"city": "NYC"},
9671+
"id": "call_1",
9672+
},
9673+
{
9674+
"type": "function_result",
9675+
"name": "get_weather",
9676+
"call_id": "call_1",
9677+
"result": {"temp": "72F"},
9678+
},
9679+
]
9680+
}
9681+
9682+
result = _evals_common._interaction_dict_to_agent_data(
9683+
interaction_dict
9684+
)
9685+
9686+
events = result.turns[0].events
9687+
assert len(events) == 2
9688+
fc_event = events[0]
9689+
assert fc_event.author == "agent"
9690+
assert fc_event.content.parts[0].function_call.name == "get_weather"
9691+
fr_event = events[1]
9692+
assert fr_event.content.parts[0].function_response.id == "call_1"
9693+
9694+
def test_resolve_interactions_to_eval_cases_happy_path(self):
9695+
"""Fetches an interaction and populates agent_data on the EvalCase."""
9696+
interaction_body = json.dumps({
9697+
"status": "completed",
9698+
"steps": [
9699+
{
9700+
"type": "user_input",
9701+
"content": [{"type": "text", "text": "What is the weather?"}],
9702+
},
9703+
{
9704+
"type": "model_output",
9705+
"content": [{"type": "text", "text": "It is sunny."}],
9706+
},
9707+
]
9708+
})
9709+
agent_body = json.dumps({
9710+
"system_instruction": "You are helpful.",
9711+
"base_agent": "gemini-2.0-flash",
9712+
})
9713+
9714+
def mock_request(method, path, *args, **kwargs):
9715+
resp = mock.MagicMock()
9716+
if path.startswith("interactions/"):
9717+
resp.body = interaction_body
9718+
elif path.startswith("agents/"):
9719+
resp.body = agent_body
9720+
else:
9721+
resp.body = None
9722+
return resp
9723+
9724+
self.mock_api_client.request.side_effect = mock_request
9725+
9726+
original_case = agentplatform_genai_types.EvalCase(
9727+
interactions_data_source=agentplatform_genai_types.InteractionsDataSource(
9728+
gemini_agent_config=agentplatform_genai_types.GeminiAgentConfig(
9729+
gemini_agent="projects/p/locations/l/agents/my-agent",
9730+
),
9731+
interaction="projects/p/locations/l/interactions/i1",
9732+
),
9733+
)
9734+
9735+
resolved = _evals_common._resolve_interactions_to_eval_cases(
9736+
self.mock_api_client, [original_case]
9737+
)
9738+
9739+
assert len(resolved) == 1
9740+
result_case = resolved[0]
9741+
9742+
# interactions_data_source should be cleared after resolution.
9743+
assert result_case.interactions_data_source is None
9744+
9745+
# agent_data should be populated with the interaction content.
9746+
agent_data = result_case.agent_data
9747+
assert agent_data is not None
9748+
assert len(agent_data.turns) == 1
9749+
events = agent_data.turns[0].events
9750+
assert len(events) == 2
9751+
assert events[0].author == "user"
9752+
assert events[0].content.parts[0].text == "What is the weather?"
9753+
assert events[1].author == "agent"
9754+
assert events[1].content.parts[0].text == "It is sunny."
9755+
9756+
# agents map should be populated with config from the Agent API.
9757+
assert "my-agent" in agent_data.agents
9758+
agent_config = agent_data.agents["my-agent"]
9759+
assert agent_config.agent_id == "my-agent"
9760+
assert agent_config.instruction == "You are helpful."
9761+
assert agent_config.agent_type == "gemini-2.0-flash"
9762+
9763+
def test_resolve_preserves_original_eval_case_fields(self):
9764+
"""Fields other than agent_data are preserved on the resolved EvalCase."""
9765+
interaction_body = json.dumps({"status": "completed", "steps": []})
9766+
9767+
def mock_request(method, path, *args, **kwargs):
9768+
resp = mock.MagicMock()
9769+
resp.body = interaction_body
9770+
return resp
9771+
9772+
self.mock_api_client.request.side_effect = mock_request
9773+
9774+
original_case = agentplatform_genai_types.EvalCase(
9775+
interactions_data_source=agentplatform_genai_types.InteractionsDataSource(
9776+
interaction="projects/p/locations/l/interactions/i1",
9777+
),
9778+
)
9779+
9780+
resolved = _evals_common._resolve_interactions_to_eval_cases(
9781+
self.mock_api_client, [original_case]
9782+
)
9783+
9784+
assert len(resolved) == 1
9785+
# agent_data is populated and interactions_data_source cleared.
9786+
assert resolved[0].agent_data is not None
9787+
assert resolved[0].interactions_data_source is None
9788+
9789+
95669790
class TestRateLimiter:
95679791
"""Tests for the RateLimiter class in _evals_utils."""
95689792

0 commit comments

Comments
 (0)