Skip to content

Commit b908a74

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
chore: Add helper functions to support Interaction-to-AgentData conversion
PiperOrigin-RevId: 945250079
1 parent f5f750c commit b908a74

2 files changed

Lines changed: 685 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 313 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,314 @@ 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 _fetch_agent_config_dict(
831+
api_client: BaseApiClient,
832+
agent_resource_name: str,
833+
) -> types.evals.AgentConfig:
834+
"""Fetches an agent's config from the Agent API and returns an AgentConfig.
835+
836+
Fetches the Agent resource via ``GET agents/{id}``, extracts the
837+
system instruction, description, base agent type, and tools, and
838+
strips the ``type`` field from each tool (which ``genai_types.Tool``
839+
rejects with ``extra="forbid"``). Built-in tools that become empty
840+
after stripping ``type`` (e.g. ``code_execution``, ``google_search``)
841+
are filtered out.
842+
843+
Args:
844+
api_client: The API client used to fetch the agent.
845+
agent_resource_name: Full resource name of the agent, e.g.
846+
``projects/p/locations/l/agents/my-agent``.
847+
848+
Returns:
849+
An AgentConfig with ``agent_id`` and, when available,
850+
``instruction``, ``description``, ``agent_type``, and ``tools``.
851+
"""
852+
agent_short_id = agent_resource_name.split("/")[-1] or "agent"
853+
854+
instruction: Optional[str] = None
855+
description: Optional[str] = None
856+
agent_type: Optional[str] = None
857+
tools: Optional[list[genai_types.Tool]] = None
858+
859+
try:
860+
agent_resp = api_client.request("get", f"agents/{agent_short_id}", {}, None)
861+
if agent_resp.body:
862+
agent_dict = json.loads(agent_resp.body)
863+
instruction = agent_dict.get("system_instruction") or None
864+
description = agent_dict.get("description") or None
865+
agent_type = agent_dict.get("base_agent") or None
866+
if agent_dict.get("tools"):
867+
cleaned_tools = []
868+
for tool in agent_dict["tools"]:
869+
if isinstance(tool, dict):
870+
tool = {k: v for k, v in tool.items() if k != "type"}
871+
if tool:
872+
cleaned_tools.append(genai_types.Tool.model_validate(tool))
873+
if cleaned_tools:
874+
tools = cleaned_tools
875+
except Exception as e: # pylint: disable=broad-exception-caught
876+
logger.warning(
877+
"Failed to fetch agent config for '%s' (continuing without it): %s",
878+
agent_resource_name,
879+
e,
880+
)
881+
882+
return types.evals.AgentConfig( # pytype: disable=missing-parameter
883+
agent_id=agent_short_id,
884+
instruction=instruction,
885+
description=description,
886+
agent_type=agent_type,
887+
tools=tools,
888+
)
889+
890+
578891
def _add_evaluation_run_labels(
579892
labels: Optional[dict[str, str]] = None,
580893
agent: Optional[str] = None,

0 commit comments

Comments
 (0)