Skip to content

Commit 4c01ebc

Browse files
jsondaicopybara-github
authored andcommitted
feat: GenAI Client(evals) - exponential backoff for Gemini agent interaction polling
PiperOrigin-RevId: 947191099
1 parent 135224c commit 4c01ebc

6 files changed

Lines changed: 750 additions & 102 deletions

File tree

agentplatform/_genai/_evals_common.py

Lines changed: 235 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,217 @@ 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+
def _agent_resource_to_agent_info(
982+
agent: str, api_client: BaseApiClient
983+
) -> "types.evals.AgentInfo":
984+
"""Builds an `AgentInfo` from a Gemini Agents API agent resource name.
985+
986+
Fetches the agent through the SDK's `api_client` (so replay recording is
987+
preserved) via `_fetch_agent_config_dict` and derives a single-agent
988+
`AgentInfo`: the agent's short name is the agents-map key and
989+
`root_agent_id`.
990+
991+
Args:
992+
agent: The Gemini Agents API agent resource name
993+
(`projects/{p}/locations/{l}/agents/{name}`).
994+
api_client: The API client used to fetch the agent.
995+
996+
Returns:
997+
An `AgentInfo` describing the fetched agent.
998+
"""
999+
agent_config = _fetch_agent_config_dict(api_client, agent)
1000+
short_name = agent_config.agent_id
1001+
return types.evals.AgentInfo( # pytype: disable=missing-parameter
1002+
name=short_name,
1003+
agents={short_name: agent_config},
1004+
root_agent_id=short_name,
1005+
)
1006+
1007+
1008+
_INTERACTION_TERMINAL_STATES = frozenset(
1009+
["completed", "failed", "cancelled", "incomplete", "budget_exceeded"]
1010+
)
1011+
1012+
_INITIAL_POLL_INTERVAL_SECONDS = 2.0
1013+
_MAX_POLL_INTERVAL_SECONDS = 30.0
1014+
_POLL_BACKOFF_MULTIPLIER = 2.0
1015+
1016+
1017+
def _await_interaction(
1018+
interactions_client: "_InteractionsRestClient",
1019+
interaction: dict[str, Any],
1020+
initial_poll_interval_seconds: float = _INITIAL_POLL_INTERVAL_SECONDS,
1021+
max_poll_interval_seconds: float = _MAX_POLL_INTERVAL_SECONDS,
1022+
poll_backoff_multiplier: float = _POLL_BACKOFF_MULTIPLIER,
1023+
timeout_seconds: float = 600.0,
1024+
) -> dict[str, Any]:
1025+
"""Polls a background interaction until it reaches a terminal state.
1026+
1027+
Gemini agent interactions must run in the background (`background=True`), so
1028+
`create` returns before the model output is ready. This polls
1029+
`interactions.get` until the interaction reaches a terminal state and then
1030+
returns the resolved interaction. The delay between polls grows
1031+
exponentially (capped at `max_poll_interval_seconds`) to avoid hitting rate
1032+
limits when evaluating large datasets.
1033+
1034+
Args:
1035+
interactions_client: The interactions client used to poll.
1036+
interaction: The interaction returned by `create`.
1037+
initial_poll_interval_seconds: Delay before the first poll.
1038+
max_poll_interval_seconds: Upper bound for the poll interval.
1039+
poll_backoff_multiplier: Factor the interval grows by after each poll.
1040+
timeout_seconds: Maximum time to wait before raising.
1041+
1042+
Returns:
1043+
The resolved interaction once it reaches a terminal state.
1044+
1045+
Raises:
1046+
TimeoutError: If the interaction does not complete within the timeout.
1047+
"""
1048+
if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1049+
return interaction
1050+
interaction_id = interaction.get("id")
1051+
deadline = time.monotonic() + timeout_seconds
1052+
poll_interval = initial_poll_interval_seconds
1053+
while time.monotonic() < deadline:
1054+
time.sleep(poll_interval)
1055+
interaction = interactions_client.get(interaction_id)
1056+
if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1057+
return interaction
1058+
poll_interval = min(
1059+
poll_interval * poll_backoff_multiplier, max_poll_interval_seconds
1060+
)
1061+
raise TimeoutError(
1062+
f"Interaction {interaction_id} did not complete within"
1063+
f" {timeout_seconds} seconds."
1064+
)
1065+
1066+
1067+
def _run_gemini_agent_inference(
1068+
*,
1069+
api_client: BaseApiClient,
1070+
gemini_agent: str,
1071+
prompt_dataset: pd.DataFrame,
1072+
) -> pd.DataFrame:
1073+
"""Runs inference against a Gemini Agents API agent via the Interactions API.
1074+
1075+
For each prompt row, creates an interaction against `gemini_agent` and
1076+
collects the interaction id, response text, and agent data.
1077+
1078+
Args:
1079+
api_client: The API client used to issue interaction calls.
1080+
gemini_agent: The Gemini Agents API agent resource name.
1081+
prompt_dataset: The prompt DataFrame. The prompt is read from the
1082+
`request` column if present, otherwise from the `prompt` column.
1083+
1084+
Returns:
1085+
A DataFrame with columns prompt, response, interaction_id, agent_data.
1086+
"""
1087+
prompt_column = (
1088+
"request" if "request" in prompt_dataset.columns else _evals_constant.PROMPT
1089+
)
1090+
if prompt_column not in prompt_dataset.columns:
1091+
raise ValueError(
1092+
"The eval dataset provided for Gemini agent inference must contain a"
1093+
f" '{_evals_constant.PROMPT}' or 'request' column."
1094+
)
1095+
1096+
interactions_client = _get_interactions_client(api_client)
1097+
1098+
agent_short_id = gemini_agent.split("/")[-1]
1099+
prompts: list[str] = []
1100+
responses: list[Optional[str]] = []
1101+
interaction_ids: list[Optional[str]] = []
1102+
agent_data: list[dict[str, Any]] = []
1103+
for prompt in tqdm(
1104+
prompt_dataset[prompt_column].tolist(), desc="Gemini Agent Inference"
1105+
):
1106+
prompts.append(prompt)
1107+
try:
1108+
interaction = interactions_client.create(
1109+
{
1110+
"agent": agent_short_id,
1111+
"input": [{"type": "text", "text": prompt}],
1112+
"store": True,
1113+
"background": True,
1114+
}
1115+
)
1116+
interaction = _await_interaction(interactions_client, interaction)
1117+
agent_data_obj = _interaction_dict_to_agent_data(interaction)
1118+
_merge_text_parts_in_agent_data(agent_data_obj)
1119+
responses.append(_agent_data_response_text(agent_data_obj))
1120+
interaction_ids.append(interaction.get("id"))
1121+
agent_data.append(agent_data_obj.model_dump(mode="json", exclude_none=True))
1122+
except Exception as e: # pylint: disable=broad-exception-caught
1123+
logger.warning(
1124+
"Gemini agent inference failed for a prompt (recording an empty"
1125+
" row and continuing): %s",
1126+
e,
1127+
)
1128+
responses.append(None)
1129+
interaction_ids.append(None)
1130+
agent_data.append({})
1131+
1132+
return pd.DataFrame(
1133+
{
1134+
_evals_constant.PROMPT: prompts,
1135+
_evals_constant.RESPONSE: responses,
1136+
_evals_constant.INTERACTION_ID: interaction_ids,
1137+
_evals_constant.AGENT_DATA: agent_data,
1138+
}
1139+
)
1140+
1141+
9311142
def _normalize_interaction_resource(
9321143
interaction: str, agent: str, location: Optional[str]
9331144
) -> str:
@@ -1838,6 +2049,7 @@ def _execute_inference(
18382049
model: Optional[Union[Callable[[Any], Any], str]] = None,
18392050
agent_engine: Optional[Union[str, types.AgentEngine]] = None,
18402051
agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
2052+
gemini_agent: Optional[str] = None,
18412053
dest: Optional[str] = None,
18422054
config: Optional[genai_types.GenerateContentConfig] = None,
18432055
prompt_template: Optional[Union[str, types.PromptTemplateOrDict]] = None,
@@ -1856,6 +2068,8 @@ def _execute_inference(
18562068
agent_engine: The agent engine to use for inference. Can be a resource
18572069
name string or an `AgentEngine` instance.
18582070
agent: The local agent to use for inference. Can be an ADK agent instance.
2071+
gemini_agent: The Gemini Agents API agent resource name to run inference
2072+
against via the Interactions API.
18592073
dest: The destination to save the inference results. Can be a string
18602074
representing a file path or a GCS URI.
18612075
config: The generation configuration for the model.
@@ -1873,9 +2087,10 @@ def _execute_inference(
18732087
if location:
18742088
api_client = _get_api_client_with_location(api_client, location)
18752089

1876-
if sum(x is not None for x in [model, agent_engine, agent]) != 1:
2090+
if sum(x is not None for x in [model, agent_engine, agent, gemini_agent]) != 1:
18772091
raise ValueError(
1878-
"Exactly one of model, agent_engine, or agent must be provided."
2092+
"Exactly one of model, agent_engine, agent, or gemini_agent must be"
2093+
" provided."
18792094
)
18802095

18812096
prompt_dataset = _load_dataframe(api_client, src)
@@ -1888,7 +2103,24 @@ def _execute_inference(
18882103

18892104
_apply_prompt_template(prompt_dataset, prompt_template)
18902105

1891-
if model:
2106+
if gemini_agent:
2107+
start_time = time.time()
2108+
logger.debug("Starting Gemini Agent inference process ...")
2109+
results_df = _run_gemini_agent_inference(
2110+
api_client=api_client,
2111+
gemini_agent=gemini_agent,
2112+
prompt_dataset=prompt_dataset,
2113+
)
2114+
end_time = time.time()
2115+
logger.info(
2116+
"Gemini Agent inference completed in %.2f seconds.",
2117+
end_time - start_time,
2118+
)
2119+
return types.EvaluationDataset(
2120+
eval_dataset_df=results_df,
2121+
candidate_name=gemini_agent.split("/")[-1],
2122+
)
2123+
elif model:
18922124
start_time = time.time()
18932125
logger.debug("Starting inference process ...")
18942126
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"

0 commit comments

Comments
 (0)