Skip to content

Commit 33c24b3

Browse files
jsondaicopybara-github
authored andcommitted
feat: GenAI Client(evals) - run_inference support for Gemini Agents API agents
PiperOrigin-RevId: 945262438
1 parent 135224c commit 33c24b3

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
@@ -928,6 +928,176 @@ def _get_resolved_location(api_client: Any) -> Optional[str]:
928928
return getattr(api_client, "location", None)
929929

930930

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

1876-
if sum(x is not None for x in [model, agent_engine, agent]) != 1:
2049+
if sum(x is not None for x in [model, agent_engine, agent, gemini_agent]) != 1:
18772050
raise ValueError(
1878-
"Exactly one of model, agent_engine, or agent must be provided."
2051+
"Exactly one of model, agent_engine, agent, or gemini_agent must be"
2052+
" provided."
18792053
)
18802054

18812055
prompt_dataset = _load_dataframe(api_client, src)
@@ -1888,7 +2062,24 @@ def _execute_inference(
18882062

18892063
_apply_prompt_template(prompt_dataset, prompt_template)
18902064

1891-
if model:
2065+
if gemini_agent:
2066+
start_time = time.time()
2067+
logger.debug("Starting Gemini Agent inference process ...")
2068+
results_df = _run_gemini_agent_inference(
2069+
api_client=api_client,
2070+
gemini_agent=gemini_agent,
2071+
prompt_dataset=prompt_dataset,
2072+
)
2073+
end_time = time.time()
2074+
logger.info(
2075+
"Gemini Agent inference completed in %.2f seconds.",
2076+
end_time - start_time,
2077+
)
2078+
return types.EvaluationDataset(
2079+
eval_dataset_df=results_df,
2080+
candidate_name=gemini_agent.split("/")[-1],
2081+
)
2082+
elif model:
18922083
start_time = time.time()
18932084
logger.debug("Starting inference process ...")
18942085
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)