3232import agentplatform
3333from google .genai import types as genai_types
3434from 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
3540from google .genai .models import Models
3641import pandas as pd
3742from 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+
423748def _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