Skip to content

Commit 2d9b6c8

Browse files
jsondaicopybara-github
authored andcommitted
feat: GenAI Client(evals) - run_inference support for Gemini Agents API agents
PiperOrigin-RevId: 945262438
1 parent 239bc13 commit 2d9b6c8

5 files changed

Lines changed: 500 additions & 88 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 194 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,176 @@ def _get_resolved_location(api_client: Any) -> Optional[str]:
938938
return getattr(api_client, "location", None)
939939

940940

941+
class _InteractionsRestClient:
942+
"""Minimal Interactions API client issued through the SDK api_client.
943+
944+
Calls go through `api_client.request()` (rather than the google.genai
945+
`_gaos` client) so that the `ReplayApiClient` records and replays them.
946+
Requests and responses are plain dicts.
947+
"""
948+
949+
def __init__(self, api_client: BaseApiClient):
950+
self._api_client = api_client
951+
952+
def create(self, request_dict: dict[str, Any]) -> dict[str, Any]:
953+
response = self._api_client.request("post", "interactions", request_dict)
954+
return json.loads(response.body) if response.body else {}
955+
956+
def get(self, interaction_id: str) -> dict[str, Any]:
957+
response = self._api_client.request("get", f"interactions/{interaction_id}", {})
958+
return json.loads(response.body) if response.body else {}
959+
960+
961+
def _get_interactions_client(api_client: BaseApiClient) -> _InteractionsRestClient:
962+
"""Returns an Interactions API client bound to `api_client`.
963+
964+
The client issues calls through the SDK's existing `api_client` (a
965+
`BaseApiClient`, or a `ReplayApiClient` in tests) so that replay recording
966+
captures the interaction calls.
967+
968+
Args:
969+
api_client: The API client used to issue interaction calls.
970+
971+
Returns:
972+
An `_InteractionsRestClient`.
973+
"""
974+
return _InteractionsRestClient(api_client)
975+
976+
977+
def _agent_data_response_text(agent_data: types.evals.AgentData) -> Optional[str]:
978+
"""Concatenates the text of all model-role events in an AgentData."""
979+
text_parts: list[str] = []
980+
for turn in agent_data.turns or []:
981+
for event in turn.events or []:
982+
content = event.content
983+
if not content or content.role != _evals_constant.MODEL_AUTHOR:
984+
continue
985+
for part in content.parts or []:
986+
if part.text:
987+
text_parts.append(part.text)
988+
return "".join(text_parts) or None
989+
990+
991+
_INTERACTION_TERMINAL_STATES = frozenset(
992+
["completed", "failed", "cancelled", "incomplete", "budget_exceeded"]
993+
)
994+
995+
996+
def _await_interaction(
997+
interactions_client: "_InteractionsRestClient",
998+
interaction: dict[str, Any],
999+
poll_interval_seconds: float = 2.0,
1000+
timeout_seconds: float = 600.0,
1001+
) -> dict[str, Any]:
1002+
"""Polls a background interaction until it reaches a terminal state.
1003+
1004+
Gemini agent interactions must run in the background (`background=True`), so
1005+
`create` returns before the model output is ready. This polls
1006+
`interactions.get` until the interaction reaches a terminal state and then
1007+
returns the resolved interaction.
1008+
1009+
Args:
1010+
interactions_client: The interactions client used to poll.
1011+
interaction: The interaction returned by `create`.
1012+
poll_interval_seconds: Delay between poll attempts.
1013+
timeout_seconds: Maximum time to wait before raising.
1014+
1015+
Returns:
1016+
The resolved interaction once it reaches a terminal state.
1017+
1018+
Raises:
1019+
TimeoutError: If the interaction does not complete within the timeout.
1020+
"""
1021+
if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1022+
return interaction
1023+
interaction_id = interaction.get("id")
1024+
deadline = time.monotonic() + timeout_seconds
1025+
while time.monotonic() < deadline:
1026+
time.sleep(poll_interval_seconds)
1027+
interaction = interactions_client.get(interaction_id)
1028+
if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1029+
return interaction
1030+
raise TimeoutError(
1031+
f"Interaction {interaction_id} did not complete within"
1032+
f" {timeout_seconds} seconds."
1033+
)
1034+
1035+
1036+
def _run_gemini_agent_inference(
1037+
*,
1038+
api_client: BaseApiClient,
1039+
gemini_agent: str,
1040+
prompt_dataset: pd.DataFrame,
1041+
) -> pd.DataFrame:
1042+
"""Runs inference against a Gemini Agents API agent via the Interactions API.
1043+
1044+
For each prompt row, creates an interaction against `gemini_agent` and
1045+
collects the interaction id, response text, and agent data.
1046+
1047+
Args:
1048+
api_client: The API client used to issue interaction calls.
1049+
gemini_agent: The Gemini Agents API agent resource name.
1050+
prompt_dataset: The prompt DataFrame. The prompt is read from the
1051+
`request` column if present, otherwise from the `prompt` column.
1052+
1053+
Returns:
1054+
A DataFrame with columns prompt, response, interaction_id, agent_data.
1055+
"""
1056+
prompt_column = (
1057+
"request" if "request" in prompt_dataset.columns else _evals_constant.PROMPT
1058+
)
1059+
if prompt_column not in prompt_dataset.columns:
1060+
raise ValueError(
1061+
"The eval dataset provided for Gemini agent inference must contain a"
1062+
f" '{_evals_constant.PROMPT}' or 'request' column."
1063+
)
1064+
1065+
interactions_client = _get_interactions_client(api_client)
1066+
1067+
agent_short_id = gemini_agent.split("/")[-1]
1068+
prompts: list[str] = []
1069+
responses: list[Optional[str]] = []
1070+
interaction_ids: list[Optional[str]] = []
1071+
agent_data: list[dict[str, Any]] = []
1072+
for prompt in tqdm(
1073+
prompt_dataset[prompt_column].tolist(), desc="Gemini Agent Inference"
1074+
):
1075+
prompts.append(prompt)
1076+
try:
1077+
interaction = interactions_client.create(
1078+
{
1079+
"agent": agent_short_id,
1080+
"input": [{"type": "text", "text": prompt}],
1081+
"store": True,
1082+
"background": True,
1083+
}
1084+
)
1085+
interaction = _await_interaction(interactions_client, interaction)
1086+
agent_data_obj = _interaction_dict_to_agent_data(interaction)
1087+
_merge_text_parts_in_agent_data(agent_data_obj)
1088+
responses.append(_agent_data_response_text(agent_data_obj))
1089+
interaction_ids.append(interaction.get("id"))
1090+
agent_data.append(agent_data_obj.model_dump(mode="json", exclude_none=True))
1091+
except Exception as e: # pylint: disable=broad-exception-caught
1092+
logger.warning(
1093+
"Gemini agent inference failed for a prompt (recording an empty"
1094+
" row and continuing): %s",
1095+
e,
1096+
)
1097+
responses.append(None)
1098+
interaction_ids.append(None)
1099+
agent_data.append({})
1100+
1101+
return pd.DataFrame(
1102+
{
1103+
_evals_constant.PROMPT: prompts,
1104+
_evals_constant.RESPONSE: responses,
1105+
_evals_constant.INTERACTION_ID: interaction_ids,
1106+
_evals_constant.AGENT_DATA: agent_data,
1107+
}
1108+
)
1109+
1110+
9411111
def _normalize_interaction_resource(
9421112
interaction: str, agent: str, location: Optional[str]
9431113
) -> str:
@@ -1999,6 +2169,7 @@ def _execute_inference(
19992169
model: Optional[Union[Callable[[Any], Any], str]] = None,
20002170
agent_engine: Optional[Union[str, types.AgentEngine]] = None,
20012171
agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
2172+
gemini_agent: Optional[str] = None,
20022173
dest: Optional[str] = None,
20032174
config: Optional[genai_types.GenerateContentConfig] = None,
20042175
prompt_template: Optional[Union[str, types.PromptTemplateOrDict]] = None,
@@ -2017,6 +2188,8 @@ def _execute_inference(
20172188
agent_engine: The agent engine to use for inference. Can be a resource
20182189
name string or an `AgentEngine` instance.
20192190
agent: The local agent to use for inference. Can be an ADK agent instance.
2191+
gemini_agent: The Gemini Agents API agent resource name to run inference
2192+
against via the Interactions API.
20202193
dest: The destination to save the inference results. Can be a string
20212194
representing a file path or a GCS URI.
20222195
config: The generation configuration for the model.
@@ -2034,9 +2207,10 @@ def _execute_inference(
20342207
if location:
20352208
api_client = _get_api_client_with_location(api_client, location)
20362209

2037-
if sum(x is not None for x in [model, agent_engine, agent]) != 1:
2210+
if sum(x is not None for x in [model, agent_engine, agent, gemini_agent]) != 1:
20382211
raise ValueError(
2039-
"Exactly one of model, agent_engine, or agent must be provided."
2212+
"Exactly one of model, agent_engine, agent, or gemini_agent must be"
2213+
" provided."
20402214
)
20412215

20422216
prompt_dataset = _load_dataframe(api_client, src)
@@ -2049,7 +2223,24 @@ def _execute_inference(
20492223

20502224
_apply_prompt_template(prompt_dataset, prompt_template)
20512225

2052-
if model:
2226+
if gemini_agent:
2227+
start_time = time.time()
2228+
logger.debug("Starting Gemini Agent inference process ...")
2229+
results_df = _run_gemini_agent_inference(
2230+
api_client=api_client,
2231+
gemini_agent=gemini_agent,
2232+
prompt_dataset=prompt_dataset,
2233+
)
2234+
end_time = time.time()
2235+
logger.info(
2236+
"Gemini Agent inference completed in %.2f seconds.",
2237+
end_time - start_time,
2238+
)
2239+
return types.EvaluationDataset(
2240+
eval_dataset_df=results_df,
2241+
candidate_name=gemini_agent.split("/")[-1],
2242+
)
2243+
elif model:
20532244
start_time = time.time()
20542245
logger.debug("Starting inference process ...")
20552246
results_df = _run_inference_internal(

agentplatform/_genai/_evals_constant.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
CONTENT = "content"
5757
PARTS = "parts"
5858
USER_AUTHOR = "user"
59+
MODEL_AUTHOR = "model"
5960
AGENT_DATA = "agent_data"
6061
INTERACTION_ID = "interaction_id"
6162
STARTING_PROMPT = "starting_prompt"

agentplatform/_genai/evals.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2185,8 +2185,13 @@ def run_inference(
21852185

21862186
agent_engine_instance = None
21872187
agent_instance = None
2188+
gemini_agent_instance = None
21882189
if agent:
2189-
if isinstance(agent, str) or isinstance(agent, types.AgentEngine):
2190+
if isinstance(agent, str) and _evals_common._is_gemini_agent_resource(
2191+
agent
2192+
):
2193+
gemini_agent_instance = agent
2194+
elif isinstance(agent, str) or isinstance(agent, types.AgentEngine):
21902195
agent_engine_instance = agent
21912196
else:
21922197
agent_instance = agent
@@ -2196,6 +2201,7 @@ def run_inference(
21962201
model=model,
21972202
agent_engine=agent_engine_instance,
21982203
agent=agent_instance,
2204+
gemini_agent=gemini_agent_instance,
21992205
src=src,
22002206
dest=config.dest,
22012207
prompt_template=config.prompt_template,

tests/unit/agentplatform/genai/replays/test_run_inference.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,31 @@ def test_inference_with_completed_and_incomplete_agent_data(client):
300300
assert len(row1_response) > 0
301301

302302

303+
def test_inference_with_gemini_agent(client):
304+
"""Tests run_inference() against a Gemini Agents API agent.
305+
306+
Drives the Interactions API client-side (one interaction per prompt) and
307+
returns an EvaluationDataset with prompt/response/interaction_id/agent_data.
308+
"""
309+
import pandas as pd
310+
311+
prompts_df = pd.DataFrame({"prompt": ["What is Taylor Swift's most recent album?"]})
312+
313+
eval_dataset = client.evals.run_inference(
314+
src=prompts_df,
315+
agent="projects/model-evaluation-dev/locations/global/agents/test-agent-eval",
316+
)
317+
318+
assert isinstance(eval_dataset, types.EvaluationDataset)
319+
result_df = eval_dataset.eval_dataset_df
320+
assert result_df is not None
321+
assert set(["prompt", "response", "interaction_id", "agent_data"]).issubset(
322+
set(result_df.columns)
323+
)
324+
assert len(result_df) == 1
325+
assert result_df["interaction_id"].iloc[0]
326+
327+
303328
pytestmark = pytest_helper.setup(
304329
file=__file__,
305330
globals_for_file=globals(),

0 commit comments

Comments
 (0)