diff --git a/agentplatform/_genai/_evals_common.py b/agentplatform/_genai/_evals_common.py index f33ceb7497..6dce480729 100644 --- a/agentplatform/_genai/_evals_common.py +++ b/agentplatform/_genai/_evals_common.py @@ -840,15 +840,28 @@ def _merge_text_parts_in_agent_data( 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. + """Maps Gemini Agents API tools to ``genai_types.Tool`` for display. + + NOTE: This is a low-fidelity, DISPLAY-ONLY mapping. 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 of built-in tool + types into function declarations (e.g. ``code_execution`` -> ``run_command`` + with parameters, ``filesystem`` -> file tools, sandbox tools) is performed + server-side in ``interaction_converter.py`` whenever the server is handed a + ``gemini_agent_config``. The result of this function must therefore only be + used to render the local System Topology in ``show()`` and must never be + sent to the server-side autorater or scenario generator (which resolve the + agent's tools themselves). + + Mapping rules: + * Recognised built-in variants (``google_search``, ``code_execution``, + ``url_context``) are mapped to the matching ``genai_types.Tool`` field. + * Tools that already carry ``function_declarations`` are passed through + via ``model_validate`` so real function tools keep their full schema. + * Any other typed built-in (e.g. ``filesystem``, ``mcp_server``) is + represented as a single named ``FunctionDeclaration`` so the tool is + still shown by name in the display instead of being dropped. Args: agent_tools: The ``tools`` list from a fetched Gemini agent dict. @@ -864,6 +877,7 @@ def _agent_tools_to_config_tools( if not isinstance(tool, dict): continue tool_type = tool.get("type") + remainder = {k: v for k, v in tool.items() if k != "type"} if tool_type == "google_search": tools.append(genai_types.Tool(google_search=genai_types.GoogleSearch())) elif tool_type == "code_execution": @@ -872,12 +886,31 @@ def _agent_tools_to_config_tools( ) 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)) + elif "function_declarations" in remainder: + # Real function tool with explicit declarations: pass through so + # the display shows the full name/parameter schema. + tools.append(genai_types.Tool.model_validate(remainder)) + elif tool_type: + # Other built-in tool type with no genai_types.Tool variant (e.g. + # "filesystem", "mcp_server"). Represent it as a named declaration + # so it is shown by name rather than dropped. Full parameter + # schemas are only available from the server-side expansion. + description = None + if 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=tool_type, description=description + ) + ] + ) + ) + elif remainder: + # Untyped tool body: best-effort pass-through. + tools.append(genai_types.Tool.model_validate(remainder)) return tools or None @@ -988,33 +1021,6 @@ def _agent_data_response_text(agent_data: types.evals.AgentData) -> Optional[str return "".join(text_parts) or None -def _agent_resource_to_agent_info( - agent: str, api_client: BaseApiClient -) -> "types.evals.AgentInfo": - """Builds an `AgentInfo` from a Gemini Agents API agent resource name. - - Fetches the agent through the SDK's `api_client` (so replay recording is - preserved) via `_fetch_agent_config_dict` and derives a single-agent - `AgentInfo`: the agent's short name is the agents-map key and - `root_agent_id`. - - Args: - agent: The Gemini Agents API agent resource name - (`projects/{p}/locations/{l}/agents/{name}`). - api_client: The API client used to fetch the agent. - - Returns: - An `AgentInfo` describing the fetched agent. - """ - agent_config = _fetch_agent_config_dict(api_client, agent) - short_name = agent_config.agent_id - return types.evals.AgentInfo( # pytype: disable=missing-parameter - name=short_name, - agents={short_name: agent_config}, - root_agent_id=short_name, - ) - - _INTERACTION_TERMINAL_STATES = frozenset( ["completed", "failed", "cancelled", "incomplete", "budget_exceeded"] ) @@ -1109,6 +1115,12 @@ def _run_gemini_agent_inference( interactions_client = _get_interactions_client(api_client) agent_short_id = gemini_agent.split("/")[-1] + + # 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) + prompts: list[str] = [] responses: list[Optional[str]] = [] interaction_ids: list[Optional[str]] = [] @@ -1128,6 +1140,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..164242b221 100644 --- a/agentplatform/_genai/_evals_visualization.py +++ b/agentplatform/_genai/_evals_visualization.py @@ -372,19 +372,42 @@ 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 = []; + + // A built-in tool variant is a Tool object with no + // function_declarations, e.g. {{"google_search": {{...}}}} or + // {{"code_execution": {{}}}}. Collect every non-null key (other than + // function_declarations) as the built-in tool's name so it renders + // by name instead of falling through to a raw JSON dump. Null keys + // (from non-exclude_none serialization) are ignored. + 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 +425,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 +940,42 @@ 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 = []; + + // A built-in tool variant is a Tool object with no + // function_declarations, e.g. {{"google_search": {{...}}}} or + // {{"code_execution": {{}}}}. Collect every non-null key (other than + // function_declarations) as the built-in tool's name so it renders + // by name instead of falling through to a raw JSON dump. Null keys + // (from non-exclude_none serialization) are ignored. + 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 +993,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/agentplatform/_genai/evals.py b/agentplatform/_genai/evals.py index 63fe6d1858..9e119b09f1 100644 --- a/agentplatform/_genai/evals.py +++ b/agentplatform/_genai/evals.py @@ -706,6 +706,13 @@ def _GenerateUserScenariosParameters_to_vertex( if getv(from_object, ["root_agent_id"]) is not None: setv(to_object, ["rootAgentId"], getv(from_object, ["root_agent_id"])) + if getv(from_object, ["gemini_agent_config", "gemini_agent"]) is not None: + setv( + to_object, + ["geminiAgentConfig", "geminiAgent"], + getv(from_object, ["gemini_agent_config", "gemini_agent"]), + ) + if getv(from_object, ["user_scenario_generation_config"]) is not None: setv( to_object, @@ -1513,6 +1520,7 @@ def _generate_user_scenarios( location: Optional[str] = None, agents: Optional[dict[str, evals_types.AgentConfigOrDict]] = None, root_agent_id: Optional[str] = None, + gemini_agent_config: Optional[types.GeminiAgentConfigOrDict] = None, user_scenario_generation_config: Optional[ evals_types.UserScenarioGenerationConfigOrDict ] = None, @@ -1527,6 +1535,7 @@ def _generate_user_scenarios( location=location, agents=agents, root_agent_id=root_agent_id, + gemini_agent_config=gemini_agent_config, user_scenario_generation_config=user_scenario_generation_config, config=config, allow_cross_region_model=allow_cross_region_model, @@ -2938,16 +2947,17 @@ def generate_conversation_scenarios( and the agent under test. Exactly one of `agent_info` or `agent` must be provided. When `agent` is - a Gemini Agents API agent resource name, the agent is fetched and an - `AgentInfo` is derived from it. + a Gemini Agents API agent resource name, the agent resource is passed to + the server, which resolves its configuration (instruction and tools, + including built-in tool expansion) into the agents map. Args: agent_info: The agent info to generate user scenarios for. Mutually exclusive with `agent`. agent: A Gemini Agents API agent resource name (`projects/{p}/locations/{l}/agents/{name}`). When provided, the - agent is fetched and its configuration is used to build the agent - info. Mutually exclusive with `agent_info`. + agent resource is sent to the server, which resolves its + configuration server-side. Mutually exclusive with `agent_info`. config: Configuration for generating user scenarios. allow_cross_region_model: Opt-in flag to authorize cross-region routing for model inference. @@ -2967,8 +2977,13 @@ def generate_conversation_scenarios( "`agent` must be a Gemini Agents API agent resource name of the" " form projects/{project}/locations/{location}/agents/{agent}." ) - parsed_agent_info = _evals_common._agent_resource_to_agent_info( - agent, self._api_client + # Pass the agent resource to the server so it resolves the agent's + # tools/instruction (with full built-in tool expansion) itself, + # rather than synthesizing a low-fidelity AgentConfig client-side. + response = self._generate_user_scenarios( + gemini_agent_config=types.GeminiAgentConfig(gemini_agent=agent), + user_scenario_generation_config=config, + allow_cross_region_model=allow_cross_region_model, ) else: parsed_agent_info = ( @@ -2976,12 +2991,12 @@ def generate_conversation_scenarios( if isinstance(agent_info, dict) else agent_info ) - response = self._generate_user_scenarios( - agents=parsed_agent_info.agents, - root_agent_id=parsed_agent_info.root_agent_id, - user_scenario_generation_config=config, - allow_cross_region_model=allow_cross_region_model, - ) + response = self._generate_user_scenarios( + agents=parsed_agent_info.agents, + root_agent_id=parsed_agent_info.root_agent_id, + user_scenario_generation_config=config, + allow_cross_region_model=allow_cross_region_model, + ) return _evals_utils._postprocess_user_scenarios_response(response) def generate_loss_clusters( @@ -3678,6 +3693,7 @@ async def _generate_user_scenarios( location: Optional[str] = None, agents: Optional[dict[str, evals_types.AgentConfigOrDict]] = None, root_agent_id: Optional[str] = None, + gemini_agent_config: Optional[types.GeminiAgentConfigOrDict] = None, user_scenario_generation_config: Optional[ evals_types.UserScenarioGenerationConfigOrDict ] = None, @@ -3692,6 +3708,7 @@ async def _generate_user_scenarios( location=location, agents=agents, root_agent_id=root_agent_id, + gemini_agent_config=gemini_agent_config, user_scenario_generation_config=user_scenario_generation_config, config=config, allow_cross_region_model=allow_cross_region_model, @@ -4729,16 +4746,17 @@ async def generate_conversation_scenarios( and the agent under test. Exactly one of `agent_info` or `agent` must be provided. When `agent` is - a Gemini Agents API agent resource name, the agent is fetched and an - `AgentInfo` is derived from it. + a Gemini Agents API agent resource name, the agent resource is passed to + the server, which resolves its configuration (instruction and tools, + including built-in tool expansion) into the agents map. Args: agent_info: The agent info to generate user scenarios for. Mutually exclusive with `agent`. agent: A Gemini Agents API agent resource name (`projects/{p}/locations/{l}/agents/{name}`). When provided, the - agent is fetched and its configuration is used to build the agent - info. Mutually exclusive with `agent_info`. + agent resource is sent to the server, which resolves its + configuration server-side. Mutually exclusive with `agent_info`. config: Configuration for generating user scenarios. allow_cross_region_model: Opt-in flag to authorize cross-region routing for model inference. @@ -4758,8 +4776,13 @@ async def generate_conversation_scenarios( "`agent` must be a Gemini Agents API agent resource name of the" " form projects/{project}/locations/{location}/agents/{agent}." ) - parsed_agent_info = _evals_common._agent_resource_to_agent_info( - agent, self._api_client + # Pass the agent resource to the server so it resolves the agent's + # tools/instruction (with full built-in tool expansion) itself, + # rather than synthesizing a low-fidelity AgentConfig client-side. + response = await self._generate_user_scenarios( + gemini_agent_config=types.GeminiAgentConfig(gemini_agent=agent), + user_scenario_generation_config=config, + allow_cross_region_model=allow_cross_region_model, ) else: parsed_agent_info = ( @@ -4767,12 +4790,12 @@ async def generate_conversation_scenarios( if isinstance(agent_info, dict) else agent_info ) - response = await self._generate_user_scenarios( - agents=parsed_agent_info.agents, - root_agent_id=parsed_agent_info.root_agent_id, - user_scenario_generation_config=config, - allow_cross_region_model=allow_cross_region_model, - ) + response = await self._generate_user_scenarios( + agents=parsed_agent_info.agents, + root_agent_id=parsed_agent_info.root_agent_id, + user_scenario_generation_config=config, + allow_cross_region_model=allow_cross_region_model, + ) return _evals_utils._postprocess_user_scenarios_response(response) async def generate_loss_clusters( diff --git a/agentplatform/_genai/types/common.py b/agentplatform/_genai/types/common.py index 79c577c7fd..d0b5d60c31 100644 --- a/agentplatform/_genai/types/common.py +++ b/agentplatform/_genai/types/common.py @@ -5491,6 +5491,13 @@ class _GenerateUserScenariosParameters(_common.BaseModel): default=None, description="""""" ) root_agent_id: Optional[str] = Field(default=None, description="""""") + gemini_agent_config: Optional[GeminiAgentConfig] = Field( + default=None, + description="""If set, the server derives the `agents` map and `root_agent_id` + from the referenced Gemini Agent, resolving its tools (including + built-in tool expansion) server-side. Mutually exclusive with + `agents`/`root_agent_id`.""", + ) user_scenario_generation_config: Optional[ evals_types.UserScenarioGenerationConfig ] = Field(default=None, description="""""") @@ -5515,6 +5522,11 @@ class _GenerateUserScenariosParametersDict(TypedDict, total=False): root_agent_id: Optional[str] """""" + gemini_agent_config: Optional[GeminiAgentConfigDict] + """If set, the server derives the `agents` map and `root_agent_id` from the + referenced Gemini Agent, resolving its tools (including built-in tool + expansion) server-side. Mutually exclusive with `agents`/`root_agent_id`.""" + user_scenario_generation_config: Optional[evals_types.UserScenarioGenerationConfig] """""" diff --git a/tests/unit/agentplatform/genai/test_evals.py b/tests/unit/agentplatform/genai/test_evals.py index 26cfb80e7f..f614607256 100644 --- a/tests/unit/agentplatform/genai/test_evals.py +++ b/tests/unit/agentplatform/genai/test_evals.py @@ -4147,11 +4147,27 @@ 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() + ), + genai_types.Tool( + google_search=genai_types.GoogleSearch() + ), + ], + ) + ) mock_df = pd.DataFrame({"prompt": ["p1", "p2"]}) mock_eval_dataset_loader.return_value.load.return_value = mock_df.to_dict( orient="records" @@ -4211,11 +4227,28 @@ def make_interaction(interaction_id, prompt_text, output_text): assert turn_events[1]["content"]["parts"][0]["text"] == "response 1" assert inference_result.candidate_name == "test-agent" + # The agents map must be populated so the display renders the + # System Topology section (the real bug that was reported). + assert "agents" in agent_data_0, ( + "agent_data must contain an 'agents' map so the System Topology " + "section renders in show(). Keys present: " + + str(list(agent_data_0.keys())) + ) + assert "test-agent" in agent_data_0["agents"] + agent_cfg = agent_data_0["agents"]["test-agent"] + assert agent_cfg["instruction"] == "You are helpful." + assert len(agent_cfg["tools"]) == 2 + + @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" @@ -9213,19 +9246,10 @@ async def test_async_generate_conversation_scenarios(self): request_body = call_args[0][2] # Third positional arg is the request dict assert request_body.get("allowCrossRegionModel") is True - @mock.patch.object(_evals_common, "_fetch_agent_config_dict") - def test_generate_conversation_scenarios_from_gemini_agent( - self, mock_fetch_agent_config - ): - mock_fetch_agent_config.return_value = ( - agentplatform_genai_types.evals.AgentConfig( - agent_id="test-agent", - instruction="You are a helpful travel assistant.", - description="An agent that books flights.", - tools=[genai_types.Tool(google_search=genai_types.GoogleSearch())], - ) - ) - + def test_generate_conversation_scenarios_from_gemini_agent(self): + """When `agent` is a Gemini agent resource, the resource is forwarded to + the server via `gemini_agent_config` and no client-side agent config is + synthesized (the server resolves the tools/instruction itself).""" evals_module = evals.Evals(api_client_=self.mock_api_client) with mock.patch.object( @@ -9237,18 +9261,28 @@ def test_generate_conversation_scenarios_from_gemini_agent( config={"count": 2}, ) - mock_fetch_agent_config.assert_called_once_with( - self.mock_api_client, _TEST_GEMINI_AGENT - ) call_kwargs = mock_generate_user_scenarios.call_args.kwargs - assert call_kwargs["root_agent_id"] == "test-agent" - agents = call_kwargs["agents"] - assert "test-agent" in agents - derived_config = agents["test-agent"] - assert derived_config.instruction == "You are a helpful travel assistant." - assert derived_config.description == "An agent that books flights." - assert derived_config.tools is not None - assert derived_config.tools[0].google_search is not None + assert call_kwargs["gemini_agent_config"].gemini_agent == _TEST_GEMINI_AGENT + # The client must not synthesize a low-fidelity agents map / root_agent_id; + # the server resolves them from the referenced agent resource. + assert call_kwargs.get("agents") is None + assert call_kwargs.get("root_agent_id") is None + + def test_generate_conversation_scenarios_from_gemini_agent_request_body(self): + """The generated request forwards geminiAgentConfig and omits the + client-side agents map.""" + evals_module = evals.Evals(api_client_=self.mock_api_client) + + evals_module.generate_conversation_scenarios( + agent=_TEST_GEMINI_AGENT, + config={"count": 2}, + ) + + self.mock_api_client.request.assert_called_once() + request_body = self.mock_api_client.request.call_args[0][2] + assert request_body["geminiAgentConfig"]["geminiAgent"] == _TEST_GEMINI_AGENT + assert "agents" not in request_body + assert "rootAgentId" not in request_body def test_generate_conversation_scenarios_agent_and_agent_info_raises(self): evals_module = evals.Evals(api_client_=self.mock_api_client) @@ -11282,3 +11316,248 @@ def test_empty_resource_name_uses_default_agent_id(self): "", ) assert result.agent_id == "agent" + + def test_builtin_tools_without_variant_are_kept_as_named_declarations(self): + """Built-in tool types with no genai_types.Tool variant (e.g. + `filesystem`, `mcp_server`) must be represented by name rather than + dropped, so the display can still show them.""" + agent_json = { + "tools": [ + {"type": "filesystem"}, + {"type": "url_context"}, + { + "type": "mcp_server", + "name": "my-mcp", + "url": "https://example.com/mcp", + }, + ], + } + 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/my-agent", + ) + # None of the three tools are dropped. + assert len(result.tools) == 3 + # url_context keeps its typed variant. + assert any(t.url_context is not None for t in result.tools) + # filesystem and mcp_server become named function declarations. + named = { + fd.name: fd + for t in result.tools + if t.function_declarations + for fd in t.function_declarations + } + assert "filesystem" in named + assert "mcp_server" in named + # mcp_server carries a human-readable label in its description. + assert "my-mcp" in (named["mcp_server"].description or "") + + def test_mcp_server_tool_does_not_drop_other_tools(self): + """An `mcp_server` entry must not raise and wipe out the whole tools + list (regression: model_validate on the mcp body used to fail).""" + agent_json = { + "tools": [ + {"type": "google_search"}, + {"type": "mcp_server", "url": "https://example.com/mcp"}, + ], + } + 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/my-agent", + ) + assert len(result.tools) == 2 + assert any(t.google_search is not None for t in result.tools) + + +class TestEvalsVisualizationSystemTopology: + """Tests that the System Topology section renders agent tools end-to-end.""" + + def _build_dataset_with_tools(self): + agent_config = agentplatform_genai_types.evals.AgentConfig( + agent_id="travel-agent", + instruction="You are a travel agent.", + tools=[ + genai_types.Tool(google_search=genai_types.GoogleSearch()), + # Built-in type with no genai variant, kept as a named decl. + genai_types.Tool( + function_declarations=[ + genai_types.FunctionDeclaration(name="filesystem") + ] + ), + # Real function tool with a parameter schema. + genai_types.Tool( + function_declarations=[ + genai_types.FunctionDeclaration( + name="run_command", + description="Runs a shell command.", + parameters=genai_types.Schema( + type="OBJECT", + properties={ + "CommandLine": genai_types.Schema( + type="STRING", + description="The command to run.", + ) + }, + required=["CommandLine"], + ), + ) + ] + ), + ], + ) + agent_data = agentplatform_genai_types.evals.AgentData( + agents={"travel-agent": agent_config}, + turns=[ + agentplatform_genai_types.evals.ConversationTurn( + turn_index=0, + turn_id="turn_0", + events=[ + agentplatform_genai_types.evals.AgentEvent( + author="user", + content=genai_types.Content( + parts=[genai_types.Part(text="hi")] + ), + ), + agentplatform_genai_types.evals.AgentEvent( + author="model", + content=genai_types.Content( + parts=[genai_types.Part(text="hello")] + ), + ), + ], + ) + ], + ) + case = agentplatform_genai_types.EvalCase(agent_data=agent_data) + return agentplatform_genai_types.EvaluationDataset(eval_cases=[case]) + + def _decode_payload(self, html): + match = re.search(r'atob\("([A-Za-z0-9+/=]+)"\)', html) + assert match, "Could not find embedded base64 payload in HTML." + return json.loads(base64.b64decode(match.group(1)).decode("utf-8")) + + def test_system_topology_includes_tools(self): + dataset = self._build_dataset_with_tools() + rows = _evals_visualization._extract_dataset_rows(dataset) + result_dump = { + "summary_metrics": [], + "eval_case_results": [ + { + "eval_case_index": 0, + "response_candidate_results": [{}], + } + ], + "metadata": {"dataset": rows}, + } + + html = _evals_visualization.get_evaluation_html(json.dumps(result_dump)) + + # The section and its renderer must be present in the template. + assert "System Topology" in html + assert "formatSystemTopology" in html + + # The embedded payload must carry the agents map with all three tools + # (none dropped), so the client renders them by name. + payload = self._decode_payload(html) + agent_data = json.loads(payload["metadata"]["dataset"][0]["agent_data"]) + tools = agent_data["agents"]["travel-agent"]["tools"] + assert len(tools) == 3 + + # google_search is preserved as a built-in variant (rendered by name). + assert any("google_search" in t for t in tools) + # filesystem and run_command are present as named function declarations. + decl_names = { + fd["name"] + for t in tools + if t.get("function_declarations") + for fd in t["function_declarations"] + } + assert "filesystem" in decl_names + assert "run_command" in decl_names + + def test_builtin_tool_serialization_roundtrip(self): + """Tests that built-in Tool variants survive the exclude_none=True + serialization that _extract_dataset_rows uses, so they don't become + empty objects that the JS renderer ignores.""" + # These are the three Tool variants the user's agent has. + builtin_tools = [ + genai_types.Tool(code_execution=genai_types.ToolCodeExecution()), + genai_types.Tool(google_search=genai_types.GoogleSearch()), + genai_types.Tool(url_context=genai_types.UrlContext()), + ] + agent_config = agentplatform_genai_types.evals.AgentConfig( + agent_id="test-agent", + tools=builtin_tools, + ) + agent_data = agentplatform_genai_types.evals.AgentData( + agents={"test-agent": agent_config}, + turns=[], + ) + # This is the serialization path used by _extract_dataset_rows (line 205) + dumped = agent_data.model_dump(mode="json", exclude_none=True) + agent_tools = dumped["agents"]["test-agent"].get("tools", []) + + # Each tool must still have at least one non-null key so the JS + # renderer can identify it by name. + for i, t in enumerate(agent_tools): + non_null_keys = [k for k, v in t.items() if v is not None] + assert non_null_keys, ( + f"Tool[{i}] serialized as empty object — the JS renderer " + f"will not display it. Full dump: {json.dumps(t)}" + ) + + def test_builtin_tools_via_extract_dataset_rows(self): + """End-to-end: tools from _agent_tools_to_config_tools survive through + _extract_dataset_rows into the HTML payload agent_data JSON.""" + # Simulate what _fetch_agent_config_dict returns for the user's agent: + # the agent API returns [code_execution, google_search, url_context] + mapped = _evals_common._agent_tools_to_config_tools([ + {"type": "code_execution"}, + {"type": "google_search"}, + {"type": "url_context"}, + ]) + assert mapped is not None, "Mapped tools should not be None" + assert len(mapped) == 3, f"Expected 3 tools, got {len(mapped)}" + + agent_config = agentplatform_genai_types.evals.AgentConfig( + agent_id="test-agent", + tools=mapped, + ) + agent_data = agentplatform_genai_types.evals.AgentData( + agents={"test-agent": agent_config}, + turns=[ + agentplatform_genai_types.evals.ConversationTurn( + turn_index=0, turn_id="t0", events=[ + agentplatform_genai_types.evals.AgentEvent( + author="user", + content=genai_types.Content( + parts=[genai_types.Part(text="hi")] + ), + ), + ], + ), + ], + ) + case = agentplatform_genai_types.EvalCase(agent_data=agent_data) + ds = agentplatform_genai_types.EvaluationDataset(eval_cases=[case]) + rows = _evals_visualization._extract_dataset_rows(ds) + assert rows, "Expected at least one row" + + ad_json = rows[0].get("agent_data") + assert ad_json, "agent_data should be a non-empty JSON string" + ad = json.loads(ad_json) + agents = ad.get("agents", {}) + assert "test-agent" in agents, f"Missing agent. keys: {list(agents.keys())}" + tools = agents["test-agent"].get("tools", []) + assert len(tools) == 3, ( + f"Expected 3 tools in serialized payload, got {len(tools)}. " + f"Serialized tools: {json.dumps(tools)}" + ) + +