Skip to content

Commit a1c09b7

Browse files
p-kris10GWeale
authored andcommitted
fix: Windows Path Handling and Normalize Cross-Platform Path Resolution in AgentLoader
Merge #3609 Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#3609 from p-kris10:fix/windows-cmd 8cb0310 PiperOrigin-RevId: 836395714
1 parent cf21ca3 commit a1c09b7

File tree

4 files changed

+143
-8
lines changed

4 files changed

+143
-8
lines changed

src/google/adk/agents/config_agent_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def resolve_agent_reference(
132132
else:
133133
return from_config(
134134
os.path.join(
135-
referencing_agent_config_abs_path.rsplit("/", 1)[0],
135+
os.path.dirname(referencing_agent_config_abs_path),
136136
ref_config.config_path,
137137
)
138138
)

src/google/adk/cli/utils/agent_loader.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class AgentLoader(BaseAgentLoader):
5858
"""
5959

6060
def __init__(self, agents_dir: str):
61-
self.agents_dir = agents_dir.rstrip("/")
61+
self.agents_dir = str(Path(agents_dir))
6262
self._original_sys_path = None
6363
self._agent_cache: dict[str, Union[BaseAgent, App]] = {}
6464

@@ -272,12 +272,13 @@ def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
272272
f"No root_agent found for '{agent_name}'. Searched in"
273273
f" '{actual_agent_name}.agent.root_agent',"
274274
f" '{actual_agent_name}.root_agent' and"
275-
f" '{actual_agent_name}/root_agent.yaml'.\n\nExpected directory"
276-
f" structure:\n <agents_dir>/\n {actual_agent_name}/\n "
277-
" agent.py (with root_agent) OR\n root_agent.yaml\n\nThen run:"
278-
f" adk web <agents_dir>\n\nEnsure '{agents_dir}/{actual_agent_name}' is"
279-
" structured correctly, an .env file can be loaded if present, and a"
280-
f" root_agent is exposed.{hint}"
275+
f" '{actual_agent_name}{os.sep}root_agent.yaml'.\n\nExpected directory"
276+
f" structure:\n <agents_dir>{os.sep}\n "
277+
f" {actual_agent_name}{os.sep}\n agent.py (with root_agent) OR\n "
278+
" root_agent.yaml\n\nThen run: adk web <agents_dir>\n\nEnsure"
279+
f" '{os.path.join(agents_dir, actual_agent_name)}' is structured"
280+
" correctly, an .env file can be loaded if present, and a root_agent"
281+
f" is exposed.{hint}"
281282
)
282283

283284
def _record_origin_metadata(

tests/unittests/agents/test_agent_config.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import ntpath
16+
import os
1517
from pathlib import Path
18+
from textwrap import dedent
1619
from typing import Literal
1720
from typing import Type
21+
from unittest import mock
1822

1923
from google.adk.agents import config_agent_utils
2024
from google.adk.agents.agent_config import AgentConfig
2125
from google.adk.agents.base_agent import BaseAgent
2226
from google.adk.agents.base_agent_config import BaseAgentConfig
27+
from google.adk.agents.common_configs import AgentRefConfig
2328
from google.adk.agents.llm_agent import LlmAgent
2429
from google.adk.agents.loop_agent import LoopAgent
2530
from google.adk.agents.parallel_agent import ParallelAgent
@@ -280,3 +285,91 @@ class MyCustomAgentConfig(BaseAgentConfig):
280285
config.root.model_dump()
281286
)
282287
assert my_custom_config.other_field == "other value"
288+
289+
290+
@pytest.mark.parametrize(
291+
("config_rel_path", "child_rel_path", "child_name", "instruction"),
292+
[
293+
(
294+
Path("main.yaml"),
295+
Path("sub_agents/child.yaml"),
296+
"child_agent",
297+
"I am a child agent",
298+
),
299+
(
300+
Path("level1/level2/nested_main.yaml"),
301+
Path("sub/nested_child.yaml"),
302+
"nested_child",
303+
"I am nested",
304+
),
305+
],
306+
)
307+
def test_resolve_agent_reference_resolves_relative_paths(
308+
config_rel_path: Path,
309+
child_rel_path: Path,
310+
child_name: str,
311+
instruction: str,
312+
tmp_path: Path,
313+
):
314+
"""Verify resolve_agent_reference resolves relative sub-agent paths."""
315+
config_file = tmp_path / config_rel_path
316+
config_file.parent.mkdir(parents=True, exist_ok=True)
317+
318+
child_config_path = config_file.parent / child_rel_path
319+
child_config_path.parent.mkdir(parents=True, exist_ok=True)
320+
child_config_path.write_text(dedent(f"""
321+
agent_class: LlmAgent
322+
name: {child_name}
323+
model: gemini-2.0-flash
324+
instruction: {instruction}
325+
""").lstrip())
326+
327+
config_file.write_text(dedent(f"""
328+
agent_class: LlmAgent
329+
name: main_agent
330+
model: gemini-2.0-flash
331+
instruction: I am the main agent
332+
sub_agents:
333+
- config_path: {child_rel_path.as_posix()}
334+
""").lstrip())
335+
336+
ref_config = AgentRefConfig(config_path=child_rel_path.as_posix())
337+
agent = config_agent_utils.resolve_agent_reference(
338+
ref_config, str(config_file)
339+
)
340+
341+
assert agent.name == child_name
342+
343+
config_dir = os.path.dirname(str(config_file.resolve()))
344+
assert config_dir == str(config_file.parent.resolve())
345+
346+
expected_child_path = os.path.join(config_dir, *child_rel_path.parts)
347+
assert os.path.exists(expected_child_path)
348+
349+
350+
def test_resolve_agent_reference_uses_windows_dirname():
351+
"""Ensure Windows-style config references resolve via os.path.dirname."""
352+
ref_config = AgentRefConfig(config_path="sub\\child.yaml")
353+
recorded: dict[str, str] = {}
354+
355+
def fake_from_config(path: str):
356+
recorded["path"] = path
357+
return "sentinel"
358+
359+
with (
360+
mock.patch.object(
361+
config_agent_utils,
362+
"from_config",
363+
autospec=True,
364+
side_effect=fake_from_config,
365+
),
366+
mock.patch.object(config_agent_utils.os, "path", ntpath),
367+
):
368+
referencing = r"C:\workspace\agents\main.yaml"
369+
result = config_agent_utils.resolve_agent_reference(ref_config, referencing)
370+
371+
expected_path = ntpath.join(
372+
ntpath.dirname(referencing), ref_config.config_path
373+
)
374+
assert result == "sentinel"
375+
assert recorded["path"] == expected_path

tests/unittests/cli/utils/test_agent_loader.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import ntpath
1516
import os
1617
from pathlib import Path
18+
from pathlib import PureWindowsPath
19+
import re
1720
import sys
1821
import tempfile
1922
from textwrap import dedent
2023

24+
from google.adk.cli.utils import agent_loader as agent_loader_module
2125
from google.adk.cli.utils.agent_loader import AgentLoader
2226
from pydantic import ValidationError
2327
import pytest
@@ -280,6 +284,43 @@ def test_load_multiple_different_agents(self):
280284
assert agent2 is not agent3
281285
assert agent1.agent_id != agent2.agent_id != agent3.agent_id
282286

287+
def test_error_messages_use_os_sep_consistently(self):
288+
"""Verify error messages use os.sep instead of hardcoded '/'."""
289+
del self
290+
with tempfile.TemporaryDirectory() as temp_dir:
291+
loader = AgentLoader(temp_dir)
292+
agent_name = "missing_agent"
293+
294+
expected_path = os.path.join(temp_dir, agent_name)
295+
296+
with pytest.raises(ValueError) as exc_info:
297+
loader.load_agent(agent_name)
298+
299+
exc_info.match(re.escape(expected_path))
300+
exc_info.match(re.escape(f"{agent_name}{os.sep}root_agent.yaml"))
301+
exc_info.match(re.escape(f"<agents_dir>{os.sep}"))
302+
303+
def test_agent_loader_with_mocked_windows_path(self, monkeypatch):
304+
"""Mock Path() to simulate Windows behavior and catch regressions.
305+
306+
REGRESSION TEST: Fails with rstrip('/'), passes with str(Path()).
307+
"""
308+
del self
309+
windows_path = "C:\\Users\\dev\\agents\\"
310+
311+
with monkeypatch.context() as m:
312+
m.setattr(
313+
agent_loader_module,
314+
"Path",
315+
lambda path_str: PureWindowsPath(path_str),
316+
)
317+
loader = AgentLoader(windows_path)
318+
319+
expected = str(PureWindowsPath(windows_path))
320+
assert loader.agents_dir == expected
321+
assert not loader.agents_dir.endswith("\\")
322+
assert not loader.agents_dir.endswith("/")
323+
283324
def test_agent_not_found_error(self):
284325
"""Test that appropriate error is raised when agent is not found."""
285326
with tempfile.TemporaryDirectory() as temp_dir:

0 commit comments

Comments
 (0)