ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
1116 lines
41 KiB
Python
1116 lines
41 KiB
Python
# 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 pathlib import PureWindowsPath
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from textwrap import dedent
|
|
from unittest import mock
|
|
|
|
from google.adk.cli.utils import agent_loader as agent_loader_module
|
|
from google.adk.cli.utils._nested_agent_loader import NestedAgentLoader
|
|
from google.adk.cli.utils.agent_loader import AgentLoader
|
|
from pydantic import ValidationError
|
|
import pytest
|
|
|
|
|
|
class TestAgentLoader:
|
|
"""Unit tests for AgentLoader focusing on interface behavior."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_sys_path(self):
|
|
"""Ensure sys.path is restored after each test."""
|
|
original_path = sys.path.copy()
|
|
original_env = os.environ.copy()
|
|
yield
|
|
sys.path[:] = original_path
|
|
# Restore environment variables
|
|
os.environ.clear()
|
|
os.environ.update(original_env)
|
|
|
|
def create_agent_structure(
|
|
self, temp_dir: Path, agent_name: str, structure_type: str
|
|
):
|
|
"""Create different agent structures for testing.
|
|
|
|
Args:
|
|
temp_dir: The temporary directory to create the agent in
|
|
agent_name: Name of the agent
|
|
structure_type: One of 'module', 'package_with_root',
|
|
'package_with_agent_module'
|
|
"""
|
|
if structure_type == "module":
|
|
# Structure: agents_dir/agent_name.py
|
|
agent_file = temp_dir / f"{agent_name}.py"
|
|
agent_file.write_text(dedent(f"""
|
|
import os
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from typing import Any
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
agent_id: Any = None
|
|
config: Any = None
|
|
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
self.agent_id = id(self)
|
|
self.config = os.environ.get("AGENT_CONFIG", "default")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
|
|
|
|
"""))
|
|
|
|
elif structure_type == "package_with_root":
|
|
# Structure: agents_dir/agent_name/__init__.py (with root_agent)
|
|
agent_dir = temp_dir / agent_name
|
|
agent_dir.mkdir()
|
|
init_file = agent_dir / "__init__.py"
|
|
init_file.write_text(dedent(f"""
|
|
import os
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from typing import Any
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
agent_id: Any = None
|
|
config: Any = None
|
|
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
self.agent_id = id(self)
|
|
self.config = os.environ.get("AGENT_CONFIG", "default")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
elif structure_type == "package_with_agent_module":
|
|
# Structure: agents_dir/agent_name/agent.py
|
|
agent_dir = temp_dir / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
# Create __init__.py
|
|
init_file = agent_dir / "__init__.py"
|
|
init_file.write_text("")
|
|
|
|
# Create agent.py with root_agent
|
|
agent_file = agent_dir / "agent.py"
|
|
agent_file.write_text(dedent(f"""
|
|
import os
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from typing import Any
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
agent_id: Any = None
|
|
config: Any = None
|
|
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
self.agent_id = id(self)
|
|
self.config = os.environ.get("AGENT_CONFIG", "default")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
def create_env_file(self, temp_dir: Path, agent_name: str, env_vars: dict):
|
|
"""Create a .env file for the agent."""
|
|
env_file = temp_dir / agent_name / ".env"
|
|
env_file.parent.mkdir(exist_ok=True)
|
|
|
|
env_content = "\n".join(
|
|
[f"{key}={value}" for key, value in env_vars.items()]
|
|
)
|
|
env_file.write_text(env_content)
|
|
|
|
def test_load_agent_as_module(self):
|
|
"""Test loading an agent structured as a single module file."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent as module
|
|
self.create_agent_structure(temp_path, "module_agent", "module")
|
|
|
|
# Load the agent
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent("module_agent")
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "module_agent"
|
|
assert hasattr(agent, "agent_id")
|
|
assert agent.config == "default"
|
|
|
|
def test_load_agent_as_package_with_root_agent(self):
|
|
"""Test loading an agent structured as a package with root_agent in __init__.py."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent as package
|
|
self.create_agent_structure(
|
|
temp_path, "package_agent", "package_with_root"
|
|
)
|
|
|
|
# Load the agent
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent("package_agent")
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "package_agent"
|
|
assert hasattr(agent, "agent_id")
|
|
|
|
def test_load_agent_as_package_with_agent_module(self):
|
|
"""Test loading an agent structured as a package with separate agent.py module."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent as package with agent.py
|
|
self.create_agent_structure(
|
|
temp_path, "modular_agent", "package_with_agent_module"
|
|
)
|
|
|
|
# Load the agent
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent("modular_agent")
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "modular_agent"
|
|
assert hasattr(agent, "agent_id")
|
|
|
|
def test_agent_caching_returns_same_instance(self):
|
|
"""Test that loading the same agent twice returns the same instance."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent
|
|
self.create_agent_structure(temp_path, "cached_agent", "module")
|
|
|
|
# Load the agent twice
|
|
loader = AgentLoader(str(temp_path))
|
|
agent1 = loader.load_agent("cached_agent")
|
|
agent2 = loader.load_agent("cached_agent")
|
|
|
|
# Assert same instance is returned
|
|
assert agent1 is agent2
|
|
assert agent1.agent_id == agent2.agent_id
|
|
|
|
def test_env_loading_for_agent(self):
|
|
"""Test that .env file is loaded for the agent."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent and .env file
|
|
self.create_agent_structure(temp_path, "env_agent", "package_with_root")
|
|
self.create_env_file(
|
|
temp_path,
|
|
"env_agent",
|
|
{"AGENT_CONFIG": "production", "AGENT_SECRET": "test_secret_123"},
|
|
)
|
|
|
|
# Load the agent
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent("env_agent")
|
|
|
|
# Assert environment variables were loaded
|
|
assert agent.config == "production"
|
|
assert os.environ.get("AGENT_SECRET") == "test_secret_123"
|
|
|
|
def test_loading_order_preference(self):
|
|
"""Test that module/package is preferred over agent.py in a sub-package."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "order_test_agent"
|
|
|
|
# Create structure 1: agents_dir/agent_name.py (expected to be loaded)
|
|
agent_module_file = temp_path / f"{agent_name}.py"
|
|
agent_module_file.write_text(dedent(f"""
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
class ModuleAgent(BaseAgent):
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}_module_version")
|
|
root_agent = ModuleAgent()
|
|
"""))
|
|
|
|
# Create structure 2: agents_dir/agent_name/agent.py (should be ignored)
|
|
agent_package_dir = temp_path / agent_name
|
|
agent_package_dir.mkdir()
|
|
agent_submodule_file = agent_package_dir / "agent.py"
|
|
agent_submodule_file.write_text(dedent(f"""
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
class SubmoduleAgent(BaseAgent):
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}_submodule_version")
|
|
root_agent = SubmoduleAgent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent(agent_name)
|
|
|
|
# Assert that the module version was loaded due to the new loading order
|
|
assert agent.name == f"{agent_name}_module_version"
|
|
|
|
def test_load_multiple_different_agents(self):
|
|
"""Test loading multiple different agents."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create multiple agents with different structures
|
|
self.create_agent_structure(temp_path, "agent_one", "module")
|
|
self.create_agent_structure(temp_path, "agent_two", "package_with_root")
|
|
self.create_agent_structure(
|
|
temp_path, "agent_three", "package_with_agent_module"
|
|
)
|
|
|
|
# Load all agents
|
|
loader = AgentLoader(str(temp_path))
|
|
agent1 = loader.load_agent("agent_one")
|
|
agent2 = loader.load_agent("agent_two")
|
|
agent3 = loader.load_agent("agent_three")
|
|
|
|
# Assert all agents were loaded correctly and are different instances
|
|
assert agent1.name == "agent_one"
|
|
assert agent2.name == "agent_two"
|
|
assert agent3.name == "agent_three"
|
|
assert agent1 is not agent2
|
|
assert agent2 is not agent3
|
|
assert agent1.agent_id != agent2.agent_id != agent3.agent_id
|
|
|
|
def test_error_messages_use_os_sep_consistently(self):
|
|
"""Verify error messages use os.sep instead of hardcoded '/'."""
|
|
del self
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
agent_name = "missing_agent"
|
|
|
|
expected_path = os.path.join(temp_dir, agent_name)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
loader.load_agent(agent_name)
|
|
|
|
exc_info.match(re.escape(expected_path))
|
|
|
|
def test_agent_loader_with_mocked_windows_path(self, monkeypatch):
|
|
"""Mock Path() to simulate Windows behavior and catch regressions.
|
|
|
|
REGRESSION TEST: Fails with rstrip('/'), passes with str(Path()).
|
|
"""
|
|
del self
|
|
windows_path = "C:\\Users\\dev\\agents\\"
|
|
|
|
class MockWindowsPath(PureWindowsPath):
|
|
|
|
def resolve(self):
|
|
return self
|
|
|
|
with monkeypatch.context() as m:
|
|
m.setattr(
|
|
agent_loader_module,
|
|
"Path",
|
|
MockWindowsPath,
|
|
)
|
|
m.setattr(
|
|
agent_loader_module,
|
|
"is_single_agent_directory",
|
|
lambda path: False,
|
|
)
|
|
loader = AgentLoader(windows_path)
|
|
|
|
expected = str(PureWindowsPath(windows_path))
|
|
assert loader.agents_dir == expected
|
|
assert not loader.agents_dir.endswith("\\")
|
|
assert not loader.agents_dir.endswith("/")
|
|
|
|
def test_agent_not_found_error(self):
|
|
"""Test that appropriate error is raised when agent is not found."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
agents_dir = temp_dir # For use in the expected message string
|
|
|
|
# Try to load nonexistent agent
|
|
with pytest.raises(ValueError) as exc_info:
|
|
loader.load_agent("nonexistent_agent")
|
|
|
|
assert "Agent not found: 'nonexistent_agent'" in str(exc_info.value)
|
|
assert os.path.join(agents_dir, "nonexistent_agent") in str(
|
|
exc_info.value
|
|
)
|
|
|
|
def test_agent_without_root_agent_error(self):
|
|
"""Test that appropriate error is raised when agent has no root_agent."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create agent without root_agent
|
|
agent_file = temp_path / "broken_agent.py"
|
|
agent_file.write_text(dedent("""
|
|
class BrokenAgent:
|
|
def __init__(self):
|
|
self.name = "broken"
|
|
|
|
# Note: No root_agent defined
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
|
|
# Try to load agent without root_agent
|
|
with pytest.raises(ValueError) as exc_info:
|
|
loader.load_agent("broken_agent")
|
|
|
|
assert "No root_agent found for 'broken_agent'" in str(exc_info.value)
|
|
|
|
def test_agent_internal_module_not_found_error(self):
|
|
"""Test error when an agent tries to import a nonexistent module."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "importer_agent"
|
|
|
|
# Create agent that imports a nonexistent module
|
|
agent_file = temp_path / f"{agent_name}.py"
|
|
agent_file.write_text(dedent(f"""
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
import non_existent_module # This will fail
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
with pytest.raises(ModuleNotFoundError) as exc_info:
|
|
loader.load_agent(agent_name)
|
|
|
|
assert f"Fail to load '{agent_name}' module." in str(exc_info.value)
|
|
assert "No module named 'non_existent_module'" in str(exc_info.value)
|
|
|
|
def test_agent_internal_syntax_error(self):
|
|
"""Test other import errors within an agent's code (e.g., SyntaxError)."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "syntax_error_agent"
|
|
|
|
# Create agent with a syntax error (which leads to ImportError)
|
|
agent_file = temp_path / f"{agent_name}.py"
|
|
agent_file.write_text(dedent(f"""
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
|
|
# Invalid syntax
|
|
this is not valid python code
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
# SyntaxError is a subclass of Exception, and importlib might wrap it
|
|
# The loader is expected to prepend its message and re-raise.
|
|
with pytest.raises(
|
|
SyntaxError
|
|
) as exc_info: # Or potentially ImportError depending on Python version specifics with importlib
|
|
loader.load_agent(agent_name)
|
|
|
|
assert str(exc_info.value).startswith(
|
|
f"Fail to load '{agent_name}' module."
|
|
)
|
|
# Check for part of the original SyntaxError message
|
|
assert "invalid syntax" in str(exc_info.value).lower()
|
|
|
|
def test_agent_internal_name_error(self):
|
|
"""Test other import errors within an agent's code (e.g., SyntaxError)."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "name_error_agent"
|
|
|
|
# Create agent with a syntax error (which leads to ImportError)
|
|
agent_file = temp_path / f"{agent_name}.py"
|
|
agent_file.write_text(dedent(f"""
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
|
|
# name is not defined
|
|
print(non_existing_name)
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
# SyntaxError is a subclass of Exception, and importlib might wrap it
|
|
# The loader is expected to prepend its message and re-raise.
|
|
with pytest.raises(
|
|
NameError
|
|
) as exc_info: # Or potentially ImportError depending on Python version specifics with importlib
|
|
loader.load_agent(agent_name)
|
|
|
|
assert str(exc_info.value).startswith(
|
|
f"Fail to load '{agent_name}' module."
|
|
)
|
|
# Check for part of the original SyntaxError message
|
|
assert "is not defined" in str(exc_info.value).lower()
|
|
|
|
def test_sys_path_modification(self):
|
|
"""Test that agents_dir is added to sys.path correctly."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir).resolve()
|
|
|
|
# Create agent
|
|
self.create_agent_structure(temp_path, "path_agent", "module")
|
|
|
|
# Check sys.path before
|
|
assert str(temp_path) not in sys.path
|
|
assert str(temp_path.resolve()) not in sys.path
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
|
|
# Path should not be added yet - only added during load
|
|
assert str(temp_path) not in sys.path
|
|
assert str(temp_path.resolve()) not in sys.path
|
|
|
|
# Load agent - this should add the path
|
|
agent = loader.load_agent("path_agent")
|
|
|
|
# Now assert path was added
|
|
assert any(
|
|
os.path.realpath(p) == os.path.realpath(str(temp_path))
|
|
for p in sys.path
|
|
)
|
|
assert agent.name == "path_agent"
|
|
|
|
def create_yaml_agent_structure(
|
|
self, temp_dir: Path, agent_name: str, yaml_content: str
|
|
):
|
|
"""Create an agent structure with YAML configuration.
|
|
|
|
Args:
|
|
temp_dir: The temporary directory to create the agent in
|
|
agent_name: Name of the agent
|
|
yaml_content: YAML content for the root_agent.yaml file
|
|
"""
|
|
agent_dir = temp_dir / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
# Create root_agent.yaml file
|
|
yaml_file = agent_dir / "root_agent.yaml"
|
|
yaml_file.write_text(yaml_content)
|
|
|
|
def test_load_agent_from_yaml_config(self):
|
|
"""Test loading an agent from YAML configuration."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "yaml_agent"
|
|
|
|
# Create YAML configuration
|
|
yaml_content = dedent("""
|
|
agent_class: LlmAgent
|
|
name: yaml_test_agent
|
|
model: gemini-2.5-flash
|
|
instruction: You are a test agent loaded from YAML configuration.
|
|
description: A test agent created from YAML config
|
|
""")
|
|
|
|
self.create_yaml_agent_structure(temp_path, agent_name, yaml_content)
|
|
|
|
# Load the agent
|
|
loader = AgentLoader(str(temp_path))
|
|
agent = loader.load_agent(agent_name)
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "yaml_test_agent"
|
|
# Check if it's an LlmAgent before accessing model and instruction
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
|
|
if isinstance(agent, LlmAgent):
|
|
assert agent.model == "gemini-2.5-flash"
|
|
# Handle instruction which can be string or InstructionProvider
|
|
instruction_text = str(agent.instruction)
|
|
assert "test agent loaded from YAML" in instruction_text
|
|
|
|
def test_yaml_agent_caching_returns_same_instance(self):
|
|
"""Test that loading the same YAML agent twice returns the same instance."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "cached_yaml_agent"
|
|
|
|
# Create YAML configuration
|
|
yaml_content = dedent("""
|
|
agent_class: LlmAgent
|
|
name: cached_yaml_test_agent
|
|
model: gemini-2.5-flash
|
|
instruction: You are a cached test agent.
|
|
""")
|
|
|
|
self.create_yaml_agent_structure(temp_path, agent_name, yaml_content)
|
|
|
|
# Load the agent twice
|
|
loader = AgentLoader(str(temp_path))
|
|
agent1 = loader.load_agent(agent_name)
|
|
agent2 = loader.load_agent(agent_name)
|
|
|
|
# Assert same instance is returned
|
|
assert agent1 is agent2
|
|
assert agent1.name == agent2.name
|
|
|
|
def test_yaml_agent_not_found_error(self):
|
|
"""Test that appropriate error is raised when YAML agent is not found."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
agents_dir = temp_dir
|
|
|
|
# Try to load nonexistent YAML agent
|
|
with pytest.raises(ValueError) as exc_info:
|
|
loader.load_agent("nonexistent_yaml_agent")
|
|
|
|
assert "Agent not found: 'nonexistent_yaml_agent'" in str(exc_info.value)
|
|
assert os.path.join(agents_dir, "nonexistent_yaml_agent") in str(
|
|
exc_info.value
|
|
)
|
|
|
|
def test_yaml_agent_invalid_yaml_error(self):
|
|
"""Test that appropriate error is raised when YAML is invalid."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "invalid_yaml_agent"
|
|
|
|
# Create invalid YAML content with wrong field name
|
|
invalid_yaml_content = dedent("""
|
|
not_exist_field: invalid_yaml_test_agent
|
|
model: gemini-2.5-flash
|
|
instruction: You are a test agent with invalid YAML
|
|
""")
|
|
|
|
self.create_yaml_agent_structure(
|
|
temp_path, agent_name, invalid_yaml_content
|
|
)
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
|
|
# Try to load agent with invalid YAML
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
loader.load_agent(agent_name)
|
|
|
|
# Should raise some form of YAML parsing error
|
|
assert "Extra inputs are not permitted" in str(exc_info.value)
|
|
|
|
def create_special_agent_structure(
|
|
self, special_agents_dir: Path, agent_name: str, structure_type: str
|
|
):
|
|
"""Create special agent structures for testing.
|
|
|
|
Args:
|
|
special_agents_dir: The special agents directory to create the agent in
|
|
agent_name: Name of the agent (without double underscore prefix)
|
|
structure_type: One of 'module', 'package_with_agent_module'
|
|
"""
|
|
if structure_type == "module":
|
|
# Structure: special_agents_dir/agent_name.py
|
|
agent_file = special_agents_dir / f"{agent_name}.py"
|
|
agent_file.write_text(dedent(f"""
|
|
import os
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from typing import Any
|
|
|
|
class Special{agent_name.title()}Agent(BaseAgent):
|
|
agent_id: Any = None
|
|
config: Any = None
|
|
|
|
def __init__(self):
|
|
super().__init__(name="special_{agent_name}")
|
|
self.agent_id = id(self)
|
|
self.config = os.environ.get("AGENT_CONFIG", "special_default")
|
|
|
|
root_agent = Special{agent_name.title()}Agent()
|
|
"""))
|
|
|
|
elif structure_type == "package_with_agent_module":
|
|
# Structure: special_agents_dir/agent_name/agent.py
|
|
agent_dir = special_agents_dir / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
# Create __init__.py
|
|
init_file = agent_dir / "__init__.py"
|
|
init_file.write_text("")
|
|
|
|
# Create agent.py with root_agent
|
|
agent_file = agent_dir / "agent.py"
|
|
agent_file.write_text(dedent(f"""
|
|
import os
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from typing import Any
|
|
|
|
class Special{agent_name.title()}Agent(BaseAgent):
|
|
agent_id: Any = None
|
|
config: Any = None
|
|
|
|
def __init__(self):
|
|
super().__init__(name="special_{agent_name}")
|
|
self.agent_id = id(self)
|
|
self.config = os.environ.get("AGENT_CONFIG", "special_default")
|
|
|
|
root_agent = Special{agent_name.title()}Agent()
|
|
"""))
|
|
|
|
def test_load_special_agent_with_double_underscore(self):
|
|
"""Test loading a special agent with double underscore prefix."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create special agents directory structure
|
|
special_agents_dir = temp_path / "src" / "google" / "adk" / "assistants"
|
|
special_agents_dir.mkdir(parents=True)
|
|
|
|
# Create a special agent
|
|
self.create_special_agent_structure(
|
|
special_agents_dir, "helper", "package_with_agent_module"
|
|
)
|
|
|
|
# Mock the SPECIAL_AGENTS_DIR to point to our test directory
|
|
from google.adk.cli.utils import agent_loader
|
|
|
|
original_special_dir = agent_loader.SPECIAL_AGENTS_DIR
|
|
|
|
try:
|
|
agent_loader.SPECIAL_AGENTS_DIR = str(special_agents_dir)
|
|
|
|
# Create a regular agents directory (can be empty for this test)
|
|
regular_agents_dir = temp_path / "regular_agents"
|
|
regular_agents_dir.mkdir()
|
|
|
|
# Load the special agent
|
|
loader = AgentLoader(str(regular_agents_dir))
|
|
loader._allow_special_agents = True
|
|
agent = loader.load_agent("__helper")
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "special_helper"
|
|
assert hasattr(agent, "agent_id")
|
|
assert agent.config == "special_default"
|
|
|
|
finally:
|
|
# Restore original SPECIAL_AGENTS_DIR
|
|
agent_loader.SPECIAL_AGENTS_DIR = original_special_dir
|
|
|
|
def test_special_agent_caching_returns_same_instance(self):
|
|
"""Test that loading the same special agent twice returns the same instance."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create special agents directory structure
|
|
special_agents_dir = temp_path / "src" / "google" / "adk" / "assistants"
|
|
special_agents_dir.mkdir(parents=True)
|
|
|
|
# Create a special agent
|
|
self.create_special_agent_structure(
|
|
special_agents_dir, "cached_helper", "module"
|
|
)
|
|
|
|
# Mock the SPECIAL_AGENTS_DIR to point to our test directory
|
|
from google.adk.cli.utils import agent_loader
|
|
|
|
original_special_dir = agent_loader.SPECIAL_AGENTS_DIR
|
|
|
|
try:
|
|
agent_loader.SPECIAL_AGENTS_DIR = str(special_agents_dir)
|
|
|
|
# Create a regular agents directory
|
|
regular_agents_dir = temp_path / "regular_agents"
|
|
regular_agents_dir.mkdir()
|
|
|
|
# Load the special agent twice
|
|
loader = AgentLoader(str(regular_agents_dir))
|
|
loader._allow_special_agents = True
|
|
agent1 = loader.load_agent("__cached_helper")
|
|
agent2 = loader.load_agent("__cached_helper")
|
|
|
|
# Assert same instance is returned
|
|
assert agent1 is agent2
|
|
assert agent1.agent_id == agent2.agent_id
|
|
assert agent1.name == "special_cached_helper"
|
|
|
|
finally:
|
|
# Restore original SPECIAL_AGENTS_DIR
|
|
agent_loader.SPECIAL_AGENTS_DIR = original_special_dir
|
|
|
|
def test_special_agent_not_found_error(self):
|
|
"""Test that appropriate error is raised when special agent is not found."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create special agents directory (but empty)
|
|
special_agents_dir = temp_path / "special_agents"
|
|
special_agents_dir.mkdir()
|
|
|
|
# Create regular agents directory
|
|
regular_agents_dir = temp_path / "regular_agents"
|
|
regular_agents_dir.mkdir()
|
|
|
|
# Mock the SPECIAL_AGENTS_DIR to point to our test directory
|
|
from google.adk.cli.utils import agent_loader
|
|
|
|
original_special_dir = agent_loader.SPECIAL_AGENTS_DIR
|
|
|
|
try:
|
|
agent_loader.SPECIAL_AGENTS_DIR = str(special_agents_dir)
|
|
|
|
loader = AgentLoader(str(regular_agents_dir))
|
|
loader._allow_special_agents = True
|
|
|
|
# Try to load nonexistent special agent
|
|
with pytest.raises(ValueError) as exc_info:
|
|
loader.load_agent("__nonexistent_special")
|
|
|
|
assert "Agent not found: '__nonexistent_special'" in str(exc_info.value)
|
|
|
|
finally:
|
|
# Restore original SPECIAL_AGENTS_DIR
|
|
agent_loader.SPECIAL_AGENTS_DIR = original_special_dir
|
|
|
|
def create_special_yaml_agent_structure(
|
|
self, special_agents_dir: Path, agent_name: str, yaml_content: str
|
|
):
|
|
"""Create a special agent structure with YAML configuration.
|
|
|
|
Args:
|
|
special_agents_dir: The special agents directory to create the agent in
|
|
agent_name: Name of the agent (without double underscore prefix)
|
|
yaml_content: YAML content for the root_agent.yaml file
|
|
"""
|
|
agent_dir = special_agents_dir / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
# Create root_agent.yaml file
|
|
yaml_file = agent_dir / "root_agent.yaml"
|
|
yaml_file.write_text(yaml_content)
|
|
|
|
def test_load_special_agent_from_yaml_config(self):
|
|
"""Test loading a special agent from YAML configuration."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create special agents directory
|
|
special_agents_dir = temp_path / "special_agents"
|
|
special_agents_dir.mkdir()
|
|
agent_name = "yaml_helper"
|
|
|
|
# Create YAML configuration for special agent
|
|
yaml_content = dedent("""
|
|
agent_class: LlmAgent
|
|
name: special_yaml_test_agent
|
|
model: gemini-2.5-flash
|
|
instruction: You are a special test agent loaded from YAML configuration.
|
|
description: A special test agent created from YAML config
|
|
""")
|
|
|
|
self.create_special_yaml_agent_structure(
|
|
special_agents_dir, agent_name, yaml_content
|
|
)
|
|
|
|
# Mock the SPECIAL_AGENTS_DIR to point to our test directory
|
|
from google.adk.cli.utils import agent_loader
|
|
|
|
original_special_dir = agent_loader.SPECIAL_AGENTS_DIR
|
|
|
|
try:
|
|
agent_loader.SPECIAL_AGENTS_DIR = str(special_agents_dir)
|
|
|
|
# Create regular agents directory
|
|
regular_agents_dir = temp_path / "regular_agents"
|
|
regular_agents_dir.mkdir()
|
|
|
|
# Load the special agent
|
|
loader = AgentLoader(str(regular_agents_dir))
|
|
loader._allow_special_agents = True
|
|
agent = loader.load_agent("__yaml_helper")
|
|
|
|
# Assert agent was loaded correctly
|
|
assert agent.name == "special_yaml_test_agent"
|
|
# Check if it's an LlmAgent before accessing model and instruction
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
|
|
if isinstance(agent, LlmAgent):
|
|
assert agent.model == "gemini-2.5-flash"
|
|
# Handle instruction which can be string or InstructionProvider
|
|
instruction_text = str(agent.instruction)
|
|
assert "special test agent loaded from YAML" in instruction_text
|
|
|
|
finally:
|
|
# Restore original SPECIAL_AGENTS_DIR
|
|
agent_loader.SPECIAL_AGENTS_DIR = original_special_dir
|
|
|
|
def test_yaml_config_agents_dir_parameter(self):
|
|
"""Test that _load_from_yaml_config respects the agents_dir parameter."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create two different directories with the same agent name
|
|
regular_agents_dir = temp_path / "regular_agents"
|
|
regular_agents_dir.mkdir()
|
|
custom_agents_dir = temp_path / "custom_agents"
|
|
custom_agents_dir.mkdir()
|
|
|
|
agent_name = "param_test_agent"
|
|
|
|
# Create YAML agent in regular directory
|
|
regular_yaml_content = dedent("""
|
|
agent_class: LlmAgent
|
|
name: regular_yaml_agent
|
|
model: gemini-2.5-flash
|
|
instruction: Regular agent from default directory.
|
|
""")
|
|
self.create_yaml_agent_structure(
|
|
regular_agents_dir, agent_name, regular_yaml_content
|
|
)
|
|
|
|
# Create YAML agent in custom directory
|
|
custom_yaml_content = dedent("""
|
|
agent_class: LlmAgent
|
|
name: custom_yaml_agent
|
|
model: gemini-2.5-flash
|
|
instruction: Custom agent from custom directory.
|
|
""")
|
|
self.create_yaml_agent_structure(
|
|
custom_agents_dir, agent_name, custom_yaml_content
|
|
)
|
|
|
|
# Create loader pointing to regular directory
|
|
loader = AgentLoader(str(regular_agents_dir))
|
|
|
|
# Test 1: Call with regular agents_dir (should use self.agents_dir)
|
|
default_agent = loader._load_from_yaml_config(
|
|
agent_name, str(regular_agents_dir)
|
|
)
|
|
assert default_agent is not None
|
|
assert default_agent.name == "regular_yaml_agent"
|
|
|
|
# Test 2: Call with explicit custom agents_dir (should use custom directory)
|
|
custom_agent = loader._load_from_yaml_config(
|
|
agent_name, str(custom_agents_dir)
|
|
)
|
|
assert custom_agent is not None
|
|
assert custom_agent.name == "custom_yaml_agent"
|
|
|
|
# Test 3: Call with self.agents_dir explicitly (should be same as test 1)
|
|
explicit_agent = loader._load_from_yaml_config(
|
|
agent_name, loader.agents_dir
|
|
)
|
|
assert explicit_agent is not None
|
|
assert explicit_agent.name == "regular_yaml_agent"
|
|
|
|
# Verify they are different agents
|
|
assert default_agent.name != custom_agent.name
|
|
assert explicit_agent.name == default_agent.name
|
|
|
|
def test_list_agents_detailed_identifies_computer_use(self):
|
|
"""Test that list_agents_detailed correctly identifies computer use capability."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "computer_use_agent"
|
|
|
|
agent_dir = temp_path / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
(agent_dir / "__init__.py").write_text(dedent(f"""
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from google.adk.tools.computer_use.computer_use_toolset import ComputerUseToolset
|
|
from google.adk.tools.computer_use.base_computer import BaseComputer
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
tools: list[Any] = []
|
|
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
self.tools = [ComputerUseToolset(computer=MagicMock(spec=BaseComputer))]
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
detailed_list = loader.list_agents_detailed()
|
|
|
|
assert len(detailed_list) == 1
|
|
assert detailed_list[0]["name"] == agent_name
|
|
assert detailed_list[0]["is_computer_use"]
|
|
|
|
def test_list_agents_detailed_detects_no_computer_use(self):
|
|
"""Test that list_agents_detailed sets is_computer_use to False when toolset is absent."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_name = "standard_agent"
|
|
|
|
agent_dir = temp_path / agent_name
|
|
agent_dir.mkdir()
|
|
|
|
(agent_dir / "__init__.py").write_text(dedent(f"""
|
|
from typing import Any
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
|
|
class {agent_name.title()}Agent(BaseAgent):
|
|
tools: list[Any] = []
|
|
|
|
def __init__(self):
|
|
super().__init__(name="{agent_name}")
|
|
self.tools = []
|
|
|
|
root_agent = {agent_name.title()}Agent()
|
|
"""))
|
|
|
|
loader = AgentLoader(str(temp_path))
|
|
detailed_list = loader.list_agents_detailed()
|
|
|
|
assert len(detailed_list) == 1
|
|
assert detailed_list[0]["name"] == agent_name
|
|
assert not detailed_list[0]["is_computer_use"]
|
|
|
|
def test_validate_agent_name_rejects_dotted_paths(self):
|
|
"""Agent names with dots are rejected in flat mode because they are invalid names."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
for name in ["os.path", "sys.modules", "subprocess.call"]:
|
|
with pytest.raises(ValueError, match="Invalid agent name"):
|
|
loader.load_agent(name)
|
|
|
|
def test_validate_agent_name_allows_nested_slash_paths_in_nested_mode(self):
|
|
"""Agent names with slashes are valid formats in nested mode, but fail if the directory/module doesn't exist on disk."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = NestedAgentLoader(temp_dir)
|
|
for name in ["os/path", "sys/modules", "subprocess/call"]:
|
|
with pytest.raises(ValueError, match="Agent not found"):
|
|
loader.load_agent(name)
|
|
|
|
def test_validate_agent_name_rejects_relative_imports(self):
|
|
"""Agent names starting with dots are rejected."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
for name in ["..foo", ".bar", "...baz"]:
|
|
with pytest.raises(ValueError, match="Invalid agent name"):
|
|
loader.load_agent(name)
|
|
|
|
def test_validate_agent_name_rejects_path_separators(self):
|
|
"""Agent names with slashes or special characters are rejected."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
for name in ["foo\\bar", "foo-bar", "foo bar"]:
|
|
with pytest.raises(ValueError, match="Invalid agent name"):
|
|
loader.load_agent(name)
|
|
|
|
# In flat mode, foo/bar is rejected as invalid name
|
|
with pytest.raises(ValueError, match="Invalid agent name"):
|
|
loader.load_agent("foo/bar")
|
|
|
|
# foo/bar is structurally valid for nested apps, but nonexistent on disk
|
|
nested_loader = NestedAgentLoader(temp_dir)
|
|
with pytest.raises(ValueError, match="Agent not found"):
|
|
nested_loader.load_agent("foo/bar")
|
|
|
|
def test_validate_agent_name_allows_valid_names(self):
|
|
"""Valid Python identifiers that exist on disk pass validation."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
for name in ["my_agent", "Agent1", "_private"]:
|
|
(temp_path / name).mkdir(exist_ok=True)
|
|
loader = AgentLoader(temp_dir)
|
|
for name in ["my_agent", "Agent1", "_private"]:
|
|
# Should not raise ValueError for name validation;
|
|
# may raise other errors because the agent has no root_agent
|
|
with pytest.raises(Exception) as exc_info:
|
|
loader.load_agent(name)
|
|
assert "Invalid agent name" not in str(exc_info.value)
|
|
assert "Agent not found" not in str(exc_info.value)
|
|
|
|
def test_validate_agent_name_rejects_nonexistent_agent(self):
|
|
"""Valid identifiers that don't exist on disk are rejected before import."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
# 'subprocess' is a valid identifier but shouldn't be importable as an agent
|
|
with pytest.raises(ValueError, match="Agent not found"):
|
|
loader.load_agent("subprocess")
|
|
|
|
def test_validate_agent_name_rejects_special_agents_by_default(self):
|
|
"""Special agents starting with __ are rejected by default (_allow_special_agents=False)."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
with pytest.raises(
|
|
PermissionError, match="Loading special internal agent"
|
|
):
|
|
loader._validate_agent_name("__adk_agent_builder_assistant")
|
|
|
|
def test_validate_agent_name_allows_special_agents_when_enabled(self):
|
|
"""Special agents starting with __ are allowed when _allow_special_agents=True."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
loader = AgentLoader(temp_dir)
|
|
loader._allow_special_agents = True
|
|
# Should not raise any exception
|
|
loader._validate_agent_name("__adk_agent_builder_assistant")
|
|
|
|
|
|
class TestDetermineAgentLanguage:
|
|
"""Tests for AgentLoader._determine_agent_language covering all 4 load patterns."""
|
|
|
|
def test_flat_module_returns_python(self):
|
|
"""Flat-module agent (agents_dir/agent_name.py) is detected as python."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
(temp_path / "my_agent.py").write_text("root_agent = None\n")
|
|
loader = AgentLoader(temp_dir)
|
|
assert loader._determine_agent_language("my_agent") == "python"
|
|
|
|
def test_agent_py_subdirectory_returns_python(self):
|
|
"""Subdirectory with agent.py is detected as python."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_dir = temp_path / "my_agent"
|
|
agent_dir.mkdir()
|
|
(agent_dir / "agent.py").write_text("root_agent = None\n")
|
|
loader = AgentLoader(temp_dir)
|
|
assert loader._determine_agent_language("my_agent") == "python"
|
|
|
|
def test_init_py_subdirectory_returns_python(self):
|
|
"""Subdirectory with __init__.py is detected as python."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_dir = temp_path / "my_agent"
|
|
agent_dir.mkdir()
|
|
(agent_dir / "__init__.py").write_text("root_agent = None\n")
|
|
loader = AgentLoader(temp_dir)
|
|
assert loader._determine_agent_language("my_agent") == "python"
|
|
|
|
def test_root_agent_yaml_returns_yaml(self):
|
|
"""Subdirectory with root_agent.yaml is detected as yaml."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_dir = temp_path / "my_agent"
|
|
agent_dir.mkdir()
|
|
(agent_dir / "root_agent.yaml").write_text("root_agent: {}\n")
|
|
loader = AgentLoader(temp_dir)
|
|
assert loader._determine_agent_language("my_agent") == "yaml"
|
|
|
|
def test_unrecognized_structure_raises_value_error(self):
|
|
"""A directory with no recognized structure raises ValueError."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
agent_dir = temp_path / "my_agent"
|
|
agent_dir.mkdir()
|
|
(agent_dir / "main.py").write_text("root_agent = None\n")
|
|
loader = AgentLoader(temp_dir)
|
|
with pytest.raises(ValueError, match="Could not determine agent type"):
|
|
loader._determine_agent_language("my_agent")
|