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.
- agentplatform/__init__.py +72 -0
- agentplatform/_genai/__init__.py +43 -0
- agentplatform/_genai/_agent_engines_utils.py +2341 -0
- agentplatform/_genai/_bigquery_utils.py +49 -0
- agentplatform/_genai/_datasets_utils.py +344 -0
- agentplatform/_genai/_evals_builtin_tools.py +209 -0
- agentplatform/_genai/_evals_common.py +4268 -0
- agentplatform/_genai/_evals_constant.py +122 -0
- agentplatform/_genai/_evals_data_converters.py +926 -0
- agentplatform/_genai/_evals_metric_handlers.py +1783 -0
- agentplatform/_genai/_evals_metric_loaders.py +401 -0
- agentplatform/_genai/_evals_utils.py +1043 -0
- agentplatform/_genai/_evals_visualization.py +2070 -0
- agentplatform/_genai/_gcs_utils.py +262 -0
- agentplatform/_genai/_logging_utils.py +47 -0
- agentplatform/_genai/_memory_bank_utils.py +206 -0
- agentplatform/_genai/_observability_data_converter.py +186 -0
- agentplatform/_genai/_operations_utils.py +94 -0
- agentplatform/_genai/_prompt_management_utils.py +147 -0
- agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
- agentplatform/_genai/_skills_utils.py +69 -0
- agentplatform/_genai/_transformers.py +628 -0
- agentplatform/_genai/a2a_task_events.py +509 -0
- agentplatform/_genai/a2a_tasks.py +861 -0
- agentplatform/_genai/agent_engines.py +3931 -0
- agentplatform/_genai/client.py +519 -0
- agentplatform/_genai/datasets.py +3045 -0
- agentplatform/_genai/endpoints.py +1149 -0
- agentplatform/_genai/evals.py +6883 -0
- agentplatform/_genai/example_stores.py +1445 -0
- agentplatform/_genai/feedback_contexts.py +700 -0
- agentplatform/_genai/feedback_entries.py +1644 -0
- agentplatform/_genai/live.py +64 -0
- agentplatform/_genai/live_agent_engines.py +179 -0
- agentplatform/_genai/memories.py +2962 -0
- agentplatform/_genai/memory_banks.py +1927 -0
- agentplatform/_genai/memory_revisions.py +465 -0
- agentplatform/_genai/model_garden.py +2638 -0
- agentplatform/_genai/prompt_optimizer.py +995 -0
- agentplatform/_genai/prompts.py +4515 -0
- agentplatform/_genai/rag.py +4961 -0
- agentplatform/_genai/runtime_revisions.py +1257 -0
- agentplatform/_genai/runtimes.py +78 -0
- agentplatform/_genai/sandbox_snapshots.py +1015 -0
- agentplatform/_genai/sandbox_templates.py +1088 -0
- agentplatform/_genai/sandboxes.py +1604 -0
- agentplatform/_genai/session_events.py +543 -0
- agentplatform/_genai/sessions.py +1449 -0
- agentplatform/_genai/skill_revisions.py +377 -0
- agentplatform/_genai/skills.py +1708 -0
- agentplatform/_genai/types/__init__.py +4695 -0
- agentplatform/_genai/types/agent_engines.py +16 -0
- agentplatform/_genai/types/common.py +32784 -0
- agentplatform/_genai/types/evals.py +1031 -0
- agentplatform/_genai/types/prompt_optimizer.py +107 -0
- agentplatform/_genai/types/prompts.py +107 -0
- agentplatform/version.py +17 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
- 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
|