Skip to content

Commit d9010e7

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

2 files changed

Lines changed: 447 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,311 @@ def _extract_response_from_completed_trace(
420420
return event_dicts
421421

422422

423+
def _has_interactions_data_source(
424+
eval_cases: list[types.EvalCase],
425+
) -> bool:
426+
"""Returns True if any EvalCase has interactions_data_source set."""
427+
return any(
428+
getattr(case, "interactions_data_source", None) is not None
429+
for case in eval_cases
430+
)
431+
432+
433+
def _interaction_dict_to_agent_data(
434+
interaction: dict[str, Any],
435+
) -> dict[str, Any]:
436+
"""Converts an Interaction API JSON response to an AgentData-compatible dict.
437+
438+
Maps the flat list of Interaction steps (user_input, model_output,
439+
function_call, function_result, etc.) into a single ConversationTurn
440+
with AgentEvents, matching the AgentData structure expected by the
441+
evaluation pipeline.
442+
443+
Args:
444+
interaction: A dict from the Interactions API GET response.
445+
446+
Returns:
447+
A dict matching the AgentData schema with a single turn containing
448+
all events from the interaction.
449+
"""
450+
events = []
451+
for step in interaction.get("steps", []):
452+
step_type = step.get("type")
453+
454+
if step_type == "user_input":
455+
parts = []
456+
for content_item in step.get("content", []):
457+
if content_item.get("type") == "text":
458+
parts.append({"text": content_item.get("text", "")})
459+
if parts:
460+
events.append({
461+
"author": "user",
462+
"content": {"role": "user", "parts": parts},
463+
})
464+
465+
elif step_type == "model_output":
466+
parts = []
467+
for content_item in step.get("content", []):
468+
if content_item.get("type") == "text":
469+
parts.append({"text": content_item.get("text", "")})
470+
if parts:
471+
events.append({
472+
"author": "agent",
473+
"content": {"role": "model", "parts": parts},
474+
})
475+
476+
elif step_type == "function_call":
477+
events.append({
478+
"author": "agent",
479+
"content": {
480+
"role": "model",
481+
"parts": [{
482+
"function_call": {
483+
"name": step.get("name", ""),
484+
"args": step.get("arguments", {}),
485+
"id": step.get("id", ""),
486+
},
487+
}],
488+
},
489+
})
490+
491+
elif step_type == "function_result":
492+
result = step.get("result")
493+
if isinstance(result, dict):
494+
result_str = json.dumps(result)
495+
elif isinstance(result, str):
496+
result_str = result
497+
else:
498+
result_str = str(result) if result is not None else ""
499+
events.append({
500+
"author": "user",
501+
"content": {
502+
"role": "user",
503+
"parts": [{
504+
"function_response": {
505+
"name": step.get("name", ""),
506+
"response": {"result": result_str},
507+
"id": step.get("callId", step.get("call_id", "")),
508+
},
509+
}],
510+
},
511+
})
512+
513+
agent_data: dict[str, Any] = {
514+
"turns": [{
515+
"turn_index": 0,
516+
"events": events,
517+
}],
518+
}
519+
return agent_data
520+
521+
522+
def _merge_text_parts_in_agent_data(
523+
agent_data: dict[str, Any],
524+
) -> None:
525+
"""Merges consecutive text events and parts for cleaner trace display.
526+
527+
The Interaction API may return multiple consecutive ``model_output``
528+
steps (one per paragraph) and/or multiple text content items within a
529+
single step. ``_interaction_dict_to_agent_data`` maps each step to a
530+
separate event, and each content item to a separate ``part``, causing
531+
the trace renderer to display them as separate visual blocks.
532+
533+
This function performs two merges:
534+
535+
1. **Event merge** -- consecutive events from the same author that
536+
contain only text parts are collapsed into a single event.
537+
2. **Part merge** -- within each (possibly merged) event, consecutive
538+
text-only parts are collapsed into a single part.
539+
540+
Mutates ``agent_data`` in place.
541+
542+
Args:
543+
agent_data: A dict matching the AgentData schema.
544+
"""
545+
for turn in agent_data.get("turns", []):
546+
events = turn.get("events")
547+
if not events:
548+
continue
549+
550+
# --- Pass 1: merge consecutive text-only events from the same author ---
551+
merged_events: list[dict[str, Any]] = []
552+
for event in events:
553+
parts = (event.get("content") or {}).get("parts", [])
554+
is_text_only = parts and all(
555+
"text" in p and len(p) == 1 for p in parts
556+
)
557+
if (
558+
merged_events
559+
and is_text_only
560+
and event.get("author") == merged_events[-1].get("author")
561+
):
562+
prev_parts = (
563+
merged_events[-1].get("content") or {}
564+
).get("parts", [])
565+
prev_is_text_only = prev_parts and all(
566+
"text" in p and len(p) == 1 for p in prev_parts
567+
)
568+
if prev_is_text_only:
569+
prev_parts.extend(parts)
570+
continue
571+
merged_events.append(event)
572+
turn["events"] = merged_events
573+
574+
# --- Pass 2: merge consecutive text parts within each event ---
575+
for event in turn["events"]:
576+
content = event.get("content")
577+
if not content:
578+
continue
579+
parts = content.get("parts")
580+
if not parts or len(parts) <= 1:
581+
continue
582+
merged_parts: list[dict[str, Any]] = []
583+
text_buffer: list[str] = []
584+
for part in parts:
585+
if "text" in part and len(part) == 1:
586+
text_buffer.append(part["text"])
587+
else:
588+
if text_buffer:
589+
merged_parts.append({"text": "\n".join(text_buffer)})
590+
text_buffer = []
591+
merged_parts.append(part)
592+
if text_buffer:
593+
merged_parts.append({"text": "\n".join(text_buffer)})
594+
content["parts"] = merged_parts
595+
596+
597+
def _resolve_interactions_to_eval_cases(
598+
api_client: BaseApiClient,
599+
eval_cases: list[types.EvalCase],
600+
) -> list[types.EvalCase]:
601+
"""Resolves EvalCases with interactions_data_source to agent_data.
602+
603+
For each EvalCase that has interactions_data_source set, fetches the
604+
Interaction via the SDK's interactions.get() API, converts the steps
605+
to AgentData, and returns a new EvalCase with agent_data populated.
606+
607+
Args:
608+
api_client: The API client (must have an interactions module).
609+
eval_cases: EvalCases with interactions_data_source set.
610+
611+
Returns:
612+
New list of EvalCases with agent_data populated from resolved
613+
interactions.
614+
615+
Raises:
616+
ValueError: If eval_cases have missing interaction references.
617+
"""
618+
# Validate all cases up front before making any API calls.
619+
for case in eval_cases:
620+
ids = case.interactions_data_source
621+
if ids is None:
622+
raise ValueError(
623+
"All eval_cases must have interactions_data_source set when"
624+
" using interaction resolution. Found a case without it. Do"
625+
" not mix interaction-based and prompt-based eval cases."
626+
)
627+
if not ids.interaction:
628+
raise ValueError(
629+
"interactions_data_source.interaction is required. Each"
630+
" EvalCase must reference an existing Interaction resource."
631+
)
632+
633+
resolved_cases = []
634+
635+
for case in eval_cases:
636+
ids = case.interactions_data_source
637+
638+
# Extract the interaction ID from the resource name.
639+
# Format: projects/{p}/locations/{l}/interactions/{id}
640+
parts = ids.interaction.split("/")
641+
if len(parts) >= 6 and parts[4] == "interactions":
642+
interaction_id = parts[5]
643+
else:
644+
interaction_id = ids.interaction
645+
646+
logger.info("Fetching interaction: %s", ids.interaction)
647+
path = f"interactions/{interaction_id}"
648+
response = api_client.request("get", path, {}, None)
649+
interaction_dict = (
650+
{} if not response.body else json.loads(response.body)
651+
)
652+
653+
agent_data = _interaction_dict_to_agent_data(interaction_dict)
654+
655+
# Derive agent short name from the resource name.
656+
gemini_cfg = getattr(ids, "gemini_agent_config", None)
657+
agent_name = (
658+
getattr(gemini_cfg, "gemini_agent", None)
659+
if gemini_cfg else None
660+
)
661+
agent_id = "agent"
662+
if agent_name:
663+
agent_parts = agent_name.split("/")
664+
if len(agent_parts) >= 2:
665+
agent_id = agent_parts[-1]
666+
667+
# Best-effort: fetch the agent config (instruction, tools,
668+
# description) from the Agent API so the display can render
669+
# the System Topology section.
670+
agent_config: dict[str, Any] = {"agent_id": agent_id}
671+
if agent_name:
672+
try:
673+
agent_short_id = agent_name.split("/")[-1]
674+
agent_resp = api_client.request(
675+
"get", f"agents/{agent_short_id}", {}, None
676+
)
677+
if agent_resp.body:
678+
agent_dict = json.loads(agent_resp.body)
679+
if agent_dict.get("system_instruction"):
680+
agent_config["instruction"] = (
681+
agent_dict["system_instruction"]
682+
)
683+
if agent_dict.get("description"):
684+
agent_config["description"] = (
685+
agent_dict["description"]
686+
)
687+
if agent_dict.get("base_agent"):
688+
agent_config["agent_type"] = (
689+
agent_dict["base_agent"]
690+
)
691+
if agent_dict.get("tools"):
692+
# Strip the ``type`` field from each tool
693+
# (e.g. "code_execution", "google_search")
694+
# since genai_types.Tool rejects it
695+
# (extra="forbid"), and filter out tools that
696+
# become empty (built-in tools without
697+
# function_declarations).
698+
cleaned_tools = []
699+
for tool in agent_dict["tools"]:
700+
if isinstance(tool, dict):
701+
tool = {
702+
k: v
703+
for k, v in tool.items()
704+
if k != "type"
705+
}
706+
if tool:
707+
cleaned_tools.append(tool)
708+
if cleaned_tools:
709+
agent_config["tools"] = cleaned_tools
710+
except Exception as e: # pylint: disable=broad-exception-caught
711+
logger.warning(
712+
"Failed to fetch agent config for '%s'"
713+
" (continuing without it): %s",
714+
agent_name,
715+
e,
716+
)
717+
agent_data["agents"] = {agent_id: agent_config}
718+
719+
# Merge consecutive text events and parts so multi-paragraph
720+
# responses render as a single block in the trace display.
721+
_merge_text_parts_in_agent_data(agent_data)
722+
723+
resolved_cases.append(types.EvalCase(agent_data=agent_data))
724+
725+
return resolved_cases
726+
727+
423728
def _resolve_dataset(
424729
api_client: BaseApiClient,
425730
dataset: Union[types.EvaluationRunDataSource, types.EvaluationDataset],
@@ -428,6 +733,15 @@ def _resolve_dataset(
428733
) -> types.EvaluationRunDataSource:
429734
"""Resolves dataset for the evaluation run."""
430735
if isinstance(dataset, types.EvaluationDataset):
736+
# Resolve EvalCases with interactions_data_source by fetching
737+
# each interaction and converting it to agent_data, then flowing
738+
# through the normal DataFrame/GCS pipeline.
739+
if dataset.eval_cases and _has_interactions_data_source(dataset.eval_cases):
740+
resolved_cases = _resolve_interactions_to_eval_cases(
741+
api_client, dataset.eval_cases
742+
)
743+
dataset = types.EvaluationDataset(eval_cases=resolved_cases)
744+
431745
candidate_name = _get_candidate_name(dataset, parsed_agent_info)
432746
eval_df = dataset.eval_dataset_df
433747
if eval_df is None and dataset.eval_cases:

0 commit comments

Comments
 (0)