diff --git a/agentplatform/_genai/_evals_builtin_tools.py b/agentplatform/_genai/_evals_builtin_tools.py new file mode 100644 index 0000000000..eb23d5dae7 --- /dev/null +++ b/agentplatform/_genai/_evals_builtin_tools.py @@ -0,0 +1,167 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Built-in tool catalog for Gemini Agent evaluation display. + +The Gemini Agents API (``GET agents/{id}``) returns each tool as a bare type +discriminator (e.g. ``{"type": "code_execution"}``) with no parameter schema +or description. The authoritative, full-fidelity expansion lives server-side +in ``cloud/ai/platform/evaluation/utils/interaction_converter.py``. + +This module is a **display-only duplicate** of that server catalog, kept here +so ``show()`` can render tools with full names and descriptions without a +server round-trip. Parameter schemas are intentionally omitted to avoid +publishing internal tool contract details. + +**If the server catalog changes, this SDK-side copy must be updated to match.** +""" + +from typing import Any, Optional + +from google.genai import types as genai_types + + +# Maps a built-in Gemini Agent tool type to the concrete FunctionDeclarations +# the agent actually exposes for that type. +# +# Source of truth: interaction_converter.py, _BUILTIN_TOOL_FUNCTION_DECLARATIONS +BUILTIN_TOOL_DECLARATIONS: dict[str, list[genai_types.FunctionDeclaration]] = { + "code_execution": [ + genai_types.FunctionDeclaration( + name="run_command", + description="Runs a shell command on the sandbox VM.", + ), + ], + "filesystem": [ + genai_types.FunctionDeclaration( + name="view_file", + description="Reads the content of a workspace file.", + ), + genai_types.FunctionDeclaration( + name="create_file", + description="Writes content to a new or existing file.", + ), + genai_types.FunctionDeclaration( + name="edit_file", + description="Replaces a specific block of text in a file.", + ), + genai_types.FunctionDeclaration( + name="list_dir", + description="Lists the files in a directory.", + ), + genai_types.FunctionDeclaration( + name="delete_file", + description="Removes a file from the workspace.", + ), + genai_types.FunctionDeclaration( + name="move_file", + description="Renames or moves a file.", + ), + ], +} + + +# Sandbox-environment orchestration tool declarations. +# +# Source of truth: interaction_converter.py, _SANDBOX_FUNCTION_DECLARATIONS +SANDBOX_DECLARATIONS: list[genai_types.FunctionDeclaration] = [ + genai_types.FunctionDeclaration( + name="provision_sandbox", + description="Provisions a sandbox environment.", + ), + genai_types.FunctionDeclaration( + name="load_sandbox", + description="Loads a previously provisioned sandbox environment.", + ), +] + + +def agent_tools_to_config_tools( + agent_tools: Optional[list[Any]], + has_environment: bool = False, +) -> Optional[list[genai_types.Tool]]: + """Maps Gemini Agents API tools to ``genai_types.Tool`` for display. + + Expands built-in agent tool types into their concrete function declarations + using ``BUILTIN_TOOL_DECLARATIONS`` (a display-only duplicate of the + server-side catalog in ``interaction_converter.py``). + + Mapping rules: + * ``code_execution`` is expanded to ``run_command``. + * ``filesystem`` is expanded to ``view_file``, ``create_file``, + ``edit_file``, ``list_dir``, ``delete_file``, ``move_file``. + * ``google_search`` and ``url_context`` are mapped to their typed + ``genai_types.Tool`` variant. + * ``mcp_server`` is represented as a named declaration with a + human-readable label. + * Tools carrying explicit ``function_declarations`` are passed through. + * When ``has_environment`` is True, sandbox orchestration tools + (``provision_sandbox``, ``load_sandbox``) are appended. + + Args: + agent_tools: The ``tools`` list from a fetched Gemini agent dict. + has_environment: Whether the agent has a sandbox environment configured. + + Returns: + A list of ``genai_types.Tool``, or ``None`` if there are no mappable + tools. + """ + if not agent_tools and not has_environment: + return None + tools: list[genai_types.Tool] = [] + for tool in agent_tools or []: + if not isinstance(tool, dict): + continue + tool_type = tool.get("type") + remainder = {k: v for k, v in tool.items() if k != "type"} + + # Check the built-in catalog first (code_execution, filesystem). + catalog_decls = BUILTIN_TOOL_DECLARATIONS.get(tool_type or "") + if catalog_decls: + tools.append(genai_types.Tool(function_declarations=list(catalog_decls))) + elif tool_type == "google_search": + tools.append(genai_types.Tool(google_search=genai_types.GoogleSearch())) + elif tool_type == "url_context": + tools.append(genai_types.Tool(url_context=genai_types.UrlContext())) + elif "function_declarations" in remainder: + # Real function tool with explicit declarations. + tools.append(genai_types.Tool.model_validate(remainder)) + elif tool_type == "mcp_server": + label = remainder.get("name") or remainder.get("url") + description = f"MCP server: {label}" if label else "MCP server." + tools.append( + genai_types.Tool( + function_declarations=[ + genai_types.FunctionDeclaration( + name="mcp_server", description=description + ) + ] + ) + ) + elif tool_type: + # Unknown built-in: show by name so it isn't silently dropped. + tools.append( + genai_types.Tool( + function_declarations=[ + genai_types.FunctionDeclaration(name=tool_type) + ] + ) + ) + elif remainder: + tools.append(genai_types.Tool.model_validate(remainder)) + + if has_environment: + tools.append(genai_types.Tool(function_declarations=list(SANDBOX_DECLARATIONS))) + + return tools or None diff --git a/agentplatform/_genai/_evals_common.py b/agentplatform/_genai/_evals_common.py index 88c61441cd..39270b330e 100644 --- a/agentplatform/_genai/_evals_common.py +++ b/agentplatform/_genai/_evals_common.py @@ -42,6 +42,7 @@ from tqdm import tqdm from pydantic import ValidationError +from . import _evals_builtin_tools from . import _evals_constant from . import _evals_data_converters from . import _evals_metric_handlers @@ -483,6 +484,7 @@ def _get_default_prompt_template( and eval_item.evaluation_request and eval_item.evaluation_request.prompt and eval_item.evaluation_request.prompt.prompt_template_data + and eval_item.evaluation_request.prompt.prompt_template_data.values ): template_values = ( eval_item.evaluation_request.prompt.prompt_template_data.values @@ -837,48 +839,7 @@ def _merge_text_parts_in_agent_data( content.parts = merged_parts -def _agent_tools_to_config_tools( - agent_tools: Optional[list[Any]], -) -> Optional[list[genai_types.Tool]]: - """Maps Gemini Agents API tools to ``genai_types.Tool`` for an AgentConfig. - - The Gemini Agents API returns built-in tool variants (``google_search``, - ``code_execution``, ``url_context``) whose schema differs from - ``genai_types.Tool``. Each recognised built-in variant is mapped to the - matching ``genai_types.Tool`` field. Tools with a non-empty body after - stripping the ``type`` key (e.g. ``function_declarations``) are passed - through ``model_validate``. Variants without a ``genai_types.Tool`` - equivalent (e.g. ``filesystem``, ``mcp_server``) are skipped. - - Args: - agent_tools: The ``tools`` list from a fetched Gemini agent dict. - - Returns: - A list of ``genai_types.Tool``, or ``None`` if there are no mappable - tools. - """ - if not agent_tools: - return None - tools: list[genai_types.Tool] = [] - for tool in agent_tools: - if not isinstance(tool, dict): - continue - tool_type = tool.get("type") - if tool_type == "google_search": - tools.append(genai_types.Tool(google_search=genai_types.GoogleSearch())) - elif tool_type == "code_execution": - tools.append( - genai_types.Tool(code_execution=genai_types.ToolCodeExecution()) - ) - elif tool_type == "url_context": - tools.append(genai_types.Tool(url_context=genai_types.UrlContext())) - else: - # For non-built-in tools (e.g. function_declarations), strip the - # type key and validate through genai_types.Tool. - remainder = {k: v for k, v in tool.items() if k != "type"} - if remainder: - tools.append(genai_types.Tool.model_validate(remainder)) - return tools or None +_agent_tools_to_config_tools = _evals_builtin_tools.agent_tools_to_config_tools def _fetch_agent_config_dict( @@ -889,9 +850,9 @@ def _fetch_agent_config_dict( Fetches the Agent resource via ``GET agents/{id}`` and extracts the system instruction, description, base agent type, and tools. Built-in - tools (``google_search``, ``code_execution``, ``url_context``) are mapped - to their ``genai_types.Tool`` equivalents via - ``_agent_tools_to_config_tools``. + tool types (``code_execution``, ``filesystem``, etc.) are expanded into + concrete ``FunctionDeclaration`` names and descriptions via the + display-only catalog in ``_evals_builtin_tools``. Args: api_client: The API client used to fetch the agent. @@ -916,7 +877,13 @@ def _fetch_agent_config_dict( instruction = agent_dict.get("system_instruction") or None description = agent_dict.get("description") or None agent_type = agent_dict.get("base_agent") or None - tools = _agent_tools_to_config_tools(agent_dict.get("tools")) + has_environment = bool( + agent_dict.get("environment_config") + or agent_dict.get("base_environment") + ) + tools = _agent_tools_to_config_tools( + agent_dict.get("tools"), has_environment=has_environment + ) except Exception as e: # pylint: disable=broad-exception-caught logger.warning( "Failed to fetch agent config for '%s' (continuing without it): %s", @@ -1108,6 +1075,11 @@ def _run_gemini_agent_inference( interactions_client = _get_interactions_client(api_client) + # Best-effort: fetch the agent config (instruction, tools, description) + # once, so every row's agent_data carries the agents map and the display + # can render the System Topology section. + agent_config = _fetch_agent_config_dict(api_client, gemini_agent) + agent_short_id = gemini_agent.split("/")[-1] prompts: list[str] = [] responses: list[Optional[str]] = [] @@ -1128,6 +1100,7 @@ def _run_gemini_agent_inference( ) interaction = _await_interaction(interactions_client, interaction) agent_data_obj = _interaction_dict_to_agent_data(interaction) + agent_data_obj.agents = {agent_config.agent_id: agent_config} _merge_text_parts_in_agent_data(agent_data_obj) responses.append(_agent_data_response_text(agent_data_obj)) interaction_ids.append(interaction.get("id")) diff --git a/agentplatform/_genai/_evals_visualization.py b/agentplatform/_genai/_evals_visualization.py index 45b50b6ebc..dc0ab0e7a1 100644 --- a/agentplatform/_genai/_evals_visualization.py +++ b/agentplatform/_genai/_evals_visualization.py @@ -372,19 +372,36 @@ def get_evaluation_html(eval_result_json: str) -> str: function formatToolDeclarations(toolDeclarations) {{ if (!toolDeclarations) return ''; let functions = []; - if (Array.isArray(toolDeclarations)) {{ - toolDeclarations.forEach(tool => {{ - if (tool.function_declarations) {{ - functions = functions.concat(tool.function_declarations); - }} else if (tool.name && tool.parameters) {{ - functions.push(tool); - }} + const builtins = []; + + function collectFromTool(tool) {{ + if (!tool || typeof tool !== 'object') return; + if (Array.isArray(tool.function_declarations) && tool.function_declarations.length > 0) {{ + functions = functions.concat(tool.function_declarations); + return; + }} + if (tool.name && tool.parameters) {{ + functions.push(tool); + return; + }} + Object.keys(tool).forEach(k => {{ + if (k === 'function_declarations') return; + if (tool[k] === null || tool[k] === undefined) return; + builtins.push(k); }}); - }} else if (typeof toolDeclarations === 'object' && toolDeclarations.function_declarations) {{ - functions = toolDeclarations.function_declarations; }} - if (functions.length === 0) {{ + if (Array.isArray(toolDeclarations)) {{ + toolDeclarations.forEach(collectFromTool); + }} else if (typeof toolDeclarations === 'object') {{ + if (toolDeclarations.function_declarations) {{ + functions = functions.concat(toolDeclarations.function_declarations); + }} else {{ + collectFromTool(toolDeclarations); + }} + }} + + if (functions.length === 0 && builtins.length === 0) {{ return `
${{DOMPurify.sanitize(JSON.stringify(toolDeclarations, null, 2))}}
`; }} @@ -402,6 +419,9 @@ def get_evaluation_html(eval_result_json: str) -> str: }}); html += ''; }}); + builtins.forEach(name => {{ + html += `
${{DOMPurify.sanitize(name)}} (built-in tool)
`; + }}); html += ''; return html; }} @@ -914,19 +934,36 @@ def get_comparison_html(eval_result_json: str) -> str: function formatToolDeclarations(toolDeclarations) {{ if (!toolDeclarations) return ''; let functions = []; - if (Array.isArray(toolDeclarations)) {{ - toolDeclarations.forEach(tool => {{ - if (tool.function_declarations) {{ - functions = functions.concat(tool.function_declarations); - }} else if (tool.name && tool.parameters) {{ - functions.push(tool); - }} + const builtins = []; + + function collectFromTool(tool) {{ + if (!tool || typeof tool !== 'object') return; + if (Array.isArray(tool.function_declarations) && tool.function_declarations.length > 0) {{ + functions = functions.concat(tool.function_declarations); + return; + }} + if (tool.name && tool.parameters) {{ + functions.push(tool); + return; + }} + Object.keys(tool).forEach(k => {{ + if (k === 'function_declarations') return; + if (tool[k] === null || tool[k] === undefined) return; + builtins.push(k); }}); - }} else if (typeof toolDeclarations === 'object' && toolDeclarations.function_declarations) {{ - functions = toolDeclarations.function_declarations; }} - if (functions.length === 0) {{ + if (Array.isArray(toolDeclarations)) {{ + toolDeclarations.forEach(collectFromTool); + }} else if (typeof toolDeclarations === 'object') {{ + if (toolDeclarations.function_declarations) {{ + functions = functions.concat(toolDeclarations.function_declarations); + }} else {{ + collectFromTool(toolDeclarations); + }} + }} + + if (functions.length === 0 && builtins.length === 0) {{ return `
${{DOMPurify.sanitize(JSON.stringify(toolDeclarations, null, 2))}}
`; }} @@ -944,6 +981,9 @@ def get_comparison_html(eval_result_json: str) -> str: }}); html += ''; }}); + builtins.forEach(name => {{ + html += `
${{DOMPurify.sanitize(name)}} (built-in tool)
`; + }}); html += ''; return html; }} diff --git a/tests/unit/agentplatform/genai/test_evals.py b/tests/unit/agentplatform/genai/test_evals.py index 26cfb80e7f..d695d88ce4 100644 --- a/tests/unit/agentplatform/genai/test_evals.py +++ b/tests/unit/agentplatform/genai/test_evals.py @@ -4147,11 +4147,24 @@ def test_run_inference_with_litellm_parsing( assert call_kwargs["model"] == "gpt-4o" pd.testing.assert_frame_equal(call_kwargs["prompt_dataset"], mock_df) + @mock.patch.object(_evals_common, "_fetch_agent_config_dict") @mock.patch.object(_evals_common, "_get_interactions_client") @mock.patch.object(_evals_utils, "EvalDatasetLoader") def test_run_inference_with_gemini_agent( - self, mock_eval_dataset_loader, mock_get_interactions_client + self, mock_eval_dataset_loader, mock_get_interactions_client, + mock_fetch_agent_config ): + mock_fetch_agent_config.return_value = ( + agentplatform_genai_types.evals.AgentConfig( + agent_id="test-agent", + instruction="You are helpful.", + tools=[ + genai_types.Tool( + code_execution=genai_types.ToolCodeExecution() + ), + ], + ) + ) mock_df = pd.DataFrame({"prompt": ["p1", "p2"]}) mock_eval_dataset_loader.return_value.load.return_value = mock_df.to_dict( orient="records" @@ -4210,12 +4223,20 @@ def make_interaction(interaction_id, prompt_text, output_text): assert turn_events[1]["content"]["role"] == "model" assert turn_events[1]["content"]["parts"][0]["text"] == "response 1" assert inference_result.candidate_name == "test-agent" + # agents map must be populated for System Topology rendering. + assert "agents" in agent_data_0 + assert "test-agent" in agent_data_0["agents"] + @mock.patch.object(_evals_common, "_fetch_agent_config_dict") @mock.patch.object(_evals_common, "_get_interactions_client") @mock.patch.object(_evals_utils, "EvalDatasetLoader") def test_run_inference_gemini_agent_continues_on_failure( - self, mock_eval_dataset_loader, mock_get_interactions_client + self, mock_eval_dataset_loader, mock_get_interactions_client, + mock_fetch_agent_config, ): + mock_fetch_agent_config.return_value = ( + agentplatform_genai_types.evals.AgentConfig(agent_id="test-agent") + ) mock_df = pd.DataFrame({"prompt": ["p1", "p2"]}) mock_eval_dataset_loader.return_value.load.return_value = mock_df.to_dict( orient="records" @@ -10708,12 +10729,19 @@ def mock_request(method, path, *args, **kwargs): assert agent_cfg.instruction == "You are a weather assistant." assert agent_cfg.description == "Helps with weather queries." assert agent_cfg.agent_type == "gemini-2.0-flash" - # Built-in tools are mapped to typed Tool objects; function tool also included. + # code_execution is expanded via catalog to run_command; + # google_search keeps its typed variant; function tool passes through. assert agent_cfg.tools is not None assert len(agent_cfg.tools) == 3 - assert any(t.code_execution is not None for t in agent_cfg.tools) + decl_names = { + fd.name + for t in agent_cfg.tools + if t.function_declarations + for fd in t.function_declarations + } + assert "run_command" in decl_names + assert "get_weather" in decl_names assert any(t.google_search is not None for t in agent_cfg.tools) - assert any(t.function_declarations is not None for t in agent_cfg.tools) def test_consecutive_model_output_steps_merged(self): """Multiple consecutive model_output steps are merged into one event.""" @@ -11238,11 +11266,19 @@ def test_extracts_full_config(self): assert result.instruction == "You are helpful." assert result.description == "A helpful agent." assert result.agent_type == "gemini-2.0-flash" - # Built-in tools are mapped to typed Tool objects; function tool also included. + # code_execution is expanded via the catalog to run_command; + # google_search keeps its typed variant; function tool passes through. assert len(result.tools) == 3 - assert any(t.code_execution is not None for t in result.tools) + # code_execution -> run_command with full parameter schema. + decl_names = { + fd.name + for t in result.tools + if t.function_declarations + for fd in t.function_declarations + } + assert "run_command" in decl_names + assert "search" in decl_names # pass-through function tool assert any(t.google_search is not None for t in result.tools) - assert any(t.function_declarations is not None for t in result.tools) def test_fetch_failure_returns_minimal_config(self): """If the API call fails, returns just the agent_id.""" @@ -11282,3 +11318,78 @@ def test_empty_resource_name_uses_default_agent_id(self): "", ) assert result.agent_id == "agent" + + def test_code_execution_expands_to_run_command(self): + """code_execution is expanded to run_command.""" + agent_json = {"tools": [{"type": "code_execution"}]} + mock_api_client = mock.MagicMock() + mock_api_client.request.return_value = self._make_api_response(agent_json) + result = _evals_common._fetch_agent_config_dict( + mock_api_client, "projects/p/locations/l/agents/a", + ) + assert len(result.tools) == 1 + decls = result.tools[0].function_declarations + assert len(decls) == 1 + assert decls[0].name == "run_command" + assert decls[0].description is not None + + def test_filesystem_expands_to_file_tools(self): + """filesystem is expanded to view_file, create_file, etc.""" + agent_json = {"tools": [{"type": "filesystem"}]} + mock_api_client = mock.MagicMock() + mock_api_client.request.return_value = self._make_api_response(agent_json) + result = _evals_common._fetch_agent_config_dict( + mock_api_client, "projects/p/locations/l/agents/a", + ) + assert len(result.tools) == 1 + names = {fd.name for fd in result.tools[0].function_declarations} + assert names == { + "view_file", "create_file", "edit_file", + "list_dir", "delete_file", "move_file", + } + + def test_environment_adds_sandbox_tools(self): + """When agent has environment_config, sandbox tools are appended.""" + agent_json = { + "tools": [{"type": "code_execution"}], + "environment_config": {"some_field": "value"}, + } + mock_api_client = mock.MagicMock() + mock_api_client.request.return_value = self._make_api_response(agent_json) + result = _evals_common._fetch_agent_config_dict( + mock_api_client, "projects/p/locations/l/agents/a", + ) + # code_execution + sandbox tool + assert len(result.tools) == 2 + all_decl_names = { + fd.name + for t in result.tools + if t.function_declarations + for fd in t.function_declarations + } + assert "run_command" in all_decl_names + assert "provision_sandbox" in all_decl_names + assert "load_sandbox" in all_decl_names + + def test_mcp_server_kept_as_named_declaration(self): + """mcp_server entries are kept as named declarations, not dropped.""" + agent_json = { + "tools": [ + {"type": "google_search"}, + {"type": "mcp_server", "name": "my-mcp", "url": "https://x.com"}, + ], + } + mock_api_client = mock.MagicMock() + mock_api_client.request.return_value = self._make_api_response(agent_json) + result = _evals_common._fetch_agent_config_dict( + mock_api_client, "projects/p/locations/l/agents/a", + ) + assert len(result.tools) == 2 + assert any(t.google_search is not None for t in result.tools) + mcp_tool = [ + t for t in result.tools + if t.function_declarations + and t.function_declarations[0].name == "mcp_server" + ] + assert len(mcp_tool) == 1 + assert "my-mcp" in mcp_tool[0].function_declarations[0].description