google-cloud-agentplatform 1.165.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. agentplatform/__init__.py +72 -0
  2. agentplatform/_genai/__init__.py +43 -0
  3. agentplatform/_genai/_agent_engines_utils.py +2341 -0
  4. agentplatform/_genai/_bigquery_utils.py +49 -0
  5. agentplatform/_genai/_datasets_utils.py +344 -0
  6. agentplatform/_genai/_evals_builtin_tools.py +209 -0
  7. agentplatform/_genai/_evals_common.py +4268 -0
  8. agentplatform/_genai/_evals_constant.py +122 -0
  9. agentplatform/_genai/_evals_data_converters.py +926 -0
  10. agentplatform/_genai/_evals_metric_handlers.py +1783 -0
  11. agentplatform/_genai/_evals_metric_loaders.py +401 -0
  12. agentplatform/_genai/_evals_utils.py +1043 -0
  13. agentplatform/_genai/_evals_visualization.py +2070 -0
  14. agentplatform/_genai/_gcs_utils.py +262 -0
  15. agentplatform/_genai/_logging_utils.py +47 -0
  16. agentplatform/_genai/_memory_bank_utils.py +206 -0
  17. agentplatform/_genai/_observability_data_converter.py +186 -0
  18. agentplatform/_genai/_operations_utils.py +94 -0
  19. agentplatform/_genai/_prompt_management_utils.py +147 -0
  20. agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
  21. agentplatform/_genai/_skills_utils.py +69 -0
  22. agentplatform/_genai/_transformers.py +628 -0
  23. agentplatform/_genai/a2a_task_events.py +509 -0
  24. agentplatform/_genai/a2a_tasks.py +861 -0
  25. agentplatform/_genai/agent_engines.py +3931 -0
  26. agentplatform/_genai/client.py +519 -0
  27. agentplatform/_genai/datasets.py +3045 -0
  28. agentplatform/_genai/endpoints.py +1149 -0
  29. agentplatform/_genai/evals.py +6883 -0
  30. agentplatform/_genai/example_stores.py +1445 -0
  31. agentplatform/_genai/feedback_contexts.py +700 -0
  32. agentplatform/_genai/feedback_entries.py +1644 -0
  33. agentplatform/_genai/live.py +64 -0
  34. agentplatform/_genai/live_agent_engines.py +179 -0
  35. agentplatform/_genai/memories.py +2962 -0
  36. agentplatform/_genai/memory_banks.py +1927 -0
  37. agentplatform/_genai/memory_revisions.py +465 -0
  38. agentplatform/_genai/model_garden.py +2638 -0
  39. agentplatform/_genai/prompt_optimizer.py +995 -0
  40. agentplatform/_genai/prompts.py +4515 -0
  41. agentplatform/_genai/rag.py +4961 -0
  42. agentplatform/_genai/runtime_revisions.py +1257 -0
  43. agentplatform/_genai/runtimes.py +78 -0
  44. agentplatform/_genai/sandbox_snapshots.py +1015 -0
  45. agentplatform/_genai/sandbox_templates.py +1088 -0
  46. agentplatform/_genai/sandboxes.py +1604 -0
  47. agentplatform/_genai/session_events.py +543 -0
  48. agentplatform/_genai/sessions.py +1449 -0
  49. agentplatform/_genai/skill_revisions.py +377 -0
  50. agentplatform/_genai/skills.py +1708 -0
  51. agentplatform/_genai/types/__init__.py +4695 -0
  52. agentplatform/_genai/types/agent_engines.py +16 -0
  53. agentplatform/_genai/types/common.py +32784 -0
  54. agentplatform/_genai/types/evals.py +1031 -0
  55. agentplatform/_genai/types/prompt_optimizer.py +107 -0
  56. agentplatform/_genai/types/prompts.py +107 -0
  57. agentplatform/version.py +17 -0
  58. google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
  59. google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
  60. google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
  61. google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
  62. google_cloud_agentplatform-1.165.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4268 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ """Common utilities for evals."""
