diff --git a/src/google/adk/agents/config_agent_utils.py b/src/google/adk/agents/config_agent_utils.py index 7982a9cf59..38ba2e2578 100644 --- a/src/google/adk/agents/config_agent_utils.py +++ b/src/google/adk/agents/config_agent_utils.py @@ -132,7 +132,7 @@ def resolve_agent_reference( else: return from_config( os.path.join( - referencing_agent_config_abs_path.rsplit("/", 1)[0], + os.path.dirname(referencing_agent_config_abs_path), ref_config.config_path, ) ) diff --git a/src/google/adk/cli/utils/agent_loader.py b/src/google/adk/cli/utils/agent_loader.py index 9f01705d4f..42dd327470 100644 --- a/src/google/adk/cli/utils/agent_loader.py +++ b/src/google/adk/cli/utils/agent_loader.py @@ -56,7 +56,7 @@ class AgentLoader(BaseAgentLoader): """ def __init__(self, agents_dir: str): - self.agents_dir = agents_dir.rstrip("/") + self.agents_dir = str(Path(agents_dir)) self._original_sys_path = None self._agent_cache: dict[str, Union[BaseAgent, App]] = {} @@ -270,12 +270,13 @@ def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]: f"No root_agent found for '{agent_name}'. Searched in" f" '{actual_agent_name}.agent.root_agent'," f" '{actual_agent_name}.root_agent' and" - f" '{actual_agent_name}/root_agent.yaml'.\n\nExpected directory" - f" structure:\n /\n {actual_agent_name}/\n " - " agent.py (with root_agent) OR\n root_agent.yaml\n\nThen run:" - f" adk web \n\nEnsure '{agents_dir}/{actual_agent_name}' is" - " structured correctly, an .env file can be loaded if present, and a" - f" root_agent is exposed.{hint}" + f" '{actual_agent_name}{os.sep}root_agent.yaml'.\n\nExpected directory" + f" structure:\n {os.sep}\n " + f" {actual_agent_name}{os.sep}\n agent.py (with root_agent) OR\n " + " root_agent.yaml\n\nThen run: adk web \n\nEnsure" + f" '{os.path.join(agents_dir, actual_agent_name)}' is structured" + " correctly, an .env file can be loaded if present, and a root_agent" + f" is exposed.{hint}" ) def _record_origin_metadata( diff --git a/tests/unittests/agents/test_agent_config.py b/tests/unittests/agents/test_agent_config.py index c2300f5f5d..bdca5ac6ed 100644 --- a/tests/unittests/agents/test_agent_config.py +++ b/tests/unittests/agents/test_agent_config.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import ntpath +import os from pathlib import Path from typing import Literal from typing import Type @@ -20,6 +22,7 @@ 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 @@ -280,3 +283,91 @@ class MyCustomAgentConfig(BaseAgentConfig): config.root.model_dump() ) assert my_custom_config.other_field == "other value" + + +def test_resolve_agent_reference_resolves_relative_paths(tmp_path: Path): + """Verify resolve_agent_reference correctly resolves relative sub-agent paths + based on the directory of the referencing config file, including nested dirs. + """ + + sub_agent_dir = tmp_path / "sub_agents" + sub_agent_dir.mkdir() + + (sub_agent_dir / "child.yaml").write_text(""" +agent_class: LlmAgent +name: child_agent +model: gemini-2.0-flash +instruction: I am a child agent +""") + + main_config = tmp_path / "main.yaml" + main_config.write_text(""" +agent_class: LlmAgent +name: main_agent +model: gemini-2.0-flash +instruction: I am the main agent +sub_agents: + - config_path: sub_agents/child.yaml +""") + + ref_config = AgentRefConfig(config_path="sub_agents/child.yaml") + agent = config_agent_utils.resolve_agent_reference( + ref_config, str(main_config) + ) + assert agent.name == "child_agent" + + main_config_abs = str(main_config.resolve()) + dirname = os.path.dirname(main_config_abs) + assert dirname == str(tmp_path.resolve()) + assert os.path.exists(os.path.join(dirname, "sub_agents", "child.yaml")) + + nested_dir = tmp_path / "level1" / "level2" + nested_dir.mkdir(parents=True) + nested_sub_dir = nested_dir / "sub" + nested_sub_dir.mkdir() + + (nested_sub_dir / "nested_child.yaml").write_text(""" +agent_class: LlmAgent +name: nested_child +model: gemini-2.0-flash +instruction: I am nested +""") + + (nested_dir / "nested_main.yaml").write_text(""" +agent_class: LlmAgent +name: nested_main +model: gemini-2.0-flash +instruction: I reference a nested child +sub_agents: + - config_path: sub/nested_child.yaml +""") + + ref_nested = AgentRefConfig(config_path="sub/nested_child.yaml") + agent_nested = config_agent_utils.resolve_agent_reference( + ref_nested, str(nested_dir / "nested_main.yaml") + ) + assert agent_nested.name == "nested_child" + + +def test_resolve_agent_reference_uses_windows_dirname(monkeypatch): + """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" + + monkeypatch.setattr( + config_agent_utils, "from_config", fake_from_config, raising=False + ) + monkeypatch.setattr(config_agent_utils.os, "path", ntpath, raising=False) + + 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 diff --git a/tests/unittests/cli/utils/test_agent_loader.py b/tests/unittests/cli/utils/test_agent_loader.py index 5c66160aed..d88980984d 100644 --- a/tests/unittests/cli/utils/test_agent_loader.py +++ b/tests/unittests/cli/utils/test_agent_loader.py @@ -12,12 +12,15 @@ # 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 sys import tempfile from textwrap import dedent +from google.adk.cli.utils import agent_loader as agent_loader_module from google.adk.cli.utils.agent_loader import AgentLoader from pydantic import ValidationError import pytest @@ -280,6 +283,46 @@ def test_load_multiple_different_agents(self): 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 '/'.""" + with tempfile.TemporaryDirectory() as temp_dir: + loader = AgentLoader(temp_dir) + agent_name = "missing_agent" + + try: + loader.load_agent(agent_name) + except ValueError as e: + message = str(e) + expected_path = os.path.join(temp_dir, agent_name) + + assert expected_path in message + assert f"{agent_name}{os.sep}root_agent.yaml" in message + assert f"{os.sep}" in message + + 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()). + """ + windows_path = "C:\\Users\\dev\\agents\\" + + def mock_path_constructor(path_str): + class MockPath: + + def __str__(self): + return str(PureWindowsPath(path_str)) + + return MockPath() + + with monkeypatch.context() as m: + m.setattr("google.adk.cli.utils.agent_loader.Path", mock_path_constructor) + 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: