Skip to content

Commit 32fbf4d

Browse files
jsondaicopybara-github
authored andcommitted
feat: GenAI Client(evals) - run_inference support for Gemini Agents API agents
PiperOrigin-RevId: 945262438
1 parent f6ecd67 commit 32fbf4d

6 files changed

Lines changed: 540 additions & 5 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 300 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
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.google_genai import GeminiNextGenInteractions
3536
from google.genai.models import Models
3637
import pandas as pd
3738
from tqdm import tqdm
@@ -575,6 +576,269 @@ def _is_gemini_agent_resource(agent: str) -> bool:
575576
)
576577

577578

579+
def _get_resolved_location(api_client: Any) -> Optional[str]:
580+
"""Returns the location configured on the API client."""
581+
return getattr(api_client, "location", None)
582+
583+
584+
def _get_interactions_client(api_client: BaseApiClient) -> Any:
585+
"""Returns a google.genai interactions client bound to `api_client`.
586+
587+
The interactions client is constructed around the SDK's existing
588+
`api_client` (a `BaseApiClient`, or a `ReplayApiClient` in tests) so that
589+
replay recording captures the interaction calls. This mirrors how
590+
`google.genai.client.Client.interactions` is built internally.
591+
592+
Args:
593+
api_client: The API client to bind the interactions client to.
594+
595+
Returns:
596+
A google.genai interactions client.
597+
"""
598+
return GeminiNextGenInteractions(api_client)
599+
600+
601+
def _interaction_to_agent_data(
602+
gemini_agent: str, prompt: str, interaction: Any
603+
) -> dict[str, Any]:
604+
"""Maps a Gemini Agents interaction into the canonical AgentData schema.
605+
606+
Populates the AgentConfig for the agent plus a single ConversationTurn with
607+
the user input event and the final model output event. The interaction's
608+
full step trajectory is not expanded event-by-event; the response text is
609+
used as the model output content.
610+
611+
Args:
612+
gemini_agent: The Gemini Agents API agent resource name.
613+
prompt: The user prompt for the interaction.
614+
interaction: The interaction returned by the Interactions API.
615+
616+
Returns:
617+
A dict representation of the AgentData for the interaction.
618+
"""
619+
agent_id = gemini_agent.split("/")[-1]
620+
agents = {
621+
agent_id: types.evals.AgentConfig( # pytype: disable=missing-parameter
622+
agent_id=agent_id,
623+
)
624+
}
625+
events = [
626+
types.evals.AgentEvent(
627+
author=_evals_constant.USER_AUTHOR,
628+
content=genai_types.Content(
629+
role=_evals_constant.USER_AUTHOR,
630+
parts=[genai_types.Part(text=prompt)],
631+
),
632+
)
633+
]
634+
output_text = getattr(interaction, "output_text", None)
635+
if output_text:
636+
events.append(
637+
types.evals.AgentEvent(
638+
author=agent_id,
639+
content=genai_types.Content(
640+
role=_evals_constant.MODEL_AUTHOR,
641+
parts=[genai_types.Part(text=output_text)],
642+
),
643+
)
644+
)
645+
turns = [
646+
types.evals.ConversationTurn(
647+
turn_index=0,
648+
turn_id=getattr(interaction, "id", None) or "turn_0",
649+
events=events,
650+
)
651+
]
652+
return types.evals.AgentData(
653+
agents=agents,
654+
turns=turns,
655+
).model_dump(mode="json", exclude_none=True)
656+
657+
658+
_INTERACTION_TERMINAL_STATES = frozenset(
659+
["completed", "failed", "cancelled", "incomplete", "budget_exceeded"]
660+
)
661+
662+
663+
def _await_interaction(
664+
interactions_client: Any,
665+
interaction: Any,
666+
poll_interval_seconds: float = 2.0,
667+
timeout_seconds: float = 600.0,
668+
) -> Any:
669+
"""Polls a background interaction until it reaches a terminal state.
670+
671+
Gemini agent interactions must run in the background (`background=True`), so
672+
`create` returns before the model output is ready. This polls
673+
`interactions.get` until the interaction reaches a terminal state and then
674+
returns the resolved interaction.
675+
676+
Args:
677+
interactions_client: The interactions client used to poll.
678+
interaction: The interaction returned by `create`.
679+
poll_interval_seconds: Delay between poll attempts.
680+
timeout_seconds: Maximum time to wait before raising.
681+
682+
Returns:
683+
The resolved interaction once it reaches a terminal state.
684+
685+
Raises:
686+
TimeoutError: If the interaction does not complete within the timeout.
687+
"""
688+
if getattr(interaction, "status", None) in _INTERACTION_TERMINAL_STATES:
689+
return interaction
690+
interaction_id = getattr(interaction, "id", None)
691+
deadline = time.monotonic() + timeout_seconds
692+
while time.monotonic() < deadline:
693+
time.sleep(poll_interval_seconds)
694+
interaction = interactions_client.get(interaction_id)
695+
if getattr(interaction, "status", None) in _INTERACTION_TERMINAL_STATES:
696+
return interaction
697+
raise TimeoutError(
698+
f"Interaction {interaction_id} did not complete within"
699+
f" {timeout_seconds} seconds."
700+
)
701+
702+
703+
def _run_gemini_agent_inference(
704+
*,
705+
api_client: BaseApiClient,
706+
gemini_agent: str,
707+
prompt_dataset: pd.DataFrame,
708+
) -> pd.DataFrame:
709+
"""Runs inference against a Gemini Agents API agent via the Interactions API.
710+
711+
For each prompt row, creates an interaction against `gemini_agent` and
712+
collects the interaction id, response text, and agent data.
713+
714+
Args:
715+
api_client: The API client used to issue interaction calls.
716+
gemini_agent: The Gemini Agents API agent resource name.
717+
prompt_dataset: The prompt DataFrame. The prompt is read from the
718+
`request` column if present, otherwise from the `prompt` column.
719+
720+
Returns:
721+
A DataFrame with columns prompt, response, interaction_id, agent_data.
722+
"""
723+
prompt_column = (
724+
"request" if "request" in prompt_dataset.columns else _evals_constant.PROMPT
725+
)
726+
if prompt_column not in prompt_dataset.columns:
727+
raise ValueError(
728+
"The eval dataset provided for Gemini agent inference must contain a"
729+
f" '{_evals_constant.PROMPT}' or 'request' column."
730+
)
731+
732+
interactions_client = _get_interactions_client(api_client)
733+
734+
prompts: list[str] = []
735+
responses: list[Optional[str]] = []
736+
interaction_ids: list[Optional[str]] = []
737+
agent_data: list[dict[str, Any]] = []
738+
for prompt in tqdm(
739+
prompt_dataset[prompt_column].tolist(), desc="Gemini Agent Inference"
740+
):
741+
interaction = interactions_client.create(
742+
agent=gemini_agent,
743+
input=prompt,
744+
store=True,
745+
background=True,
746+
)
747+
interaction = _await_interaction(interactions_client, interaction)
748+
prompts.append(prompt)
749+
responses.append(getattr(interaction, "output_text", None))
750+
interaction_ids.append(getattr(interaction, "id", None))
751+
agent_data.append(_interaction_to_agent_data(gemini_agent, prompt, interaction))
752+
753+
return pd.DataFrame(
754+
{
755+
_evals_constant.PROMPT: prompts,
756+
_evals_constant.RESPONSE: responses,
757+
_evals_constant.INTERACTION_ID: interaction_ids,
758+
_evals_constant.AGENT_DATA: agent_data,
759+
}
760+
)
761+
762+
763+
def _normalize_interaction_resource(
764+
interaction: str, agent: str, location: Optional[str]
765+
) -> str:
766+
"""Normalizes an interaction id into a full resource name.
767+
768+
A bare interaction id is expanded to
769+
`projects/{project}/locations/{location}/interactions/{id}` using the
770+
project and location parsed from the agent resource name. Fully-qualified
771+
interaction resource names are returned unchanged.
772+
"""
773+
if interaction.startswith("projects/"):
774+
return interaction
775+
parts = agent.split("/")
776+
project = parts[1]
777+
agent_location = parts[3] if len(parts) > 3 else (location or "global")
778+
return f"projects/{project}/locations/{agent_location}/interactions/{interaction}"
779+
780+
781+
def _build_interaction_id_dataset(
782+
loaded_data: list[dict[str, Any]],
783+
agent: Optional[str],
784+
location: Optional[str],
785+
) -> Optional[types.EvaluationDataset]:
786+
"""Builds an EvaluationDataset from rows that carry an `interaction_id`.
787+
788+
When the dataset contains an `interaction_id` column, each row is turned
789+
into an EvalCase whose `interactions_data_source` references the interaction
790+
and the Gemini agent. The backend resolves the interaction trace and agent
791+
config; no client-side prompt/response is required. Returns None if the
792+
data does not contain interaction ids.
793+
"""
794+
has_interaction_id = bool(loaded_data) and any(
795+
_evals_constant.INTERACTION_ID in row for row in loaded_data
796+
)
797+
if not has_interaction_id:
798+
if agent:
799+
raise ValueError(
800+
"An `agent` was provided but the dataset does not contain an"
801+
" `interaction_id` column. The `agent` argument is only used to"
802+
" resolve an `interaction_id` dataset column (so the backend can"
803+
" fetch the interaction trace and Agent config). To evaluate"
804+
" with an agent, provide a dataset with an `interaction_id`"
805+
" column; otherwise omit `agent`."
806+
)
807+
return None
808+
809+
if not agent:
810+
raise ValueError(
811+
"An `agent` resource name is required when the dataset contains an"
812+
" `interaction_id` column, so the backend can resolve the Agent"
813+
" config for each interaction."
814+
)
815+
if not _is_gemini_agent_resource(agent):
816+
raise ValueError(
817+
"`agent` must be a Gemini Agents API resource name of the form"
818+
" projects/{project}/locations/{location}/agents/{agent} when"
819+
" evaluating interaction ids. Got: %s" % agent
820+
)
821+
822+
gemini_agent_config = types.GeminiAgentConfig(gemini_agent=agent)
823+
eval_cases = []
824+
for i, row in enumerate(loaded_data):
825+
interaction = row.get(_evals_constant.INTERACTION_ID)
826+
if not interaction:
827+
raise ValueError("Missing `interaction_id` value for row %d." % i)
828+
eval_cases.append(
829+
types.EvalCase(
830+
eval_case_id="eval_case_%s" % i,
831+
interactions_data_source=types.InteractionsDataSource(
832+
interaction=_normalize_interaction_resource(
833+
str(interaction), agent, location
834+
),
835+
gemini_agent_config=gemini_agent_config,
836+
),
837+
)
838+
)
839+
return types.EvaluationDataset(eval_cases=eval_cases)
840+
841+
578842
def _add_evaluation_run_labels(
579843
labels: Optional[dict[str, str]] = None,
580844
agent: Optional[str] = None,
@@ -1406,6 +1670,7 @@ def _execute_inference(
14061670
model: Optional[Union[Callable[[Any], Any], str]] = None,
14071671
agent_engine: Optional[Union[str, types.AgentEngine]] = None,
14081672
agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
1673+
gemini_agent: Optional[str] = None,
14091674
dest: Optional[str] = None,
14101675
config: Optional[genai_types.GenerateContentConfig] = None,
14111676
prompt_template: Optional[Union[str, types.PromptTemplateOrDict]] = None,
@@ -1424,6 +1689,8 @@ def _execute_inference(
14241689
agent_engine: The agent engine to use for inference. Can be a resource
14251690
name string or an `AgentEngine` instance.
14261691
agent: The local agent to use for inference. Can be an ADK agent instance.
1692+
gemini_agent: The Gemini Agents API agent resource name to run inference
1693+
against via the Interactions API.
14271694
dest: The destination to save the inference results. Can be a string
14281695
representing a file path or a GCS URI.
14291696
config: The generation configuration for the model.
@@ -1441,9 +1708,10 @@ def _execute_inference(
14411708
if location:
14421709
api_client = _get_api_client_with_location(api_client, location)
14431710

1444-
if sum(x is not None for x in [model, agent_engine, agent]) != 1:
1711+
if sum(x is not None for x in [model, agent_engine, agent, gemini_agent]) != 1:
14451712
raise ValueError(
1446-
"Exactly one of model, agent_engine, or agent must be provided."
1713+
"Exactly one of model, agent_engine, agent, or gemini_agent must be"
1714+
" provided."
14471715
)
14481716

14491717
prompt_dataset = _load_dataframe(api_client, src)
@@ -1456,7 +1724,24 @@ def _execute_inference(
14561724

14571725
_apply_prompt_template(prompt_dataset, prompt_template)
14581726

1459-
if model:
1727+
if gemini_agent:
1728+
start_time = time.time()
1729+
logger.debug("Starting Gemini Agent inference process ...")
1730+
results_df = _run_gemini_agent_inference(
1731+
api_client=api_client,
1732+
gemini_agent=gemini_agent,
1733+
prompt_dataset=prompt_dataset,
1734+
)
1735+
end_time = time.time()
1736+
logger.info(
1737+
"Gemini Agent inference completed in %.2f seconds.",
1738+
end_time - start_time,
1739+
)
1740+
return types.EvaluationDataset(
1741+
eval_dataset_df=results_df,
1742+
candidate_name=gemini_agent.split("/")[-1],
1743+
)
1744+
elif model:
14601745
start_time = time.time()
14611746
logger.debug("Starting inference process ...")
14621747
results_df = _run_inference_internal(
@@ -1591,6 +1876,8 @@ def _resolve_dataset_inputs(
15911876
dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]],
15921877
loader: "_evals_utils.EvalDatasetLoader",
15931878
agent_info: Optional[types.evals.AgentInfo] = None,
1879+
agent: Optional[str] = None,
1880+
api_client: Any = None,
15941881
) -> tuple[types.EvaluationDataset, int]:
15951882
"""Loads and processes single or multiple datasets for evaluation.
15961883
@@ -1640,6 +1927,13 @@ def _resolve_dataset_inputs(
16401927
ds_source_for_loader = _get_dataset_source(ds_item)
16411928
current_loaded_data = loader.load(ds_source_for_loader)
16421929

1930+
interaction_dataset = _build_interaction_id_dataset(
1931+
current_loaded_data, agent, _get_resolved_location(api_client)
1932+
)
1933+
if interaction_dataset is not None:
1934+
parsed_evaluation_datasets.append(interaction_dataset)
1935+
continue
1936+
16431937
if dataset_schema:
16441938
current_schema = _evals_data_converters.EvalDatasetSchema(dataset_schema)
16451939
else:
@@ -1797,6 +2091,7 @@ def _execute_evaluation( # type: ignore[no-untyped-def]
17972091
api_client: Any,
17982092
dataset: Union[types.EvaluationDataset, list[types.EvaluationDataset]],
17992093
metrics: list[types.Metric],
2094+
agent: Optional[str] = None,
18002095
dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]] = None,
18012096
dest: Optional[str] = None,
18022097
location: Optional[str] = None,
@@ -1877,6 +2172,8 @@ def _execute_evaluation( # type: ignore[no-untyped-def]
18772172
dataset_schema=dataset_schema,
18782173
loader=loader,
18792174
agent_info=validated_agent_info,
2175+
agent=agent,
2176+
api_client=api_client,
18802177
)
18812178

18822179
resolved_metrics = _resolve_metrics(metrics, api_client)

agentplatform/_genai/_evals_constant.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@
5656
CONTENT = "content"
5757
PARTS = "parts"
5858
USER_AUTHOR = "user"
59+
MODEL_AUTHOR = "model"
5960
AGENT_DATA = "agent_data"
61+
INTERACTION_ID = "interaction_id"
6062
STARTING_PROMPT = "starting_prompt"
6163
CONVERSATION_PLAN = "conversation_plan"
6264
HISTORY = "history"
@@ -74,5 +76,6 @@
7476
STARTING_PROMPT,
7577
CONVERSATION_PLAN,
7678
AGENT_DATA,
79+
INTERACTION_ID,
7780
}
7881
)

0 commit comments

Comments
 (0)