16
+
17
+ import asyncio
18
+ import base64
19
+ import collections
20
+ import concurrent.futures
21
+ import contextlib
22
+ import datetime
23
+ import json
24
+ import logging
25
+ import os
26
+ import threading
27
+ import time
28
+ from typing import Any, Callable, Literal, Optional, Union, cast
29
+ import uuid
30
+
31
+ from google.api_core import exceptions as api_exceptions
32
+ from google.genai import errors as genai_errors
33
+ import agentplatform
34
+ from google.genai import types as genai_types
35
+ from google.genai._api_client import BaseApiClient
36
+ from google.genai._gaos.types.interactions import interaction as interaction_types
37
+ from google.genai._gaos.types.interactions import functioncallstep
38
+ from google.genai._gaos.types.interactions import functionresultstep
39
+ from google.genai._gaos.types.interactions import modeloutputstep
40
+ from google.genai._gaos.types.interactions import userinputstep
41
+ from google.genai.models import Models
42
+ import pandas as pd
43
+ from tqdm import tqdm
44
+ from pydantic import ValidationError
45
+
46
+ from . import _evals_builtin_tools
47
+ from . import _evals_constant
48
+ from . import _evals_data_converters
49
+ from . import _evals_metric_handlers
50
+ from . import _evals_metric_loaders
51
+ from . import _evals_utils
52
+ from . import _gcs_utils
53
+ from . import evals
54
+ from . import types
55
+ from . import _transformers as t
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ try:
60
+ import litellm
61
+ except ImportError:
62
+ litellm = None
63
+
64
+
65
+ _thread_local_data = threading.local()
66
+
67
+ MAX_WORKERS = 100
68
+ AGENT_MAX_WORKERS = 20
69
+ _MAX_INTERACTION_CHAIN_DEPTH = 10
70
+ # Default per-request timeout (milliseconds) for a user simulator model turn.
71
+ _USER_SIMULATOR_TIMEOUT_MS = 300000
72
+ # Per-request timeout (milliseconds) for individual Interactions API calls so a
73
+ # single create/poll cannot block forever.
74
+ _INTERACTION_REQUEST_TIMEOUT_MS = 60000
75
+ # Hard ceiling (seconds) for a single multi-turn simulated conversation.
76
+ _USER_SIMULATION_SCENARIO_TIMEOUT_SECONDS = 1200.0
77
+ # Expected, per-scenario failures during user simulation. These are recorded as
78
+ # an empty row so one bad scenario does not abort the whole batch; anything else
79
+ # propagates.
80
+ _USER_SIMULATION_SCENARIO_ERRORS = (
81
+ TimeoutError,
82
+ ValueError,
83
+ RuntimeError,
84
+ genai_errors.APIError,
85
+ api_exceptions.GoogleAPICallError,
86
+ )
87
+ CONTENT = _evals_constant.CONTENT
88
+ PARTS = _evals_constant.PARTS
89
+ USER_AUTHOR = _evals_constant.USER_AUTHOR
90
+ AGENT_DATA = _evals_constant.AGENT_DATA
91
+ _DEFAULT_CANDIDATE_NAME = _evals_constant.DEFAULT_CANDIDATE_NAME
92
+
93
+
94
+ def _local_timestamp() -> str:
95
+ """Returns the current local time as 'M/D/YYYY, H:MM:SS AM/PM'.
96
+
97
+ Matches the Agent Platform UI's default experiment name timestamp format
98
+ (e.g. '6/1/2026, 1:12:29 PM').
99
+ """
100
+ now = datetime.datetime.now()
101
+ hour_12 = now.hour % 12 or 12
102
+ meridiem = "AM" if now.hour < 12 else "PM"
103
+ return (
104
+ f"{now.month}/{now.day}/{now.year}, "
105
+ f"{hour_12}:{now.minute:02d}:{now.second:02d} {meridiem}"
106
+ )
107
+
108
+
109
+ @contextlib.contextmanager
110
+ def _temp_logger_level(logger_name: str, level: int) -> None: # type: ignore[misc]
111
+ """Temporarily sets the level of a logger."""
112
+ logger_instance = logging.getLogger(logger_name)
113
+ original_level = logger_instance.getEffectiveLevel()
114
+ logger_instance.setLevel(level)
115
+ try:
116
+ yield
117
+ finally:
118
+ logger_instance.setLevel(original_level)
119
+
120
+
121
+ def _get_api_client_with_location(
122
+ api_client: BaseApiClient, location: Optional[str]
123
+ ) -> BaseApiClient:
124
+ """Returns a new API client with the specified location."""
125
+ if not location or location == api_client.location:
126
+ return api_client
127
+
128
+ logger.info(
129
+ "Model endpoint location set to %s, overriding client location %s for"
130
+ " this API call.",
131
+ location,
132
+ api_client.location,
133
+ )
134
+ return agentplatform.Client( # type: ignore[no-any-return]
135
+ project=api_client.project,
136
+ location=location,
137
+ credentials=api_client._credentials,
138
+ http_options=api_client._http_options,
139
+ )._api_client
140
+
141
+
142
+ def _get_agent_engine_instance(
143
+ agent_name: str, api_client: BaseApiClient
144
+ ) -> Union[types.AgentEngine, Any]:
145
+ """Gets or creates an agent engine instance for the current thread."""
146
+ if not hasattr(_thread_local_data, "agent_engine_instances"):
147
+ _thread_local_data.agent_engine_instances = {}
148
+ if agent_name not in _thread_local_data.agent_engine_instances:
149
+ client = agentplatform.Client(
150
+ project=api_client.project,
151
+ location=api_client.location,
152
+ )
153
+ _thread_local_data.agent_engine_instances[agent_name] = (
154
+ client.agent_engines.get(name=agent_name)
155
+ )
156
+ return _thread_local_data.agent_engine_instances[agent_name]
157
+
158
+
159
+ def _generate_content_with_retry(
160
+ api_client: BaseApiClient,
161
+ model: str,
162
+ contents: Union[genai_types.ContentListUnion, genai_types.ContentListUnionDict],
163
+ config: Optional[genai_types.GenerateContentConfig] = None,
164
+ max_retries: int = 3,
165
+ ) -> Union[genai_types.GenerateContentResponse, dict[str, Any]]:
166
+ """Generates content using the model's generate_content with retries."""
167
+ models_module = Models(api_client_=api_client)
168
+
169
+ for attempt in range(max_retries):
170
+ try:
171
+ response = models_module.generate_content(
172
+ model=model,
173
+ contents=contents,
174
+ config=config,
175
+ )
176
+ if not response.candidates:
177
+ logger.warning(
178
+ "Prompt blocked. Attempt %d/%d. Feedback: %s. Prompt: %s.",
179
+ attempt + 1,
180
+ max_retries,
181
+ response.prompt_feedback,
182
+ contents,
183
+ )
184
+ if attempt == max_retries - 1:
185
+ feedback_dict = {}
186
+ if response.prompt_feedback:
187
+ feedback_dict = response.prompt_feedback.model_dump(
188
+ mode="json", exclude_none=True
189
+ )
190
+ return {
191
+ "error": "Prompt blocked after retries",
192
+ "prompt_feedback": feedback_dict,
193
+ }
194
+ else:
195
+ candidate = response.candidates[0]
196
+ if candidate.finish_reason not in (
197
+ genai_types.FinishReason.STOP,
198
+ genai_types.FinishReason.MAX_TOKENS,
199
+ genai_types.FinishReason.FINISH_REASON_UNSPECIFIED,
200
+ ):
201
+ logger.warning(
202
+ "Generate content did not finish successfully."
203
+ "Finish reason: %s. Finish message: %s."
204
+ "Retry attempt: %d/%d",
205
+ candidate.finish_reason,
206
+ candidate.finish_message,
207
+ attempt + 1,
208
+ max_retries,
209
+ )
210
+ if attempt == max_retries - 1:
211
+ return {
212
+ "error": (
213
+ "Generate content unsuccessful after retries:"
214
+ f" {candidate.finish_reason}"
215
+ ),
216
+ "finish_reason": str(candidate.finish_reason),
217
+ "finish_message": candidate.finish_message or "",
218
+ }
219
+ else:
220
+ return response
221
+ except api_exceptions.ResourceExhausted as e:
222
+ logger.warning(
223
+ "Resource Exhausted error on attempt %d/%d: %s. Retrying in %s"
224
+ " seconds...",
225
+ attempt + 1,
226
+ max_retries,
227
+ e,
228
+ 2**attempt,
229
+ )
230
+ if attempt == max_retries - 1:
231
+ return {"error": f"Resource exhausted after retries: {e}"}
232
+ time.sleep(2**attempt)
233
+ except Exception as e: # pylint: disable=broad-exception-caught
234
+ logger.error(
235
+ "Unexpected error during generate_content on attempt %d/%d: %s",
236
+ attempt + 1,
237
+ max_retries,
238
+ e,
239
+ )
240
+
241
+ if attempt == max_retries - 1:
242
+ return {"error": f"Failed after retries: {e}"}
243
+ time.sleep(1)
244
+ return {"error": f"Failed to generate content after {max_retries} retries"}
245
+
246
+
247
+ def _build_generate_content_config(
248
+ request_dict: Union[dict[str, Any], str],
249
+ global_config: Optional[genai_types.GenerateContentConfig] = None,
250
+ ) -> genai_types.GenerateContentConfig:
251
+ """Builds a GenerateContentConfig from the request dictionary or provided config."""
252
+ if global_config:
253
+ # If a global config is provided, apply it as a base config. Parts of
254
+ # the global config can be overridden by providing configs in the
255
+ # request.
256
+ merged_config_dict = global_config.model_dump(exclude_none=True)
257
+ else:
258
+ merged_config_dict = {}
259
+
260
+ if not isinstance(request_dict, dict):
261
+ return genai_types.GenerateContentConfig(**merged_config_dict)
262
+
263
+ for key in [
264
+ "system_instruction",
265
+ "tools",
266
+ "tools_config",
267
+ "safety_settings",
268
+ "labels",
269
+ ]:
270
+ if key in request_dict:
271
+ merged_config_dict[key] = request_dict[key]
272
+ if "generation_config" in request_dict and isinstance(
273
+ request_dict["generation_config"], dict
274
+ ):
275
+ merged_config_dict.update(request_dict["generation_config"])
276
+ if "labels" in request_dict:
277
+ merged_config_dict["labels"] = request_dict["labels"]
278
+
279
+ return genai_types.GenerateContentConfig(**merged_config_dict)
280
+
281
+
282
+ def _extract_contents_for_inference(
283
+ request_dict_or_raw_text: Any,
284
+ ) -> Any:
285
+ """Extracts contents from a request dictionary or returns the raw text."""
286
+ if not request_dict_or_raw_text:
287
+ raise ValueError("Prompt cannot be empty.")
288
+ if isinstance(request_dict_or_raw_text, dict):
289
+ contents_for_fn = request_dict_or_raw_text.get("contents", None)
290
+ if not contents_for_fn:
291
+ raise ValueError("Contents in the request cannot be empty.")
292
+ return contents_for_fn
293
+ else:
294
+ return request_dict_or_raw_text
295
+
296
+
297
+ def _eval_cases_to_dataframe(
298
+ eval_cases: list[types.EvalCase],
299
+ ) -> pd.DataFrame:
300
+ """Converts a list of EvalCase objects to a pandas DataFrame.
301
+
302
+ Each EvalCase is converted to a row in the DataFrame. Structured fields
303
+ like ``agent_data`` are preserved as-is (not flattened) so that downstream
304
+ agent execution paths can consume them directly.
305
+
306
+ Args:
307
+ eval_cases: The list of EvalCase objects to convert.
308
+
309
+ Returns:
310
+ A DataFrame with one row per EvalCase.
311
+ """
312
+ rows = []
313
+ for case in eval_cases:
314
+ row: dict[str, Any] = {}
315
+ if case.prompt:
316
+ row[_evals_constant.PROMPT] = _evals_data_converters._get_content_text(
317
+ case.prompt
318
+ )
319
+
320
+ if case.responses and len(case.responses) > 0 and case.responses[0].response:
321
+ row[_evals_constant.RESPONSE] = _evals_data_converters._get_content_text(
322
+ case.responses[0].response
323
+ )
324
+
325
+ if case.reference and case.reference.response:
326
+ row[_evals_constant.REFERENCE] = _evals_data_converters._get_content_text(
327
+ case.reference.response
328
+ )
329
+
330
+ if case.agent_data:
331
+ row[AGENT_DATA] = case.agent_data
332
+
333
+ if case.intermediate_events:
334
+ row[_evals_constant.INTERMEDIATE_EVENTS] = [
335
+ {CONTENT: event.content}
336
+ for event in case.intermediate_events
337
+ if event.content
338
+ ]
339
+
340
+ if case.conversation_history:
341
+ history_parts = []
342
+ for msg in case.conversation_history:
343
+ if msg.content:
344
+ role = msg.content.role or "user"
345
+ text = _evals_data_converters._get_content_text(msg.content)
346
+ history_parts.append(f"{role}: {text}")
347
+ if history_parts:
348
+ row[_evals_constant.CONVERSATION_HISTORY] = "\n".join(history_parts)
349
+
350
+ if case.user_scenario:
351
+ if case.user_scenario.starting_prompt:
352
+ row[_evals_constant.STARTING_PROMPT] = (
353
+ case.user_scenario.starting_prompt
354
+ )
355
+ if case.user_scenario.conversation_plan:
356
+ row[_evals_constant.CONVERSATION_PLAN] = (
357
+ case.user_scenario.conversation_plan
358
+ )
359
+
360
+ if case.interactions_data_source:
361
+ row["interaction"] = getattr(
362
+ case.interactions_data_source, "interaction", None
363
+ )
364
+ gemini_cfg = getattr(
365
+ case.interactions_data_source, "gemini_agent_config", None
366
+ )
367
+ if gemini_cfg:
368
+ row["gemini_agent"] = getattr(gemini_cfg, "gemini_agent", None)
369
+
370
+ rows.append(row)
371
+ return pd.DataFrame(rows)
372
+
373
+
374
+ def _extract_prompt_from_agent_data(
375
+ agent_data: types.evals.AgentData,
376
+ ) -> tuple[genai_types.Content, list[types.evals.AgentEvent]]:
377
+ """Extracts the last user message and prior events from agent_data.
378
+
379
+ The last event across all turns must be authored by ``"user"``; it is
380
+ treated as the current prompt that the agent should respond to.
381
+ Everything before it is returned as conversation history.
382
+
383
+ Args:
384
+ agent_data: The AgentData containing conversation turns.
385
+
386
+ Returns:
387
+ A tuple of ``(last_user_content, history_events)`` where
388
+ ``last_user_content`` is the ``Content`` of the final user event
389
+ and ``history_events`` is the ordered list of all prior
390
+ ``AgentEvent`` objects.
391
+
392
+ Raises:
393
+ ValueError: If ``agent_data`` has no turns, no events, or the last
394
+ event is not a user event.
395
+ """
396
+ if not agent_data.turns:
397
+ raise ValueError("agent_data must have at least one turn.")
398
+
399
+ all_events: list[types.evals.AgentEvent] = []
400
+ for turn in agent_data.turns:
401
+ if turn.events:
402
+ all_events.extend(turn.events)
403
+
404
+ if not all_events:
405
+ raise ValueError("agent_data turns contain no events.")
406
+
407
+ last_event = all_events[-1]
408
+ if last_event.author != USER_AUTHOR:
409
+ raise ValueError(
410
+ "agent_data must end with a user event, but the last event has"
411
+ f" author='{last_event.author}'."
412
+ )
413
+
414
+ if not last_event.content:
415
+ raise ValueError("The last user event in agent_data has no content.")
416
+
417
+ return last_event.content, all_events[:-1]
418
+
419
+
420
+ def _is_n_plus_1_inference(
421
+ agent_data: Union[types.evals.AgentData, dict[str, Any]],
422
+ ) -> bool:
423
+ """Returns True if agent_data represents an N+1 inference case.
424
+
425
+ An N+1 case means the trace is incomplete: N prior conversation turns
426
+ exist plus 1 final user query that the agent should respond to. This
427
+ is detected by checking whether the very last event across all turns
428
+ is authored by ``"user"``.
429
+
430
+ Returns ``False`` for completed traces (last event from the agent),
431
+ empty traces, or invalid data.
432
+ """
433
+ if isinstance(agent_data, dict):
434
+ try:
435
+ agent_data = types.evals.AgentData.model_validate(agent_data)
436
+ except Exception: # pylint: disable=broad-exception-caught
437
+ return False
438
+ if not isinstance(agent_data, types.evals.AgentData):
439
+ return False
440
+ if not agent_data.turns:
441
+ return False
442
+ all_events: list[types.evals.AgentEvent] = []
443
+ for turn in agent_data.turns or []:
444
+ if turn.events:
445
+ all_events.extend(turn.events)
446
+ if not all_events:
447
+ return False
448
+ return all_events[-1].author == USER_AUTHOR
449
+
450
+
451
+ def _extract_response_from_completed_trace(
452
+ agent_data: types.evals.AgentData,
453
+ ) -> list[dict[str, Any]]:
454
+ """Extracts all events from a completed agent trace as event dicts.
455
+
456
+ For BYOD (bring-your-own-data) use cases where the agent trace is
457
+ already complete, this returns all events formatted as a list of
458
+ dicts compatible with ``_process_single_turn_agent_response``. The
459
+ last element is the final agent response; preceding elements become
460
+ intermediate events.
461
+ """
462
+ event_dicts: list[dict[str, Any]] = []
463
+ for turn in agent_data.turns or []:
464
+ if not turn.events:
465
+ continue
466
+ for event in turn.events:
467
+ d: dict[str, Any] = {"author": event.author or "agent"}
468
+ if event.content:
469
+ d[CONTENT] = event.content.model_dump(exclude_none=True)
470
+ event_dicts.append(d)
471
+ return event_dicts
472
+
473
+
474
+ def _resolve_dataset(
475
+ api_client: BaseApiClient,
476
+ dataset: Union[types.EvaluationRunDataSource, types.EvaluationDataset],
477
+ dest: str,
478
+ parsed_agent_info: Optional[types.evals.AgentInfo] = None,
479
+ ) -> types.EvaluationRunDataSource:
480
+ """Resolves dataset for the evaluation run."""
481
+ if isinstance(dataset, types.EvaluationDataset):
482
+ # Resolve EvalCases with interactions_data_source by fetching
483
+ # each interaction and converting it to agent_data, then flowing
484
+ # through the normal DataFrame/GCS pipeline.
485
+ if dataset.eval_cases and _has_interactions_data_source(dataset.eval_cases):
486
+ resolved_cases = _resolve_interactions_to_eval_cases(
487
+ api_client, dataset.eval_cases
488
+ )
489
+ dataset = types.EvaluationDataset(eval_cases=resolved_cases)
490
+
491
+ candidate_name = _get_candidate_name(dataset, parsed_agent_info)
492
+ eval_df = dataset.eval_dataset_df
493
+ if eval_df is None and dataset.eval_cases:
494
+ eval_df = _eval_cases_to_dataframe(dataset.eval_cases)
495
+
496
+ eval_set = _create_evaluation_set_from_dataframe(
497
+ api_client,
498
+ dest,
499
+ eval_df,
500
+ candidate_name,
501
+ parsed_agent_info=parsed_agent_info,
502
+ )
503
+ dataset = types.EvaluationRunDataSource(evaluation_set=eval_set.name)
504
+ return dataset
505
+
506
+
507
+ def _get_default_prompt_template(
508
+ api_client: BaseApiClient,
509
+ inference_config: types.EvaluationRunInferenceConfigOrDict,
510
+ dataset: types.EvaluationRunDataSource,
511
+ ) -> Any:
512
+ """Resolves prompt template data for the evaluation run."""
513
+ if isinstance(inference_config, dict):
514
+ if inference_config.get("prompt_template"):
515
+ return inference_config["prompt_template"]
516
+ elif inference_config.prompt_template:
517
+ return inference_config.prompt_template
518
+
519
+ try:
520
+ evals_module = evals.Evals(api_client_=api_client)
521
+ eval_set = evals_module.get_evaluation_set(name=dataset.evaluation_set)
522
+ if eval_set and eval_set.evaluation_items:
523
+ eval_item = evals_module.get_evaluation_item(
524
+ name=eval_set.evaluation_items[0]
525
+ )
526
+ if (
527
+ eval_item
528
+ and eval_item.evaluation_request
529
+ and eval_item.evaluation_request.prompt
530
+ and eval_item.evaluation_request.prompt.prompt_template_data
531
+ and eval_item.evaluation_request.prompt.prompt_template_data.values
532
+ ):
533
+ template_values = (
534
+ eval_item.evaluation_request.prompt.prompt_template_data.values
535
+ )
536
+ if template_values and "prompt" in template_values:
537
+ return "{prompt}"
538
+ except Exception as e:
539
+ logger.warning("Failed to get prompt template from evaluation set: %s", e)
540
+ return None
541
+
542
+
543
+ def _resolve_inference_configs(
544
+ api_client: BaseApiClient,
545
+ dataset: types.EvaluationRunDataSource,
546
+ inference_configs: Optional[
547
+ dict[str, types.EvaluationRunInferenceConfigOrDict]
548
+ ] = None,
549
+ parsed_agent_info: Optional[types.evals.AgentInfo] = None,
550
+ ) -> Optional[dict[str, types.EvaluationRunInferenceConfigOrDict]]:
551
+ """Resolves inference configs for the evaluation run."""
552
+ # Resolve agent config
553
+ if parsed_agent_info and parsed_agent_info.name:
554
+ if inference_configs is None:
555
+ inference_configs = {}
556
+
557
+ # We might have used the default candidate name as a placeholder key
558
+ # in the caller; migrate it to the agent name.
559
+ if _DEFAULT_CANDIDATE_NAME in inference_configs:
560
+ inference_configs[parsed_agent_info.name] = inference_configs.pop(
561
+ _DEFAULT_CANDIDATE_NAME
562
+ )
563
+
564
+ if parsed_agent_info.name not in inference_configs:
565
+ inference_configs[parsed_agent_info.name] = (
566
+ types.EvaluationRunInferenceConfig(
567
+ agent_configs=parsed_agent_info.agents
568
+ )
569
+ )
570
+ else:
571
+ config = inference_configs[parsed_agent_info.name]
572
+ if isinstance(config, dict):
573
+ config["agent_configs"] = parsed_agent_info.agents
574
+ else:
575
+ config.agent_configs = parsed_agent_info.agents
576
+
577
+ if inference_configs:
578
+ for inference_config in inference_configs.values():
579
+ model_val = (
580
+ inference_config.get("model")
581
+ if isinstance(inference_config, dict)
582
+ else inference_config.model
583
+ )
584
+ if model_val:
585
+ normalized_model = _normalize_inference_model_name(
586
+ model_val, api_client
587
+ )
588
+ if isinstance(inference_config, dict):
589
+ inference_config["model"] = normalized_model
590
+ else:
591
+ inference_config.model = normalized_model
592
+ prompt_template_val = (
593
+ inference_config.get("prompt_template")
594
+ if isinstance(inference_config, dict)
595
+ else inference_config.prompt_template
596
+ )
597
+ if not prompt_template_val:
598
+ default_prompt_template = _get_default_prompt_template(
599
+ api_client, inference_config, dataset
600
+ )
601
+ if default_prompt_template:
602
+ prompt_template_to_set = default_prompt_template
603
+ if not isinstance(
604
+ default_prompt_template, types.EvaluationRunPromptTemplate
605
+ ):
606
+ prompt_template_to_set = types.EvaluationRunPromptTemplate(
607
+ prompt_template=default_prompt_template
608
+ )
609
+ if isinstance(inference_config, dict):
610
+ inference_config["prompt_template"] = (
611
+ prompt_template_to_set.model_dump(exclude_none=True)
612
+ )
613
+ else:
614
+ inference_config.prompt_template = (
615
+ prompt_template_to_set.model_dump(exclude_none=True)
616
+ )
617
+ return inference_configs
618
+
619
+
620
+ def _is_gemini_agent_resource(agent: str) -> bool:
621
+ """Returns True if `agent` is a Gemini Agent resource name.
622
+
623
+ A Gemini Agent resource name has the format
624
+ `projects/{project}/locations/{location}/agents/{agent}`, as opposed to an
625
+ Agent Engine resource name which uses `.../reasoningEngines/{id}`.
626
+ """
627
+ parts = agent.split("/")
628
+ return (
629
+ len(parts) == 6
630
+ and parts[0] == "projects"
631
+ and parts[2] == "locations"
632
+ and parts[4] == "agents"
633
+ and bool(parts[1])
634
+ and bool(parts[3])
635
+ and bool(parts[5])
636
+ )
637
+
638
+
639
+ def _step_to_agent_event(step: Any) -> Optional[types.evals.AgentEvent]:
640
+ """Converts a typed GenAI SDK Interaction step to an AgentEvent.
641
+
642
+ Uses ``isinstance`` checks against the GenAI SDK step classes so that
643
+ attribute access stays in sync with SDK/proto changes.
644
+
645
+ Args:
646
+ step: A step from ``Interaction.steps`` (a GenAI SDK step type).
647
+
648
+ Returns:
649
+ An AgentEvent, or ``None`` if the step type is not handled.
650
+ """
651
+ if isinstance(step, userinputstep.UserInputStep):
652
+ return _text_step_to_event(step, author="user", role="user")
653
+ elif isinstance(step, modeloutputstep.ModelOutputStep):
654
+ return _text_step_to_event(step, author="agent", role="model")
655
+ elif isinstance(step, functioncallstep.FunctionCallStep):
656
+ return _function_call_step_to_event(step)
657
+ elif isinstance(step, functionresultstep.FunctionResultStep):
658
+ return _function_response_step_to_event(step)
659
+ else:
660
+ logger.info("Skipping unhandled interaction step type: %s", type(step).__name__)
661
+ return None
662
+
663
+
664
+ def _function_response_step_to_event(
665
+ step: functionresultstep.FunctionResultStep,
666
+ ) -> types.evals.AgentEvent:
667
+ """Converts a FunctionResultStep to an AgentEvent."""
668
+ result = step.result
669
+ if isinstance(result, dict):
670
+ result_str = json.dumps(result)
671
+ elif isinstance(result, str):
672
+ result_str = result
673
+ else:
674
+ result_str = str(result) if result is not None else ""
675
+ return types.evals.AgentEvent( # pytype: disable=missing-parameter
676
+ author="user",
677
+ content=genai_types.Content(
678
+ role="user",
679
+ parts=[
680
+ genai_types.Part(
681
+ function_response=genai_types.FunctionResponse(
682
+ name=step.name or "",
683
+ response={"result": result_str},
684
+ id=step.call_id or "",
685
+ )
686
+ )
687
+ ],
688
+ ),
689
+ )
690
+
691
+
692
+ def _function_call_step_to_event(
693
+ step: functioncallstep.FunctionCallStep,
694
+ ) -> types.evals.AgentEvent:
695
+ """Converts a FunctionCallStep to an AgentEvent."""
696
+ return types.evals.AgentEvent( # pytype: disable=missing-parameter
697
+ author="agent",
698
+ content=genai_types.Content(
699
+ role="model",
700
+ parts=[
701
+ genai_types.Part(
702
+ function_call=genai_types.FunctionCall(
703
+ name=step.name or "",
704
+ args=step.arguments or {},
705
+ id=step.id or "",
706
+ )
707
+ )
708
+ ],
709
+ ),
710
+ )
711
+
712
+
713
+ def _text_step_to_event(
714
+ step: Any, *, author: str, role: str
715
+ ) -> Optional[types.evals.AgentEvent]:
716
+ """Converts a text-bearing step (UserInputStep / ModelOutputStep) to an AgentEvent.
717
+
718
+ Args:
719
+ step: A GenAI SDK step with a ``content`` attribute.
720
+ author: The event author (``"user"`` or ``"agent"``).
721
+ role: The content role (``"user"`` or ``"model"``).
722
+
723
+ Returns:
724
+ An AgentEvent, or ``None`` if no text parts were found.
725
+ """
726
+ parts = []
727
+ for content_item in step.content or []:
728
+ if getattr(content_item, "text", None):
729
+ parts.append(genai_types.Part(text=content_item.text))
730
+ if not parts:
731
+ return None
732
+ return types.evals.AgentEvent( # pytype: disable=missing-parameter
733
+ author=author,
734
+ content=genai_types.Content(role=role, parts=parts),
735
+ )
736
+
737
+
738
+ def _interaction_steps_to_events(
739
+ steps: list[Any],
740
+ ) -> list[tuple[types.evals.AgentEvent, type]]:
741
+ """Converts a list of typed Interaction steps to AgentEvents.
742
+
743
+ Each step is mapped via ``_step_to_agent_event``. Steps whose type is
744
+ not handled are skipped with a log message. The originating SDK step
745
+ class is returned alongside each event so callers can determine turn
746
+ boundaries without inspecting event content.
747
+
748
+ Args:
749
+ steps: The ``steps`` list from a GenAI SDK ``Interaction`` object.
750
+
751
+ Returns:
752
+ A list of ``(AgentEvent, step_class)`` tuples.
753
+ """
754
+ events: list[tuple[types.evals.AgentEvent, type]] = []
755
+ for step in steps:
756
+ event = _step_to_agent_event(step)
757
+ if event is not None:
758
+ events.append((event, type(step)))
759
+ return events
760
+
761
+
762
+ def _interaction_dict_to_agent_data(
763
+ interaction: dict[str, Any],
764
+ ) -> types.evals.AgentData:
765
+ """Converts an Interaction API JSON response to an AgentData object.
766
+
767
+ Parses the raw dict into a typed ``Interaction`` object (from the GenAI
768
+ SDK) so that step conversion uses ``isinstance`` checks and typed
769
+ attribute access. Steps are grouped into ConversationTurns -- each
770
+ ``UserInputStep`` starts a new turn, so multi-turn conversations
771
+ produce multiple turns.
772
+
773
+ Args:
774
+ interaction: A dict from the Interactions API GET response.
775
+
776
+ Returns:
777
+ An AgentData object with one or more ConversationTurns.
778
+ """
779
+ typed_interaction = interaction_types.Interaction.model_validate(interaction)
780
+ all_events = _interaction_steps_to_events(typed_interaction.steps or [])
781
+
782
+ # Group events into turns. Each UserInputStep starts a new turn.
783
+ grouped: list[list[types.evals.AgentEvent]] = []
784
+ for event, step_type in all_events:
785
+ if not grouped or step_type is userinputstep.UserInputStep:
786
+ grouped.append([])
787
+ grouped[-1].append(event)
788
+
789
+ # Merge leading sandbox-only turns into the first real turn.
790
+ # Sandbox provisioning events (provision_sandbox, load_sandbox) are
791
+ # infrastructure setup that precedes the user's first real prompt.
792
+ while len(grouped) > 1 and _evals_builtin_tools.is_sandbox_only_turn(grouped[0]):
793
+ grouped[1] = grouped[0] + grouped[1]
794
+ grouped.pop(0)
795
+
796
+ if not grouped:
797
+ return types.evals.AgentData( # pytype: disable=missing-parameter
798
+ turns=[
799
+ types.evals.ConversationTurn( # pytype: disable=missing-parameter
800
+ turn_index=0, events=[]
801
+ )
802
+ ]
803
+ )
804
+ return types.evals.AgentData( # pytype: disable=missing-parameter
805
+ turns=[
806
+ types.evals.ConversationTurn( # pytype: disable=missing-parameter
807
+ turn_index=i, events=events
808
+ )
809
+ for i, events in enumerate(grouped)
810
+ ]
811
+ )
812
+
813
+
814
+ def _merge_text_parts_in_agent_data(
815
+ agent_data: types.evals.AgentData,
816
+ ) -> None:
817
+ """Merges consecutive text events and parts for cleaner trace display.
818
+
819
+ The Interaction API may return multiple consecutive ``model_output``
820
+ steps (one per paragraph) and/or multiple text content items within a
821
+ single step. ``_interaction_dict_to_agent_data`` maps each step to a
822
+ separate event, and each content item to a separate ``part``, causing
823
+ the trace renderer to display them as separate visual blocks.
824
+
825
+ This function performs two merges:
826
+
827
+ 1. **Event merge** -- consecutive events from the same author that
828
+ contain only text parts are collapsed into a single event.
829
+ 2. **Part merge** -- within each (possibly merged) event, consecutive
830
+ text-only parts are collapsed into a single part.
831
+
832
+ Mutates ``agent_data`` in place.
833
+
834
+ Args:
835
+ agent_data: An AgentData object to merge in place.
836
+ """
837
+ for turn in agent_data.turns or []:
838
+ events = turn.events
839
+ if not events:
840
+ continue
841
+
842
+ # --- Pass 1: merge consecutive text-only events from the same author ---
843
+ merged_events: list[types.evals.AgentEvent] = []
844
+ for event in events:
845
+ parts = (event.content.parts if event.content else None) or []
846
+ is_text_only = parts and all(
847
+ p.text is not None
848
+ and p.function_call is None
849
+ and p.function_response is None
850
+ for p in parts
851
+ )
852
+ if (
853
+ merged_events
854
+ and is_text_only
855
+ and event.author == merged_events[-1].author
856
+ ):
857
+ prev_content = merged_events[-1].content
858
+ prev_parts = (prev_content.parts if prev_content else None) or []
859
+ prev_parts.extend(parts)
860
+ continue
861
+ merged_events.append(event)
862
+ turn.events = merged_events
863
+
864
+ # --- Pass 2: merge consecutive text parts within each event ---
865
+ for event in turn.events:
866
+ content = event.content
867
+ if not content:
868
+ continue
869
+ parts = content.parts
870
+ if not parts or len(parts) <= 1:
871
+ continue
872
+ merged_parts: list[genai_types.Part] = []
873
+ text_buffer: list[str] = []
874
+ for part in parts:
875
+ if (
876
+ part.text is not None
877
+ and part.function_call is None
878
+ and part.function_response is None
879
+ ):
880
+ text_buffer.append(part.text)
881
+ else:
882
+ if text_buffer:
883
+ merged_parts.append(
884
+ genai_types.Part(text="\n".join(text_buffer))
885
+ )
886
+ text_buffer = []
887
+ merged_parts.append(part)
888
+ if text_buffer:
889
+ merged_parts.append(genai_types.Part(text="\n".join(text_buffer)))
890
+ content.parts = merged_parts
891
+
892
+
893
+ _agent_tools_to_config_tools = _evals_builtin_tools.agent_tools_to_config_tools
894
+
895
+
896
+ def _fetch_agent_config_dict(
897
+ api_client: BaseApiClient,
898
+ agent_resource_name: str,
899
+ ) -> types.evals.AgentConfig:
900
+ """Fetches an agent's config from the Agent API and returns an AgentConfig.
901
+
902
+ Fetches the Agent resource via ``GET agents/{id}`` and extracts the
903
+ system instruction, description, base agent type, and tools. Built-in
904
+ tool types (``code_execution``, ``filesystem``, etc.) are expanded into
905
+ concrete ``FunctionDeclaration`` names and descriptions via the
906
+ display-only catalog in ``_evals_builtin_tools``.
907
+
908
+ Args:
909
+ api_client: The API client used to fetch the agent.
910
+ agent_resource_name: Full resource name of the agent, e.g.
911
+ ``projects/p/locations/l/agents/my-agent``.
912
+
913
+ Returns:
914
+ An AgentConfig with ``agent_id`` and, when available,
915
+ ``instruction``, ``description``, ``agent_type``, and ``tools``.
916
+ """
917
+ parts = agent_resource_name.split("/")
918
+ agent_location = None
919
+ agent_short_id = "agent"
920
+
921
+ # Expected format: projects/{project}/locations/{location}/agents/{agent_id}
922
+ if (
923
+ len(parts) >= 6
924
+ and parts[0] == "projects"
925
+ and parts[2] == "locations"
926
+ and parts[4] == "agents"
927
+ ):
928
+ agent_location = parts[3]
929
+ agent_short_id = parts[5]
930
+ else:
931
+ agent_short_id = parts[-1] or "agent"
932
+
933
+ instruction: Optional[str] = None
934
+ description: Optional[str] = None
935
+ agent_type: Optional[str] = None
936
+ tools: Optional[list[genai_types.Tool]] = None
937
+
938
+ client_location = _get_resolved_location(api_client)
939
+
940
+ if agent_location and client_location and agent_location != client_location:
941
+ logger.warning(
942
+ "Skipping agent config fetch for '%s' due to location mismatch. "
943
+ "Agent location is '%s', but client location is '%s'. "
944
+ "To fetch the agent config, configure a client with matching location.",
945
+ agent_resource_name,
946
+ agent_location,
947
+ client_location,
948
+ )
949
+ else:
950
+ try:
951
+ request_path = (
952
+ agent_resource_name if agent_location else f"agents/{agent_short_id}"
953
+ )
954
+ agent_resp = api_client.request("get", request_path, {}, None)
955
+ if agent_resp.body:
956
+ agent_dict = json.loads(agent_resp.body)
957
+ instruction = agent_dict.get("system_instruction") or None
958
+ description = agent_dict.get("description") or None
959
+ agent_type = agent_dict.get("base_agent") or None
960
+ tools = _agent_tools_to_config_tools(agent_dict.get("tools"))
961
+ except Exception as e: # pylint: disable=broad-exception-caught
962
+ logger.warning(
963
+ "Failed to fetch agent config for '%s' (continuing without it): %s",
964
+ agent_resource_name,
965
+ e,
966
+ )
967
+
968
+ return types.evals.AgentConfig( # pytype: disable=missing-parameter
969
+ agent_id=agent_short_id,
970
+ instruction=instruction,
971
+ description=description,
972
+ agent_type=agent_type,
973
+ tools=tools,
974
+ )
975
+
976
+
977
+ def _get_resolved_location(api_client: Any) -> Optional[str]:
978
+ """Returns the location configured on the API client."""
979
+ loc = getattr(api_client, "location", None)
980
+ if isinstance(loc, str):
981
+ return loc
982
+ return None
983
+
984
+
985
+ class _InteractionsRestClient:
986
+ """Minimal Interactions API client issued through the SDK api_client.
987
+
988
+ Calls go through `api_client.request()` (rather than the google.genai
989
+ `_gaos` client) so that the `ReplayApiClient` records and replays them.
990
+ Requests and responses are plain dicts.
991
+ """
992
+
993
+ def __init__(self, api_client: BaseApiClient):
994
+ self._api_client = api_client
995
+
996
+ def create(self, request_dict: dict[str, Any]) -> dict[str, Any]:
997
+ response = self._api_client.request(
998
+ "post",
999
+ "interactions",
1000
+ request_dict,
1001
+ http_options={"timeout": _INTERACTION_REQUEST_TIMEOUT_MS},
1002
+ )
1003
+ return json.loads(response.body) if response.body else {}
1004
+
1005
+ def get(self, interaction_id: str) -> dict[str, Any]:
1006
+ response = self._api_client.request(
1007
+ "get",
1008
+ f"interactions/{interaction_id}",
1009
+ {},
1010
+ http_options={"timeout": _INTERACTION_REQUEST_TIMEOUT_MS},
1011
+ )
1012
+ return json.loads(response.body) if response.body else {}
1013
+
1014
+
1015
+ def _get_interactions_client(api_client: BaseApiClient) -> _InteractionsRestClient:
1016
+ """Returns an Interactions API client bound to `api_client`.
1017
+
1018
+ The client issues calls through the SDK's existing `api_client` (a
1019
+ `BaseApiClient`, or a `ReplayApiClient` in tests) so that replay recording
1020
+ captures the interaction calls.
1021
+
1022
+ Args:
1023
+ api_client: The API client used to issue interaction calls.
1024
+
1025
+ Returns:
1026
+ An `_InteractionsRestClient`.
1027
+ """
1028
+ return _InteractionsRestClient(api_client)
1029
+
1030
+
1031
+ def _agent_data_response_text(agent_data: types.evals.AgentData) -> Optional[str]:
1032
+ """Concatenates the text of all model-role events in an AgentData."""
1033
+ text_parts: list[str] = []
1034
+ for turn in agent_data.turns or []:
1035
+ for event in turn.events or []:
1036
+ content = event.content
1037
+ if not content or content.role != _evals_constant.MODEL_AUTHOR:
1038
+ continue
1039
+ for part in content.parts or []:
1040
+ if part.text:
1041
+ text_parts.append(part.text)
1042
+ return "".join(text_parts) or None
1043
+
1044
+
1045
+ _INTERACTION_TERMINAL_STATES = frozenset(
1046
+ ["completed", "failed", "cancelled", "incomplete", "budget_exceeded"]
1047
+ )
1048
+
1049
+ _INITIAL_POLL_INTERVAL_SECONDS = 2.0
1050
+ _MAX_POLL_INTERVAL_SECONDS = 30.0
1051
+ _POLL_BACKOFF_MULTIPLIER = 2.0
1052
+
1053
+
1054
+ def _await_interaction(
1055
+ interactions_client: "_InteractionsRestClient",
1056
+ interaction: dict[str, Any],
1057
+ initial_poll_interval_seconds: float = _INITIAL_POLL_INTERVAL_SECONDS,
1058
+ max_poll_interval_seconds: float = _MAX_POLL_INTERVAL_SECONDS,
1059
+ poll_backoff_multiplier: float = _POLL_BACKOFF_MULTIPLIER,
1060
+ timeout_seconds: float = 600.0,
1061
+ ) -> dict[str, Any]:
1062
+ """Polls a background interaction until it reaches a terminal state.
1063
+
1064
+ Gemini agent interactions must run in the background (`background=True`), so
1065
+ `create` returns before the model output is ready. This polls
1066
+ `interactions.get` until the interaction reaches a terminal state and then
1067
+ returns the resolved interaction. The delay between polls grows
1068
+ exponentially (capped at `max_poll_interval_seconds`) to avoid hitting rate
1069
+ limits when evaluating large datasets.
1070
+
1071
+ Args:
1072
+ interactions_client: The interactions client used to poll.
1073
+ interaction: The interaction returned by `create`.
1074
+ initial_poll_interval_seconds: Delay before the first poll.
1075
+ max_poll_interval_seconds: Upper bound for the poll interval.
1076
+ poll_backoff_multiplier: Factor the interval grows by after each poll.
1077
+ timeout_seconds: Maximum time to wait before raising.
1078
+
1079
+ Returns:
1080
+ The resolved interaction once it reaches a terminal state.
1081
+
1082
+ Raises:
1083
+ TimeoutError: If the interaction does not complete within the timeout.
1084
+ """
1085
+ if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1086
+ return interaction
1087
+ interaction_id = interaction.get("id")
1088
+ deadline = time.monotonic() + timeout_seconds
1089
+ poll_interval = initial_poll_interval_seconds
1090
+ while True:
1091
+ remaining = deadline - time.monotonic()
1092
+ if remaining <= 0:
1093
+ break
1094
+ time.sleep(min(poll_interval, remaining))
1095
+ interaction = interactions_client.get(interaction_id)
1096
+ if interaction.get("status") in _INTERACTION_TERMINAL_STATES:
1097
+ return interaction
1098
+ poll_interval = min(
1099
+ poll_interval * poll_backoff_multiplier, max_poll_interval_seconds
1100
+ )
1101
+ raise TimeoutError(
1102
+ f"Interaction {interaction_id} did not complete within"
1103
+ f" {timeout_seconds} seconds."
1104
+ )
1105
+
1106
+
1107
+ def _run_gemini_agent_inference(
1108
+ *,
1109
+ api_client: BaseApiClient,
1110
+ gemini_agent: str,
1111
+ prompt_dataset: pd.DataFrame,
1112
+ ) -> pd.DataFrame:
1113
+ """Runs inference against a Gemini Agents API agent via the Interactions API.
1114
+
1115
+ For each prompt row, creates an interaction against `gemini_agent` and
1116
+ collects the interaction id, response text, and agent data.
1117
+
1118
+ Args:
1119
+ api_client: The API client used to issue interaction calls.
1120
+ gemini_agent: The Gemini Agents API agent resource name.
1121
+ prompt_dataset: The prompt DataFrame. The prompt is read from the
1122
+ `request` column if present, otherwise from the `prompt` column.
1123
+
1124
+ Returns:
1125
+ A DataFrame with columns prompt, response, interaction_id, agent_data.
1126
+ """
1127
+ prompt_column = (
1128
+ "request" if "request" in prompt_dataset.columns else _evals_constant.PROMPT
1129
+ )
1130
+ if prompt_column not in prompt_dataset.columns:
1131
+ raise ValueError(
1132
+ "The eval dataset provided for Gemini agent inference must contain a"
1133
+ f" '{_evals_constant.PROMPT}' or 'request' column."
1134
+ )
1135
+
1136
+ interactions_client = _get_interactions_client(api_client)
1137
+
1138
+ # Best-effort: fetch the agent config (instruction, tools, description)
1139
+ # once, so every row's agent_data carries the agents map and the display
1140
+ # can render the System Topology section.
1141
+ agent_config = _fetch_agent_config_dict(api_client, gemini_agent)
1142
+
1143
+ agent_short_id = gemini_agent.split("/")[-1]
1144
+ prompts: list[str] = []
1145
+ responses: list[Optional[str]] = []
1146
+ interaction_ids: list[Optional[str]] = []
1147
+ agent_data: list[dict[str, Any]] = []
1148
+ for prompt in tqdm(
1149
+ prompt_dataset[prompt_column].tolist(), desc="Gemini Agent Inference"
1150
+ ):
1151
+ prompts.append(prompt)
1152
+ try:
1153
+ interaction = interactions_client.create(
1154
+ {
1155
+ "agent": agent_short_id,
1156
+ "input": [{"type": "text", "text": prompt}],
1157
+ "store": True,
1158
+ "background": True,
1159
+ }
1160
+ )
1161
+ interaction = _await_interaction(interactions_client, interaction)
1162
+ agent_data_obj = _interaction_dict_to_agent_data(interaction)
1163
+ agent_data_obj.agents = {agent_config.agent_id: agent_config}
1164
+ _merge_text_parts_in_agent_data(agent_data_obj)
1165
+ responses.append(_agent_data_response_text(agent_data_obj))
1166
+ interaction_ids.append(interaction.get("id"))
1167
+ agent_data.append(agent_data_obj.model_dump(mode="json", exclude_none=True))
1168
+ except Exception as e: # pylint: disable=broad-exception-caught
1169
+ logger.warning(
1170
+ "Gemini agent inference failed for a prompt (recording an empty"
1171
+ " row and continuing): %s",
1172
+ e,
1173
+ )
1174
+ responses.append(None)
1175
+ interaction_ids.append(None)
1176
+ agent_data.append({})
1177
+
1178
+ return pd.DataFrame(
1179
+ {
1180
+ _evals_constant.PROMPT: prompts,
1181
+ _evals_constant.RESPONSE: responses,
1182
+ _evals_constant.INTERACTION_ID: interaction_ids,
1183
+ _evals_constant.AGENT_DATA: agent_data,
1184
+ }
1185
+ )
1186
+
1187
+
1188
+ def _build_user_simulator(
1189
+ row: pd.Series,
1190
+ config: Optional[types.evals.UserSimulatorConfig],
1191
+ api_client: BaseApiClient,
1192
+ ) -> Any:
1193
+ """Builds an ADK `LlmBackedUserSimulator` for a scenario row.
1194
+
1195
+ Reads `starting_prompt` and `conversation_plan` from the row and maps the
1196
+ SDK `UserSimulatorConfig` fields onto ADK's `LlmBackedUserSimulatorConfig`
1197
+ (`model`, `model_configuration`, `max_allowed_invocations`).
1198
+
1199
+ The simulator's model runs in the caller's region. When the installed ADK
1200
+ exposes the `Gemini.client_kwargs` field, the model is pinned to
1201
+ `api_client`'s project and location; otherwise it inherits the ambient
1202
+ client configuration (the SDK client and the ADK model read the same
1203
+ environment). Either way it is never routed to a different region, and the
1204
+ environment is never mutated. A default request timeout is applied so an
1205
+ individual simulator turn cannot hang indefinitely.
1206
+
1207
+ Args:
1208
+ row: A prompt DataFrame row carrying `starting_prompt` and
1209
+ `conversation_plan`.
1210
+ config: The SDK user simulator config, or None to use ADK defaults. It
1211
+ is read only, never mutated.
1212
+ api_client: The SDK API client; its project and location pin the
1213
+ simulator model's region when supported by ADK.
1214
+
1215
+ Returns:
1216
+ A configured `LlmBackedUserSimulator`.
1217
+
1218
+ Raises:
1219
+ ValueError: If `starting_prompt` or `conversation_plan` is missing.
1220
+ """
1221
+ from google.adk.evaluation.conversation_scenarios import ConversationScenario
1222
+ from google.adk.evaluation.simulation.llm_backed_user_simulator import (
1223
+ LlmBackedUserSimulator,
1224
+ )
1225
+ from google.adk.evaluation.simulation.llm_backed_user_simulator import (
1226
+ LlmBackedUserSimulatorConfig,
1227
+ )
1228
+
1229
+ starting_prompt = row.get("starting_prompt")
1230
+ conversation_plan = row.get("conversation_plan")
1231
+ if not starting_prompt or not conversation_plan:
1232
+ raise ValueError(
1233
+ "User simulation requires 'starting_prompt' and 'conversation_plan'"
1234
+ " columns."
1235
+ )
1236
+
1237
+ scenario = ConversationScenario(
1238
+ starting_prompt=starting_prompt,
1239
+ conversation_plan=conversation_plan,
1240
+ user_persona="EVALUATOR",
1241
+ )
1242
+
1243
+ simulator_kwargs: dict[str, Any] = {}
1244
+ model_configuration: dict[str, Any] = {}
1245
+ if config:
1246
+ if config.model_name:
1247
+ simulator_kwargs["model"] = config.model_name
1248
+ if config.model_configuration is not None:
1249
+ model_configuration = config.model_configuration.model_dump(
1250
+ exclude_none=True
1251
+ )
1252
+ if config.max_turn is not None:
1253
+ simulator_kwargs["max_allowed_invocations"] = config.max_turn
1254
+
1255
+ # Bound each simulator turn so a single model call cannot hang forever.
1256
+ http_options = model_configuration.setdefault("http_options", {})
1257
+ http_options.setdefault("timeout", _USER_SIMULATOR_TIMEOUT_MS)
1258
+ simulator_kwargs["model_configuration"] = model_configuration
1259
+
1260
+ simulator = LlmBackedUserSimulator(
1261
+ conversation_scenario=scenario,
1262
+ config=LlmBackedUserSimulatorConfig(**simulator_kwargs),
1263
+ )
1264
+ # Pin the simulator's model to the api_client's project and location so the
1265
+ # simulated-user traffic stays in the caller's region (never routed
1266
+ # elsewhere). ADK builds the model's google.genai client from client_kwargs
1267
+ # when that field is available; otherwise the model inherits the ambient
1268
+ # client configuration (same region as the SDK client).
1269
+ llm = simulator._llm # pylint: disable=protected-access
1270
+ if "client_kwargs" in getattr(type(llm), "model_fields", {}):
1271
+ setattr(
1272
+ llm,
1273
+ "client_kwargs",
1274
+ {
1275
+ "vertexai": True,
1276
+ "project": api_client.project,
1277
+ "location": api_client.location,
1278
+ },
1279
+ )
1280
+ return simulator
1281
+
1282
+
1283
+ def _agent_events_to_adk_events(
1284
+ events: list[types.evals.AgentEvent],
1285
+ ) -> list[Any]:
1286
+ """Converts AgentEvents into ADK `Event`s for the user simulator.
1287
+
1288
+ The user simulator's `get_next_user_message` reads only `author` and
1289
+ `content` off each event, so a minimal ADK `Event` is sufficient.
1290
+
1291
+ Args:
1292
+ events: The conversation history as AgentEvents.
1293
+
1294
+ Returns:
1295
+ A list of ADK `Event` objects.
1296
+ """
1297
+ from google.adk.events.event import Event as AdkEvent
1298
+
1299
+ return [
1300
+ AdkEvent(
1301
+ author=event.author or _evals_constant.MODEL_AUTHOR,
1302
+ content=event.content,
1303
+ invocation_id="user_simulation",
1304
+ )
1305
+ for event in events
1306
+ ]
1307
+
1308
+
1309
+ async def _simulate_scenario(
1310
+ *,
1311
+ api_client: BaseApiClient,
1312
+ interactions_client: "_InteractionsRestClient",
1313
+ agent_short_id: str,
1314
+ row: pd.Series,
1315
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig],
1316
+ ) -> tuple[Optional[str], list[types.evals.ConversationTurn]]:
1317
+ """Runs a single multi-turn simulated conversation for one scenario row.
1318
+
1319
+ Drives the Interactions API turn by turn: the user simulator generates each
1320
+ user message, which is sent to the agent and chained via
1321
+ `previous_interaction_id`. Each interaction's steps are appended as
1322
+ ConversationTurns. Stops when the simulator returns a non-SUCCESS status.
1323
+
1324
+ The loop cannot hang indefinitely: the turn count is bounded by the
1325
+ simulator's `max_allowed_invocations`, each simulator model call and each
1326
+ Interactions API call has a per-request timeout, and the caller wraps this
1327
+ coroutine in an overall per-scenario timeout.
1328
+
1329
+ This coroutine must run inside a single event loop for the whole
1330
+ conversation: the ADK simulator's async HTTP client binds to the running
1331
+ loop, so awaiting each turn on one loop (rather than a per-turn
1332
+ ``asyncio.run``) avoids "Event loop is closed" errors on turn two onward.
1333
+
1334
+ Args:
1335
+ api_client: The SDK API client; pins the simulator model's region.
1336
+ interactions_client: The Interactions API client.
1337
+ agent_short_id: The agent's short id (last path segment).
1338
+ row: The scenario row with `starting_prompt` and `conversation_plan`.
1339
+ user_simulator_config: The user simulator configuration.
1340
+
1341
+ Returns:
1342
+ A tuple of (last interaction id, list of ConversationTurns).
1343
+
1344
+ Raises:
1345
+ RuntimeError: If no turns are produced for the scenario.
1346
+ """
1347
+ from google.adk.evaluation.simulation.user_simulator import Status
1348
+
1349
+ simulator = _build_user_simulator(row, user_simulator_config, api_client)
1350
+ turns: list[types.evals.ConversationTurn] = []
1351
+ conversation: list[types.evals.AgentEvent] = []
1352
+ previous_interaction_id: Optional[str] = None
1353
+
1354
+ while True:
1355
+ next_message = await simulator.get_next_user_message(
1356
+ _agent_events_to_adk_events(conversation)
1357
+ )
1358
+ if next_message.status != Status.SUCCESS:
1359
+ break
1360
+
1361
+ request: dict[str, Any] = {
1362
+ "agent": agent_short_id,
1363
+ "input": [
1364
+ {
1365
+ "type": "text",
1366
+ "text": _evals_data_converters._get_content_text(
1367
+ next_message.user_message
1368
+ ),
1369
+ }
1370
+ ],
1371
+ "store": True,
1372
+ "background": True,
1373
+ }
1374
+ if previous_interaction_id:
1375
+ request["previous_interaction_id"] = previous_interaction_id
1376
+
1377
+ interaction = interactions_client.create(request)
1378
+ interaction = _await_interaction(interactions_client, interaction)
1379
+ previous_interaction_id = interaction.get("id")
1380
+
1381
+ turn_data = _interaction_dict_to_agent_data(interaction)
1382
+ _merge_text_parts_in_agent_data(turn_data)
1383
+ for turn in turn_data.turns or []:
1384
+ turn.turn_index = len(turns)
1385
+ turns.append(turn)
1386
+ conversation.extend(turn.events or [])
1387
+
1388
+ if not turns:
1389
+ raise RuntimeError("User simulation produced no turns for a scenario.")
1390
+ return previous_interaction_id, turns
1391
+
1392
+
1393
+ def _run_gemini_agent_user_simulation(
1394
+ *,
1395
+ api_client: BaseApiClient,
1396
+ gemini_agent: str,
1397
+ prompt_dataset: pd.DataFrame,
1398
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
1399
+ allow_cross_region_model: bool = False,
1400
+ ) -> pd.DataFrame:
1401
+ """Runs multi-turn user simulation against a Gemini Agents API agent.
1402
+
1403
+ For each scenario row, an ADK `LlmBackedUserSimulator` generates the user
1404
+ turns while the conversation is driven client-side through the Interactions
1405
+ API. Turns are chained with `previous_interaction_id` (stateful mode) so the
1406
+ backend maintains conversation history. The loop stops when the simulator
1407
+ signals completion (turn limit, stop signal, or no message). The simulator
1408
+ model runs in `api_client`'s project and location.
1409
+
1410
+ Args:
1411
+ api_client: The API client used to issue interaction calls and to pin
1412
+ the simulator model's region.
1413
+ gemini_agent: The Gemini Agents API agent resource name.
1414
+ prompt_dataset: The scenario DataFrame. Each row must contain
1415
+ `starting_prompt` and `conversation_plan` columns.
1416
+ user_simulator_config: The user simulator configuration. `model_name`
1417
+ selects the simulator model and `max_turn` caps invocations.
1418
+ allow_cross_region_model: Accepted for API compatibility. The simulator
1419
+ always runs in `api_client`'s region and is never routed elsewhere.
1420
+
1421
+ Returns:
1422
+ A DataFrame with columns starting_prompt, conversation_plan,
1423
+ interaction_id (the last interaction id of the chain), and agent_data
1424
+ (the full multi-turn trace).
1425
+ """
1426
+ del allow_cross_region_model # Simulator always runs in the client region.
1427
+ interactions_client = _get_interactions_client(api_client)
1428
+ agent_short_id = gemini_agent.split("/")[-1]
1429
+
1430
+ def _simulate_one(
1431
+ row: pd.Series,
1432
+ ) -> tuple[Optional[str], dict[str, Any]]:
1433
+ # Run the scenario's async loop in this worker thread. Using
1434
+ # ``asyncio.run`` here (rather than on the calling thread) keeps the
1435
+ # path usable from environments that already run an event loop, such as
1436
+ # Colab or Jupyter, where a top-level ``asyncio.run`` would raise
1437
+ # "asyncio.run() cannot be called from a running event loop".
1438
+ last_interaction_id, turns = asyncio.run(
1439
+ asyncio.wait_for(
1440
+ _simulate_scenario(
1441
+ api_client=api_client,
1442
+ interactions_client=interactions_client,
1443
+ agent_short_id=agent_short_id,
1444
+ row=row,
1445
+ user_simulator_config=user_simulator_config,
1446
+ ),
1447
+ timeout=_USER_SIMULATION_SCENARIO_TIMEOUT_SECONDS,
1448
+ )
1449
+ )
1450
+ return last_interaction_id, types.evals.AgentData(
1451
+ turns=turns
1452
+ ).model_dump( # pytype: disable=missing-parameter
1453
+ mode="json", exclude_none=True
1454
+ )
1455
+
1456
+ num_rows = len(prompt_dataset)
1457
+ interaction_ids: list[Optional[str]] = [None] * num_rows
1458
+ agent_data: list[dict[str, Any]] = [{}] * num_rows
1459
+ max_workers = max(1, min(num_rows, AGENT_MAX_WORKERS))
1460
+ with tqdm(total=num_rows, desc="Gemini Agent User Simulation") as pbar:
1461
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
1462
+ future_to_index = {
1463
+ executor.submit(_simulate_one, row): index
1464
+ for index, (_, row) in enumerate(prompt_dataset.iterrows())
1465
+ }
1466
+ for future in concurrent.futures.as_completed(future_to_index):
1467
+ index = future_to_index[future]
1468
+ try:
1469
+ interaction_ids[index], agent_data[index] = future.result()
1470
+ except _USER_SIMULATION_SCENARIO_ERRORS:
1471
+ logger.exception(
1472
+ "Gemini agent user simulation failed for a scenario"
1473
+ " (recording an empty row and continuing)."
1474
+ )
1475
+ pbar.update(1)
1476
+
1477
+ results_df = prompt_dataset.reset_index(drop=True).copy()
1478
+ overlap = results_df.columns.intersection(
1479
+ [_evals_constant.INTERACTION_ID, _evals_constant.AGENT_DATA]
1480
+ )
1481
+ if not overlap.empty:
1482
+ results_df = results_df.drop(columns=overlap)
1483
+ results_df[_evals_constant.INTERACTION_ID] = interaction_ids
1484
+ results_df[_evals_constant.AGENT_DATA] = agent_data
1485
+ return results_df
1486
+
1487
+
1488
+ def _normalize_interaction_resource(
1489
+ interaction: str, agent: str, location: Optional[str]
1490
+ ) -> str:
1491
+ """Normalizes an interaction id into a full resource name.
1492
+
1493
+ A bare interaction id is expanded to
1494
+ `projects/{project}/locations/{location}/interactions/{id}` using the
1495
+ project and location parsed from the agent resource name. Fully-qualified
1496
+ interaction resource names are returned unchanged.
1497
+ """
1498
+ if interaction.startswith("projects/"):
1499
+ return interaction
1500
+ parts = agent.split("/")
1501
+ project = parts[1]
1502
+ agent_location = parts[3] if len(parts) > 3 else (location or "global")
1503
+ return f"projects/{project}/locations/{agent_location}/interactions/{interaction}"
1504
+
1505
+
1506
+ def _build_interaction_id_dataset(
1507
+ loaded_data: list[dict[str, Any]],
1508
+ agent: Optional[str],
1509
+ location: Optional[str],
1510
+ ) -> Optional[types.EvaluationDataset]:
1511
+ """Builds an EvaluationDataset from rows that carry an `interaction_id`.
1512
+
1513
+ When the dataset contains an `interaction_id` column, each row is turned
1514
+ into an EvalCase whose `interactions_data_source` references the interaction
1515
+ and the Gemini agent. The backend resolves the interaction trace and agent
1516
+ config; no client-side prompt/response is required. Returns None if the
1517
+ data does not contain interaction ids.
1518
+ """
1519
+ has_interaction_id = bool(loaded_data) and any(
1520
+ _evals_constant.INTERACTION_ID in row for row in loaded_data
1521
+ )
1522
+ if not has_interaction_id:
1523
+ if agent:
1524
+ raise ValueError(
1525
+ "An `agent` was provided but the dataset does not contain an"
1526
+ " `interaction_id` column. The `agent` argument is only used to"
1527
+ " resolve an `interaction_id` dataset column (so the backend can"
1528
+ " fetch the interaction trace and Agent config). To evaluate"
1529
+ " with an agent, provide a dataset with an `interaction_id`"
1530
+ " column; otherwise omit `agent`."
1531
+ )
1532
+ return None
1533
+
1534
+ if not agent:
1535
+ raise ValueError(
1536
+ "An `agent` resource name is required when the dataset contains an"
1537
+ " `interaction_id` column, so the backend can resolve the Agent"
1538
+ " config for each interaction."
1539
+ )
1540
+ if not _is_gemini_agent_resource(agent):
1541
+ raise ValueError(
1542
+ "`agent` must be a Gemini Agents API resource name of the form"
1543
+ " projects/{project}/locations/{location}/agents/{agent} when"
1544
+ f" evaluating interaction ids. Got: {agent}"
1545
+ )
1546
+
1547
+ gemini_agent_config = types.GeminiAgentConfig(gemini_agent=agent)
1548
+ eval_cases = []
1549
+ for i, row in enumerate(loaded_data):
1550
+ interaction = row.get(_evals_constant.INTERACTION_ID)
1551
+ if not interaction:
1552
+ raise ValueError(f"Missing `interaction_id` value for row {i}.")
1553
+ eval_cases.append(
1554
+ types.EvalCase(
1555
+ eval_case_id=f"eval_case_{i}",
1556
+ interactions_data_source=types.InteractionsDataSource(
1557
+ interaction=_normalize_interaction_resource(
1558
+ str(interaction), agent, location
1559
+ ),
1560
+ gemini_agent_config=gemini_agent_config,
1561
+ ),
1562
+ )
1563
+ )
1564
+ return types.EvaluationDataset(eval_cases=eval_cases)
1565
+
1566
+
1567
+ # Metrics supported for Managed Agent evaluation.
1568
+ _MANAGED_AGENT_SUPPORTED_METRICS = frozenset(
1569
+ {
1570
+ "safety_v1",
1571
+ "final_response_quality_v1",
1572
+ "multi_turn_task_success_v1",
1573
+ }
1574
+ )
1575
+
1576
+
1577
+ def _has_interactions_data_source(
1578
+ eval_cases: list[types.EvalCase],
1579
+ ) -> bool:
1580
+ """Returns True if any EvalCase has interactions_data_source set."""
1581
+ return any(case.interactions_data_source is not None for case in eval_cases)
1582
+
1583
+
1584
+ def _validate_managed_agent_metrics(
1585
+ agent: Optional[str],
1586
+ metrics: Union[list[types.Metric], list[types.EvaluationRunMetric]],
1587
+ ) -> None:
1588
+ """Validates metrics are supported for Managed Agent evaluation.
1589
+
1590
+ When the ``agent`` parameter is a Gemini Agent resource name
1591
+ (``projects/{p}/locations/{l}/agents/{id}``), only a subset of
1592
+ metrics are supported for Preview. This function raises ValueError
1593
+ if any unsupported metrics are requested.
1594
+
1595
+ Args:
1596
+ agent: The agent resource name, or None.
1597
+ metrics: The list of metrics to validate. Accepts either
1598
+ ``types.Metric`` objects (which expose a ``name`` attribute)
1599
+ or ``types.EvaluationRunMetric`` objects (which expose a
1600
+ ``metric`` attribute holding the metric name string).
1601
+
1602
+ Raises:
1603
+ ValueError: If any metric is not in the supported set.
1604
+ """
1605
+ if not agent or not _is_gemini_agent_resource(agent):
1606
+ return
1607
+
1608
+ unsupported = []
1609
+ for metric in metrics:
1610
+ # EvaluationRunMetric uses `.metric` (str); types.Metric uses `.name`.
1611
+ name = getattr(metric, "metric", None) or getattr(metric, "name", None)
1612
+ if name:
1613
+ name_lower = name.lower()
1614
+ if name_lower not in _MANAGED_AGENT_SUPPORTED_METRICS:
1615
+ unsupported.append(name_lower)
1616
+ if unsupported:
1617
+ raise ValueError(
1618
+ f"Metrics {unsupported} are not supported for Managed Agent"
1619
+ " evaluation. Supported metrics:"
1620
+ f" {sorted(_MANAGED_AGENT_SUPPORTED_METRICS)}."
1621
+ )
1622
+
1623
+
1624
+ def _resolve_interactions_to_eval_cases(
1625
+ api_client: BaseApiClient,
1626
+ eval_cases: list[types.EvalCase],
1627
+ ) -> list[types.EvalCase]:
1628
+ """Resolves EvalCases with interactions_data_source to agent_data.
1629
+
1630
+ For each EvalCase that has interactions_data_source set, fetches the
1631
+ Interaction via the SDK's interactions.get() API, converts the steps
1632
+ to AgentData, and returns a new EvalCase with agent_data populated.
1633
+
1634
+ Args:
1635
+ api_client: The API client (must have an interactions module).
1636
+ eval_cases: EvalCases with interactions_data_source set.
1637
+
1638
+ Returns:
1639
+ New list of EvalCases with agent_data populated from resolved
1640
+ interactions.
1641
+
1642
+ Raises:
1643
+ ValueError: If eval_cases have missing interaction references.
1644
+ """
1645
+ # Validate all cases up front before making any API calls.
1646
+ for case in eval_cases:
1647
+ ids = case.interactions_data_source
1648
+ if ids is None:
1649
+ raise ValueError(
1650
+ "All eval_cases must have interactions_data_source set when"
1651
+ " using interaction resolution. Found a case without it. Do"
1652
+ " not mix interaction-based and prompt-based eval cases."
1653
+ )
1654
+ if not ids.interaction:
1655
+ raise ValueError(
1656
+ "interactions_data_source.interaction is required. Each"
1657
+ " EvalCase must reference an existing Interaction resource."
1658
+ )
1659
+
1660
+ resolved_cases = []
1661
+
1662
+ for case in eval_cases:
1663
+ if case.agent_data:
1664
+ resolved_cases.append(case)
1665
+ continue
1666
+ ids = case.interactions_data_source
1667
+
1668
+ # Extract the interaction short ID from the resource name.
1669
+ # Handles both full resource names (projects/.../interactions/{id})
1670
+ # and bare IDs by always taking the last path component.
1671
+ interaction_id = ids.interaction.split("/")[-1]
1672
+
1673
+ logger.info("Fetching interaction: %s", ids.interaction)
1674
+
1675
+ current_interaction_id = interaction_id
1676
+ interactions = []
1677
+ seen_ids = set()
1678
+ for _ in range(_MAX_INTERACTION_CHAIN_DEPTH):
1679
+ if current_interaction_id in seen_ids:
1680
+ break
1681
+ seen_ids.add(current_interaction_id)
1682
+ path = f"interactions/{current_interaction_id}"
1683
+ response = api_client.request("get", path, {}, None)
1684
+ if not response.body:
1685
+ if not interactions:
1686
+ logger.warning(
1687
+ "Empty response fetching interaction %s.",
1688
+ ids.interaction,
1689
+ )
1690
+ break
1691
+ interaction_dict = json.loads(response.body)
1692
+ try:
1693
+ typed_interaction = interaction_types.Interaction.model_validate(
1694
+ interaction_dict
1695
+ )
1696
+ except Exception as e:
1697
+ logger.warning("Failed to validate interaction model: %s", e)
1698
+ break
1699
+
1700
+ interactions.append(typed_interaction)
1701
+ if not typed_interaction.previous_interaction_id:
1702
+ break
1703
+ current_interaction_id = typed_interaction.previous_interaction_id.split(
1704
+ "/"
1705
+ )[-1]
1706
+
1707
+ if not interactions:
1708
+ agent_data = types.evals.AgentData(turns=[]) # Fallback
1709
+ else:
1710
+ interactions.reverse() # chronological order
1711
+ all_steps = []
1712
+ for i_typed in interactions:
1713
+ all_steps.extend(i_typed.steps or [])
1714
+
1715
+ combined_interaction = interactions[-1].model_dump()
1716
+ combined_interaction["steps"] = all_steps
1717
+ agent_data = _interaction_dict_to_agent_data(combined_interaction)
1718
+
1719
+ # Best-effort: fetch the agent config (instruction, tools,
1720
+ # description) from the Agent API so the display can render
1721
+ # the System Topology section.
1722
+ gemini_cfg = ids.gemini_agent_config
1723
+ agent_name = gemini_cfg.gemini_agent if gemini_cfg else None
1724
+ agent_config = _fetch_agent_config_dict(api_client, agent_name or "")
1725
+ agent_data.agents = {agent_config.agent_id: agent_config}
1726
+
1727
+ # Merge consecutive text events and parts so multi-paragraph
1728
+ # responses render as a single block in the trace display.
1729
+ _merge_text_parts_in_agent_data(agent_data)
1730
+
1731
+ # Preserve all original EvalCase fields; only update agent_data
1732
+ # and clear the now-resolved interactions_data_source.
1733
+ resolved_cases.append(
1734
+ case.model_copy(
1735
+ update={
1736
+ "agent_data": agent_data,
1737
+ "interactions_data_source": None,
1738
+ }
1739
+ )
1740
+ )
1741
+
1742
+ return resolved_cases
1743
+
1744
+
1745
+ def _resolve_interactions_for_display(
1746
+ api_client: BaseApiClient,
1747
+ dataset_list: list[types.EvaluationDataset],
1748
+ ) -> list[types.EvaluationDataset]:
1749
+ """Resolves Interaction traces for visualization."""
1750
+ resolved_datasets = []
1751
+ for dataset in dataset_list:
1752
+ if dataset.eval_cases and _has_interactions_data_source(dataset.eval_cases):
1753
+ try:
1754
+ resolved_cases = _resolve_interactions_to_eval_cases(
1755
+ api_client, dataset.eval_cases
1756
+ )
1757
+ resolved_datasets.append(
1758
+ dataset.model_copy(update={"eval_cases": resolved_cases})
1759
+ )
1760
+ except Exception as e:
1761
+ logger.warning("Failed to resolve interactions for display: %s", e)
1762
+ resolved_datasets.append(dataset)
1763
+ else:
1764
+ resolved_datasets.append(dataset)
1765
+ return resolved_datasets
1766
+
1767
+
1768
+ def _add_evaluation_run_labels(
1769
+ labels: Optional[dict[str, str]] = None,
1770
+ agent: Optional[str] = None,
1771
+ ) -> Optional[dict[str, str]]:
1772
+ """Adds labels to the evaluation run."""
1773
+ if agent and "reasoningEngines/" in agent and not _is_gemini_agent_resource(agent):
1774
+ labels = labels or {}
1775
+ labels["vertex-ai-evaluation-agent-engine-id"] = agent.split(
1776
+ "reasoningEngines/"
1777
+ )[-1]
1778
+ return labels
1779
+
1780
+
1781
+ def _get_candidate_name(
1782
+ dataset: types.EvaluationDataset,
1783
+ parsed_agent_info: Optional[types.evals.AgentInfo] = None,
1784
+ ) -> Optional[str]:
1785
+ """Internal helper to get candidate name."""
1786
+ if parsed_agent_info is not None and (
1787
+ dataset.candidate_name
1788
+ and parsed_agent_info
1789
+ and parsed_agent_info.name
1790
+ and dataset.candidate_name != parsed_agent_info.name
1791
+ ):
1792
+ logger.warning(
1793
+ "Evaluation dataset candidate_name and agent_info.name are different. Please make sure this is intended."
1794
+ )
1795
+ elif dataset.candidate_name is None and parsed_agent_info:
1796
+ return parsed_agent_info.name
1797
+ return dataset.candidate_name or None
1798
+
1799
+
1800
+ def _execute_inference_concurrently(
1801
+ api_client: BaseApiClient,
1802
+ prompt_dataset: pd.DataFrame,
1803
+ progress_desc: str,
1804
+ model_or_fn: Optional[Union[str, Callable[[Any], Any]]] = None,
1805
+ gemini_config: Optional[genai_types.GenerateContentConfig] = None,
1806
+ inference_fn: Optional[Callable[..., Any]] = None,
1807
+ agent_engine: Optional[Union[str, types.AgentEngine]] = None,
1808
+ agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
1809
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
1810
+ ) -> list[
1811
+ Union[
1812
+ genai_types.GenerateContentResponse,
1813
+ dict[str, Any],
1814
+ list[dict[str, Any]],
1815
+ ]
1816
+ ]:
1817
+ """Internal helper to run inference with concurrency."""
1818
+ logger.info(
1819
+ "Generating responses for %d prompts using model or function: %s",
1820
+ len(prompt_dataset),
1821
+ model_or_fn,
1822
+ )
1823
+ responses: list[
1824
+ Union[
1825
+ genai_types.GenerateContentResponse,
1826
+ dict[str, Any],
1827
+ list[dict[str, Any]],
1828
+ None,
1829
+ ]
1830
+ ] = [None] * len(prompt_dataset)
1831
+ tasks = []
1832
+
1833
+ # When running with an agent and agent_data is present, we extract the
1834
+ # prompt from the structured agent_data rather than requiring a flat
1835
+ # prompt/request column.
1836
+ has_agent_data = (
1837
+ agent is not None or agent_engine is not None
1838
+ ) and AGENT_DATA in prompt_dataset.columns
1839
+
1840
+ primary_prompt_column: Optional[str] = None
1841
+ if "request" in prompt_dataset.columns:
1842
+ primary_prompt_column = "request"
1843
+ elif "prompt" in prompt_dataset.columns:
1844
+ primary_prompt_column = "prompt"
1845
+ elif "starting_prompt" in prompt_dataset.columns:
1846
+ primary_prompt_column = "starting_prompt"
1847
+ elif not has_agent_data:
1848
+ raise ValueError(
1849
+ "Dataset must contain either 'prompt', 'request', or"
1850
+ " 'starting_prompt'."
1851
+ f" Found: {prompt_dataset.columns.tolist()}"
1852
+ )
1853
+
1854
+ max_workers = AGENT_MAX_WORKERS if agent_engine or agent else MAX_WORKERS
1855
+ with tqdm(total=len(prompt_dataset), desc=progress_desc) as pbar:
1856
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
1857
+ for index, row in prompt_dataset.iterrows():
1858
+ try:
1859
+ if (
1860
+ has_agent_data
1861
+ and AGENT_DATA in row.index
1862
+ and row.get(AGENT_DATA) is not None
1863
+ ):
1864
+ agent_data_obj = row[AGENT_DATA]
1865
+ if isinstance(agent_data_obj, dict):
1866
+ agent_data_obj = types.evals.AgentData.model_validate(
1867
+ agent_data_obj
1868
+ )
1869
+ if _is_n_plus_1_inference(agent_data_obj):
1870
+ last_user_content, _ = _extract_prompt_from_agent_data(
1871
+ agent_data_obj
1872
+ )
1873
+ contents = _evals_data_converters._get_content_text(
1874
+ last_user_content
1875
+ )
1876
+ else:
1877
+ logger.info(
1878
+ "Row %s has a completed agent trace"
1879
+ " (last event is not from user)."
1880
+ " Skipping inference and using existing"
1881
+ " agent response.",
1882
+ index,
1883
+ )
1884
+ responses[index] = _extract_response_from_completed_trace(
1885
+ agent_data_obj
1886
+ )
1887
+ pbar.update(1)
1888
+ continue
1889
+ else:
1890
+ if primary_prompt_column is None:
1891
+ raise ValueError(
1892
+ "Row has no agent_data and dataset has no"
1893
+ " 'prompt', 'request', or 'starting_prompt'"
1894
+ " column."
1895
+ )
1896
+ request_dict_or_raw_text = row[primary_prompt_column]
1897
+ contents = _extract_contents_for_inference(
1898
+ request_dict_or_raw_text
1899
+ )
1900
+ except ValueError as e:
1901
+ error_message = (
1902
+ f"Failed to extract contents for prompt at index"
1903
+ f" {index}: {e}. Skipping prompt."
1904
+ )
1905
+ logger.error(error_message)
1906
+ responses[index] = {"error": error_message}
1907
+ pbar.update(1)
1908
+ continue
1909
+
1910
+ if agent_engine or agent:
1911
+
1912
+ def agent_run_wrapper( # type: ignore[no-untyped-def]
1913
+ row_arg,
1914
+ contents_arg,
1915
+ agent_engine_arg,
1916
+ agent_arg,
1917
+ inference_fn_arg,
1918
+ api_client_arg,
1919
+ user_simulator_config_arg,
1920
+ ) -> Any:
1921
+ if agent_engine_arg:
1922
+ if isinstance(agent_engine_arg, str):
1923
+ agent_engine_instance = _get_agent_engine_instance(
1924
+ agent_engine_arg, api_client_arg
1925
+ )
1926
+ else:
1927
+ agent_engine_instance = agent_engine_arg
1928
+
1929
+ return inference_fn_arg(
1930
+ row=row_arg,
1931
+ contents=contents_arg,
1932
+ agent_engine=agent_engine_instance,
1933
+ )
1934
+ elif agent_arg:
1935
+ return inference_fn_arg(
1936
+ row=row_arg,
1937
+ contents=contents_arg,
1938
+ user_simulator_config=user_simulator_config_arg,
1939
+ agent=agent_arg,
1940
+ api_client=api_client_arg,
1941
+ )
1942
+
1943
+ future = executor.submit(
1944
+ agent_run_wrapper,
1945
+ row,
1946
+ contents,
1947
+ agent_engine,
1948
+ agent,
1949
+ inference_fn,
1950
+ api_client,
1951
+ user_simulator_config,
1952
+ )
1953
+ elif isinstance(model_or_fn, str):
1954
+ generation_content_config = _build_generate_content_config(
1955
+ request_dict_or_raw_text,
1956
+ gemini_config,
1957
+ )
1958
+ future = executor.submit(
1959
+ inference_fn,
1960
+ api_client=api_client,
1961
+ model=model_or_fn,
1962
+ contents=contents,
1963
+ config=generation_content_config,
1964
+ )
1965
+ else:
1966
+ future = executor.submit(model_or_fn, contents)
1967
+ future.add_done_callback(lambda _: pbar.update(1))
1968
+ tasks.append((future, index))
1969
+
1970
+ for future, index in tasks:
1971
+ try:
1972
+ result = future.result()
1973
+ responses[index] = result
1974
+ except Exception as e:
1975
+ logger.error(
1976
+ "Error processing prompt at index %d: %s",
1977
+ index,
1978
+ e,
1979
+ )
1980
+ responses[index] = {"error": f"Inference task failed: {e}"}
1981
+ return responses # type: ignore[return-value]
1982
+
1983
+
1984
+ def _run_gemini_inference(
1985
+ api_client: BaseApiClient,
1986
+ model: str,
1987
+ prompt_dataset: pd.DataFrame,
1988
+ config: Optional[genai_types.GenerateContentConfig] = None,
1989
+ ) -> list[
1990
+ Union[
1991
+ genai_types.GenerateContentResponse,
1992
+ dict[str, Any],
1993
+ list[dict[str, Any]],
1994
+ ]
1995
+ ]:
1996
+ """Internal helper to run inference using Gemini model with concurrency."""
1997
+ return _execute_inference_concurrently(
1998
+ api_client=api_client,
1999
+ model_or_fn=model,
2000
+ prompt_dataset=prompt_dataset,
2001
+ progress_desc="Gemini Inference",
2002
+ gemini_config=config,
2003
+ inference_fn=_generate_content_with_retry,
2004
+ )
2005
+
2006
+
2007
+ def _run_custom_inference(
2008
+ model_fn: Callable[[Any], Any],
2009
+ prompt_dataset: pd.DataFrame,
2010
+ ) -> list[Any]:
2011
+ """Internal helper to run inference using a custom function with concurrency."""
2012
+ return _execute_inference_concurrently(
2013
+ api_client=None,
2014
+ model_or_fn=model_fn,
2015
+ prompt_dataset=prompt_dataset,
2016
+ progress_desc="Custom Inference",
2017
+ )
2018
+
2019
+
2020
+ def _convert_prompt_row_to_litellm_messages(
2021
+ row: pd.Series,
2022
+ ) -> list[dict[str, Any]]:
2023
+ """Converts a DataFrame row into LiteLLM's messages format by detecting the input schema."""
2024
+ messages: list[dict[str, Any]] = []
2025
+ row_dict = row.to_dict()
2026
+
2027
+ # Case 1: The row is an OpenAI request body itself.
2028
+ if "messages" in row_dict and isinstance(row_dict.get("messages"), list):
2029
+ return row_dict["messages"] # type: ignore[no-any-return]
2030
+
2031
+ # Case 2: The row contains a 'request' key with an OpenAI request body.
2032
+ elif "request" in row_dict and isinstance(row_dict.get("request"), dict):
2033
+ request_body = row_dict["request"]
2034
+ if "messages" in request_body and isinstance(
2035
+ request_body.get("messages"), list
2036
+ ):
2037
+ return request_body["messages"] # type: ignore[no-any-return]
2038
+
2039
+ # Case 3: The 'request' key is in Gemini 'contents' format.
2040
+ elif "contents" in request_body and isinstance(
2041
+ request_body.get("contents"), list
2042
+ ):
2043
+ for content in request_body["contents"]:
2044
+ role = content.get("role", USER_AUTHOR)
2045
+ text_parts = [part.get("text", "") for part in content.get("parts", [])]
2046
+ messages.append({"role": role, "content": " ".join(text_parts)})
2047
+ return messages
2048
+
2049
+ # Case 4: Fallback to a simple 'prompt' key with a raw string.
2050
+ elif "prompt" in row_dict and isinstance(row_dict.get("prompt"), str):
2051
+ return [{"role": USER_AUTHOR, "content": row_dict["prompt"]}]
2052
+
2053
+ raise ValueError(
2054
+ "Could not determine prompt/messages format from input row. Expected"
2055
+ " OpenAI request body with a 'messages' key, or a 'request' key with"
2056
+ " OpenAI request body, or Gemini request body with a 'contents' key, or"
2057
+ f" a 'prompt' key with a raw string. Found keys: {list(row_dict.keys())}"
2058
+ )
2059
+
2060
+
2061
+ def _call_litellm_completion(
2062
+ model: str, messages: list[dict[str, Any]]
2063
+ ) -> dict[str, Any]:
2064
+ """Wrapper for a single litellm.completion call."""
2065
+ try:
2066
+ response = litellm.completion(model=model, messages=messages)
2067
+ return response.model_dump() # type: ignore[no-any-return]
2068
+ except Exception as e:
2069
+ logger.error("LiteLLM completion failed for model %s: %s", model, e)
2070
+ return {"error": str(e)}
2071
+
2072
+
2073
+ def _run_litellm_inference(
2074
+ model: str, prompt_dataset: pd.DataFrame
2075
+ ) -> list[Optional[dict[str, Any]]]:
2076
+ """Runs inference using LiteLLM with concurrency."""
2077
+ logger.info(
2078
+ "Generating responses for %d prompts using LiteLLM for third party model: %s",
2079
+ len(prompt_dataset),
2080
+ model,
2081
+ )
2082
+ responses: list[Optional[dict[str, Any]]] = [None] * len(prompt_dataset)
2083
+ tasks = []
2084
+
2085
+ with tqdm(total=len(prompt_dataset), desc=f"LiteLLM Inference ({model})") as pbar:
2086
+ with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
2087
+ for index, row in prompt_dataset.iterrows():
2088
+ messages = _convert_prompt_row_to_litellm_messages(row)
2089
+ future = executor.submit(
2090
+ _call_litellm_completion, model=model, messages=messages
2091
+ )
2092
+ future.add_done_callback(lambda _: pbar.update(1))
2093
+ tasks.append((future, index))
2094
+
2095
+ for future, index in tasks:
2096
+ try:
2097
+ result = future.result()
2098
+ responses[index] = result
2099
+ except Exception as e:
2100
+ logger.error("Error processing prompt at index %d: %s", index, e)
2101
+ responses[index] = {"error": f"LiteLLM task failed: {e}"}
2102
+
2103
+ return responses
2104
+
2105
+
2106
+ def _is_litellm_vertex_maas_model(model: str) -> bool:
2107
+ """Checks if the model is a Vertex MAAS model to be handled by LiteLLM."""
2108
+ return any(
2109
+ model.startswith(prefix)
2110
+ for prefix in _evals_constant.SUPPORTED_VERTEX_MAAS_MODEL_PREFIXES
2111
+ )
2112
+
2113
+
2114
+ def _is_litellm_model(model: str) -> bool:
2115
+ """Checks if the model name corresponds to a valid LiteLLM model name."""
2116
+ if litellm is None:
2117
+ return False
2118
+
2119
+ try:
2120
+ litellm.get_llm_provider(model)
2121
+ return True
2122
+ except ValueError:
2123
+ return False
2124
+
2125
+
2126
+ def _is_gemini_model(model: str) -> bool:
2127
+ """Checks if the model name corresponds to a Gemini/Vertex AI model."""
2128
+ return (
2129
+ model.startswith("gemini-")
2130
+ or model.startswith("projects/")
2131
+ or model.startswith("models/")
2132
+ or model.startswith("publishers/")
2133
+ or model.startswith("tunedModels/")
2134
+ )
2135
+
2136
+
2137
+ def _normalize_inference_model_name(model: str, api_client: BaseApiClient) -> str:
2138
+ """Expands a model name to a fully-qualified resource name for inference.
2139
+
2140
+ A short or location-less model name has no serving location for the
2141
+ Evaluation Service to route on, so it is expanded using the client's
2142
+ project and location. Already fully-qualified names pass through. Raises
2143
+ ValueError if the client is missing a project or location, or if the model
2144
+ name is not a recognized Vertex form.
2145
+ """
2146
+ if not model:
2147
+ return model
2148
+
2149
+ if model.startswith("projects/"):
2150
+ return model
2151
+
2152
+ project = getattr(api_client, "project", None)
2153
+ location = getattr(api_client, "location", None)
2154
+ prefix = f"projects/{project}/locations/{location}/"
2155
+
2156
+ def _require_project_location() -> None:
2157
+ if not project or not location:
2158
+ raise ValueError(
2159
+ f"Cannot expand model name '{model}' to a fully-qualified"
2160
+ " resource name because the client is missing a project or"
2161
+ " location. Set project and location on the client, or pass a"
2162
+ " fully-qualified"
2163
+ " 'projects/{project}/locations/{location}/publishers/google/models/{model}'"
2164
+ " resource name."
2165
+ )
2166
+
2167
+ if (
2168
+ model.startswith("publishers/")
2169
+ or model.startswith("endpoints/")
2170
+ or model.startswith("tunedModels/")
2171
+ ):
2172
+ _require_project_location()
2173
+ return f"{prefix}{model}"
2174
+
2175
+ if model.startswith("models/"):
2176
+ _require_project_location()
2177
+ return f"{prefix}publishers/google/{model}"
2178
+
2179
+ if "/" not in model and _is_gemini_model(model):
2180
+ _require_project_location()
2181
+ return f"{prefix}publishers/google/models/{model}"
2182
+
2183
+ raise ValueError(
2184
+ f"Unrecognized model name '{model}'. Provide a Gemini model name (e.g."
2185
+ " 'gemini-2.5-flash'), or a fully-qualified publisher-model or endpoint"
2186
+ " resource name (e.g."
2187
+ " 'projects/{project}/locations/{location}/publishers/google/models/gemini-2.5-flash'"
2188
+ " or"
2189
+ " 'projects/{project}/locations/{location}/endpoints/{endpoint}')."
2190
+ )
2191
+
2192
+
2193
+ def _run_inference_internal(
2194
+ api_client: BaseApiClient,
2195
+ model: Union[Callable[[Any], Any], str],
2196
+ prompt_dataset: pd.DataFrame,
2197
+ config: Optional[genai_types.GenerateContentConfig] = None,
2198
+ ) -> pd.DataFrame:
2199
+ """Runs inference on a given dataset using the specified model or function."""
2200
+
2201
+ if isinstance(model, str) and _is_gemini_model(model):
2202
+ if (
2203
+ "prompt" not in prompt_dataset.columns
2204
+ and "request" not in prompt_dataset.columns
2205
+ and "starting_prompt" not in prompt_dataset.columns
2206
+ ):
2207
+ raise ValueError(
2208
+ "Prompt dataset for Gemini model must contain either 'prompt',"
2209
+ " 'request' or 'starting_prompt' column for inference. "
2210
+ f"Found columns: {prompt_dataset.columns.tolist()}"
2211
+ )
2212
+
2213
+ logger.info("Running inference with Gemini model name: %s", model)
2214
+ raw_responses = _run_gemini_inference(
2215
+ api_client=api_client,
2216
+ model=model,
2217
+ prompt_dataset=prompt_dataset,
2218
+ config=config,
2219
+ )
2220
+ processed_responses = []
2221
+ for resp_item in raw_responses:
2222
+ if isinstance(resp_item, genai_types.GenerateContentResponse):
2223
+ text_response = resp_item.text
2224
+ processed_responses.append(
2225
+ text_response
2226
+ if text_response is not None
2227
+ else json.dumps({"error": "Empty response text"})
2228
+ )
2229
+ elif isinstance(resp_item, dict) and "error" in resp_item:
2230
+ processed_responses.append(json.dumps(resp_item))
2231
+ else:
2232
+ error_payload = {
2233
+ "error": "Unexpected response type from Gemini inference",
2234
+ "response_type": str(type(resp_item)),
2235
+ "details": str(resp_item),
2236
+ }
2237
+ processed_responses.append(json.dumps(error_payload))
2238
+ responses = processed_responses
2239
+ elif callable(model):
2240
+ logger.info("Running inference with custom callable function.")
2241
+ custom_responses_raw = _run_custom_inference(
2242
+ model_fn=model, prompt_dataset=prompt_dataset
2243
+ )
2244
+ processed_custom_responses = []
2245
+ for resp_item in custom_responses_raw:
2246
+ if isinstance(resp_item, str):
2247
+ processed_custom_responses.append(resp_item)
2248
+ elif isinstance(resp_item, dict) and "error" in resp_item:
2249
+ processed_custom_responses.append(json.dumps(resp_item))
2250
+ else:
2251
+ try:
2252
+ processed_custom_responses.append(json.dumps(resp_item))
2253
+ except TypeError:
2254
+ processed_custom_responses.append(str(resp_item))
2255
+ responses = processed_custom_responses
2256
+ elif isinstance(model, str):
2257
+ if litellm is None:
2258
+ raise ImportError(
2259
+ "The 'litellm' library is required to use this model."
2260
+ " Please install it using 'pip install"
2261
+ " google-cloud-aiplatform[evaluation]'."
2262
+ )
2263
+
2264
+ processed_model_id = model
2265
+ if model.startswith("vertex_ai/"):
2266
+ # Already correctly prefixed for LiteLLM's Vertex AI provider
2267
+ pass
2268
+ elif _is_litellm_vertex_maas_model(model):
2269
+ processed_model_id = f"vertex_ai/{model}"
2270
+ logger.info(
2271
+ "Detected Vertex AI Model Garden managed MaaS model. "
2272
+ "Using LiteLLM ID: %s",
2273
+ processed_model_id,
2274
+ )
2275
+ elif _is_litellm_model(model):
2276
+ # Other LiteLLM supported model
2277
+ logger.info("Running inference with LiteLLM for model: %s", model)
2278
+ else:
2279
+ # Unsupported model string
2280
+ raise TypeError(
2281
+ f"Unsupported string model name: {model}. Expecting a Gemini model"
2282
+ " name (e.g., 'gemini-2.5-pro', 'projects/.../models/...') or a"
2283
+ " LiteLLM supported model name (e.g., 'openai/gpt-4o')."
2284
+ " If using a third-party model via LiteLLM, ensure the"
2285
+ " necessary environment variables are set (e.g., for OpenAI:"
2286
+ " `os.environ['OPENAI_API_KEY'] = 'Your API Key'`). See"
2287
+ " LiteLLM documentation for details:"
2288
+ " https://docs.litellm.ai/docs/set_keys#environment-variables"
2289
+ )
2290
+
2291
+ logger.info("Running inference via LiteLLM for model: %s", processed_model_id)
2292
+ raw_responses = _run_litellm_inference( # type: ignore[assignment]
2293
+ model=processed_model_id, prompt_dataset=prompt_dataset
2294
+ )
2295
+ processed_llm_responses = []
2296
+ for response_dict in raw_responses:
2297
+ if not isinstance(response_dict, dict):
2298
+ processed_llm_responses.append(
2299
+ json.dumps(
2300
+ {
2301
+ "error": "Invalid LiteLLM response format",
2302
+ "details": str(response_dict),
2303
+ }
2304
+ )
2305
+ )
2306
+ continue
2307
+
2308
+ if "error" in response_dict:
2309
+ processed_llm_responses.append(json.dumps(response_dict))
2310
+ continue
2311
+
2312
+ if (
2313
+ "choices" in response_dict
2314
+ and isinstance(response_dict["choices"], list)
2315
+ and len(response_dict["choices"]) > 0
2316
+ ):
2317
+ first_choice = response_dict["choices"][0]
2318
+ if "message" in first_choice and isinstance(
2319
+ first_choice["message"], dict
2320
+ ):
2321
+ message = first_choice["message"]
2322
+ if "content" in message and isinstance(message["content"], str):
2323
+ processed_llm_responses.append(message["content"])
2324
+ else:
2325
+ processed_llm_responses.append(
2326
+ json.dumps(
2327
+ {
2328
+ "error": "LiteLLM response missing 'content' in message",
2329
+ "details": response_dict,
2330
+ }
2331
+ )
2332
+ )
2333
+ else:
2334
+ processed_llm_responses.append(
2335
+ json.dumps(
2336
+ {
2337
+ "error": "LiteLLM response missing 'message' in first choice",
2338
+ "details": response_dict,
2339
+ }
2340
+ )
2341
+ )
2342
+ else:
2343
+ processed_llm_responses.append(
2344
+ json.dumps(
2345
+ {
2346
+ "error": "LiteLLM response missing 'choices'",
2347
+ "details": response_dict,
2348
+ }
2349
+ )
2350
+ )
2351
+ responses = processed_llm_responses
2352
+ else:
2353
+ raise TypeError(
2354
+ f"Unsupported model type: {type(model)}. Expecting string (model"
2355
+ " name) or Callable."
2356
+ )
2357
+
2358
+ if len(responses) != len(prompt_dataset):
2359
+ raise RuntimeError(
2360
+ "Critical prompt/response count mismatch: %d prompts vs %d"
2361
+ " responses. This indicates an issue in response collection."
2362
+ % (len(prompt_dataset), len(responses))
2363
+ )
2364
+
2365
+ results_df_responses_only = pd.DataFrame(
2366
+ {
2367
+ _evals_constant.RESPONSE: responses,
2368
+ }
2369
+ )
2370
+
2371
+ prompt_dataset_indexed = prompt_dataset.reset_index(drop=True)
2372
+
2373
+ # Drop existing 'response' column to prevent duplicate column names when
2374
+ # re-running inference on a dataset that already has responses.
2375
+ if _evals_constant.RESPONSE in prompt_dataset_indexed.columns:
2376
+ logger.warning(
2377
+ "A column named '%s' already exists in the prompt dataset. "
2378
+ "The existing column will be dropped and replaced with the new "
2379
+ "inference results.",
2380
+ _evals_constant.RESPONSE,
2381
+ )
2382
+ prompt_dataset_indexed = prompt_dataset_indexed.drop(
2383
+ columns=[_evals_constant.RESPONSE]
2384
+ )
2385
+
2386
+ results_df_responses_only_indexed = results_df_responses_only.reset_index(drop=True)
2387
+
2388
+ results_df = pd.concat(
2389
+ [prompt_dataset_indexed, results_df_responses_only_indexed], axis=1
2390
+ )
2391
+
2392
+ return results_df
2393
+
2394
+
2395
+ async def _run_adk_user_simulation(
2396
+ row: pd.Series,
2397
+ agent: "LlmAgent", # type: ignore # noqa: F821
2398
+ api_client: BaseApiClient,
2399
+ config: Optional[types.evals.UserSimulatorConfig] = None,
2400
+ ) -> list[dict[str, Any]]:
2401
+ """Runs a multi-turn user simulation using ADK's EvaluationGenerator."""
2402
+ # Lazy-import ADK dependencies to avoid top-level import failures when
2403
+ # google-adk is not installed.
2404
+ from google.adk.evaluation.eval_case import SessionInput as ADK_SessionInput
2405
+ from google.adk.evaluation.evaluation_generator import EvaluationGenerator
2406
+
2407
+ user_simulator = _build_user_simulator(row, config, api_client)
2408
+
2409
+ try:
2410
+ initial_session = _get_session_inputs(row)
2411
+ app_name = initial_session.app_name or "user_simulation_app"
2412
+ user_id = initial_session.user_id or "user_simulation_default_user"
2413
+ state = initial_session.state or {}
2414
+ except (KeyError, TypeError, ValueError):
2415
+ app_name = "user_simulation_app"
2416
+ user_id = "user_simulation_default_user"
2417
+ state = {}
2418
+
2419
+ invocations = await EvaluationGenerator._generate_inferences_from_root_agent( # pylint: disable=protected-access
2420
+ root_agent=agent,
2421
+ user_simulator=user_simulator,
2422
+ reset_func=getattr(agent, "reset_data", None),
2423
+ initial_session=ADK_SessionInput(
2424
+ app_name=app_name,
2425
+ user_id=user_id,
2426
+ state=state,
2427
+ ),
2428
+ )
2429
+
2430
+ turns = []
2431
+ for i, invocation in enumerate(invocations):
2432
+ events = []
2433
+ if invocation.user_content:
2434
+ events.append(
2435
+ {
2436
+ "author": "user",
2437
+ "content": invocation.user_content.model_dump(
2438
+ mode="json", exclude_none=True
2439
+ ),
2440
+ "event_time": datetime.datetime.fromtimestamp(
2441
+ invocation.creation_timestamp, tz=datetime.timezone.utc
2442
+ ),
2443
+ }
2444
+ )
2445
+ if invocation.intermediate_data:
2446
+ if (
2447
+ hasattr(invocation.intermediate_data, "invocation_events")
2448
+ and invocation.intermediate_data.invocation_events
2449
+ ):
2450
+ for ie in invocation.intermediate_data.invocation_events:
2451
+ events.append(
2452
+ {
2453
+ "author": ie.author,
2454
+ "content": (
2455
+ ie.content.model_dump(mode="json", exclude_none=True)
2456
+ if ie.content
2457
+ else None
2458
+ ),
2459
+ "event_time": datetime.datetime.fromtimestamp(
2460
+ invocation.creation_timestamp, tz=datetime.timezone.utc
2461
+ ),
2462
+ }
2463
+ )
2464
+ elif hasattr(invocation.intermediate_data, "tool_uses"):
2465
+ for tool_call in invocation.intermediate_data.tool_uses:
2466
+ events.append(
2467
+ {
2468
+ "author": "tool_call",
2469
+ "content": tool_call.model_dump(
2470
+ mode="json", exclude_none=True
2471
+ ),
2472
+ "event_time": datetime.datetime.fromtimestamp(
2473
+ invocation.creation_timestamp, tz=datetime.timezone.utc
2474
+ ),
2475
+ }
2476
+ )
2477
+
2478
+ if invocation.final_response:
2479
+ events.append(
2480
+ {
2481
+ "author": "agent",
2482
+ "content": invocation.final_response.model_dump(
2483
+ mode="json", exclude_none=True
2484
+ ),
2485
+ "event_time": datetime.datetime.fromtimestamp(
2486
+ invocation.creation_timestamp, tz=datetime.timezone.utc
2487
+ ),
2488
+ }
2489
+ )
2490
+
2491
+ turns.append(
2492
+ {
2493
+ "turn_index": i,
2494
+ "turn_id": invocation.invocation_id or str(uuid.uuid4()),
2495
+ "events": events,
2496
+ }
2497
+ )
2498
+
2499
+ return turns
2500
+
2501
+
2502
+ def _apply_prompt_template(
2503
+ df: pd.DataFrame, prompt_template: types.PromptTemplate
2504
+ ) -> None:
2505
+ """Applies a prompt template to a DataFrame.
2506
+
2507
+ The DataFrame is expected to have columns corresponding to the variables
2508
+ in the prompt_template_str. The result will be in a new 'request' column.
2509
+
2510
+ Args:
2511
+ df: The input DataFrame to modify.
2512
+ prompt_template: The prompt template to apply.
2513
+
2514
+ Returns:
2515
+ None. The DataFrame is modified in place.
2516
+ """
2517
+ missing_vars = [var for var in prompt_template.variables if var not in df.columns]
2518
+ if missing_vars:
2519
+ raise ValueError(
2520
+ "Missing columns in DataFrame for prompt template variables:"
2521
+ f" {', '.join(missing_vars)}. Available columns:"
2522
+ f" {', '.join(df.columns.tolist())}"
2523
+ )
2524
+
2525
+ if "prompt" in df.columns:
2526
+ logger.info(
2527
+ "Templated prompts stored in 'request' and will be used for"
2528
+ " inference.Original 'prompt' column is kept but not used for"
2529
+ " inference."
2530
+ )
2531
+ elif "prompt" not in df.columns and "request" in df.columns:
2532
+ logger.info("The 'request' column will be replaced with templated prompts.")
2533
+
2534
+ templated_prompts = []
2535
+ for _, row in df.iterrows():
2536
+ templated_prompts.append(prompt_template.assemble(**row.to_dict()))
2537
+
2538
+ df["request"] = templated_prompts
2539
+
2540
+
2541
+ def _load_dataframe(
2542
+ api_client: BaseApiClient, src: Union[str, pd.DataFrame]
2543
+ ) -> pd.DataFrame:
2544
+ """Loads and prepares the prompt dataset for inference."""
2545
+ logger.info("Loading prompt dataset from: %s", src)
2546
+ try:
2547
+ loader = _evals_utils.EvalDatasetLoader(api_client=api_client)
2548
+ dataset_list_of_dicts = loader.load(src)
2549
+ if not dataset_list_of_dicts:
2550
+ raise ValueError("Prompt dataset 'prompt_dataset' must not be empty.")
2551
+ return pd.DataFrame(dataset_list_of_dicts)
2552
+ except Exception as e:
2553
+ logger.error("Failed to load prompt dataset from source: %s. Error: %s", src, e)
2554
+ raise e
2555
+
2556
+
2557
+ def _execute_inference(
2558
+ *,
2559
+ api_client: BaseApiClient,
2560
+ src: Union[str, pd.DataFrame],
2561
+ model: Optional[Union[Callable[[Any], Any], str]] = None,
2562
+ agent_engine: Optional[Union[str, types.AgentEngine]] = None,
2563
+ agent: Optional["LlmAgent"] = None, # type: ignore # noqa: F821
2564
+ gemini_agent: Optional[str] = None,
2565
+ dest: Optional[str] = None,
2566
+ config: Optional[genai_types.GenerateContentConfig] = None,
2567
+ prompt_template: Optional[Union[str, types.PromptTemplateOrDict]] = None,
2568
+ location: Optional[str] = None,
2569
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
2570
+ allow_cross_region_model: bool = False,
2571
+ ) -> pd.DataFrame:
2572
+ """Executes inference on a given dataset using the specified model.
2573
+
2574
+ Args:
2575
+ api_client: The API client.
2576
+ src: The source of the dataset. Can be a string (path to a local file, a
2577
+ GCS path, or a BigQuery table) or a Pandas DataFrame.
2578
+ model: The model to use for inference. Can be a callable function or a
2579
+ string representing a model.
2580
+ agent_engine: The agent engine to use for inference. Can be a resource
2581
+ name string or an `AgentEngine` instance.
2582
+ agent: The local agent to use for inference. Can be an ADK agent instance.
2583
+ gemini_agent: The Gemini Agents API agent resource name to run inference
2584
+ against via the Interactions API.
2585
+ dest: The destination to save the inference results. Can be a string
2586
+ representing a file path or a GCS URI.
2587
+ config: The generation configuration for the model.
2588
+ prompt_template: The prompt template to use for inference.
2589
+ location: The location to use for the inference. If not specified, the
2590
+ location configured in the client will be used.
2591
+ user_simulator_config: The configuration for the user simulator in
2592
+ multi-turn agent scraping.
2593
+
2594
+ Returns:
2595
+ A pandas DataFrame containing the inference results.
2596
+ """
2597
+ if not api_client:
2598
+ raise ValueError("'api_client' instance must be provided.")
2599
+ if location:
2600
+ api_client = _get_api_client_with_location(api_client, location)
2601
+
2602
+ if sum(x is not None for x in [model, agent_engine, agent, gemini_agent]) != 1:
2603
+ raise ValueError(
2604
+ "Exactly one of model, agent_engine, agent, or gemini_agent must be"
2605
+ " provided."
2606
+ )
2607
+
2608
+ prompt_dataset = _load_dataframe(api_client, src)
2609
+ if prompt_template:
2610
+ logger.info("Applying prompt template...")
2611
+ if isinstance(prompt_template, str):
2612
+ prompt_template = types.PromptTemplate(text=prompt_template)
2613
+ elif isinstance(prompt_template, dict):
2614
+ prompt_template = types.PromptTemplate.model_validate(prompt_template)
2615
+
2616
+ _apply_prompt_template(prompt_dataset, prompt_template)
2617
+
2618
+ if gemini_agent:
2619
+ start_time = time.time()
2620
+ if _is_multi_turn_agent_simulation(user_simulator_config, prompt_dataset):
2621
+ logger.debug("Starting Gemini Agent user simulation process ...")
2622
+ results_df = _run_gemini_agent_user_simulation(
2623
+ api_client=api_client,
2624
+ gemini_agent=gemini_agent,
2625
+ prompt_dataset=prompt_dataset,
2626
+ user_simulator_config=user_simulator_config,
2627
+ allow_cross_region_model=allow_cross_region_model,
2628
+ )
2629
+ else:
2630
+ logger.debug("Starting Gemini Agent inference process ...")
2631
+ results_df = _run_gemini_agent_inference(
2632
+ api_client=api_client,
2633
+ gemini_agent=gemini_agent,
2634
+ prompt_dataset=prompt_dataset,
2635
+ )
2636
+ end_time = time.time()
2637
+ logger.info(
2638
+ "Gemini Agent inference completed in %.2f seconds.",
2639
+ end_time - start_time,
2640
+ )
2641
+ return types.EvaluationDataset(
2642
+ eval_dataset_df=results_df,
2643
+ candidate_name=gemini_agent.split("/")[-1],
2644
+ )
2645
+ elif model:
2646
+ start_time = time.time()
2647
+ logger.debug("Starting inference process ...")
2648
+ results_df = _run_inference_internal(
2649
+ api_client=api_client,
2650
+ model=model,
2651
+ prompt_dataset=prompt_dataset,
2652
+ config=config,
2653
+ )
2654
+ end_time = time.time()
2655
+ logger.info("Inference completed in %.2f seconds.", end_time - start_time)
2656
+
2657
+ candidate_name = None
2658
+ if isinstance(model, str):
2659
+ candidate_name = model
2660
+ elif callable(model):
2661
+ candidate_name = getattr(model, "__name__", None)
2662
+
2663
+ results_df = _drop_empty_columns(results_df)
2664
+ evaluation_dataset = types.EvaluationDataset(
2665
+ eval_dataset_df=results_df,
2666
+ candidate_name=candidate_name,
2667
+ )
2668
+ elif agent_engine or agent:
2669
+ candidate_name = None
2670
+ if agent_engine:
2671
+ candidate_name = "agent_engine_0"
2672
+ elif agent:
2673
+ agent_config = types.evals.AgentConfig.from_agent(agent)
2674
+ candidate_name = agent_config.agent_id or "agent_0"
2675
+
2676
+ if (
2677
+ agent_engine
2678
+ and not isinstance(agent_engine, str)
2679
+ and not (
2680
+ hasattr(agent_engine, "api_client")
2681
+ and type(agent_engine).__name__ == "AgentEngine"
2682
+ )
2683
+ ):
2684
+ raise TypeError(
2685
+ f"Unsupported agent_engine type: {type(agent_engine)}. Expecting a"
2686
+ " string (agent engine resource name in"
2687
+ " 'projects/{project_id}/locations/{location_id}/reasoningEngines/{reasoning_engine_id}'"
2688
+ " format) or a types.AgentEngine instance."
2689
+ )
2690
+ if (
2691
+ _evals_constant.INTERMEDIATE_EVENTS in prompt_dataset.columns
2692
+ or _evals_constant.RESPONSE in prompt_dataset.columns
2693
+ ):
2694
+ raise ValueError(
2695
+ "The eval dataset provided for agent run should not contain"
2696
+ f" '{_evals_constant.INTERMEDIATE_EVENTS}' or"
2697
+ f" '{_evals_constant.RESPONSE}' columns, as these columns will be"
2698
+ " generated by the agent run."
2699
+ )
2700
+ start_time = time.time()
2701
+ logger.debug("Starting Agent Run process ...")
2702
+ results_df = _run_agent_internal(
2703
+ api_client=api_client,
2704
+ agent_engine=agent_engine,
2705
+ agent=agent,
2706
+ prompt_dataset=prompt_dataset,
2707
+ user_simulator_config=user_simulator_config,
2708
+ allow_cross_region_model=allow_cross_region_model,
2709
+ )
2710
+ end_time = time.time()
2711
+ logger.info("Agent Run completed in %.2f seconds.", end_time - start_time)
2712
+
2713
+ results_df = _drop_empty_columns(results_df)
2714
+ evaluation_dataset = types.EvaluationDataset(
2715
+ eval_dataset_df=results_df,
2716
+ candidate_name=candidate_name,
2717
+ )
2718
+ else:
2719
+ raise ValueError("Either model, agent_engine or agent must be provided.")
2720
+
2721
+ if dest:
2722
+ file_name = "inference_results.jsonl" if model else "agent_run_results.jsonl"
2723
+ is_gcs_path = dest.startswith(_gcs_utils.GCS_PREFIX)
2724
+
2725
+ if is_gcs_path:
2726
+ full_dest_path = os.path.join(dest, file_name)
2727
+ else:
2728
+ os.makedirs(dest, exist_ok=True)
2729
+ full_dest_path = os.path.join(dest, file_name)
2730
+
2731
+ logger.info("Saving inference / agent run results to: %s", full_dest_path)
2732
+ try:
2733
+ if is_gcs_path:
2734
+ _gcs_utils.GcsUtils(api_client=api_client).upload_dataframe(
2735
+ df=results_df,
2736
+ gcs_destination_blob_path=full_dest_path,
2737
+ file_type="jsonl",
2738
+ )
2739
+ logger.info("Results saved to GCS: %s", full_dest_path)
2740
+ evaluation_dataset.gcs_source = genai_types.GcsSource(
2741
+ uris=[full_dest_path]
2742
+ )
2743
+ else:
2744
+ results_df.to_json(full_dest_path, orient="records", lines=True)
2745
+ logger.info("Results saved locally to: %s", full_dest_path)
2746
+ except Exception as e: # pylint: disable=broad-exception-caught
2747
+ logger.error("Failed to save results to %s. Error: %s", full_dest_path, e)
2748
+
2749
+ return evaluation_dataset
2750
+
2751
+
2752
+ def _get_dataset_source(
2753
+ ds_item: types.EvaluationDataset,
2754
+ ) -> Union[str, pd.DataFrame]:
2755
+ """Returns the source of the dataset, either a DataFrame, GCS URI, or BigQuery URI."""
2756
+ if ds_item.eval_dataset_df is not None:
2757
+ return ds_item.eval_dataset_df
2758
+ elif ds_item.gcs_source is not None and ds_item.gcs_source.uris:
2759
+ if len(ds_item.gcs_source.uris) > 1:
2760
+ logger.warning(
2761
+ "Multiple GCS URIs in GcsSource. Using the first one: %s",
2762
+ ds_item.gcs_source.uris[0],
2763
+ )
2764
+ return ds_item.gcs_source.uris[0]
2765
+ elif ds_item.bigquery_source is not None and ds_item.bigquery_source.input_uri:
2766
+ return ds_item.bigquery_source.input_uri
2767
+ else:
2768
+ raise ValueError(
2769
+ "EvaluationDataset item has no valid source"
2770
+ " (eval_dataset_df, gcs_source with uris, or bigquery_source with"
2771
+ " input_uri)."
2772
+ )
2773
+
2774
+
2775
+ def _resolve_dataset_inputs(
2776
+ dataset: list[types.EvaluationDataset],
2777
+ dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]],
2778
+ loader: "_evals_utils.EvalDatasetLoader",
2779
+ agent_info: Optional[types.evals.AgentInfo] = None,
2780
+ agent: Optional[str] = None,
2781
+ api_client: Any = None,
2782
+ ) -> tuple[types.EvaluationDataset, int]:
2783
+ """Loads and processes single or multiple datasets for evaluation.
2784
+
2785
+ Args:
2786
+ dataset: The dataset(s) to process. Can be a single EvaluationDataset or a
2787
+ list of them.
2788
+ dataset_schema: The schema to use for the dataset(s). If None, it will be
2789
+ auto-detected.
2790
+ loader: An instance of EvalDatasetLoader to load data.
2791
+ agent_info: The agent info of the agent under evaluation.
2792
+
2793
+ Returns:
2794
+ A tuple containing:
2795
+ - processed_eval_dataset: The processed EvaluationDataset containing
2796
+ evaluation cases.
2797
+ - num_response_candidates: The number of response candidates.
2798
+ """
2799
+ if not dataset:
2800
+ raise ValueError("Input dataset list cannot be empty.")
2801
+
2802
+ num_response_candidates = len(dataset)
2803
+ datasets_to_process = dataset
2804
+ logger.info("Processing %s dataset(s).", num_response_candidates)
2805
+
2806
+ if len(datasets_to_process) == 1 and datasets_to_process[0].eval_cases:
2807
+ return datasets_to_process[0], 1
2808
+
2809
+ parsed_evaluation_datasets: list[types.EvaluationDataset] = []
2810
+
2811
+ for i, ds_item in enumerate(datasets_to_process):
2812
+ if not isinstance(ds_item, types.EvaluationDataset):
2813
+ logger.error(
2814
+ "Unexpected item type in dataset list at index %d: %s. Expected"
2815
+ " types.EvaluationDataset.",
2816
+ i,
2817
+ type(ds_item),
2818
+ )
2819
+ raise TypeError(
2820
+ f"Item at index {i} is not an EvaluationDataset: {type(ds_item)}"
2821
+ )
2822
+
2823
+ if ds_item.eval_cases:
2824
+ logger.info("Dataset %d already contains eval_cases.", i)
2825
+ parsed_evaluation_datasets.append(ds_item)
2826
+ continue
2827
+
2828
+ ds_source_for_loader = _get_dataset_source(ds_item)
2829
+ current_loaded_data = loader.load(ds_source_for_loader)
2830
+
2831
+ interaction_dataset = _build_interaction_id_dataset(
2832
+ current_loaded_data, agent, _get_resolved_location(api_client)
2833
+ )
2834
+ if interaction_dataset is not None:
2835
+ if dataset_schema:
2836
+ raise ValueError(
2837
+ "`dataset_schema` is not supported for datasets with an"
2838
+ " `interaction_id` column. The interaction trace and agent"
2839
+ " config are resolved by the backend, so no client-side"
2840
+ " schema conversion is applied. Omit `dataset_schema` when"
2841
+ " evaluating an interaction_id dataset."
2842
+ )
2843
+ parsed_evaluation_datasets.append(interaction_dataset)
2844
+ continue
2845
+
2846
+ if dataset_schema:
2847
+ current_schema = _evals_data_converters.EvalDatasetSchema(dataset_schema)
2848
+ else:
2849
+ current_schema = _evals_data_converters.auto_detect_dataset_schema( # type: ignore[assignment]
2850
+ current_loaded_data
2851
+ )
2852
+
2853
+ logger.info(
2854
+ "Dataset %d: Schema: %s. Using %s converter.",
2855
+ i,
2856
+ current_schema,
2857
+ _evals_data_converters.get_dataset_converter(
2858
+ current_schema
2859
+ ).__class__.__name__,
2860
+ )
2861
+ converter = _evals_data_converters.get_dataset_converter(current_schema)
2862
+ parsed_evaluation_datasets.append(converter.convert(current_loaded_data))
2863
+
2864
+ processed_eval_dataset = _evals_data_converters.merge_evaluation_datasets(
2865
+ datasets=parsed_evaluation_datasets,
2866
+ agent_info=agent_info,
2867
+ )
2868
+
2869
+ if not processed_eval_dataset.eval_cases:
2870
+ raise ValueError("No evaluation cases found in the dataset.")
2871
+ return processed_eval_dataset, num_response_candidates
2872
+
2873
+
2874
+ def _resolve_evaluation_run_metrics(
2875
+ metrics: Union[list[types.EvaluationRunMetric], list[types.Metric]], api_client: Any
2876
+ ) -> list[types.EvaluationRunMetric]:
2877
+ """Resolves a list of evaluation run metric instances, loading RubricMetric if necessary."""
2878
+ if not metrics:
2879
+ return []
2880
+ resolved_metrics_list = []
2881
+ for metric_instance in metrics:
2882
+ if isinstance(metric_instance, types.EvaluationRunMetric):
2883
+ resolved_metrics_list.append(metric_instance)
2884
+ elif isinstance(
2885
+ metric_instance, _evals_metric_loaders.LazyLoadedPrebuiltMetric
2886
+ ):
2887
+ try:
2888
+ resolved_metric = metric_instance.resolve(api_client=api_client)
2889
+ if resolved_metric.name:
2890
+ resolved_metrics_list.append(
2891
+ types.EvaluationRunMetric(
2892
+ metric=resolved_metric.name,
2893
+ metric_config=types.UnifiedMetric(
2894
+ predefined_metric_spec=genai_types.PredefinedMetricSpec(
2895
+ metric_spec_name=resolved_metric.name,
2896
+ )
2897
+ ),
2898
+ )
2899
+ )
2900
+ except Exception as e:
2901
+ logger.error(
2902
+ "Failed to resolve RubricMetric %s@%s: %s",
2903
+ metric_instance.name,
2904
+ metric_instance.version,
2905
+ e,
2906
+ )
2907
+ raise
2908
+ elif isinstance(metric_instance, types.Metric):
2909
+ config_dict = t.t_metrics([metric_instance])[0]
2910
+ res_name = getattr(metric_instance, "metric_resource_name", None)
2911
+ resolved_metrics_list.append(
2912
+ types.EvaluationRunMetric(
2913
+ metric=metric_instance.name,
2914
+ metric_config=config_dict if config_dict else None,
2915
+ metric_resource_name=res_name,
2916
+ )
2917
+ )
2918
+ else:
2919
+ try:
2920
+ metric_name_str = str(metric_instance)
2921
+ lazy_metric_instance = getattr(
2922
+ _evals_metric_loaders.RubricMetric, metric_name_str.upper()
2923
+ )
2924
+ if isinstance(
2925
+ lazy_metric_instance, _evals_metric_loaders.LazyLoadedPrebuiltMetric
2926
+ ):
2927
+ resolved_metric = lazy_metric_instance.resolve(
2928
+ api_client=api_client
2929
+ )
2930
+ if resolved_metric.name:
2931
+ resolved_metrics_list.append(
2932
+ types.EvaluationRunMetric(
2933
+ metric=resolved_metric.name,
2934
+ metric_config=types.UnifiedMetric(
2935
+ predefined_metric_spec=genai_types.PredefinedMetricSpec(
2936
+ metric_spec_name=resolved_metric.name,
2937
+ )
2938
+ ),
2939
+ )
2940
+ )
2941
+ else:
2942
+ raise TypeError(
2943
+ f"RubricMetric.{metric_name_str.upper()} cannot be resolved."
2944
+ )
2945
+ except AttributeError as exc:
2946
+ raise TypeError(
2947
+ "Unsupported metric type or invalid RubricMetric name:"
2948
+ f" {metric_instance}"
2949
+ ) from exc
2950
+ return resolved_metrics_list
2951
+
2952
+
2953
+ def _resolve_metrics(
2954
+ metrics: list[types.Metric], api_client: Any
2955
+ ) -> list[types.Metric]:
2956
+ """Resolves a list of metric instances, loading RubricMetric if necessary."""
2957
+ resolved_metrics_list = []
2958
+ for metric_instance in metrics:
2959
+ if isinstance(metric_instance, _evals_metric_loaders.LazyLoadedPrebuiltMetric):
2960
+ try:
2961
+ resolved_metrics_list.append(
2962
+ metric_instance.resolve(api_client=api_client)
2963
+ )
2964
+ except Exception as e:
2965
+ logger.error(
2966
+ "Failed to resolve RubricMetric %s@%s: %s",
2967
+ metric_instance.name,
2968
+ metric_instance.version,
2969
+ e,
2970
+ )
2971
+ raise
2972
+ elif isinstance(metric_instance, types.Metric):
2973
+ resolved_metrics_list.append(metric_instance)
2974
+ else:
2975
+ try:
2976
+ metric_name_str = str(metric_instance)
2977
+ lazy_metric_instance = getattr(
2978
+ _evals_metric_loaders.RubricMetric, metric_name_str.upper()
2979
+ )
2980
+ if isinstance(
2981
+ lazy_metric_instance, _evals_metric_loaders.LazyLoadedPrebuiltMetric
2982
+ ):
2983
+ resolved_metrics_list.append(
2984
+ lazy_metric_instance.resolve(api_client=api_client)
2985
+ )
2986
+ else:
2987
+ raise TypeError(
2988
+ f"RubricMetric.{metric_name_str.upper()} cannot be resolved."
2989
+ )
2990
+ except AttributeError as exc:
2991
+ raise TypeError(
2992
+ "Unsupported metric type or invalid RubricMetric name:"
2993
+ f" {metric_instance}"
2994
+ ) from exc
2995
+ return resolved_metrics_list
2996
+
2997
+
2998
+ def _execute_evaluation( # type: ignore[no-untyped-def]
2999
+ *,
3000
+ api_client: Any,
3001
+ dataset: Union[types.EvaluationDataset, list[types.EvaluationDataset]],
3002
+ metrics: list[types.Metric],
3003
+ agent: Optional[str] = None,
3004
+ dataset_schema: Optional[Literal["GEMINI", "FLATTEN", "OPENAI"]] = None,
3005
+ dest: Optional[str] = None,
3006
+ location: Optional[str] = None,
3007
+ evaluation_service_qps: Optional[float] = None,
3008
+ **kwargs,
3009
+ ) -> types.EvaluationResult:
3010
+ """Evaluates a dataset using the provided metrics.
3011
+
3012
+ Args:
3013
+ api_client: The API client.
3014
+ dataset: The dataset to evaluate.
3015
+ metrics: The metrics to evaluate the dataset against.
3016
+ dataset_schema: The schema of the dataset.
3017
+ dest: The destination to save the evaluation results.
3018
+ location: The location to use for the evaluation. If not specified, the
3019
+ location configured in the client will be used.
3020
+ evaluation_service_qps: The rate limit (queries per second) for calls
3021
+ to the evaluation service. Defaults to 10. Increase this value if
3022
+ your project has a higher EvaluateInstances API quota.
3023
+ **kwargs: Extra arguments to pass to evaluation, such as `agent_info`.
3024
+
3025
+ Returns:
3026
+ The evaluation result.
3027
+ """
3028
+
3029
+ if location:
3030
+ api_client = _get_api_client_with_location(api_client, location)
3031
+
3032
+ logger.info("Preparing dataset(s) and metrics...")
3033
+ if isinstance(dataset, types.EvaluationDataset):
3034
+ dataset_list = [dataset]
3035
+ elif isinstance(dataset, list):
3036
+ for item in dataset:
3037
+ if not isinstance(item, types.EvaluationDataset):
3038
+ raise TypeError(
3039
+ f"Unsupported dataset type: {type(item)}. "
3040
+ "Must be EvaluationDataset."
3041
+ )
3042
+ dataset_list = dataset
3043
+ else:
3044
+ raise TypeError(
3045
+ f"Unsupported dataset type: {type(dataset)}. Must be an"
3046
+ " EvaluationDataset or a list of EvaluationDataset."
3047
+ )
3048
+ original_candidate_names = [
3049
+ ds.candidate_name or f"candidate_{i + 1}" for i, ds in enumerate(dataset_list)
3050
+ ]
3051
+ name_counts = collections.Counter(original_candidate_names)
3052
+ deduped_candidate_names = []
3053
+ current_name_counts: collections.defaultdict[Any, int] = collections.defaultdict(
3054
+ int
3055
+ )
3056
+
3057
+ for name in original_candidate_names:
3058
+ if name_counts[name] > 1:
3059
+ current_name_counts[name] += 1
3060
+ deduped_candidate_names.append(f"{name} #{current_name_counts[name]}")
3061
+ else:
3062
+ deduped_candidate_names.append(name)
3063
+
3064
+ loader = _evals_utils.EvalDatasetLoader(api_client=api_client)
3065
+
3066
+ agent_info = kwargs.get("agent_info", None)
3067
+ validated_agent_info = None
3068
+ if agent_info:
3069
+ if isinstance(agent_info, dict):
3070
+ validated_agent_info = types.evals.AgentInfo.model_validate(agent_info)
3071
+ elif isinstance(agent_info, types.evals.AgentInfo):
3072
+ validated_agent_info = agent_info
3073
+ else:
3074
+ raise TypeError(
3075
+ "agent_info values must be of type types.evals.AgentInfo or dict,"
3076
+ f" but got {type(agent_info)}'"
3077
+ )
3078
+
3079
+ processed_eval_dataset, num_response_candidates = _resolve_dataset_inputs(
3080
+ dataset=dataset_list,
3081
+ dataset_schema=dataset_schema,
3082
+ loader=loader,
3083
+ agent_info=validated_agent_info,
3084
+ agent=agent,
3085
+ api_client=api_client,
3086
+ )
3087
+
3088
+ resolved_metrics = _resolve_metrics(metrics, api_client)
3089
+
3090
+ # Validate metrics are supported for Managed Agent evaluation.
3091
+ _validate_managed_agent_metrics(agent, resolved_metrics)
3092
+
3093
+ evaluation_run_config = _evals_metric_handlers.EvaluationRunConfig(
3094
+ evals_module=evals.Evals(api_client_=api_client),
3095
+ dataset=processed_eval_dataset,
3096
+ metrics=resolved_metrics,
3097
+ num_response_candidates=num_response_candidates,
3098
+ )
3099
+
3100
+ logger.info("Running Metric Computation...")
3101
+ t1 = time.perf_counter()
3102
+ evaluation_result = _evals_metric_handlers.compute_metrics_and_aggregate(
3103
+ evaluation_run_config,
3104
+ evaluation_service_qps=evaluation_service_qps,
3105
+ )
3106
+ t2 = time.perf_counter()
3107
+ logger.info("Evaluation took: %f seconds", t2 - t1)
3108
+
3109
+ # Resolve interactions_data_source to agent_data for display.
3110
+ # This fetches Interaction trace data client-side so that show() can
3111
+ # render the System Topology and Conversation Trace sections.
3112
+ dataset_list = _resolve_interactions_for_display(api_client, dataset_list)
3113
+
3114
+ evaluation_result.evaluation_dataset = dataset_list
3115
+ evaluation_result.agent_info = validated_agent_info
3116
+
3117
+ if not evaluation_result.metadata:
3118
+ evaluation_result.metadata = types.EvaluationRunMetadata()
3119
+
3120
+ evaluation_result.metadata.creation_timestamp = datetime.datetime.now(
3121
+ datetime.timezone.utc
3122
+ )
3123
+
3124
+ if deduped_candidate_names:
3125
+ evaluation_result.metadata.candidate_names = deduped_candidate_names
3126
+
3127
+ logger.info("Evaluation run completed.")
3128
+
3129
+ if dest:
3130
+ uploaded_path = _gcs_utils.GcsUtils(
3131
+ api_client=api_client
3132
+ ).upload_json_to_prefix(
3133
+ data=evaluation_result.model_dump(
3134
+ mode="json",
3135
+ exclude_none=True,
3136
+ exclude={"evaluation_dataset"},
3137
+ ),
3138
+ gcs_dest_prefix=dest,
3139
+ filename_prefix="evaluation_result",
3140
+ )
3141
+ logger.info(
3142
+ "Evaluation results uploaded successfully to GCS: %s", uploaded_path
3143
+ )
3144
+ return evaluation_result
3145
+
3146
+
3147
+ def _get_session_inputs(row: pd.Series) -> types.evals.SessionInput:
3148
+ """Parses session inputs from a row."""
3149
+ if isinstance(row["session_inputs"], str):
3150
+ return types.evals.SessionInput.model_validate(
3151
+ json.loads(row["session_inputs"])
3152
+ )
3153
+ elif isinstance(row["session_inputs"], dict):
3154
+ return types.evals.SessionInput.model_validate(row["session_inputs"])
3155
+ elif isinstance(row["session_inputs"], types.evals.SessionInput):
3156
+ return row["session_inputs"]
3157
+ else:
3158
+ raise TypeError(
3159
+ f"Unsupported session_inputs type: {type(row['session_inputs'])}. "
3160
+ "Expecting string or dict in types.evals.SessionInput format."
3161
+ )
3162
+
3163
+
3164
+ def _is_multi_turn_agent_simulation(
3165
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
3166
+ prompt_dataset: pd.DataFrame = None,
3167
+ ) -> bool:
3168
+ """Checks if the agent run is a multi-turn user simulation."""
3169
+ return (
3170
+ user_simulator_config is not None
3171
+ or "conversation_plan" in prompt_dataset.columns
3172
+ )
3173
+
3174
+
3175
+ def _process_multi_turn_agent_response(
3176
+ resp_item: Any,
3177
+ agent_data_agents: Optional[dict[str, Any]],
3178
+ ) -> Optional[Union[str, dict[str, Any]]]:
3179
+ """Processes a multi-turn agent response."""
3180
+ if isinstance(resp_item, dict) and "error" in resp_item:
3181
+ return json.dumps(resp_item)
3182
+ return types.evals.AgentData(
3183
+ turns=resp_item,
3184
+ agents=agent_data_agents,
3185
+ ).model_dump(exclude_unset=True)
3186
+
3187
+
3188
+ def _process_single_turn_agent_response(
3189
+ resp_item: Any,
3190
+ agent_data_agents: Optional[dict[str, Any]],
3191
+ ) -> tuple[
3192
+ Optional[Union[str, dict[str, Any]]],
3193
+ list[dict[str, Any]],
3194
+ Optional[Union[str, dict[str, Any]]],
3195
+ ]:
3196
+ """Processes a single-turn agent response."""
3197
+ intermediate_events_row: list[dict[str, Any]] = []
3198
+ response_row: Optional[Union[str, dict[str, Any]]] = None
3199
+ agent_data_row: Optional[Union[str, dict[str, Any]]] = None
3200
+
3201
+ if isinstance(resp_item, list):
3202
+ try:
3203
+ response_row = resp_item[-1]["content"]["parts"][0]["text"]
3204
+ for intermediate_event in resp_item[:-1]:
3205
+ intermediate_events_row.append(
3206
+ {
3207
+ "event_id": intermediate_event.get("id"),
3208
+ "content": intermediate_event.get("content"),
3209
+ "creation_timestamp": intermediate_event.get("timestamp"),
3210
+ "author": intermediate_event.get("author"),
3211
+ }
3212
+ )
3213
+ # Construct AgentData natively for single-turn runs
3214
+ agent_events = []
3215
+ for event_dict in resp_item:
3216
+ content_dict = event_dict.get("content")
3217
+ content_obj = None
3218
+ if content_dict:
3219
+ content_obj = genai_types.Content.model_validate(content_dict)
3220
+
3221
+ agent_events.append(
3222
+ types.evals.AgentEvent(
3223
+ author=event_dict.get("author", "model"),
3224
+ content=content_obj,
3225
+ )
3226
+ )
3227
+
3228
+ turn = types.evals.ConversationTurn(
3229
+ turn_index=0,
3230
+ turn_id="turn_0",
3231
+ events=agent_events,
3232
+ )
3233
+ agent_data_row = types.evals.AgentData(
3234
+ turns=[turn],
3235
+ agents=agent_data_agents,
3236
+ ).model_dump(exclude_unset=True)
3237
+ except Exception as e: # pylint: disable=broad-exception-caught
3238
+ error_payload = {
3239
+ "error": (
3240
+ f"Failed to parse agent run response {str(resp_item)} to "
3241
+ f"agent data: {e}"
3242
+ ),
3243
+ }
3244
+ response_row = json.dumps(error_payload)
3245
+ agent_data_row = json.dumps(error_payload)
3246
+ elif isinstance(resp_item, dict) and "error" in resp_item:
3247
+ response_row = json.dumps(resp_item)
3248
+ else:
3249
+ error_payload = {
3250
+ "error": "Unexpected response type from agent run",
3251
+ "response_type": str(type(resp_item)),
3252
+ "details": str(resp_item),
3253
+ }
3254
+ response_row = json.dumps(error_payload)
3255
+
3256
+ return response_row, intermediate_events_row, agent_data_row
3257
+
3258
+
3259
+ def _create_agent_results_dataframe(
3260
+ prompt_dataset: pd.DataFrame,
3261
+ processed_responses: list[Any],
3262
+ processed_intermediate_events: list[Any],
3263
+ processed_agent_data: list[Any],
3264
+ is_user_simulation: bool,
3265
+ ) -> pd.DataFrame:
3266
+ """Creates a DataFrame from the processed agent responses."""
3267
+ df_dict: dict[str, Any] = {}
3268
+ if is_user_simulation:
3269
+ df_dict[AGENT_DATA] = processed_agent_data
3270
+ if len(processed_agent_data) != len(prompt_dataset):
3271
+ raise RuntimeError(
3272
+ "Critical prompt/agent_data count mismatch: %d"
3273
+ " prompts vs %d agent_data. This indicates an issue in response"
3274
+ " collection."
3275
+ % (
3276
+ len(prompt_dataset),
3277
+ len(processed_agent_data),
3278
+ )
3279
+ )
3280
+ else:
3281
+ df_dict[_evals_constant.INTERMEDIATE_EVENTS] = processed_intermediate_events
3282
+ df_dict[_evals_constant.RESPONSE] = processed_responses
3283
+ df_dict[AGENT_DATA] = processed_agent_data
3284
+ if len(processed_responses) != len(prompt_dataset) or len(
3285
+ processed_responses
3286
+ ) != len(processed_intermediate_events):
3287
+ raise RuntimeError(
3288
+ "Critical prompt/response/intermediate_events count mismatch: %d"
3289
+ " prompts vs %d vs %d responses. This indicates an issue in response"
3290
+ " collection."
3291
+ % (
3292
+ len(prompt_dataset),
3293
+ len(processed_responses),
3294
+ len(processed_intermediate_events),
3295
+ )
3296
+ )
3297
+
3298
+ results_df_raw = pd.DataFrame(df_dict)
3299
+
3300
+ prompt_dataset_indexed = prompt_dataset.reset_index(drop=True)
3301
+ results_df_responses_only_indexed = results_df_raw.reset_index(drop=True)
3302
+
3303
+ # Drop columns from input that will be overwritten by results to avoid
3304
+ # duplicate columns after concatenation (e.g. agent_data).
3305
+ overlap = prompt_dataset_indexed.columns.intersection(
3306
+ results_df_responses_only_indexed.columns
3307
+ )
3308
+ if not overlap.empty:
3309
+ prompt_dataset_indexed = prompt_dataset_indexed.drop(columns=overlap)
3310
+
3311
+ results_df = pd.concat(
3312
+ [prompt_dataset_indexed, results_df_responses_only_indexed], axis=1
3313
+ )
3314
+ return results_df
3315
+
3316
+
3317
+ def _run_agent_internal(
3318
+ api_client: BaseApiClient,
3319
+ agent_engine: Optional[Union[str, types.AgentEngine]],
3320
+ agent: Optional["LlmAgent"], # type: ignore # noqa: F821
3321
+ prompt_dataset: pd.DataFrame,
3322
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
3323
+ allow_cross_region_model: bool = False,
3324
+ ) -> pd.DataFrame:
3325
+ """Runs an agent."""
3326
+ raw_responses = _run_agent(
3327
+ api_client=api_client,
3328
+ agent_engine=agent_engine,
3329
+ agent=agent,
3330
+ prompt_dataset=prompt_dataset,
3331
+ user_simulator_config=user_simulator_config,
3332
+ allow_cross_region_model=allow_cross_region_model,
3333
+ )
3334
+ processed_intermediate_events = []
3335
+ processed_responses = []
3336
+ processed_agent_data = []
3337
+ agent_data_agents = None
3338
+ if agent:
3339
+ agent_data_agents = types.evals.AgentData.get_agents_map(agent)
3340
+
3341
+ is_user_simulation = _is_multi_turn_agent_simulation(
3342
+ user_simulator_config, prompt_dataset
3343
+ )
3344
+
3345
+ for resp_item in raw_responses:
3346
+ if is_user_simulation:
3347
+ agent_data_row = _process_multi_turn_agent_response(
3348
+ resp_item, agent_data_agents
3349
+ )
3350
+ processed_agent_data.append(agent_data_row)
3351
+ else:
3352
+ response_row, intermediate_events_row, agent_data_row = (
3353
+ _process_single_turn_agent_response(resp_item, agent_data_agents)
3354
+ )
3355
+ processed_responses.append(response_row)
3356
+ processed_intermediate_events.append(intermediate_events_row)
3357
+ processed_agent_data.append(agent_data_row)
3358
+
3359
+ return _create_agent_results_dataframe(
3360
+ prompt_dataset,
3361
+ processed_responses,
3362
+ processed_intermediate_events,
3363
+ processed_agent_data,
3364
+ is_user_simulation,
3365
+ )
3366
+
3367
+
3368
+ def _run_agent(
3369
+ api_client: BaseApiClient,
3370
+ agent_engine: Optional[Union[str, types.AgentEngine]],
3371
+ agent: Optional["LlmAgent"], # type: ignore # noqa: F821
3372
+ prompt_dataset: pd.DataFrame,
3373
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
3374
+ allow_cross_region_model: bool = False,
3375
+ ) -> list[
3376
+ Union[
3377
+ list[dict[str, Any]],
3378
+ dict[str, Any],
3379
+ genai_types.GenerateContentResponse,
3380
+ ]
3381
+ ]:
3382
+ """Internal helper to run inference using Gemini model with concurrency.
3383
+
3384
+ The simulator model (when user simulation is enabled) runs in `api_client`'s
3385
+ region; `allow_cross_region_model` is accepted for API compatibility but the
3386
+ simulator is never routed to a different region.
3387
+ """
3388
+ del allow_cross_region_model # Simulator always runs in the client region.
3389
+ if agent_engine:
3390
+ return _execute_inference_concurrently(
3391
+ api_client=api_client,
3392
+ agent_engine=agent_engine,
3393
+ prompt_dataset=prompt_dataset,
3394
+ progress_desc="Agent Run",
3395
+ gemini_config=None,
3396
+ user_simulator_config=None,
3397
+ inference_fn=_execute_agent_run_with_retry,
3398
+ )
3399
+ elif agent:
3400
+ return _execute_inference_concurrently(
3401
+ api_client=api_client,
3402
+ agent=agent,
3403
+ prompt_dataset=prompt_dataset,
3404
+ progress_desc="Local Agent Run",
3405
+ gemini_config=None,
3406
+ user_simulator_config=user_simulator_config,
3407
+ inference_fn=_execute_local_agent_run_with_retry,
3408
+ )
3409
+ else:
3410
+ raise ValueError("Neither agent_engine nor agent is provided.")
3411
+
3412
+
3413
+ def _create_agent_engine_session(
3414
+ *,
3415
+ agent_engine: types.AgentEngine,
3416
+ user_id: str,
3417
+ session_state: Optional[dict[str, Any]] = None,
3418
+ ) -> Any:
3419
+ """Creates a session for an agent engine and returns the session ID.
3420
+
3421
+ First attempts to use the agent engine's own `create_session` operation
3422
+ (available for agents deployed via AdkApp). If the agent engine does not
3423
+ have `create_session` registered, falls back to the managed Vertex AI
3424
+ Sessions API.
3425
+
3426
+ Args:
3427
+ agent_engine: The AgentEngine instance.
3428
+ user_id: The user ID for the session.
3429
+ session_state: Optional initial state for the session.
3430
+
3431
+ Returns:
3432
+ The session ID string.
3433
+
3434
+ Raises:
3435
+ RuntimeError: If the session could not be created via either path.
3436
+ """
3437
+ try:
3438
+ session = agent_engine.create_session( # type: ignore[attr-defined]
3439
+ user_id=user_id,
3440
+ state=session_state,
3441
+ )
3442
+ return session["id"]
3443
+ except AttributeError as exc:
3444
+ # Agent engine does not have create_session registered (e.g. deployed
3445
+ # via Console, gcloud, or source code deployment without AdkApp).
3446
+ # Fall back to the managed Vertex AI Sessions API.
3447
+ logger.info(
3448
+ "Agent engine does not have 'create_session' operation registered."
3449
+ " Falling back to managed Sessions API."
3450
+ )
3451
+ if agent_engine.api_resource is None:
3452
+ raise RuntimeError(
3453
+ "Failed to create session: agent_engine.api_resource is None."
3454
+ ) from exc
3455
+ if agent_engine.api_client is None:
3456
+ raise RuntimeError(
3457
+ "Failed to create session: agent_engine.api_client is None."
3458
+ ) from exc
3459
+ operation = agent_engine.api_client.sessions.create(
3460
+ name=agent_engine.api_resource.name,
3461
+ user_id=user_id,
3462
+ config=types.CreateAgentEngineSessionConfig(
3463
+ session_state=session_state,
3464
+ ),
3465
+ )
3466
+ if operation.response and operation.response.name:
3467
+ # Session name format:
3468
+ # projects/{p}/locations/{l}/reasoningEngines/{re}/sessions/{id}
3469
+ return operation.response.name.split("/")[-1]
3470
+ elif operation.error:
3471
+ raise RuntimeError(
3472
+ f"Failed to create session via managed API: {operation.error}"
3473
+ ) from exc
3474
+ else:
3475
+ raise RuntimeError(
3476
+ "Failed to create session via managed API: "
3477
+ "operation returned no response."
3478
+ ) from exc
3479
+
3480
+
3481
+ def _execute_agent_run_with_retry(
3482
+ row: pd.Series,
3483
+ contents: Union[genai_types.ContentListUnion, genai_types.ContentListUnionDict],
3484
+ agent_engine: types.AgentEngine,
3485
+ max_retries: int = 3,
3486
+ ) -> Union[list[dict[str, Any]], dict[str, Any]]:
3487
+ """Executes agent run over agent engine for a single prompt."""
3488
+ try:
3489
+ if "session_inputs" in row.index and row.get("session_inputs") is not None:
3490
+ session_inputs = _get_session_inputs(row)
3491
+ user_id = session_inputs.user_id or str(uuid.uuid4())
3492
+ session_state = session_inputs.state
3493
+ else:
3494
+ user_id = str(uuid.uuid4())
3495
+ session_state = None
3496
+ except KeyError as e:
3497
+ return {"error": f"Failed to get all required agent engine inputs: {e}"}
3498
+
3499
+ try:
3500
+ session_id = _create_agent_engine_session(
3501
+ agent_engine=agent_engine,
3502
+ user_id=user_id,
3503
+ session_state=session_state,
3504
+ )
3505
+ except Exception as e: # pylint: disable=broad-exception-caught
3506
+ return {"error": f"Failed to create a new session: {e}"}
3507
+
3508
+ # Pre-populate remote session with agent_data history (N+1 case only).
3509
+ if (
3510
+ AGENT_DATA in row.index
3511
+ and row.get(AGENT_DATA) is not None
3512
+ and _is_n_plus_1_inference(row[AGENT_DATA])
3513
+ ):
3514
+ agent_data_obj = row[AGENT_DATA]
3515
+ if isinstance(agent_data_obj, dict):
3516
+ agent_data_obj = types.evals.AgentData.model_validate(agent_data_obj)
3517
+ _, history_events = _extract_prompt_from_agent_data(agent_data_obj)
3518
+
3519
+ if agent_engine.api_resource is None:
3520
+ return {"error": "agent_engine.api_resource is None."}
3521
+ if agent_engine.api_client is None:
3522
+ return {"error": "agent_engine.api_client is None."}
3523
+ session_name = f"{agent_engine.api_resource.name}/sessions/{session_id}"
3524
+ base_ts = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
3525
+ for i, ag_event in enumerate(history_events):
3526
+ agent_engine.api_client.sessions.events.append(
3527
+ name=session_name,
3528
+ author=ag_event.author or "user",
3529
+ invocation_id="history",
3530
+ timestamp=base_ts + datetime.timedelta(seconds=i),
3531
+ config=types.AppendAgentEngineSessionEventConfig(
3532
+ content=ag_event.content,
3533
+ ),
3534
+ )
3535
+
3536
+ # stream_query retry loop (shared for both agent_data and prompt paths).
3537
+ for attempt in range(max_retries):
3538
+ try:
3539
+ responses = []
3540
+ for event in agent_engine.stream_query( # type: ignore[attr-defined]
3541
+ user_id=user_id,
3542
+ session_id=session_id,
3543
+ message=contents,
3544
+ ):
3545
+ if event and CONTENT in event and PARTS in event[CONTENT]:
3546
+ responses.append(event)
3547
+ return responses
3548
+ except api_exceptions.ResourceExhausted as e:
3549
+ logger.warning(
3550
+ "Resource Exhausted error on attempt %d/%d: %s. Retrying in %s"
3551
+ " seconds...",
3552
+ attempt + 1,
3553
+ max_retries,
3554
+ e,
3555
+ 2**attempt,
3556
+ )
3557
+ if attempt == max_retries - 1:
3558
+ return {"error": f"Resource exhausted after retries: {e}"}
3559
+ time.sleep(2**attempt)
3560
+ except Exception as e: # pylint: disable=broad-exception-caught
3561
+ logger.error(
3562
+ "Unexpected error during agent engine run on attempt %d/%d: %s",
3563
+ attempt + 1,
3564
+ max_retries,
3565
+ e,
3566
+ )
3567
+ if attempt == max_retries - 1:
3568
+ return {"error": f"Failed after retries: {e}"}
3569
+ time.sleep(1)
3570
+ return {"error": f"Failed to get agent run results after {max_retries} retries"}
3571
+
3572
+
3573
+ def _execute_local_agent_run_with_retry(
3574
+ row: pd.Series,
3575
+ contents: Union[genai_types.ContentListUnion, genai_types.ContentListUnionDict],
3576
+ agent: "LlmAgent", # type: ignore # noqa: F821
3577
+ api_client: BaseApiClient,
3578
+ max_retries: int = 3,
3579
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
3580
+ ) -> Union[list[dict[str, Any]], dict[str, Any]]:
3581
+ """Executes agent run locally for a single prompt synchronously."""
3582
+ return asyncio.run(
3583
+ _execute_local_agent_run_with_retry_async(
3584
+ row, contents, agent, api_client, max_retries, user_simulator_config
3585
+ )
3586
+ )
3587
+
3588
+
3589
+ async def _execute_local_agent_run_with_retry_async(
3590
+ row: pd.Series,
3591
+ contents: Union[genai_types.ContentListUnion, genai_types.ContentListUnionDict],
3592
+ agent: "LlmAgent", # type: ignore # noqa: F821
3593
+ api_client: BaseApiClient,
3594
+ max_retries: int = 3,
3595
+ user_simulator_config: Optional[types.evals.UserSimulatorConfig] = None,
3596
+ ) -> Union[list[dict[str, Any]], dict[str, Any]]:
3597
+ """Executes agent run locally for a single prompt asynchronously."""
3598
+ # Lazy-import ADK dependencies to avoid top-level import failures when
3599
+ # google-adk is not installed.
3600
+ from google.adk.runners import Runner
3601
+ from google.adk.sessions import InMemorySessionService
3602
+
3603
+ # Multi-turn agent scraping with user simulation.
3604
+ if user_simulator_config or "conversation_plan" in row:
3605
+ try:
3606
+ return await _run_adk_user_simulation(
3607
+ row, agent, api_client, user_simulator_config
3608
+ )
3609
+ except Exception as e: # pylint: disable=broad-exception-caught
3610
+ logger.error("Multi-turn agent run with user simulation failed: %s", e)
3611
+ return {"error": f"Multi-turn agent run with user simulation failed: {e}"}
3612
+
3613
+ if "session_inputs" in row.index and row.get("session_inputs") is not None:
3614
+ session_inputs = _get_session_inputs(row)
3615
+ user_id = session_inputs.user_id or str(uuid.uuid4())
3616
+ app_name = session_inputs.app_name or "local_agent_run"
3617
+ else:
3618
+ user_id = str(uuid.uuid4())
3619
+ app_name = "local_agent_run"
3620
+ session_id = str(uuid.uuid4())
3621
+
3622
+ session_service = InMemorySessionService()
3623
+ await session_service.create_session(
3624
+ app_name=app_name, user_id=user_id, session_id=session_id
3625
+ )
3626
+
3627
+ # Pre-populate session with agent_data history (N+1 case only).
3628
+ if (
3629
+ AGENT_DATA in row.index
3630
+ and row.get(AGENT_DATA) is not None
3631
+ and _is_n_plus_1_inference(row[AGENT_DATA])
3632
+ ):
3633
+ from google.adk.events.event import Event as AdkEvent
3634
+
3635
+ agent_data_obj = row[AGENT_DATA]
3636
+ if isinstance(agent_data_obj, dict):
3637
+ agent_data_obj = types.evals.AgentData.model_validate(agent_data_obj)
3638
+ _, history_events = _extract_prompt_from_agent_data(agent_data_obj)
3639
+ internal_session = session_service.sessions[app_name][user_id][session_id]
3640
+ for ag_event in history_events:
3641
+ adk_event = AdkEvent(
3642
+ author=ag_event.author or "user",
3643
+ content=ag_event.content,
3644
+ invocation_id="history",
3645
+ )
3646
+ internal_session.events.append(adk_event)
3647
+
3648
+ agent_runner = Runner(
3649
+ agent=agent, app_name=app_name, session_service=session_service
3650
+ )
3651
+ new_message_content = genai_types.Content(
3652
+ role=USER_AUTHOR,
3653
+ parts=[genai_types.Part(text=contents)],
3654
+ )
3655
+ # Avoid printing out warning from agent_runner.run()
3656
+ # WARNING:google_genai.types:Warning: there are non-text parts in the
3657
+ # response: ['function_call'], returning concatenated text result from
3658
+ # text parts. Check the full candidates.content.parts accessor to get
3659
+ # the full model response.
3660
+ # TODO: Update retry mechanism
3661
+ with _temp_logger_level("google_genai.types", logging.ERROR):
3662
+ for attempt in range(max_retries):
3663
+ try:
3664
+ events = []
3665
+ async for event in agent_runner.run_async(
3666
+ user_id=user_id,
3667
+ session_id=session_id,
3668
+ new_message=new_message_content,
3669
+ ):
3670
+ if event:
3671
+ event = event.model_dump(exclude_none=True)
3672
+ if event and CONTENT in event and PARTS in event[CONTENT]:
3673
+ events.append(event)
3674
+ return events
3675
+ except api_exceptions.ResourceExhausted as e:
3676
+ logger.warning(
3677
+ "Resource Exhausted error on attempt %d/%d: %s. Retrying"
3678
+ " in %s seconds...",
3679
+ attempt + 1,
3680
+ max_retries,
3681
+ e,
3682
+ 2**attempt,
3683
+ )
3684
+ if attempt == max_retries - 1:
3685
+ return {"error": f"Resource exhausted after retries: {e}"}
3686
+ await asyncio.sleep(2**attempt)
3687
+ except Exception as e: # pylint: disable=broad-exception-caught
3688
+ logger.error(
3689
+ "Unexpected error during agent run on attempt %d/%d: %s",
3690
+ attempt + 1,
3691
+ max_retries,
3692
+ e,
3693
+ )
3694
+ if attempt == max_retries - 1:
3695
+ return {"error": f"Failed after retries: {e}"}
3696
+ await asyncio.sleep(1)
3697
+ return {"error": f"Failed to get agent run results after {max_retries} retries"}
3698
+
3699
+
3700
+ def _convert_gcs_to_evaluation_item_result(
3701
+ api_client: BaseApiClient,
3702
+ gcs_uri: str,
3703
+ ) -> types.EvaluationItemResult:
3704
+ """Converts a json file to an EvaluationItemResult."""
3705
+ logger.info("Loading evaluation item result from GCS: %s", gcs_uri)
3706
+ gcs_utils = _gcs_utils.GcsUtils(api_client=api_client)
3707
+ try:
3708
+ eval_item_data = json.loads(gcs_utils.read_file_contents(gcs_uri))
3709
+ return types.EvaluationItemResult(**eval_item_data)
3710
+ except Exception as e:
3711
+ logger.error(
3712
+ "Failed to load evaluation result from GCS: %s. Error: %s", gcs_uri, e
3713
+ )
3714
+ return types.EvaluationItemResult()
3715
+
3716
+
3717
+ def _convert_gcs_to_evaluation_item_request(
3718
+ api_client: BaseApiClient,
3719
+ gcs_uri: str,
3720
+ ) -> types.EvaluationItemRequest:
3721
+ """Converts a json file to an EvaluationItemRequest."""
3722
+ logger.info("Loading evaluation item request from GCS: %s", gcs_uri)
3723
+ gcs_utils = _gcs_utils.GcsUtils(api_client=api_client)
3724
+ try:
3725
+ eval_item_data = json.loads(gcs_utils.read_file_contents(gcs_uri))
3726
+ return types.EvaluationItemRequest(**eval_item_data)
3727
+ except Exception as e:
3728
+ logger.error(
3729
+ "Failed to load evaluation request from GCS: %s. Error: %s", gcs_uri, e
3730
+ )
3731
+ return types.EvaluationItemRequest()
3732
+
3733
+
3734
+ def _get_aggregated_metrics(
3735
+ results: types.EvaluationRunResults,
3736
+ ) -> list[types.AggregatedMetricResult]:
3737
+ """Retrieves an EvaluationResult from the resource name."""
3738
+ if (
3739
+ not results
3740
+ or not results.summary_metrics
3741
+ or not results.summary_metrics.metrics
3742
+ ):
3743
+ return []
3744
+
3745
+ aggregated_metrics_dict: dict[str, dict[str, Any]] = {}
3746
+ for name, value in results.summary_metrics.metrics.items():
3747
+ result = name.rsplit("/", 1)
3748
+ full_metric_name = result[0]
3749
+ aggregated_metric_name = result[1]
3750
+ if full_metric_name not in aggregated_metrics_dict:
3751
+ aggregated_metrics_dict[full_metric_name] = {}
3752
+ aggregated_metrics_dict[full_metric_name]["sub_metric_name"] = (
3753
+ full_metric_name.split("/")[-1]
3754
+ )
3755
+ aggregated_metrics_dict[full_metric_name][aggregated_metric_name] = value
3756
+
3757
+ items_sorted = sorted(
3758
+ aggregated_metrics_dict.items(),
3759
+ key=lambda item: (item[1]["sub_metric_name"], item[0]),
3760
+ )
3761
+
3762
+ return [
3763
+ types.AggregatedMetricResult(
3764
+ metric_name=name.split("/")[-1],
3765
+ mean_score=values.get("AVERAGE"),
3766
+ stdev_score=values.get("STANDARD_DEVIATION"),
3767
+ )
3768
+ for name, values in items_sorted
3769
+ ]
3770
+
3771
+
3772
+ def _get_eval_case_result_from_eval_item(
3773
+ index: int,
3774
+ eval_item: types.EvaluationItem,
3775
+ ) -> types.EvalCaseResult:
3776
+ """Transforms EvaluationItem to EvalCaseResult."""
3777
+ metric_results = {}
3778
+ if (
3779
+ eval_item.evaluation_response
3780
+ and eval_item.evaluation_response.candidate_results
3781
+ ):
3782
+ for candidate_result in eval_item.evaluation_response.candidate_results:
3783
+ metric_results[candidate_result.metric] = types.EvalCaseMetricResult(
3784
+ metric_name=candidate_result.metric,
3785
+ score=candidate_result.score,
3786
+ explanation=candidate_result.explanation,
3787
+ rubric_verdicts=candidate_result.rubric_verdicts,
3788
+ error_message=(eval_item.error.message if eval_item.error else None),
3789
+ )
3790
+ return types.EvalCaseResult(
3791
+ eval_case_index=index,
3792
+ response_candidate_results=[
3793
+ types.ResponseCandidateResult(
3794
+ response_index=0,
3795
+ metric_results=metric_results,
3796
+ )
3797
+ ],
3798
+ )
3799
+
3800
+
3801
+ def _convert_request_to_dataset_row(
3802
+ request: types.EvaluationItemRequest,
3803
+ ) -> dict[str, Any]:
3804
+ """Converts an EvaluationItemRequest to a dictionary."""
3805
+ dict_row: dict[str, Any] = {}
3806
+ dict_row[_evals_constant.PROMPT] = (
3807
+ request.prompt.text if request.prompt and request.prompt.text else None
3808
+ )
3809
+ dict_row[_evals_constant.REFERENCE] = request.golden_response
3810
+
3811
+ if request.prompt and request.prompt.user_scenario:
3812
+ dict_row[_evals_constant.STARTING_PROMPT] = (
3813
+ request.prompt.user_scenario.starting_prompt
3814
+ )
3815
+ dict_row[_evals_constant.CONVERSATION_PLAN] = (
3816
+ request.prompt.user_scenario.conversation_plan
3817
+ )
3818
+
3819
+ intermediate_events = []
3820
+ agent_data = None
3821
+ if request.candidate_responses:
3822
+ for candidate in request.candidate_responses:
3823
+ if candidate.candidate is not None:
3824
+ dict_row[candidate.candidate] = (
3825
+ candidate.text if candidate.text else None
3826
+ )
3827
+ if candidate.events:
3828
+ for event in candidate.events:
3829
+ content_dict = {"parts": event.parts, "role": event.role}
3830
+ int_events_dict = {
3831
+ "event_id": candidate.candidate,
3832
+ "content": content_dict,
3833
+ }
3834
+ intermediate_events.append(int_events_dict)
3835
+ agent_data = request.candidate_responses[0].agent_data
3836
+
3837
+ dict_row[_evals_constant.INTERMEDIATE_EVENTS] = intermediate_events
3838
+ dict_row[_evals_constant.AGENT_DATA] = (
3839
+ agent_data.model_dump() if agent_data else None
3840
+ )
3841
+ return dict_row
3842
+
3843
+
3844
+ def _drop_empty_columns(df: "pd.DataFrame") -> "pd.DataFrame":
3845
+ """Drops columns that are all None or all empty lists/dicts."""
3846
+ if df is None or df.empty or pd is None:
3847
+ return df
3848
+
3849
+ def is_empty(x: Any) -> bool:
3850
+ if isinstance(x, (list, dict)):
3851
+ return not x
3852
+ return pd.isna(x) # type: ignore[no-any-return]
3853
+
3854
+ cols_to_drop = [col for col in df.columns if df[col].apply(is_empty).all()]
3855
+ return df.drop(columns=cols_to_drop)
3856
+
3857
+
3858
+ def _transform_dataframe(
3859
+ rows: list[dict[str, Any]],
3860
+ ) -> list[types.EvaluationDataset]:
3861
+ """Transforms rows to a list of EvaluationDatasets.
3862
+
3863
+ Args:
3864
+ rows: A list of rows, each row is a dictionary of candidate name to response
3865
+ text.
3866
+
3867
+ Returns:
3868
+ A list of EvaluationDatasets, one for each candidate.
3869
+ """
3870
+ df = pd.DataFrame(rows)
3871
+ candidates = [
3872
+ col for col in df.columns if col not in _evals_constant.COMMON_DATASET_COLUMNS
3873
+ ]
3874
+
3875
+ eval_dfs = []
3876
+ for candidate in candidates:
3877
+ temp_df = df.rename(columns={candidate: _evals_constant.RESPONSE})
3878
+ temp_df = _drop_empty_columns(temp_df)
3879
+ eval_dfs.append(
3880
+ types.EvaluationDataset(
3881
+ candidate_name=candidate,
3882
+ eval_dataset_df=temp_df,
3883
+ )
3884
+ )
3885
+ return eval_dfs
3886
+
3887
+
3888
+ def _get_eval_cases_eval_dfs_from_eval_items(
3889
+ eval_items: list[types.EvaluationItem],
3890
+ ) -> tuple[list[types.EvalCaseResult], list[types.EvaluationDataset]]:
3891
+ """Converts an EvaluationSet to a list of EvaluationCaseResults and EvaluationDatasets.
3892
+
3893
+ Args:
3894
+ api_client: The API client.
3895
+ evaluation_set_name: The name of the evaluation set.
3896
+
3897
+ Returns:
3898
+ A tuple of two lists:
3899
+ - eval_case_results: A list of EvalCaseResults, one for each evaluation
3900
+ item.
3901
+ - eval_dfs: A list of EvaluationDatasets, one for each candidate.
3902
+ """
3903
+ dataset_rows = []
3904
+ eval_case_results = []
3905
+ for index, eval_item in enumerate(eval_items):
3906
+ if (
3907
+ eval_item
3908
+ and eval_item.evaluation_response
3909
+ and eval_item.evaluation_response.request
3910
+ ):
3911
+ eval_case_results.append(
3912
+ _get_eval_case_result_from_eval_item(index, eval_item)
3913
+ )
3914
+ dataset_rows.append(
3915
+ _convert_request_to_dataset_row(eval_item.evaluation_response.request)
3916
+ )
3917
+ eval_dfs = _transform_dataframe(dataset_rows)
3918
+ return eval_case_results, eval_dfs
3919
+
3920
+
3921
+ def _get_agent_info_from_inference_configs(
3922
+ candidate_names: list[str],
3923
+ inference_configs: Optional[dict[str, types.EvaluationRunInferenceConfig]] = None,
3924
+ ) -> Optional[types.evals.AgentInfo]:
3925
+ """Retrieves an AgentInfo from the inference configs."""
3926
+ # TODO(lakeyk): Support multiple agents.
3927
+ if not (
3928
+ inference_configs
3929
+ and candidate_names
3930
+ and candidate_names[0] in inference_configs
3931
+ and inference_configs[candidate_names[0]].agent_config
3932
+ ):
3933
+ return None
3934
+ if len(inference_configs.keys()) > 1:
3935
+ logger.warning(
3936
+ "Multiple agents are not supported yet. Displaying the first agent."
3937
+ )
3938
+ agent_config = inference_configs[candidate_names[0]].agent_config
3939
+ di = (
3940
+ agent_config.developer_instruction
3941
+ if agent_config and agent_config.developer_instruction
3942
+ else None
3943
+ )
3944
+ instruction = di.parts[0].text if di and di.parts and di.parts[0].text else None
3945
+ tools = agent_config.tools if agent_config and agent_config.tools else None
3946
+
3947
+ return types.evals.AgentInfo(
3948
+ name=candidate_names[0],
3949
+ agents={
3950
+ "agent_0": types.evals.AgentConfig(
3951
+ instruction=instruction,
3952
+ tools=tools,
3953
+ )
3954
+ },
3955
+ root_agent_id="agent_0",
3956
+ )
3957
+
3958
+
3959
+ def _get_eval_result_from_eval_items(
3960
+ results: types.EvaluationRunResults,
3961
+ eval_items: list[types.EvaluationItem],
3962
+ inference_configs: Optional[dict[str, types.EvaluationRunInferenceConfig]] = None,
3963
+ ) -> types.EvaluationResult:
3964
+ """Retrieves an EvaluationResult from the EvaluationRunResults.
3965
+
3966
+ This function is used to convert an EvaluationRunResults object used by the
3967
+ Evaluation Management API to an EvaluationResult object. It is used to display
3968
+ the evaluation results in the UI.
3969
+
3970
+ Args:
3971
+ results: The EvaluationRunResults object.
3972
+ eval_items: The list of EvaluationItems.
3973
+
3974
+ Returns:
3975
+ An EvaluationResult object.
3976
+ """
3977
+ aggregated_metrics = _get_aggregated_metrics(results)
3978
+ eval_case_results, eval_dfs = _get_eval_cases_eval_dfs_from_eval_items(eval_items)
3979
+ candidate_names = [eval_df.candidate_name for eval_df in eval_dfs]
3980
+ eval_result = types.EvaluationResult(
3981
+ summary_metrics=aggregated_metrics,
3982
+ eval_case_results=eval_case_results,
3983
+ evaluation_dataset=eval_dfs,
3984
+ metadata=types.EvaluationRunMetadata(
3985
+ candidate_names=candidate_names,
3986
+ ),
3987
+ agent_info=_get_agent_info_from_inference_configs(
3988
+ candidate_names, inference_configs
3989
+ ),
3990
+ )
3991
+ return eval_result
3992
+
3993
+
3994
+ def _build_eval_item_map(
3995
+ eval_items: list[types.EvaluationItem],
3996
+ ) -> dict[str, dict[str, Any]]:
3997
+ """Builds a mapping from EvaluationItem resource name to serialized data.
3998
+
3999
+ This is used by the loss analysis visualization to enrich examples with
4000
+ scenario and rubric data from the original evaluation items.
4001
+
4002
+ Args:
4003
+ eval_items: The list of EvaluationItem objects.
4004
+
4005
+ Returns:
4006
+ A dict mapping evaluation item resource name to the serialized
4007
+ evaluation_response dict (which the JS visualization reads as
4008
+ ``evaluation_result``).
4009
+ """
4010
+ item_map: dict[str, dict[str, Any]] = {}
4011
+ for item in eval_items:
4012
+ if item.name and item.evaluation_response:
4013
+ try:
4014
+ item_map[item.name] = item.evaluation_response.model_dump(
4015
+ mode="json", exclude_none=True
4016
+ )
4017
+ except Exception:
4018
+ pass
4019
+ return item_map
4020
+
4021
+
4022
+ def _convert_evaluation_run_results(
4023
+ api_client: BaseApiClient,
4024
+ evaluation_run_results: types.EvaluationRunResults,
4025
+ inference_configs: Optional[dict[str, types.EvaluationRunInferenceConfig]] = None,
4026
+ ) -> tuple[Optional[types.EvaluationResult], dict[str, dict[str, Any]]]:
4027
+ """Retrieves an EvaluationResult and item map from EvaluationRunResults.
4028
+
4029
+ Returns:
4030
+ A tuple of (EvaluationResult, eval_item_map). The eval_item_map maps
4031
+ evaluation item resource names to their serialized evaluation response
4032
+ data, used for enriching loss analysis visualization.
4033
+ """
4034
+ if not evaluation_run_results or not evaluation_run_results.evaluation_set:
4035
+ return None, {}
4036
+
4037
+ evals_module = evals.Evals(api_client_=api_client)
4038
+ eval_set = evals_module.get_evaluation_set(
4039
+ name=evaluation_run_results.evaluation_set
4040
+ )
4041
+
4042
+ eval_items = []
4043
+ if eval_set and eval_set.evaluation_items:
4044
+ eval_items = [
4045
+ evals_module.get_evaluation_item(name=item_name)
4046
+ for item_name in eval_set.evaluation_items
4047
+ ]
4048
+ eval_result = _get_eval_result_from_eval_items(
4049
+ evaluation_run_results, eval_items, inference_configs
4050
+ )
4051
+ eval_item_map = _build_eval_item_map(eval_items)
4052
+ return eval_result, eval_item_map
4053
+
4054
+
4055
+ async def _convert_evaluation_run_results_async(
4056
+ api_client: BaseApiClient,
4057
+ evaluation_run_results: types.EvaluationRunResults,
4058
+ inference_configs: Optional[dict[str, types.EvaluationRunInferenceConfig]] = None,
4059
+ ) -> tuple[Optional[types.EvaluationResult], dict[str, dict[str, Any]]]:
4060
+ """Retrieves an EvaluationResult and item map from EvaluationRunResults."""
4061
+ if not evaluation_run_results or not evaluation_run_results.evaluation_set:
4062
+ return None, {}
4063
+
4064
+ evals_module = evals.AsyncEvals(api_client_=api_client)
4065
+ eval_set = await evals_module.get_evaluation_set(
4066
+ name=evaluation_run_results.evaluation_set
4067
+ )
4068
+
4069
+ eval_items = []
4070
+ if eval_set and eval_set.evaluation_items:
4071
+ tasks = [
4072
+ evals_module.get_evaluation_item(name=eval_item)
4073
+ for eval_item in eval_set.evaluation_items
4074
+ ]
4075
+ eval_items = await asyncio.gather(*tasks)
4076
+ eval_result = _get_eval_result_from_eval_items(
4077
+ evaluation_run_results, eval_items, inference_configs
4078
+ )
4079
+ eval_item_map = _build_eval_item_map(eval_items)
4080
+ return eval_result, eval_item_map
4081
+
4082
+
4083
+ def _object_to_dict(obj: Any) -> Union[dict[str, Any], Any]:
4084
+ """Converts an object to a dictionary."""
4085
+ if obj is None:
4086
+ return obj
4087
+ if isinstance(obj, (int, float, str, bool)):
4088
+ return obj
4089
+ if isinstance(obj, datetime.datetime):
4090
+ return obj.isoformat()
4091
+ if isinstance(obj, bytes):
4092
+ return base64.b64encode(obj).decode("utf-8")
4093
+ if isinstance(obj, (list, tuple)):
4094
+ return [_object_to_dict(item) for item in obj]
4095
+ if isinstance(obj, dict):
4096
+ return {k: _object_to_dict(v) for k, v in obj.items()}
4097
+
4098
+ if not hasattr(obj, "__dict__"):
4099
+ return obj # Not an object with attributes, return as is (e.g., set)
4100
+
4101
+ result: dict[str, Any] = {}
4102
+ for key, value in obj.__dict__.items():
4103
+ if value is None:
4104
+ continue
4105
+ result[key] = _object_to_dict(value)
4106
+ return result
4107
+
4108
+
4109
+ def _get_content(row: dict[str, Any], column: str) -> Optional[genai_types.Content]:
4110
+ if isinstance(row[column], str):
4111
+ return genai_types.Content(
4112
+ parts=[genai_types.Part(text=row[column])],
4113
+ role=_evals_constant.USER_AUTHOR,
4114
+ )
4115
+ elif isinstance(row[column], genai_types.Content):
4116
+ return cast(genai_types.Content, row[column])
4117
+ else:
4118
+ raise ValueError(
4119
+ f"{column} must be a string or a Content object. Got {type(row[column])}."
4120
+ )
4121
+
4122
+
4123
+ def _create_evaluation_set_from_dataframe(
4124
+ api_client: BaseApiClient,
4125
+ gcs_dest_prefix: str,
4126
+ eval_df: pd.DataFrame,
4127
+ candidate_name: Optional[str] = None,
4128
+ parsed_agent_info: Optional[types.evals.AgentInfo] = None,
4129
+ ) -> Union[types.EvaluationSet, Any]:
4130
+ """Converts a dataframe to an EvaluationSet."""
4131
+ eval_item_requests = []
4132
+ for _, row in eval_df.iterrows():
4133
+ intermediate_events = []
4134
+ if (
4135
+ _evals_constant.INTERMEDIATE_EVENTS in row
4136
+ and isinstance(row[_evals_constant.INTERMEDIATE_EVENTS], list)
4137
+ and len(row[_evals_constant.INTERMEDIATE_EVENTS]) > 0
4138
+ ):
4139
+ for event in row[_evals_constant.INTERMEDIATE_EVENTS]:
4140
+ if CONTENT in event:
4141
+ intermediate_events.append(event[CONTENT])
4142
+
4143
+ agent_data_obj = None
4144
+ if _evals_constant.AGENT_DATA in row:
4145
+ agent_data_val = row[AGENT_DATA]
4146
+ if isinstance(agent_data_val, str):
4147
+ try:
4148
+ agent_data_val = json.loads(agent_data_val)
4149
+ except json.JSONDecodeError:
4150
+ pass
4151
+ if isinstance(agent_data_val, dict):
4152
+ try:
4153
+ agent_data_obj = types.evals.AgentData.model_validate(
4154
+ agent_data_val
4155
+ )
4156
+ except ValidationError:
4157
+ pass
4158
+ elif isinstance(agent_data_val, types.evals.AgentData):
4159
+ agent_data_obj = agent_data_val
4160
+
4161
+ # When agent_data exists but has no agents map (e.g. from remote
4162
+ # agent_engine inference), inject the agents map from agent_info so
4163
+ # the server-side autorater can access tool definitions and
4164
+ # instructions.
4165
+ if (
4166
+ agent_data_obj
4167
+ and not agent_data_obj.agents
4168
+ and parsed_agent_info
4169
+ and parsed_agent_info.agents
4170
+ ):
4171
+ agent_data_obj.agents = parsed_agent_info.agents
4172
+
4173
+ candidate_responses = []
4174
+ if _evals_constant.RESPONSE in row or agent_data_obj or intermediate_events:
4175
+ # Resolve the oneof conflict: prioritize agent_data over flat text
4176
+ response_text = row.get(_evals_constant.RESPONSE) or None
4177
+
4178
+ if agent_data_obj and response_text:
4179
+ logger.info(
4180
+ "Both 'response' and 'agent_data' columns found in the evaluation"
4181
+ " dataset. Prioritizing 'agent_data' and omitting 'response' text"
4182
+ " to satisfy CandidateResponse protobuf oneof constraints."
4183
+ )
4184
+ response_text = None
4185
+
4186
+ candidate_responses.append(
4187
+ types.CandidateResponse(
4188
+ candidate=candidate_name or "Candidate 1",
4189
+ text=response_text,
4190
+ events=intermediate_events or None,
4191
+ agent_data=agent_data_obj,
4192
+ )
4193
+ )
4194
+
4195
+ prompt = None
4196
+ # Determine which history column name is present, preferring
4197
+ # "conversation_history" over "history" if both exist.
4198
+ history_col = None
4199
+ if _evals_constant.CONVERSATION_HISTORY in row:
4200
+ history_col = _evals_constant.CONVERSATION_HISTORY
4201
+ elif _evals_constant.HISTORY in row:
4202
+ history_col = _evals_constant.HISTORY
4203
+
4204
+ if (
4205
+ _evals_constant.STARTING_PROMPT in row
4206
+ and _evals_constant.CONVERSATION_PLAN in row
4207
+ ):
4208
+ prompt = types.EvaluationPrompt(
4209
+ user_scenario=types.evals.UserScenario(
4210
+ starting_prompt=row[_evals_constant.STARTING_PROMPT],
4211
+ conversation_plan=row[_evals_constant.CONVERSATION_PLAN],
4212
+ )
4213
+ )
4214
+ elif _evals_constant.CONTEXT in row or history_col:
4215
+ values = {}
4216
+ if _evals_constant.CONTEXT in row:
4217
+ values[_evals_constant.CONTEXT] = _get_content(
4218
+ row, _evals_constant.CONTEXT
4219
+ )
4220
+ if history_col:
4221
+ values[_evals_constant.CONVERSATION_HISTORY] = _get_content(
4222
+ row, history_col
4223
+ )
4224
+ if _evals_constant.PROMPT in row:
4225
+ values[_evals_constant.PROMPT] = _get_content(
4226
+ row, _evals_constant.PROMPT
4227
+ )
4228
+ prompt = types.EvaluationPrompt(
4229
+ prompt_template_data=types.PromptTemplateData(values=values)
4230
+ )
4231
+ elif _evals_constant.PROMPT in row:
4232
+ prompt = types.EvaluationPrompt(text=row[_evals_constant.PROMPT])
4233
+
4234
+ eval_item_requests.append(
4235
+ types.EvaluationItemRequest(
4236
+ prompt=prompt or None,
4237
+ golden_response=(
4238
+ types.CandidateResponse(text=row[_evals_constant.REFERENCE])
4239
+ if _evals_constant.REFERENCE in row
4240
+ else None
4241
+ ),
4242
+ candidate_responses=(
4243
+ candidate_responses if candidate_responses else None
4244
+ ),
4245
+ )
4246
+ )
4247
+ logger.info("Writing evaluation item requests to GCS.")
4248
+ gcs_utils = _gcs_utils.GcsUtils(api_client=api_client)
4249
+ evals_module = evals.Evals(api_client_=api_client)
4250
+ eval_items = []
4251
+ for eval_item_request in eval_item_requests:
4252
+ gcs_uri = gcs_utils.upload_json_to_prefix(
4253
+ data=_object_to_dict(eval_item_request),
4254
+ gcs_dest_prefix=gcs_dest_prefix,
4255
+ filename_prefix="request",
4256
+ )
4257
+ eval_item = evals_module.create_evaluation_item(
4258
+ evaluation_item_type=types.EvaluationItemType.REQUEST,
4259
+ gcs_uri=gcs_uri,
4260
+ display_name="sdk-generated-eval-item",
4261
+ )
4262
+ eval_items.append(eval_item.name)
4263
+ logger.info("Creating evaluation set from GCS URIs")
4264
+ evaluation_set = evals_module.create_evaluation_set(
4265
+ evaluation_items=eval_items,
4266
+ )
4267
+
4268
+ return evaluation_set