Skip to content

Commit 35a3e4c

Browse files
wyf7107copybara-github
authored andcommitted
feat: refactor ADK CLI server to support nested agents in dev mode and simplify API
This change introduces `NestedAgentLoader` for the `adk web` command, enabling recursive discovery of agents within subdirectories and using dot-separated names (e.g., `folder.app`). The production `adk api_server` continues to use the flat `AgentLoader` Co-authored-by: Yifan Wang <wanyif@google.com> PiperOrigin-RevId: 938188643
1 parent d3e793f commit 35a3e4c

9 files changed

Lines changed: 622 additions & 21 deletions

File tree

contributing/adk_project_overview_and_architecture.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ agent.py: Must define the agent and assign it to a variable named root_agent. Th
4545

4646
`__init__.py`: In each agent directory, it must contain `from . import agent` to make the agent discoverable.
4747

48+
### Nested Agent Directories (Dev Mode / `adk web`)
49+
50+
In the local development server (`adk web` / `dev_server`), ADK supports deeply nested agent directories (e.g., sub-packages or structured folders).
51+
52+
- **Recursive Discovery**: The loader recursively walks directories to discover all valid agent applications containing an `agent.py`, `root_agent.yaml`, or `__init__.py` file.
53+
- **Dot Naming Convention**: Nested agents are represented in the system and referenced inside the Web UI using a standard dot-separated namespace notation (e.g., `agent_samples.empty_agent` or `workflow_samples.fan_out_fan_in`).
54+
- **Isolation**: Production environments (`adk api_server`) only support flat single-level agent directories for maximum security and isolation.
55+
4856
## Local Development & Debugging
4957

5058
Interactive UI (adk web): This is our primary debugging tool. It's a decoupled system:

src/google/adk/cli/api_server.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@
8787
from .cli_eval import EVAL_SESSION_ID_PREFIX
8888
from .utils import cleanup
8989
from .utils import common
90-
from .utils import envs
9190
from .utils.base_agent_loader import BaseAgentLoader
9291
from .utils.shared_value import SharedValue
9392

@@ -569,6 +568,11 @@ def _setup_instrumentation_lib_if_installed():
569568
)
570569

571570

571+
def _get_app_basename(name: str) -> str:
572+
"""Returns the last segment of a dot-delimited app name."""
573+
return name.split(".")[-1]
574+
575+
572576
class ApiServer:
573577
"""Helper class for setting up and running the ADK web server on FastAPI.
574578
@@ -657,7 +661,6 @@ async def get_runner_async(self, app_name: str) -> Runner:
657661
return self.runner_dict[app_name]
658662

659663
# Create new runner
660-
envs.load_dotenv_for_agent(os.path.basename(app_name), self.agents_dir)
661664
agent_or_app = self.agent_loader.load_agent(app_name)
662665

663666
if self.default_llm_model:
@@ -712,7 +715,7 @@ def _wrap_loaded_agent(
712715
plugins=plugins,
713716
)
714717
return App(
715-
name=app_name,
718+
name=_get_app_basename(app_name),
716719
root_agent=agent_or_app,
717720
plugins=plugins,
718721
)
@@ -737,7 +740,7 @@ def _wrap_loaded_agent(
737740
if is_visual_builder_agent:
738741
object.__setattr__(agentic_app, "_is_visual_builder_app", True)
739742

740-
runner = self._create_runner(agentic_app)
743+
runner = self._create_runner(agentic_app, app_name)
741744
self.runner_dict[app_name] = runner
742745
return runner
743746

@@ -747,10 +750,11 @@ def _get_root_agent(self, agent_or_app: BaseAgent | App) -> BaseAgent:
747750
return agent_or_app.root_agent
748751
return agent_or_app
749752

750-
def _create_runner(self, agentic_app: App) -> Runner:
753+
def _create_runner(self, agentic_app: App, app_name: str) -> Runner:
751754
"""Create a runner with common services."""
752755
return Runner(
753756
app=agentic_app,
757+
app_name=app_name,
754758
artifact_service=self.artifact_service,
755759
session_service=self.session_service,
756760
memory_service=self.memory_service,

src/google/adk/cli/dev_server.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
from ..evaluation.eval_result import EvalSetResult
6161
from ..evaluation.eval_set import EvalSet
6262
from .api_server import ApiServer
63+
64+
NESTED_APP_SEPARATOR = "."
6365
from .utils import common
6466
from .utils import evals
6567
from .utils.graph_serialization import serialize_app_info
@@ -157,6 +159,40 @@ class DevServer(ApiServer):
157159
endpoints for evaluation, debugging, and developer UI features.
158160
"""
159161

