# 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. import ntpath import os from pathlib import Path from textwrap import dedent from typing import Literal from typing import Type from unittest import mock from google.adk.agents import config_agent_utils from google.adk.agents.agent_config import AgentConfig from google.adk.agents.base_agent import BaseAgent from google.adk.agents.base_agent_config import BaseAgentConfig from google.adk.agents.common_configs import AgentRefConfig from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.loop_agent import LoopAgent from google.adk.agents.parallel_agent import ParallelAgent from google.adk.agents.sequential_agent import SequentialAgent from google.adk.models.lite_llm import LiteLlm import pytest import yaml def test_agent_config_discriminator_default_is_llm_agent(tmp_path: Path): yaml_content = """\ name: search_agent model: gemini-2.5-flash description: a sample description instruction: a fake instruction tools: - name: google_search """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, LlmAgent) assert config.root.agent_class == "LlmAgent" @pytest.mark.parametrize( "agent_class_value", [ "LlmAgent", "google.adk.agents.LlmAgent", "google.adk.agents.llm_agent.LlmAgent", ], ) def test_agent_config_discriminator_llm_agent( agent_class_value: str, tmp_path: Path ): yaml_content = f"""\ agent_class: {agent_class_value} name: search_agent model: gemini-2.5-flash description: a sample description instruction: a fake instruction tools: - name: google_search """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, LlmAgent) assert config.root.agent_class == agent_class_value @pytest.mark.parametrize( "agent_class_value", [ "LoopAgent", "google.adk.agents.LoopAgent", "google.adk.agents.loop_agent.LoopAgent", ], ) def test_agent_config_discriminator_loop_agent( agent_class_value: str, tmp_path: Path ): yaml_content = f"""\ agent_class: {agent_class_value} name: CodePipelineAgent description: Executes a sequence of code writing, reviewing, and refactoring. sub_agents: [] """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, LoopAgent) assert config.root.agent_class == agent_class_value @pytest.mark.parametrize( "agent_class_value", [ "ParallelAgent", "google.adk.agents.ParallelAgent", "google.adk.agents.parallel_agent.ParallelAgent", ], ) def test_agent_config_discriminator_parallel_agent( agent_class_value: str, tmp_path: Path ): yaml_content = f"""\ agent_class: {agent_class_value} name: CodePipelineAgent description: Executes a sequence of code writing, reviewing, and refactoring. sub_agents: [] """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, ParallelAgent) assert config.root.agent_class == agent_class_value @pytest.mark.parametrize( "agent_class_value", [ "SequentialAgent", "google.adk.agents.SequentialAgent", "google.adk.agents.sequential_agent.SequentialAgent", ], ) def test_agent_config_discriminator_sequential_agent( agent_class_value: str, tmp_path: Path ): yaml_content = f"""\ agent_class: {agent_class_value} name: CodePipelineAgent description: Executes a sequence of code writing, reviewing, and refactoring. sub_agents: [] """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, SequentialAgent) assert config.root.agent_class == agent_class_value @pytest.mark.parametrize( ("agent_class_value", "expected_agent_type"), [ ("LoopAgent", LoopAgent), ("google.adk.agents.LoopAgent", LoopAgent), ("google.adk.agents.loop_agent.LoopAgent", LoopAgent), ("ParallelAgent", ParallelAgent), ("google.adk.agents.ParallelAgent", ParallelAgent), ("google.adk.agents.parallel_agent.ParallelAgent", ParallelAgent), ("SequentialAgent", SequentialAgent), ("google.adk.agents.SequentialAgent", SequentialAgent), ("google.adk.agents.sequential_agent.SequentialAgent", SequentialAgent), ], ) def test_agent_config_discriminator_with_sub_agents( agent_class_value: str, expected_agent_type: Type[BaseAgent], tmp_path: Path ): # Create sub-agent config files sub_agent_dir = tmp_path / "sub_agents" sub_agent_dir.mkdir() sub_agent_config = """\ name: sub_agent_{index} model: gemini-2.5-flash description: a sub agent instruction: sub agent instruction """ (sub_agent_dir / "sub_agent1.yaml").write_text( sub_agent_config.format(index=1) ) (sub_agent_dir / "sub_agent2.yaml").write_text( sub_agent_config.format(index=2) ) yaml_content = f"""\ agent_class: {agent_class_value} name: main_agent description: main agent with sub agents sub_agents: - config_path: sub_agents/sub_agent1.yaml - config_path: sub_agents/sub_agent2.yaml """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, expected_agent_type) assert config.root.agent_class == agent_class_value @pytest.mark.parametrize( ("agent_class_value", "expected_agent_type"), [ ("LlmAgent", LlmAgent), ("google.adk.agents.LlmAgent", LlmAgent), ("google.adk.agents.llm_agent.LlmAgent", LlmAgent), ], ) def test_agent_config_discriminator_llm_agent_with_sub_agents( agent_class_value: str, expected_agent_type: Type[BaseAgent], tmp_path: Path ): # Create sub-agent config files sub_agent_dir = tmp_path / "sub_agents" sub_agent_dir.mkdir() sub_agent_config = """\ name: sub_agent_{index} model: gemini-2.5-flash description: a sub agent instruction: sub agent instruction """ (sub_agent_dir / "sub_agent1.yaml").write_text( sub_agent_config.format(index=1) ) (sub_agent_dir / "sub_agent2.yaml").write_text( sub_agent_config.format(index=2) ) yaml_content = f"""\ agent_class: {agent_class_value} name: main_agent model: gemini-2.5-flash description: main agent with sub agents instruction: main agent instruction sub_agents: - config_path: sub_agents/sub_agent1.yaml - config_path: sub_agents/sub_agent2.yaml """ config_file = tmp_path / "test_config.yaml" config_file.write_text(yaml_content) config = AgentConfig.model_validate(yaml.safe_load(yaml_content)) agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, expected_agent_type) assert config.root.agent_class == agent_class_value def test_agent_config_model_code_resolves_preconfigured_client(tmp_path: Path): """model_code references a pre-built model instance by fully qualified name. Configured clients (custom api_base, etc.) must be constructed in Python and referenced from YAML; YAML cannot pass constructor arguments. """ preconfigured = LiteLlm( model="kimi/k2", api_base="https://proxy.litellm.ai/v1" ) yaml_content = """\ name: managed_api_agent description: Agent using LiteLLM managed endpoint instruction: Respond concisely. model_code: name: my_library.clients.my_litellm """ config_file = tmp_path / "litellm_agent.yaml" config_file.write_text(yaml_content) with mock.patch.object( config_agent_utils, "resolve_code_reference", return_value=preconfigured, ): agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, LlmAgent) assert agent.model is preconfigured def test_agent_config_discriminator_custom_agent(): class MyCustomAgentConfig(BaseAgentConfig): agent_class: Literal["mylib.agents.MyCustomAgent"] = ( "mylib.agents.MyCustomAgent" ) other_field: str yaml_content = """\ agent_class: mylib.agents.MyCustomAgent name: CodePipelineAgent description: Executes a sequence of code writing, reviewing, and refactoring. other_field: other value """ config_data = yaml.safe_load(yaml_content) config = AgentConfig.model_validate(config_data) # pylint: disable=unidiomatic-typecheck Needs exact class matching. assert type(config.root) is BaseAgentConfig assert config.root.agent_class == "mylib.agents.MyCustomAgent" assert config.root.model_extra == {"other_field": "other value"} my_custom_config = MyCustomAgentConfig.model_validate( config.root.model_dump() ) assert my_custom_config.other_field == "other value" def test_from_config_passes_extra_yaml_fields_to_custom_agent_constructor( tmp_path: Path, ): """Custom agent fields in YAML reach the constructor without a custom config_type. Mirrors the 1.x AgentConfigMapper behavior: a custom agent subclass with extra Pydantic fields declared on the agent (not on a config_type) can populate those fields directly from YAML. """ class MyCustomAgent(BaseAgent): custom_field: str = "" yaml_content = """\ agent_class: mylib.agents.MyCustomAgent name: custom_agent description: a custom agent custom_field: hello from yaml """ config_file = tmp_path / "custom_agent.yaml" config_file.write_text(yaml_content) with mock.patch.object( config_agent_utils, "resolve_fully_qualified_name", return_value=MyCustomAgent, ): agent = config_agent_utils.from_config(str(config_file)) assert isinstance(agent, MyCustomAgent) assert agent.custom_field == "hello from yaml" def test_from_config_ignores_extra_yaml_fields_not_on_agent(tmp_path: Path): """Extra YAML keys that don't map to constructor params are silently dropped.""" class MyCustomAgent(BaseAgent): custom_field: str = "" yaml_content = """\ agent_class: mylib.agents.MyCustomAgent name: custom_agent description: a custom agent custom_field: kept unknown_field: dropped """ config_file = tmp_path / "custom_agent.yaml" config_file.write_text(yaml_content) with mock.patch.object( config_agent_utils, "resolve_fully_qualified_name", return_value=MyCustomAgent, ): agent = config_agent_utils.from_config(str(config_file)) assert agent.custom_field == "kept" assert not hasattr(agent, "unknown_field") @pytest.mark.parametrize( ("config_rel_path", "child_rel_path", "child_name", "instruction"), [ ( Path("main.yaml"), Path("sub_agents/child.yaml"), "child_agent", "I am a child agent", ), ( Path("level1/level2/nested_main.yaml"), Path("sub/nested_child.yaml"), "nested_child", "I am nested", ), ], ) def test_resolve_agent_reference_resolves_relative_paths( config_rel_path: Path, child_rel_path: Path, child_name: str, instruction: str, tmp_path: Path, ): """Verify resolve_agent_reference resolves relative sub-agent paths.""" config_file = tmp_path / config_rel_path config_file.parent.mkdir(parents=True, exist_ok=True) child_config_path = config_file.parent / child_rel_path child_config_path.parent.mkdir(parents=True, exist_ok=True) child_config_path.write_text(dedent(f""" agent_class: LlmAgent name: {child_name} model: gemini-2.5-flash instruction: {instruction} """).lstrip()) config_file.write_text(dedent(f""" agent_class: LlmAgent name: main_agent model: gemini-2.5-flash instruction: I am the main agent sub_agents: - config_path: {child_rel_path.as_posix()} """).lstrip()) ref_config = AgentRefConfig(config_path=child_rel_path.as_posix()) agent = config_agent_utils.resolve_agent_reference( ref_config, str(config_file) ) assert agent.name == child_name config_dir = os.path.dirname(str(config_file.resolve())) assert config_dir == str(config_file.parent.resolve()) expected_child_path = os.path.join(config_dir, *child_rel_path.parts) assert os.path.exists(expected_child_path) def test_resolve_agent_reference_uses_windows_dirname(): """Ensure Windows-style config references resolve via os.path.dirname.""" ref_config = AgentRefConfig(config_path="sub\\child.yaml") recorded: dict[str, str] = {} def fake_from_config(path: str): recorded["path"] = path return "sentinel" with ( mock.patch.object( config_agent_utils, "from_config", autospec=True, side_effect=fake_from_config, ), mock.patch.object(config_agent_utils.os, "path", ntpath), ): referencing = r"C:\workspace\agents\main.yaml" result = config_agent_utils.resolve_agent_reference(ref_config, referencing) expected_path = ntpath.join( ntpath.dirname(referencing), ref_config.config_path ) assert result == "sentinel" assert recorded["path"] == expected_path def test_resolve_agent_reference_blocks_absolute_path(): """Verify resolve_agent_reference raises ValueError for absolute paths.""" ref_config = AgentRefConfig(config_path="/etc/passwd") with pytest.raises( ValueError, match="Absolute paths are not allowed in AgentRefConfig config_path", ): config_agent_utils.resolve_agent_reference( ref_config, "/workspace/main.yaml" ) def test_resolve_agent_reference_blocks_path_traversal(): """Verify resolve_agent_reference raises ValueError for path traversal.""" ref_config = AgentRefConfig(config_path="../outside.yaml") with pytest.raises(ValueError, match="Path traversal detected"): config_agent_utils.resolve_agent_reference( ref_config, "/workspace/agents/main.yaml" ) # --- Security tests: module blocklist for YAML agent config code references --- def test_resolve_code_reference_blocks_os_when_enforced(): """Verify resolve_code_reference blocks os module directly.""" from google.adk.agents.common_configs import CodeConfig with pytest.raises(ValueError, match="Blocked module reference"): config_agent_utils.resolve_code_reference(CodeConfig(name="os.system")) def test_resolve_fully_qualified_name_blocks_subprocess_when_enforced(): """Verify resolve_fully_qualified_name blocks subprocess module. resolve_fully_qualified_name wraps all exceptions in ValueError("Invalid fully qualified name: ..."), so we check the wrapper and verify the __cause__ carries the blocklist message. """ with pytest.raises( ValueError, match="Invalid fully qualified name" ) as exc_info: config_agent_utils.resolve_fully_qualified_name("subprocess.Popen") assert "Blocked module reference" in str(exc_info.value.__cause__) def test_allowed_module_passes_when_enforced(tmp_path: Path): """Verify that google.adk modules are NOT blocked by the module denylist.""" # This should NOT raise — google.adk modules must remain allowed result = config_agent_utils.resolve_fully_qualified_name( "google.adk.agents.llm_agent.LlmAgent" ) assert result is LlmAgent @pytest.mark.parametrize( "blocked_module", [ "os.system", "subprocess.call", "builtins.exec", ], ) def test_resolve_agent_code_reference_blocks_when_enforced( blocked_module: str, ): """Verify _resolve_agent_code_reference blocks dangerous modules.""" with pytest.raises(ValueError, match="Blocked module reference"): config_agent_utils._resolve_agent_code_reference(blocked_module) @pytest.mark.parametrize( "blocked_ref", [ "os.system", "subprocess.call", "builtins.exec", "pickle.loads", ], ) def test_resolve_tools_blocks_dangerous_modules(blocked_ref: str): """Verify _resolve_tools blocks dangerous modules for user-defined tools.""" from google.adk.agents.llm_agent import LlmAgent from google.adk.tools.tool_configs import ToolConfig tool_config = ToolConfig(name=blocked_ref) with pytest.raises(ValueError, match="Blocked module reference"): LlmAgent._resolve_tools([tool_config], "/fake/path.yaml") def test_resolve_tools_allows_builtin_adk_tools(): """Verify _resolve_tools allows ADK built-in tools (no dot in name).""" from google.adk.agents.llm_agent import LlmAgent from google.adk.tools.tool_configs import ToolConfig # Built-in tools have no dot — they import from google.adk.tools tool_config = ToolConfig(name="google_search") # Should NOT raise — this is a safe, hardcoded import path resolved = LlmAgent._resolve_tools([tool_config], "/fake/path.yaml") assert len(resolved) == 1 @pytest.mark.parametrize( "blocked_ref", [ "ftplib.FTP", "smtplib.SMTP", "xmlrpc.client", "telnetlib.Telnet", "poplib.POP3", "imaplib.IMAP4", "asyncio.run", "pathlib.Path", ], ) def test_newly_blocked_network_modules_are_rejected(blocked_ref: str): """Verify newly added network-capable modules are blocked. resolve_fully_qualified_name wraps errors, so we check the cause. """ with pytest.raises( ValueError, match="Invalid fully qualified name" ) as exc_info: config_agent_utils.resolve_fully_qualified_name(blocked_ref) assert "Blocked module reference" in str(exc_info.value.__cause__) def test_denylist_can_be_disabled(): """Verify _set_enforce_denylist(False) disables module blocking.""" config_agent_utils._set_enforce_denylist(False) try: # os.getcwd is a real, importable reference — should succeed result = config_agent_utils.resolve_fully_qualified_name("os.getcwd") assert callable(result) finally: config_agent_utils._set_enforce_denylist(True) def test_load_config_from_path_blocks_args_when_enforced(tmp_path: Path): """_load_config_from_path blocks the 'args' key when enforcement is on.""" config_file = tmp_path / "agent.yaml" config_file.write_text("name: my_agent\nargs:\n key: value\n") config_agent_utils._set_enforce_yaml_key_denylist(True) try: with pytest.raises(ValueError) as exc_info: config_agent_utils._load_config_from_path(str(config_file)) assert "Blocked key 'args' found" in str(exc_info.value) finally: config_agent_utils._set_enforce_yaml_key_denylist(False)