@@ -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+
423728def _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