162+
def _get_agent_dir(self, app_name: str) -> str:
163+
"""Resolves the agent directory and validates the app name to prevent path traversal."""
164+
if not self.agents_dir:
165+
raise HTTPException(
166+
status_code=500, detail="Agents directory is not configured"
167+
)
168+
if not app_name:
169+
raise HTTPException(status_code=400, detail="App name cannot be empty")
170+
171+
# Validate app_name structure (must be dot-separated identifiers)
172+
parts = app_name.split(NESTED_APP_SEPARATOR)
173+
for part in parts:
174+
if not part or not part.isidentifier():
175+
raise HTTPException(
176+
status_code=400,
177+
detail=(
178+
f"Invalid app name: {app_name!r}. App names must be valid "
179+
"Python identifiers or paths separated by dots."
180+
),
181+
)
182+
183+
# Resolve path
184+
app_path = app_name.replace(NESTED_APP_SEPARATOR, "/")
185+
agents_base = Path(self.agents_dir).resolve()
186+
resolved_path = (agents_base / app_path).resolve()
187+
188+
if not resolved_path.is_relative_to(agents_base):
189+
raise HTTPException(
190+
status_code=400,
191+
detail=f"Access denied: {app_name!r} is outside the agents directory",
192+
)
193+
194+
return str(resolved_path)
195+
160196
def _register_dev_endpoints(
161197
self,
162198
app: FastAPI,
@@ -502,7 +538,8 @@ async def get_app_info(app_name: str) -> Any:
502538
if self.agents_dir:
503539
import os
504540

505-
readme_path = os.path.join(self.agents_dir, app_name, "README.md")
541+
agent_dir = self._get_agent_dir(app_name)
542+
readme_path = os.path.join(agent_dir, "README.md")
506543
if os.path.exists(readme_path):
507544
try:
508545
with open(readme_path, "r", encoding="utf-8") as f:
@@ -557,7 +594,7 @@ async def get_app_info_image(
557594
@app.get("/dev/apps/{app_name}/tests")
558595
async def list_tests(app_name: str) -> list[str]:
559596
"""Lists all test JSON files for the given app."""
560-
agent_dir = os.path.join(self.agents_dir, app_name)
597+
agent_dir = self._get_agent_dir(app_name)
561598
tests_dir = os.path.join(agent_dir, "tests")
562599
if not os.path.exists(tests_dir):
563600
return []
@@ -573,7 +610,7 @@ async def rebuild_app_tests(
573610
app_name: str, test_name: Optional[str] = None
574611
) -> dict[str, str]:
575612
"""Rebuilds tests for the app."""
576-
agent_dir = os.path.join(self.agents_dir, app_name)
613+
agent_dir = self._get_agent_dir(app_name)
577614

578615
if test_name:
579616
if not test_name.endswith(".json"):
@@ -592,7 +629,7 @@ async def run_app_tests(
592629
app_name: str, test_name: Optional[str] = None
593630
) -> StreamingResponse:
594631
"""Runs tests and streams pytest output."""
595-
agent_dir = os.path.join(self.agents_dir, app_name)
632+
agent_dir = self._get_agent_dir(app_name)
596633

597634
import subprocess
598635
import sys
@@ -656,7 +693,7 @@ async def create_test(
656693
"""Creates or updates a test file from session data."""
657694
# Sanitize test_name to prevent directory traversal
658695
test_name = os.path.basename(test_name)
659-
agent_dir = os.path.join(self.agents_dir, app_name)
696+
agent_dir = self._get_agent_dir(app_name)
660697
tests_dir = os.path.join(agent_dir, "tests")
661698
os.makedirs(tests_dir, exist_ok=True)
662699

@@ -673,7 +710,7 @@ async def create_test(
673710
@app.delete("/dev/apps/{app_name}/tests/{test_name}")
674711
async def delete_test(app_name: str, test_name: str) -> dict[str, str]:
675712
"""Deletes a specific test file."""
676-
agent_dir = os.path.join(self.agents_dir, app_name)
713+
agent_dir = self._get_agent_dir(app_name)
677714
tests_dir = os.path.join(agent_dir, "tests")
678715

679716
if not test_name.endswith(".json"):
@@ -690,7 +727,7 @@ async def delete_test(app_name: str, test_name: str) -> dict[str, str]:
690727
@app.get("/dev/apps/{app_name}/tests/{test_name}")
691728
async def get_test_content(app_name: str, test_name: str) -> dict[str, Any]:
692729
"""Fetches the content of a specific test file."""
693-
agent_dir = os.path.join(self.agents_dir, app_name)
730+
agent_dir = self._get_agent_dir(app_name)
694731
tests_dir = os.path.join(agent_dir, "tests")
695732

696733
if not test_name.endswith(".json"):

src/google/adk/cli/fast_api.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class _QueryRequest(BaseModel):
8080

8181
_LAZY_SERVICE_IMPORTS: dict[str, str] = {
8282
"AgentLoader": ".utils.agent_loader",
83+
"NestedAgentLoader": ".utils._nested_agent_loader",
8384
"LocalEvalSetResultsManager": "..evaluation.local_eval_set_results_manager",
8485
"LocalEvalSetsManager": "..evaluation.local_eval_sets_manager",
8586
}
@@ -514,7 +515,10 @@ def get_fast_api_app(
514515
# initialize Agent Loader if not passed as argument
515516
this_module = sys.modules[__name__]
516517
if agent_loader is None:
517-
agent_loader = this_module.AgentLoader(original_agents_dir)
518+
if web:
519+
agent_loader = this_module.NestedAgentLoader(original_agents_dir)
520+
else:
521+
agent_loader = this_module.AgentLoader(original_agents_dir)
518522
elif is_single_agent and isinstance(agent_loader, this_module.AgentLoader):
519523
agent_loader._set_single_agent_mode(single_agent_name, agents_dir)
520524

0 commit comments

Comments
 (0)