Skip to content

Commit 293bfe5

Browse files
Merge branch 'main' into main
2 parents d9980e6 + b2dda6e commit 293bfe5

57 files changed

Lines changed: 5965 additions & 427 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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:

contributing/samples/mcp/mcp_sse_agent/agent.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@
1414

1515

1616
import os
17+
import pprint
18+
from typing import Any
1719

1820
from google.adk.agents.llm_agent import LlmAgent
1921
from google.adk.agents.mcp_instruction_provider import McpInstructionProvider
22+
from google.adk.tools.base_tool import BaseTool
2023
from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
2124
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
25+
from google.adk.tools.tool_context import ToolContext
2226

2327
_allowed_path = os.path.dirname(os.path.abspath(__file__))
2428

@@ -27,6 +31,20 @@
2731
headers={'Accept': 'text/event-stream'},
2832
)
2933

34+
35+
def after_tool_debug_callback(
36+
tool: BaseTool,
37+
args: dict[str, Any],
38+
tool_context: ToolContext,
39+
tool_response: dict[str, Any],
40+
) -> dict[str, Any] | None:
41+
# pylint: disable=unused-argument
42+
print(f'\n=== HTTP Debug Info (from Callback for {tool.name}) ===')
43+
pprint.pprint(tool_context.custom_metadata.get('http_debug_info'))
44+
print('====================================================\n')
45+
return None
46+
47+
3048
root_agent = LlmAgent(
3149
name='enterprise_assistant',
3250
instruction=McpInstructionProvider(
@@ -57,4 +75,5 @@
5775
require_confirmation=True,
5876
)
5977
],
78+
after_tool_callback=after_tool_debug_callback,
6079
)

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ optional-dependencies.extensions = [
154154
"llama-index-embeddings-google-genai>=0.3",
155155
"llama-index-readers-file>=0.4",
156156
"lxml>=5.3",
157+
"openai>=2.20,<3",
157158
"pypika>=0.50",
158159
"toolbox-adk>=1,<2",
159160
]
@@ -195,6 +196,7 @@ optional-dependencies.test = [
195196
"anyio>=4.9,<5",
196197
"beautifulsoup4>=3.2.2",
197198
"crewai[tools]; python_version>='3.11' and python_version<'3.12'", # For CrewaiTool tests; chromadb/pypika fail on 3.12+
199+
"docker>=7", # For ContainerCodeExecutor tests
198200
"e2b>=2,<3",
199201
"gepa>=0.1",
200202
"google-antigravity>=0.1,<0.2",
@@ -223,7 +225,7 @@ optional-dependencies.test = [
223225
"llama-index-readers-file>=0.4",
224226
"lxml>=5.3",
225227
"mcp>=1.24,<2",
226-
"openai>=1.100.2",
228+
"openai>=2.20,<3",
227229
"opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0",
228230
"opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2",
229231
"opentelemetry-exporter-gcp-trace>=1.9,<2",

src/google/adk/agents/config_agent_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,31 @@ def _resolve_agent_class(agent_class: str) -> type[BaseAgent]:
8282
)
8383

8484

85+
_BLOCKED_YAML_KEYS = frozenset({"args"})
86+
_ENFORCE_YAML_KEY_DENYLIST = False
87+
88+
89+
def _set_enforce_yaml_key_denylist(value: bool) -> None:
90+
global _ENFORCE_YAML_KEY_DENYLIST
91+
_ENFORCE_YAML_KEY_DENYLIST = value
92+
93+
94+
def _check_config_for_blocked_keys(node: Any, filename: str) -> None:
95+
"""Recursively check if the configuration contains any blocked keys."""
96+
if isinstance(node, dict):
97+
for key, value in node.items():
98+
if key in _BLOCKED_YAML_KEYS:
99+
raise ValueError(
100+
f"Blocked key {key!r} found in {filename!r}. "
101+
f"The '{key}' field is not allowed in agent configurations "
102+
"because it can execute arbitrary code."
103+
)
104+
_check_config_for_blocked_keys(value, filename)
105+
elif isinstance(node, list):
106+
for item in node:
107+
_check_config_for_blocked_keys(item, filename)
108+
109+
85110
def _load_config_from_path(config_path: str) -> AgentConfig:
86111
"""Load an agent's configuration from a YAML file.
87112
@@ -102,6 +127,9 @@ def _load_config_from_path(config_path: str) -> AgentConfig:
102127
with open(config_path, "r", encoding="utf-8") as f:
103128
config_data = yaml.safe_load(f)
104129

130+
if _ENFORCE_YAML_KEY_DENYLIST:
131+
_check_config_for_blocked_keys(config_data, config_path)
132+
105133
return AgentConfig.model_validate(config_data)
106134

107135

src/google/adk/agents/context.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ def __init__(
229229
self._error: Exception | None = None
230230
self._error_node_path: str = ''
231231

232+
@property
233+
def custom_metadata(self) -> dict[str, Any]:
234+
"""Returns the custom metadata dictionary."""
235+
# pylint: disable=protected-access
236+
return self._invocation_context._custom_metadata
237+
232238
@property
233239
def function_call_id(self) -> str | None:
234240
"""The function call id of the current tool call."""
@@ -446,10 +452,47 @@ async def run_node(
446452
use_sub_branch: If True, the dynamic node will be executed in a sub-branch
447453
to isolate its state and events from the main branch.
448454
override_branch: An optional branch to use instead of parent's branch.
455+
override_isolation_scope: An optional isolation scope to use instead of
456+
the parent's scope.
457+
raise_on_wait: If True, raises NodeInterruptedError when the child node
458+
is WAITING instead of returning None.
449459
450460
Returns:
451461
The output of the dynamically executed node, once it finishes executing.
452462
"""
463+
return await self._run_node_internal(
464+
node,
465+
node_input,
466+
use_as_output=use_as_output,
467+
run_id=run_id,
468+
use_sub_branch=use_sub_branch,
469+
override_branch=override_branch,
470+
override_isolation_scope=override_isolation_scope,
471+
raise_on_wait=raise_on_wait,
472+
resume_inputs=None,
473+
return_ctx=False,
474+
)
475+
476+
async def _run_node_internal(
477+
self,
478+
node: NodeLike,
479+
node_input: Any = None,
480+
*,
481+
use_as_output: bool = False,
482+
run_id: str | None = None,
483+
use_sub_branch: bool = False,
484+
override_branch: str | None = None,
485+
override_isolation_scope: str | None = None,
486+
raise_on_wait: bool = False,
487+
return_ctx: bool = False,
488+
resume_inputs: dict[str, Any] | None = None,
489+
) -> Any:
490+
"""Executes a node dynamically (Internal Orchestration API).
491+
492+
See public ``run_node`` for public argument details.
493+
Additional internal args:
494+
return_ctx: If True, returns the child's Context instead of its output.
495+
"""
453496

454497
if not self._node_rerun_on_resume:
455498
raise ValueError(
@@ -532,7 +575,7 @@ async def run_node(
532575
and not child_ctx.actions.transfer_to_agent
533576
):
534577
raise NodeInterruptedError()
535-
return child_ctx.output
578+
return child_ctx if return_ctx else child_ctx.output
536579

537580
# Mode 2: Standalone execution (outside of workflow).
538581
# Run the node directly via NodeRunner.
@@ -544,6 +587,7 @@ async def run_node(
544587
override_branch=override_branch,
545588
override_isolation_scope=override_isolation_scope,
546589
run_id=run_id,
590+
resume_inputs=resume_inputs,
547591
)
548592
if result.error:
549593
from ..workflow import _errors
@@ -562,7 +606,7 @@ async def run_node(
562606
from ..workflow._errors import NodeInterruptedError
563607

564608
raise NodeInterruptedError()
565-
return result.output
609+
return result if return_ctx else result.output
566610

567611
# ============================================================================
568612
# Artifact methods

src/google/adk/agents/invocation_context.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,9 @@ class InvocationContext(BaseModel):
259259
credential_by_key: dict[str, AuthCredential] = Field(default_factory=dict)
260260
"""The resolved credentials for this invocation, keyed by credential_key."""
261261

262+
_custom_metadata: dict[str, Any] = PrivateAttr(default_factory=dict)
263+
"""Custom metadata for attaching low-level execution telemetry."""
264+
262265
_invocation_cost_manager: _InvocationCostManager = PrivateAttr(
263266
default_factory=_InvocationCostManager
264267
)

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"):

0 commit comments

Comments
 (0)