Skip to content

Commit 86c1a35

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
chore: Add helper functions to support Interaction-to-AgentData conversion
PiperOrigin-RevId: 947098781
1 parent 89d2b91 commit 86c1a35

2 files changed

Lines changed: 723 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 348 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
@@ -575,6 +580,349 @@ def _is_gemini_agent_resource(agent: str) -> bool:
575580
)
576581

577582

583+
def _step_to_agent_event(step: Any) -> Optional[types.evals.AgentEvent]:
584+
"""Converts a typed GenAI SDK Interaction step to an AgentEvent.
585+
586+
Uses ``isinstance`` checks against the GenAI SDK step classes so that
587+
attribute access stays in sync with SDK/proto changes.
588+
589+
Args:
590+
step: A step from ``Interaction.steps`` (a GenAI SDK step type).
591+
592+
Returns:
593+
An AgentEvent, or ``None`` if the step type is not handled.
594+
"""
595+
if isinstance(step, userinputstep.UserInputStep):
596+
return _text_step_to_event(step, author="user", role="user")
597+
elif isinstance(step, modeloutputstep.ModelOutputStep):
598+
return _text_step_to_event(step, author="agent", role="model")
599+
elif isinstance(step, functioncallstep.FunctionCallStep):
600+
return _function_call_step_to_event(step)
601+
elif isinstance(step, functionresultstep.FunctionResultStep):
602+
return _function_response_step_to_event(step)
603+
else:
604+
logger.info("Skipping unhandled interaction step type: %s", type(step).__name__)
605+
return None
606+
607+
608+
def _function_response_step_to_event(
609+
step: functionresultstep.FunctionResultStep,
610+
) -> types.evals.AgentEvent:
611+
"""Converts a FunctionResultStep to an AgentEvent."""
612+
result = step.result
613+
if isinstance(result, dict):
614+
result_str = json.dumps(result)
615+
elif isinstance(result, str):
616+
result_str = result
617+
else:
618+
result_str = str(result) if result is not None else ""
619+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
620+
author="user",
621+
content=genai_types.Content(
622+
role="user",
623+
parts=[
624+
genai_types.Part(
625+
function_response=genai_types.FunctionResponse(
626+
name=step.name or "",
627+
response={"result": result_str},
628+
id=step.call_id or "",
629+
)
630+
)
631+
],
632+
),
633+
)
634+
635+
636+
def _function_call_step_to_event(
637+
step: functioncallstep.FunctionCallStep,
638+
) -> types.evals.AgentEvent:
639+
"""Converts a FunctionCallStep to an AgentEvent."""
640+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
641+
author="agent",
642+
content=genai_types.Content(
643+
role="model",
644+
parts=[
645+
genai_types.Part(
646+
function_call=genai_types.FunctionCall(
647+
name=step.name or "",
648+
args=step.arguments or {},
649+
id=step.id or "",
650+
)
651+
)
652+
],
653+
),
654+
)
655+
656+
657+
def _text_step_to_event(
658+
step: Any, *, author: str, role: str
659+
) -> Optional[types.evals.AgentEvent]:
660+
"""Converts a text-bearing step (UserInputStep / ModelOutputStep) to an AgentEvent.
661+
662+
Args:
663+
step: A GenAI SDK step with a ``content`` attribute.
664+
author: The event author (``"user"`` or ``"agent"``).
665+
role: The content role (``"user"`` or ``"model"``).
666+
667+
Returns:
668+
An AgentEvent, or ``None`` if no text parts were found.
669+
"""
670+
parts = []
671+
for content_item in step.content or []:
672+
if getattr(content_item, "text", None):
673+
parts.append(genai_types.Part(text=content_item.text))
674+
if not parts:
675+
return None
676+
return types.evals.AgentEvent( # pytype: disable=missing-parameter
677+
author=author,
678+
content=genai_types.Content(role=role, parts=parts),
679+
)
680+
681+
682+
def _interaction_steps_to_events(
683+
steps: list[Any],
684+
) -> list[tuple[types.evals.AgentEvent, type]]:
685+
"""Converts a list of typed Interaction steps to AgentEvents.
686+
687+
Each step is mapped via ``_step_to_agent_event``. Steps whose type is
688+
not handled are skipped with a log message. The originating SDK step
689+
class is returned alongside each event so callers can determine turn
690+
boundaries without inspecting event content.
691+
692+
Args:
693+
steps: The ``steps`` list from a GenAI SDK ``Interaction`` object.
694+
695+
Returns:
696+
A list of ``(AgentEvent, step_class)`` tuples.
697+
"""
698+
events: list[tuple[types.evals.AgentEvent, type]] = []
699+
for step in steps:
700+
event = _step_to_agent_event(step)
701+
if event is not None:
702+
events.append((event, type(step)))
703+
return events
704+
705+
706+
def _interaction_dict_to_agent_data(
707+
interaction: dict[str, Any],
708+
) -> types.evals.AgentData:
709+
"""Converts an Interaction API JSON response to an AgentData object.
710+
711+
Parses the raw dict into a typed ``Interaction`` object (from the GenAI
712+
SDK) so that step conversion uses ``isinstance`` checks and typed
713+
attribute access. Steps are grouped into ConversationTurns -- each
714+
``UserInputStep`` starts a new turn, so multi-turn conversations
715+
produce multiple turns.
716+
717+
Args:
718+
interaction: A dict from the Interactions API GET response.
719+
720+
Returns:
721+
An AgentData object with one or more ConversationTurns.
722+
"""
723+
typed_interaction = interaction_types.Interaction.model_validate(interaction)
724+
all_events = _interaction_steps_to_events(typed_interaction.steps or [])
725+
726+
# Group events into turns. Each UserInputStep starts a new turn.
727+
grouped: list[list[types.evals.AgentEvent]] = []
728+
for event, step_type in all_events:
729+
if not grouped or step_type is userinputstep.UserInputStep:
730+
grouped.append([])
731+
grouped[-1].append(event)
732+
733+
if not grouped:
734+
return types.evals.AgentData( # pytype: disable=missing-parameter
735+
turns=[
736+
types.evals.ConversationTurn( # pytype: disable=missing-parameter
737+
turn_index=0, events=[]
738+
)
739+
]
740+
)
741+
return types.evals.AgentData( # pytype: disable=missing-parameter
742+
turns=[
743+
types.evals.ConversationTurn( # pytype: disable=missing-parameter
744+
turn_index=i, events=events
745+
)
746+
for i, events in enumerate(grouped)
747+
]
748+
)
749+
750+
751+
def _merge_text_parts_in_agent_data(
752+
agent_data: types.evals.AgentData,
753+
) -> None:
754+
"""Merges consecutive text events and parts for cleaner trace display.
755+
756+
The Interaction API may return multiple consecutive ``model_output``
757+
steps (one per paragraph) and/or multiple text content items within a
758+
single step. ``_interaction_dict_to_agent_data`` maps each step to a
759+
separate event, and each content item to a separate ``part``, causing
760+
the trace renderer to display them as separate visual blocks.
761+
762+
This function performs two merges:
763+
764+
1. **Event merge** -- consecutive events from the same author that
765+
contain only text parts are collapsed into a single event.
766+
2. **Part merge** -- within each (possibly merged) event, consecutive
767+
text-only parts are collapsed into a single part.
768+
769+
Mutates ``agent_data`` in place.
770+
771+
Args:
772+
agent_data: An AgentData object to merge in place.
773+
"""
774+
for turn in agent_data.turns or []:
775+
events = turn.events
776+
if not events:
777+
continue
778+
779+
# --- Pass 1: merge consecutive text-only events from the same author ---
780+
merged_events: list[types.evals.AgentEvent] = []
781+
for event in events:
782+
parts = (event.content.parts if event.content else None) or []
783+
is_text_only = parts and all(
784+
p.text is not None
785+
and p.function_call is None
786+
and p.function_response is None
787+
for p in parts
788+
)
789+
if (
790+
merged_events
791+
and is_text_only
792+
and event.author == merged_events[-1].author
793+
):
794+
prev_content = merged_events[-1].content
795+
prev_parts = (prev_content.parts if prev_content else None) or []
796+
prev_parts.extend(parts)
797+
continue
798+
merged_events.append(event)
799+
turn.events = merged_events
800+
801+
# --- Pass 2: merge consecutive text parts within each event ---
802+
for event in turn.events:
803+
content = event.content
804+
if not content:
805+
continue
806+
parts = content.parts
807+
if not parts or len(parts) <= 1:
808+
continue
809+
merged_parts: list[genai_types.Part] = []
810+
text_buffer: list[str] = []
811+
for part in parts:
812+
if (
813+
part.text is not None
814+
and part.function_call is None
815+
and part.function_response is None
816+
):
817+
text_buffer.append(part.text)
818+
else:
819+
if text_buffer:
820+
merged_parts.append(
821+
genai_types.Part(text="\n".join(text_buffer))
822+
)
823+
text_buffer = []
824+
merged_parts.append(part)
825+
if text_buffer:
826+
merged_parts.append(genai_types.Part(text="\n".join(text_buffer)))
827+
content.parts = merged_parts
828+
829+
830+
def _agent_tools_to_config_tools(
831+
agent_tools: Optional[list[Any]],
832+
) -> Optional[list[genai_types.Tool]]:
833+
"""Maps Gemini Agents API tools to ``genai_types.Tool`` for an AgentConfig.
834+
835+
The Gemini Agents API returns built-in tool variants (``google_search``,
836+
``code_execution``, ``url_context``) whose schema differs from
837+
``genai_types.Tool``. Each recognised built-in variant is mapped to the
838+
matching ``genai_types.Tool`` field. Tools with a non-empty body after
839+
stripping the ``type`` key (e.g. ``function_declarations``) are passed
840+
through ``model_validate``. Variants without a ``genai_types.Tool``
841+
equivalent (e.g. ``filesystem``, ``mcp_server``) are skipped.
842+
843+
Args:
844+
agent_tools: The ``tools`` list from a fetched Gemini agent dict.
845+
846+
Returns:
847+
A list of ``genai_types.Tool``, or ``None`` if there are no mappable
848+
tools.
849+
"""
850+
if not agent_tools:
851+
return None
852+
tools: list[genai_types.Tool] = []
853+
for tool in agent_tools:
854+
if not isinstance(tool, dict):
855+
continue
856+
tool_type = tool.get("type")
857+
if tool_type == "google_search":
858+
tools.append(genai_types.Tool(google_search=genai_types.GoogleSearch()))
859+
elif tool_type == "code_execution":
860+
tools.append(
861+
genai_types.Tool(code_execution=genai_types.ToolCodeExecution())
862+
)
863+
elif tool_type == "url_context":
864+
tools.append(genai_types.Tool(url_context=genai_types.UrlContext()))
865+
else:
866+
# For non-built-in tools (e.g. function_declarations), strip the
867+
# type key and validate through genai_types.Tool.
868+
remainder = {k: v for k, v in tool.items() if k != "type"}
869+
if remainder:
870+
tools.append(genai_types.Tool.model_validate(remainder))
871+
return tools or None
872+
873+
874+
def _fetch_agent_config_dict(
875+
api_client: BaseApiClient,
876+
agent_resource_name: str,
877+
) -> types.evals.AgentConfig:
878+
"""Fetches an agent's config from the Agent API and returns an AgentConfig.
879+
880+
Fetches the Agent resource via ``GET agents/{id}`` and extracts the
881+
system instruction, description, base agent type, and tools. Built-in
882+
tools (``google_search``, ``code_execution``, ``url_context``) are mapped
883+
to their ``genai_types.Tool`` equivalents via
884+
``_agent_tools_to_config_tools``.
885+
886+
Args:
887+
api_client: The API client used to fetch the agent.
888+
agent_resource_name: Full resource name of the agent, e.g.
889+
``projects/p/locations/l/agents/my-agent``.
890+
891+
Returns:
892+
An AgentConfig with ``agent_id`` and, when available,
893+
``instruction``, ``description``, ``agent_type``, and ``tools``.
894+
"""
895+
agent_short_id = agent_resource_name.split("/")[-1] or "agent"
896+
897+
instruction: Optional[str] = None
898+
description: Optional[str] = None
899+
agent_type: Optional[str] = None
900+
tools: Optional[list[genai_types.Tool]] = None
901+
902+
try:
903+
agent_resp = api_client.request("get", f"agents/{agent_short_id}", {}, None)
904+
if agent_resp.body:
905+
agent_dict = json.loads(agent_resp.body)
906+
instruction = agent_dict.get("system_instruction") or None
907+
description = agent_dict.get("description") or None
908+
agent_type = agent_dict.get("base_agent") or None
909+
tools = _agent_tools_to_config_tools(agent_dict.get("tools"))
910+
except Exception as e: # pylint: disable=broad-exception-caught
911+
logger.warning(
912+
"Failed to fetch agent config for '%s' (continuing without it): %s",
913+
agent_resource_name,
914+
e,
915+
)
916+
917+
return types.evals.AgentConfig( # pytype: disable=missing-parameter
918+
agent_id=agent_short_id,
919+
instruction=instruction,
920+
description=description,
921+
agent_type=agent_type,
922+
tools=tools,
923+
)
924+
925+
578926
def _add_evaluation_run_labels(
579927
labels: Optional[dict[str, str]] = None,
580928
agent: Optional[str] = None,

0 commit comments

Comments
 (0)