Skip to content

Commit 1b64331

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

6 files changed

Lines changed: 736 additions & 89 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 298 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,260 @@ def _fetch_agent_config_dict(
923923
)
924924

925925

926+
def _get_resolved_location(api_client: Any) -> Optional[str]:
927+
"""Returns the location configured on the API client."""
928+
return getattr(api_client, "location", None)
929+
930+
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+
1101+
def _normalize_interaction_resource(
1102+
interaction: str, agent: str, location: Optional[str]
1103+
) -> str:
1104+
"""Normalizes an interaction id into a full resource name.
1105+
1106+
A bare interaction id is expanded to
1107+
`projects/{project}/locations/{location}/interactions/{id}` using the
1108+
project and location parsed from the agent resource name. Fully-qualified
1109+
interaction resource names are returned unchanged.
1110+
"""
1111+
if interaction.startswith("projects/"):
1112+
return interaction
1113+
parts = agent.split("/")
1114+
project = parts[1]
1115+
agent_location = parts[3] if len(parts) > 3 else (location or "global")
1116+
return f"projects/{project}/locations/{agent_location}/interactions/{interaction}"
1117+
1118+
1119+
def _build_interaction_id_dataset(
1120+
loaded_data: list[dict[str, Any]],
1121+
agent: Optional[str],
1122+
location: Optional[str],
1123+
) -> Optional[types.EvaluationDataset]:
1124+
"""Builds an EvaluationDataset from rows that carry an `interaction_id`.
1125+
1126+
When the dataset contains an `interaction_id` column, each row is turned
1127+
into an EvalCase whose `interactions_data_source` references the interaction
1128+
and the Gemini agent. The backend resolves the interaction trace and agent
1129+
config; no client-side prompt/response is required. Returns None if the
1130+
data does not contain interaction ids.
1131+
"""
1132+
has_interaction_id = bool(loaded_data) and any(
1133+
_evals_constant.INTERACTION_ID in row for row in loaded_data
1134+
)
1135+
if not has_interaction_id:
1136+
if agent:
1137+
raise ValueError(
1138+
"An `agent` was provided but the dataset does not contain an"
1139+
" `interaction_id` column. The `agent` argument is only used to"
1140+
" resolve an `interaction_id` dataset column (so the backend can"
1141+
" fetch the interaction trace and Agent config). To evaluate"
1142+
" with an agent, provide a dataset with an `interaction_id`"
1143+
" column; otherwise omit `agent`."
1144+
)
1145+
return None
1146+
1147+
if not agent:
1148+
raise ValueError(
1149+
"An `agent` resource name is required when the dataset contains an"
1150+
" `interaction_id` column, so the backend can resolve the Agent"
1151+
" config for each interaction."
1152+
)
1153+
if not _is_gemini_agent_resource(agent):
1154+
raise ValueError(
1155+
"`agent` must be a Gemini Agents API resource name of the form"
1156+
" projects/{project}/locations/{location}/agents/{agent} when"
1157+
f" evaluating interaction ids. Got: {agent}"
1158+
)
1159+
1160+
gemini_agent_config = types.GeminiAgentConfig(gemini_agent=agent)
1161+
eval_cases = []
1162+
for i, row in enumerate(loaded_data):
1163+
interaction = row.get(_evals_constant.INTERACTION_ID)
1164+
if not interaction:
1165+
raise ValueError(f"Missing `interaction_id` value for row {i}.")
1166+
eval_cases.append(
1167+
types.EvalCase(
1168+
eval_case_id=f"eval_case_{i}",
1169+
interactions_data_source=types.InteractionsDataSource(
1170+
interaction=_normalize_interaction_resource(
1171+
str(interaction), agent, location
1172+
),
1173+
gemini_agent_config=gemini_agent_config,
1174+
),
1175+
)
1176+
)
1177+
return types.EvaluationDataset(eval_cases=eval_cases)
1178+
1179+
9261180
def _add_evaluation_run_labels(
9271181
labels: Optional[dict[str, str]] = None,
9281182
agent: Optional[str] = None,
@@ -1754,6 +2008,7 @@ def _execute_inference(
17542008
model: Optional[Union[Callable[[Any], Any], str]] = None,
17552009
agent_engine: Optional[Union[str, types.AgentEngine]] = None,
17562010
agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
2011+
gemini_agent: Optional[str] = None,
17572012
dest: Optional[str] = None,
17582013
config: Optional[genai_types.GenerateContentConfig] = None,
17592014
prompt_template: Optional[Union[str, types.PromptTemplateOrDict]] = None,
@@ -1772,6 +2027,8 @@ def _execute_inference(
17722027
agent_engine: The agent engine to use for inference. Can be a resource
17732028
name string or an `AgentEngine` instance.
17742029
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.
17752032
dest: The destination to save the inference results. Can be a string
17762033
representing a file path or a GCS URI.
17772034
config: The generation configuration for the model.
@@ -1789,9 +2046,10 @@ def _execute_inference(
17892046
if location:
17902047
api_client = _get_api_client_with_location(api_client, location)
17912048

1792-
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:
17932050
raise ValueError(
1794-
"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."
17952053
)
17962054

17972055
prompt_dataset = _load_dataframe(api_client, src)
@@ -1804,7 +2062,24 @@ def _execute_inference(
18042062

18052063
_apply_prompt_template(prompt_dataset, prompt_template)
18062064

1807-
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:
18082083
start_time = time.time()
18092084
logger.debug("Starting inference process ...")
18102085
results_df = _run_inference_internal(
@@ -1939,6 +2214,8 @@ def _resolve_dataset_inputs(
19392214
dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]],
19402215
loader: "_evals_utils.EvalDatasetLoader",
19412216
agent_info: Optional[types.evals.AgentInfo] = None,
2217+
agent: Optional[str] = None,
2218+
api_client: Any = None,
19422219
) -> tuple[types.EvaluationDataset, int]:
19432220
"""Loads and processes single or multiple datasets for evaluation.
19442221
@@ -1988,6 +2265,21 @@ def _resolve_dataset_inputs(
19882265
ds_source_for_loader = _get_dataset_source(ds_item)
19892266
current_loaded_data = loader.load(ds_source_for_loader)
19902267

2268+
interaction_dataset = _build_interaction_id_dataset(
2269+
current_loaded_data, agent, _get_resolved_location(api_client)
2270+
)
2271+
if interaction_dataset is not None:
2272+
if dataset_schema:
2273+
raise ValueError(
2274+
"`dataset_schema` is not supported for datasets with an"
2275+
" `interaction_id` column. The interaction trace and agent"
2276+
" config are resolved by the backend, so no client-side"
2277+
" schema conversion is applied. Omit `dataset_schema` when"
2278+
" evaluating an interaction_id dataset."
2279+
)
2280+
parsed_evaluation_datasets.append(interaction_dataset)
2281+
continue
2282+
19912283
if dataset_schema:
19922284
current_schema = _evals_data_converters.EvalDatasetSchema(dataset_schema)
19932285
else:
@@ -2145,6 +2437,7 @@ def _execute_evaluation( # type: ignore[no-untyped-def]
21452437
api_client: Any,
21462438
dataset: Union[types.EvaluationDataset, list[types.EvaluationDataset]],
21472439
metrics: list[types.Metric],
2440+
agent: Optional[str] = None,
21482441
dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]] = None,
21492442
dest: Optional[str] = None,
21502443
location: Optional[str] = None,
@@ -2225,6 +2518,8 @@ def _execute_evaluation( # type: ignore[no-untyped-def]
22252518
dataset_schema=dataset_schema,
22262519
loader=loader,
22272520
agent_info=validated_agent_info,
2521+
agent=agent,
2522+
api_client=api_client,
22282523
)
22292524

22302525
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)