Skip to content

Commit 1b86d8e

Browse files
committed
chore: Add single folder support in adk web
Change-Id: If593c0118bb44ec4a4dd836d8c5d5c4025591af2
1 parent 99087ba commit 1b86d8e

7 files changed

Lines changed: 268 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ root_agent = Workflow(
9393
# Interactive CLI
9494
adk run path/to/my_agent
9595

96-
# Web UI
96+
# Web UI (supports multi-agent directories or pointing directly to a single agent folder)
9797
adk web path/to/agents_dir
9898
```
9999

contributing/adk_project_overview_and_architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async def health_check():
8383

8484
By default, the ADK API server expects an explicit application context in all requests (e.g., via the `/apps/{app_name}/...` path or in the payload body).
8585

86-
However, if the environment variable `ADK_DEFAULT_APP_NAME` is set, the server will automatically resolve and fall back to the default application whenever a request lacks an explicit app name:
86+
However, if the environment variable `ADK_DEFAULT_APP_NAME` is set, or if the server is running in **single agent mode** (when pointing directly to a directory containing an agent instead of a directory of agents), the server will automatically resolve and fall back to that agent as the default application whenever a request lacks an explicit app name. In single agent mode, the local agent takes precedence over the `ADK_DEFAULT_APP_NAME` environment variable.
8787

8888
- **URL Path-Rewriting (Production Endpoints)**: Requests to production endpoints that omit the `/apps/{app_name}` prefix (such as `/users/{user_id}/sessions` or `/app-info`) are automatically rewritten by an internal ASGI middleware to target the default application. (Note: `/dev` and `/builder` endpoints are excluded from rewriting).
8989
- **Agent Execution & Streaming**: Requests to `/run`, `/run_sse`, or `/run_live` that omit the `app_name` parameter in their payload body or query string will automatically resolve to the default application.

src/google/adk/cli/cli_tools_click.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,8 +1774,9 @@ def cli_web(
17741774
):
17751775
"""Starts a FastAPI server with Web UI for agents.
17761776
1777-
AGENTS_DIR: The directory of agents, where each subdirectory is a single
1778-
agent, containing at least `__init__.py` and `agent.py` files.
1777+
AGENTS_DIR: The directory of agents (where each subdirectory is a single
1778+
agent containing `agent.py` or `root_agent.yaml` files) or a path pointing
1779+
directly to a single agent folder.
17791780
17801781
Example:
17811782
@@ -1893,8 +1894,9 @@ def cli_api_server(
18931894
):
18941895
"""Starts a FastAPI server for agents.
18951896
1896-
AGENTS_DIR: The directory of agents, where each subdirectory is a single
1897-
agent, containing at least `__init__.py` and `agent.py` files.
1897+
AGENTS_DIR: The directory of agents (where each subdirectory is a single
1898+
agent containing `agent.py` or `root_agent.yaml` files) or a path pointing
1899+
directly to a single agent folder.
18981900
18991901
Example:
19001902

src/google/adk/cli/fast_api.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .service_registry import load_services_module
4545
from .utils import envs
4646
from .utils.agent_change_handler import AgentChangeEventHandler
47+
from .utils.agent_loader import is_single_agent_directory
4748
from .utils.base_agent_loader import BaseAgentLoader
4849
from .utils.service_factory import _create_task_store_from_options
4950
from .utils.service_factory import create_artifact_service_from_options
@@ -459,6 +460,16 @@ def get_fast_api_app(
459460

460461
config_agent_utils._set_enforce_denylist(True)
461462

463+
# Detect single agent mode
464+
agents_path = Path(agents_dir).resolve()
465+
is_single_agent = is_single_agent_directory(agents_path)
466+
467+
original_agents_dir = agents_dir
468+
single_agent_name = None
469+
if is_single_agent:
470+
single_agent_name = agents_path.name
471+
agents_dir = str(agents_path.parent)
472+
462473
# Set up eval managers.
463474
if eval_storage_uri:
464475
from .utils import evals
@@ -476,9 +487,11 @@ def get_fast_api_app(
476487
)
477488

478489
# initialize Agent Loader if not passed as argument
490+
this_module = sys.modules[__name__]
479491
if agent_loader is None:
480-
this_module = sys.modules[__name__]
481-
agent_loader = this_module.AgentLoader(agents_dir)
492+
agent_loader = this_module.AgentLoader(original_agents_dir)
493+
elif is_single_agent and isinstance(agent_loader, this_module.AgentLoader):
494+
agent_loader._set_single_agent_mode(single_agent_name, agents_dir)
482495

483496
# Load services.py from agents_dir for custom service registration.
484497
load_services_module(agents_dir)
@@ -537,6 +550,10 @@ def get_fast_api_app(
537550
default_llm_model=default_llm_model,
538551
)
539552

553+
# In single agent mode, use that agent as the default app.
554+
if is_single_agent:
555+
adk_web_server.default_app_name = single_agent_name
556+
540557
# Callbacks & other optional args for when constructing the FastAPI instance
541558
extra_fast_api_args = {}
542559

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
3939

4040
logger = logging.getLogger("google_adk." + __name__)
4141

42+
43+
def is_single_agent_directory(path: Path | str) -> bool:
44+
"""Returns True if the directory contains a single agent configuration or file."""
45+
p = Path(path).resolve()
46+
return (
47+
p.joinpath("agent.py").is_file()
48+
or p.joinpath("root_agent.yaml").is_file()
49+
)
50+
51+
4252
# Special agents directory for agents with names starting with double underscore
4353
SPECIAL_AGENTS_DIR = os.path.join(
4454
os.path.dirname(__file__), "..", "built_in_agents"
@@ -60,10 +70,36 @@ class AgentLoader(BaseAgentLoader):
6070
"""
6171

6272
def __init__(self, agents_dir: str):
63-
self.agents_dir = str(Path(agents_dir))
73+
agents_path = Path(agents_dir).resolve()
74+
is_single_agent = is_single_agent_directory(agents_path)
75+
if is_single_agent:
76+
self._is_single_agent = True
77+
self._single_agent_name = agents_path.name
78+
self.agents_dir = str(agents_path.parent)
79+
else:
80+
self._is_single_agent = False
81+
self._single_agent_name = None
82+
self.agents_dir = str(agents_path)
83+
6484
self._original_sys_path = None
6585
self._agent_cache: dict[str, Union[BaseAgent, App]] = {}
6686

87+
@property
88+
def is_single_agent(self) -> bool:
89+
"""Returns True if the loader is in single agent mode."""
90+
return self._is_single_agent
91+
92+
@property
93+
def single_agent_name(self) -> Optional[str]:
94+
"""Returns the name of the agent in single agent mode."""
95+
return self._single_agent_name
96+
97+
def _set_single_agent_mode(self, name: str, agents_dir: str) -> None:
98+
"""Internal method to force single agent mode. Use with care."""
99+
self._is_single_agent = True
100+
self._single_agent_name = name
101+
self.agents_dir = agents_dir
102+
67103
def _load_from_module_or_package(
68104
self, agent_name: str
69105
) -> Optional[Union[BaseAgent, App]]:
@@ -204,6 +240,13 @@ def _validate_agent_name(self, agent_name: str) -> None:
204240
name_to_check = agent_name
205241
check_dir = self.agents_dir
206242

243+
if self._is_single_agent and not agent_name.startswith("__"):
244+
if agent_name != self._single_agent_name:
245+
raise ValueError(
246+
f"Agent not found: {agent_name!r}. In single agent mode, only "
247+
f"'{self._single_agent_name}' is accessible."
248+
)
249+
207250
if not self._VALID_AGENT_NAME_RE.match(name_to_check):
208251
raise ValueError(
209252
f"Invalid agent name: {agent_name!r}. Agent names must be valid"
@@ -368,6 +411,8 @@ def load_agent(self, agent_name: str) -> Union[BaseAgent, App]:
368411
@override
369412
def list_agents(self) -> list[str]:
370413
"""Lists all agents available in the agent loader (sorted alphabetically)."""
414+
if self._is_single_agent:
415+
return [self._single_agent_name]
371416
base_path = Path.cwd() / self.agents_dir
372417
agent_names = [
373418
x

tests/unittests/cli/test_fast_api.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2753,5 +2753,188 @@ def test_run_live_websocket_missing_app_name_raises_error(
27532753
assert exc_info.value.code == 1008
27542754

27552755

2756+
def test_is_single_agent_directory(tmp_path):
2757+
"""Verify that is_single_agent_directory only identifies directories with agent.py or root_agent.yaml."""
2758+
from google.adk.cli.utils.agent_loader import is_single_agent_directory
2759+
2760+
# Directory with agent.py (should be identified as agent)
2761+
agent_py_dir = tmp_path / "agent_py_dir"
2762+
agent_py_dir.mkdir()
2763+
(agent_py_dir / "agent.py").write_text("root_agent = 'dummy'")
2764+
assert is_single_agent_directory(str(agent_py_dir)) is True
2765+
2766+
# Directory with root_agent.yaml (should be identified as agent)
2767+
yaml_dir = tmp_path / "yaml_dir"
2768+
yaml_dir.mkdir()
2769+
(yaml_dir / "root_agent.yaml").write_text("root_agent: dummy")
2770+
assert is_single_agent_directory(str(yaml_dir)) is True
2771+
2772+
# Normal directory or standard package with __init__.py only (should NOT be identified as agent)
2773+
normal_pkg = tmp_path / "normal_pkg"
2774+
normal_pkg.mkdir()
2775+
(normal_pkg / "__init__.py").write_text(
2776+
"from .app import App\nimport something"
2777+
)
2778+
assert is_single_agent_directory(str(normal_pkg)) is False
2779+
2780+
2781+
def test_agent_loader_single_agent_mode(tmp_path):
2782+
"""Verify that AgentLoader automatically detects and configures single agent mode."""
2783+
agent_folder = tmp_path / "my_test_agent"
2784+
agent_folder.mkdir()
2785+
(agent_folder / "agent.py").write_text("root_agent = 'dummy'")
2786+
2787+
loader = fast_api_module.AgentLoader(str(agent_folder))
2788+
2789+
assert loader._is_single_agent is True
2790+
assert loader._single_agent_name == "my_test_agent"
2791+
assert loader.agents_dir == str(tmp_path)
2792+
assert loader.list_agents() == ["my_test_agent"]
2793+
2794+
2795+
def test_single_agent_mode_detection(
2796+
tmp_path,
2797+
mock_session_service,
2798+
mock_artifact_service,
2799+
mock_memory_service,
2800+
mock_eval_sets_manager,
2801+
mock_eval_set_results_manager,
2802+
):
2803+
"""Verify that pointing agents_dir to a single agent folder enables single agent mode."""
2804+
agent_folder = tmp_path / "my_only_agent"
2805+
agent_folder.mkdir()
2806+
(agent_folder / "agent.py").write_text("root_agent = None")
2807+
2808+
with (
2809+
patch.object(signal, "signal", autospec=True, return_value=None),
2810+
patch.object(
2811+
fast_api_module,
2812+
"create_session_service_from_options",
2813+
autospec=True,
2814+
return_value=mock_session_service,
2815+
),
2816+
patch.object(
2817+
fast_api_module,
2818+
"create_artifact_service_from_options",
2819+
autospec=True,
2820+
return_value=mock_artifact_service,
2821+
),
2822+
patch.object(
2823+
fast_api_module,
2824+
"create_memory_service_from_options",
2825+
autospec=True,
2826+
return_value=mock_memory_service,
2827+
),
2828+
patch.object(
2829+
fast_api_module,
2830+
"LocalEvalSetsManager",
2831+
autospec=True,
2832+
return_value=mock_eval_sets_manager,
2833+
),
2834+
patch.object(
2835+
fast_api_module,
2836+
"LocalEvalSetResultsManager",
2837+
autospec=True,
2838+
return_value=mock_eval_set_results_manager,
2839+
),
2840+
):
2841+
app = get_fast_api_app(
2842+
agents_dir=str(agent_folder),
2843+
web=True,
2844+
session_service_uri="",
2845+
artifact_service_uri="",
2846+
memory_service_uri="",
2847+
allow_origins=None,
2848+
a2a=False,
2849+
host="127.0.0.1",
2850+
port=8000,
2851+
)
2852+
client = TestClient(app)
2853+
2854+
response = client.get("/list-apps")
2855+
assert response.status_code == 200
2856+
assert response.json() == ["my_only_agent"]
2857+
2858+
2859+
def test_single_agent_mode_sets_default_app(
2860+
tmp_path,
2861+
mock_session_service,
2862+
mock_artifact_service,
2863+
mock_memory_service,
2864+
mock_eval_sets_manager,
2865+
mock_eval_set_results_manager,
2866+
monkeypatch,
2867+
):
2868+
"""Verify that in single agent mode, the agent is used as default app."""
2869+
# Set environment variable to something else, but single mode should take precedence.
2870+
monkeypatch.setenv("ADK_DEFAULT_APP_NAME", "some_other_app")
2871+
2872+
agent_folder = tmp_path / "my_only_agent"
2873+
agent_folder.mkdir()
2874+
(agent_folder / "agent.py").write_text("root_agent = None")
2875+
2876+
# Setup session data in the in-memory service
2877+
async def setup_session():
2878+
await mock_session_service.create_session(
2879+
app_name="my_only_agent",
2880+
user_id="test_user",
2881+
session_id="test_session",
2882+
state={},
2883+
)
2884+
2885+
asyncio.run(setup_session())
2886+
2887+
with (
2888+
patch.object(signal, "signal", autospec=True, return_value=None),
2889+
patch.object(
2890+
fast_api_module,
2891+
"create_session_service_from_options",
2892+
autospec=True,
2893+
return_value=mock_session_service,
2894+
),
2895+
patch.object(
2896+
fast_api_module,
2897+
"create_artifact_service_from_options",
2898+
autospec=True,
2899+
return_value=mock_artifact_service,
2900+
),
2901+
patch.object(
2902+
fast_api_module,
2903+
"create_memory_service_from_options",
2904+
autospec=True,
2905+
return_value=mock_memory_service,
2906+
),
2907+
patch.object(
2908+
fast_api_module,
2909+
"LocalEvalSetsManager",
2910+
autospec=True,
2911+
return_value=mock_eval_sets_manager,
2912+
),
2913+
patch.object(
2914+
fast_api_module,
2915+
"LocalEvalSetResultsManager",
2916+
autospec=True,
2917+
return_value=mock_eval_set_results_manager,
2918+
),
2919+
):
2920+
app = get_fast_api_app(
2921+
agents_dir=str(agent_folder),
2922+
web=True,
2923+
session_service_uri="",
2924+
artifact_service_uri="",
2925+
memory_service_uri="",
2926+
allow_origins=None,
2927+
a2a=False,
2928+
host="127.0.0.1",
2929+
port=8000,
2930+
)
2931+
client = TestClient(app)
2932+
2933+
# Accessing /users/{user_id}/sessions/{session_id} should work because of rewrite
2934+
response = client.get("/users/test_user/sessions/test_session")
2935+
assert response.status_code == 200
2936+
assert response.json()["id"] == "test_session"
2937+
2938+
27562939
if __name__ == "__main__":
27572940
pytest.main(["-xvs", __file__])

tests/unittests/cli/utils/test_agent_loader.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,21 @@ def test_agent_loader_with_mocked_windows_path(self, monkeypatch):
308308
del self
309309
windows_path = "C:\\Users\\dev\\agents\\"
310310

311+
class MockWindowsPath(PureWindowsPath):
312+
313+
def resolve(self):
314+
return self
315+
311316
with monkeypatch.context() as m:
312317
m.setattr(
313318
agent_loader_module,
314319
"Path",
315-
lambda path_str: PureWindowsPath(path_str),
320+
MockWindowsPath,
321+
)
322+
m.setattr(
323+
agent_loader_module,
324+
"is_single_agent_directory",
325+
lambda path: False,
316326
)
317327
loader = AgentLoader(windows_path)
318328

@@ -458,7 +468,7 @@ def __init__(self):
458468
def test_sys_path_modification(self):
459469
"""Test that agents_dir is added to sys.path correctly."""
460470
with tempfile.TemporaryDirectory() as temp_dir:
461-
temp_path = Path(temp_dir)
471+
temp_path = Path(temp_dir).resolve()
462472

463473
# Create agent
464474
self.create_agent_structure(temp_path, "path_agent", "module")

0 commit comments

Comments
 (0)