Skip to content

Commit a1aa137

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

2 files changed

Lines changed: 555 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
import agentplatform
3333
from google.genai import types as genai_types
3434
from google.genai._api_client import BaseApiClient
35+
from google.genai._gaos.types.interactions import interaction as interaction_types
36+
from google.genai._gaos.types.interactions import functioncallstep
37+
from google.genai._gaos.types.interactions import functionresultstep
38+
from google.genai._gaos.types.interactions import modeloutputstep
39+
from google.genai._gaos.types.interactions import userinputstep
3540
from google.genai.models import Models
3641
import pandas as pd
3742
from tqdm import tqdm
@@ -420,6 +425,326 @@ def _extract_response_from_completed_trace(
420425
return event_dicts
421426

422427

428+
def _function_response_step_to_event(
429+
step: functionresultstep.FunctionResultStep,
430+
) -> types.evals.AgentEvent:
431+
"""Converts a FunctionResultStep to an AgentEvent."""
432+
result = step.result
433+
if isinstance(result, dict):
434+
result_str = json.dumps(result)
435+
elif isinstance(result, str):
436+
result_str = result
437+
else:
438+
result_str = str(result) if result is not None else ""
439+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
440+
author="user",
441+
content=genai_types.Content(
442+
role="user",
443+
parts=[
444+
genai_types.Part(
445+
function_response=genai_types.FunctionResponse(
446+
name=step.name or "",
447+
response={"result": result_str},
448+
id=step.call_id or "",
449+
)
450+
)
451+
],
452+
),
453+
)
454+
455+
456+
def _function_call_step_to_event(
457+
step: functioncallstep.FunctionCallStep,
458+
) -> types.evals.AgentEvent:
459+
"""Converts a FunctionCallStep to an AgentEvent."""
460+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
461+
author="agent",
462+
content=genai_types.Content(
463+
role="model",
464+
parts=[
465+
genai_types.Part(
466+
function_call=genai_types.FunctionCall(
467+
name=step.name or "",
468+
args=step.arguments or {},
469+
id=step.id or "",
470+
)
471+
)
472+
],
473+
),
474+
)
475+
476+
477+
def _text_step_to_event(
478+
step: Any, *, author: str, role: str
479+
) -> Optional[types.evals.AgentEvent]:
480+
"""Converts a text-bearing step (UserInputStep / ModelOutputStep) to an AgentEvent."""
481+
parts = []
482+
for content_item in step.content or []:
483+
if getattr(content_item, "text", None):
484+
parts.append(genai_types.Part(text=content_item.text))
485+
if not parts:
486+
return None
487+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
488+
author=author,
489+
content=genai_types.Content(role=role, parts=parts),
490+
)
491+
492+
493+
def _step_to_agent_event(step: Any) -> Optional[types.evals.AgentEvent]:
494+
"""Converts a typed GenAI SDK Interaction step to an AgentEvent."""
495+
if isinstance(step, userinputstep.UserInputStep):
496+
return _text_step_to_event(step, author="user", role="user")
497+
elif isinstance(step, modeloutputstep.ModelOutputStep):
498+
return _text_step_to_event(step, author="agent", role="model")
499+
elif isinstance(step, functioncallstep.FunctionCallStep):
500+
return _function_call_step_to_event(step)
501+
elif isinstance(step, functionresultstep.FunctionResultStep):
502+
return _function_response_step_to_event(step)
503+
else:
504+
logger.info(
505+
"Skipping unhandled interaction step type: %s", type(step).__name__
506+
)
507+
return None
508+
509+
510+
def _interaction_steps_to_events(
511+
steps: list[Any],
512+
) -> list[tuple[types.evals.AgentEvent, type]]:
513+
"""Converts a list of typed Interaction steps to AgentEvents."""
514+
events: list[tuple[types.evals.AgentEvent, type]] = []
515+
for step in steps:
516+
event = _step_to_agent_event(step)
517+
if event is not None:
518+
events.append((event, type(step)))
519+
return events
520+
521+
522+
def _interaction_dict_to_agent_data(
523+
interaction: dict[str, Any],
524+
) -> types.evals.AgentData:
525+
"""Converts an Interaction API JSON response to an AgentData object.
526+
527+
Parses the raw dict into a typed Interaction object, then groups steps
528+
into ConversationTurns (each UserInputStep starts a new turn).
529+
"""
530+
typed_interaction = interaction_types.Interaction.model_validate(interaction)
531+
all_events = _interaction_steps_to_events(typed_interaction.steps or [])
532+
533+
grouped: list[list[types.evals.AgentEvent]] = []
534+
for event, step_type in all_events:
535+
if not grouped or step_type is userinputstep.UserInputStep:
536+
grouped.append([])
537+
grouped[-1].append(event)
538+
539+
if not grouped:
540+
return types.evals.AgentData( # pytype: disable=missing-parameter
541+
turns=[types.evals.ConversationTurn( # pytype: disable=missing-parameter
542+
turn_index=0, events=[]
543+
)]
544+
)
545+
return types.evals.AgentData( # pytype: disable=missing-parameter
546+
turns=[
547+
types.evals.ConversationTurn( # pytype: disable=missing-parameter
548+
turn_index=i, events=events
549+
)
550+
for i, events in enumerate(grouped)
551+
]
552+
)
553+
554+
555+
def _merge_text_parts_in_agent_data(
556+
agent_data: types.evals.AgentData,
557+
) -> None:
558+
"""Merges consecutive same-author text events and text parts in place."""
559+
for turn in agent_data.turns or []:
560+
events = turn.events
561+
if not events:
562+
continue
563+
564+
# Pass 1: merge consecutive text-only events from the same author.
565+
merged_events: list[types.evals.AgentEvent] = []
566+
for event in events:
567+
parts = (event.content.parts if event.content else None) or []
568+
is_text_only = parts and all(
569+
p.text is not None
570+
and p.function_call is None
571+
and p.function_response is None
572+
for p in parts
573+
)
574+
if (
575+
merged_events
576+
and is_text_only
577+
and event.author == merged_events[-1].author
578+
):
579+
prev_content = merged_events[-1].content
580+
prev_parts = (prev_content.parts if prev_content else None) or []
581+
prev_parts.extend(parts)
582+
continue
583+
merged_events.append(event)
584+
turn.events = merged_events
585+
586+
# Pass 2: merge consecutive text parts within each event.
587+
for event in turn.events:
588+
content = event.content
589+
if not content:
590+
continue
591+
parts = content.parts
592+
if not parts or len(parts) <= 1:
593+
continue
594+
merged_parts: list[genai_types.Part] = []
595+
text_buffer: list[str] = []
596+
for part in parts:
597+
if (
598+
part.text is not None
599+
and part.function_call is None
600+
and part.function_response is None
601+
):
602+
text_buffer.append(part.text)
603+
else:
604+
if text_buffer:
605+
merged_parts.append(
606+
genai_types.Part(text="\n".join(text_buffer))
607+
)
608+
text_buffer = []
609+
merged_parts.append(part)
610+
if text_buffer:
611+
merged_parts.append(genai_types.Part(text="\n".join(text_buffer)))
612+
content.parts = merged_parts
613+
614+
615+
def _fetch_agent_config_dict(
616+
api_client: BaseApiClient,
617+
agent_resource_name: str,
618+
) -> types.evals.AgentConfig:
619+
"""Fetches an agent's config from the Agent API and returns an AgentConfig."""
620+
agent_short_id = agent_resource_name.split("/")[-1] or "agent"
621+
622+
instruction: Optional[str] = None
623+
description: Optional[str] = None
624+
agent_type: Optional[str] = None
625+
tools: Optional[list[genai_types.Tool]] = None
626+
627+
try:
628+
agent_resp = api_client.request("get", f"agents/{agent_short_id}", {}, None)
629+
if agent_resp.body:
630+
agent_dict = json.loads(agent_resp.body)
631+
instruction = agent_dict.get("system_instruction") or None
632+
description = agent_dict.get("description") or None
633+
agent_type = agent_dict.get("base_agent") or None
634+
if agent_dict.get("tools"):
635+
cleaned_tools = []
636+
for tool in agent_dict["tools"]:
637+
if isinstance(tool, dict):
638+
tool = {k: v for k, v in tool.items() if k != "type"}
639+
if tool:
640+
cleaned_tools.append(genai_types.Tool.model_validate(tool))
641+
if cleaned_tools:
642+
tools = cleaned_tools
643+
except Exception as e: # pylint: disable=broad-exception-caught
644+
logger.warning(
645+
"Failed to fetch agent config for '%s' (continuing without it): %s",
646+
agent_resource_name,
647+
e,
648+
)
649+
650+
return types.evals.AgentConfig( # pytype: disable=missing-parameter
651+
agent_id=agent_short_id,
652+
instruction=instruction,
653+
description=description,
654+
agent_type=agent_type,
655+
tools=tools,
656+
)
657+
658+
659+
def _has_interactions_data_source(
660+
eval_cases: list[types.EvalCase],
661+
) -> bool:
662+
"""Returns True if any EvalCase has interactions_data_source set."""
663+
return any(
664+
case.interactions_data_source is not None
665+
for case in eval_cases
666+
)
667+
668+
669+
def _resolve_interactions_to_eval_cases(
670+
api_client: BaseApiClient,
671+
eval_cases: list[types.EvalCase],
672+
) -> list[types.EvalCase]:
673+
"""Resolves EvalCases with interactions_data_source to agent_data.
674+
675+
For each EvalCase that has interactions_data_source set, fetches the
676+
Interaction via the SDK's interactions.get() API, converts the steps
677+
to AgentData, and returns a new EvalCase with agent_data populated.
678+
679+
Args:
680+
api_client: The API client (must have an interactions module).
681+
eval_cases: EvalCases with interactions_data_source set.
682+
683+
Returns:
684+
New list of EvalCases with agent_data populated from resolved
685+
interactions.
686+
687+
Raises:
688+
ValueError: If eval_cases have missing interaction references.
689+
"""
690+
# Validate all cases up front before making any API calls.
691+
for case in eval_cases:
692+
ids = case.interactions_data_source
693+
if ids is None:
694+
raise ValueError(
695+
"All eval_cases must have interactions_data_source set when"
696+
" using interaction resolution. Found a case without it. Do"
697+
" not mix interaction-based and prompt-based eval cases."
698+
)
699+
if not ids.interaction:
700+
raise ValueError(
701+
"interactions_data_source.interaction is required. Each"
702+
" EvalCase must reference an existing Interaction resource."
703+
)
704+
705+
resolved_cases = []
706+
707+
for case in eval_cases:
708+
ids = case.interactions_data_source
709+
710+
# Extract the interaction short ID from the resource name.
711+
# Handles both full resource names (projects/.../interactions/{id})
712+
# and bare IDs by always taking the last path component.
713+
interaction_id = ids.interaction.split("/")[-1]
714+
715+
logger.info("Fetching interaction: %s", ids.interaction)
716+
path = f"interactions/{interaction_id}"
717+
response = api_client.request("get", path, {}, None)
718+
interaction_dict = (
719+
{} if not response.body else json.loads(response.body)
720+
)
721+
722+
agent_data = _interaction_dict_to_agent_data(interaction_dict)
723+
724+
# Best-effort: fetch the agent config (instruction, tools,
725+
# description) from the Agent API so the display can render
726+
# the System Topology section.
727+
gemini_cfg = ids.gemini_agent_config
728+
agent_name = gemini_cfg.gemini_agent if gemini_cfg else None
729+
agent_config = _fetch_agent_config_dict(
730+
api_client, agent_name or ""
731+
)
732+
agent_data.agents = {agent_config.agent_id: agent_config}
733+
734+
# Merge consecutive text events and parts so multi-paragraph
735+
# responses render as a single block in the trace display.
736+
_merge_text_parts_in_agent_data(agent_data)
737+
738+
# Preserve all original EvalCase fields; only update agent_data
739+
# and clear the now-resolved interactions_data_source.
740+
resolved_cases.append(case.model_copy(update={
741+
"agent_data": agent_data,
742+
"interactions_data_source": None,
743+
}))
744+
745+
return resolved_cases
746+
747+
423748
def _resolve_dataset(
424749
api_client: BaseApiClient,
425750
dataset: Union[types.EvaluationRunDataSource, types.EvaluationDataset],
@@ -428,6 +753,15 @@ def _resolve_dataset(
428753
) -> types.EvaluationRunDataSource:
429754
"""Resolves dataset for the evaluation run."""
430755
if isinstance(dataset, types.EvaluationDataset):
756+
# Resolve EvalCases with interactions_data_source by fetching
757+
# each interaction and converting it to agent_data, then flowing
758+
# through the normal DataFrame/GCS pipeline.
759+
if dataset.eval_cases and _has_interactions_data_source(dataset.eval_cases):
760+
resolved_cases = _resolve_interactions_to_eval_cases(
761+
api_client, dataset.eval_cases
762+
)
763+
dataset = types.EvaluationDataset(eval_cases=resolved_cases)
764+
431765
candidate_name = _get_candidate_name(dataset, parsed_agent_info)
432766
eval_df = dataset.eval_dataset_df
433767
if eval_df is None and dataset.eval_cases:

0 commit comments

Comments
 (0)