Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/google/adk/agents/config_agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
)
Expand Down
15 changes: 8 additions & 7 deletions src/google/adk/cli/utils/agent_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}

Expand Down Expand Up @@ -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 <agents_dir>/\n {actual_agent_name}/\n "
" agent.py (with root_agent) OR\n root_agent.yaml\n\nThen run:"
f" adk web <agents_dir>\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 <agents_dir>{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 <agents_dir>\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(
Expand Down
91 changes: 91 additions & 0 deletions tests/unittests/agents/test_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
43 changes: 43 additions & 0 deletions tests/unittests/cli/utils/test_agent_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"<agents_dir>{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:
Expand Down