Skip to content

Commit b8adcbb

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

2 files changed

Lines changed: 687 additions & 0 deletions

File tree

agentplatform/_genai/_evals_common.py

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

0 commit comments

Comments
 (0)