|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
17 | 17 | import json |
| 18 | +import os |
18 | 19 | from pathlib import Path |
| 20 | +import sys |
19 | 21 |
|
| 22 | +from google.adk.agents import config_agent_utils |
20 | 23 | from google.adk.apps.app import App |
21 | 24 | from google.adk.cli.agent_test_runner import test_agent_replay as _test_agent_replay |
| 25 | +from google.adk.cli.utils.agent_loader import AgentLoader |
22 | 26 | from google.genai import types |
23 | 27 | import pytest |
24 | 28 |
|
25 | 29 | CONTRIBUTING_DIR = Path(__file__).parent.parent.parent / "contributing" |
| 30 | +SAMPLES_DIR = CONTRIBUTING_DIR / "samples" |
| 31 | + |
| 32 | + |
| 33 | +@pytest.fixture(autouse=True) |
| 34 | +def _load_samples_like_adk_run(): |
| 35 | + """Loads samples with the YAML key denylist off, matching `adk run`. |
| 36 | +
|
| 37 | + The denylist is a hosted-web-server guard that fast_api enables globally and |
| 38 | + never resets, so a fast_api test earlier in the process would otherwise leave |
| 39 | + it on and block valid config samples here. |
| 40 | + """ |
| 41 | + saved = config_agent_utils._ENFORCE_YAML_KEY_DENYLIST |
| 42 | + config_agent_utils._set_enforce_yaml_key_denylist(False) |
| 43 | + try: |
| 44 | + yield |
| 45 | + finally: |
| 46 | + config_agent_utils._set_enforce_yaml_key_denylist(saved) |
26 | 47 |
|
27 | 48 |
|
28 | 49 | def get_test_files(): |
@@ -57,3 +78,154 @@ def get_test_files(): |
57 | 78 | def test_sample(sample_dir: Path, test_file: Path, monkeypatch): |
58 | 79 | """Tests a sample by replaying exported session events.""" |
59 | 80 | _test_agent_replay(sample_dir, test_file, monkeypatch) |
| 81 | + |
| 82 | + |
| 83 | +# Samples that cannot be loaded offline: they reach an external service, need an |
| 84 | +# optional dependency outside [all], or are not an independently loadable root. |
| 85 | +SKIP_LOAD = { |
| 86 | + "integrations/agent_registry_agent": "calls Agent Registry API at import", |
| 87 | + "integrations/api_registry_agent": "calls Cloud API Registry at import", |
| 88 | + "integrations/application_integration_agent": ( |
| 89 | + "calls Integration Connectors API at import" |
| 90 | + ), |
| 91 | + "integrations/integration_connector_euc_agent": ( |
| 92 | + "calls Integration Connectors API at import" |
| 93 | + ), |
| 94 | + "multimodal/static_non_text_content": ( |
| 95 | + "uploads a file via the genai API at import" |
| 96 | + ), |
| 97 | + "integrations/authn-adk-all-in-one/adk_agents/agent_openapi_tools": ( |
| 98 | + "needs a local identity provider server on :5000" |
| 99 | + ), |
| 100 | + "mcp/mcp_postgres_agent": ( |
| 101 | + "needs POSTGRES_CONNECTION_STRING and a postgres server" |
| 102 | + ), |
| 103 | + "code_execution/custom_code_execution": ( |
| 104 | + "provisions a Vertex code-interpreter extension at import" |
| 105 | + ), |
| 106 | + "code_execution/vertex_code_execution": ( |
| 107 | + "provisions a Vertex code-interpreter extension at import" |
| 108 | + ), |
| 109 | + "integrations/crewai_tool_kwargs": ( |
| 110 | + "needs the crewai package (not installed on every Python version)" |
| 111 | + ), |
| 112 | + "integrations/files_retrieval_agent": ( |
| 113 | + "needs the llama-index-embeddings-google-genai package" |
| 114 | + ), |
| 115 | + "integrations/toolbox_agent": ( |
| 116 | + "needs the toolbox-adk package and a toolbox server" |
| 117 | + ), |
| 118 | + "multimodal/computer_use": "needs the playwright package", |
| 119 | + "integrations/gepa": "experiment package, exposes no root_agent", |
| 120 | + "integrations/slack_agent": ( |
| 121 | + "builds its agent inside main(), no module-level root_agent" |
| 122 | + ), |
| 123 | + "adk_team/adk_documentation": ( |
| 124 | + "package dir; its child agents are the samples" |
| 125 | + ), |
| 126 | + "adk_team/adk_answering_agent/gemini_assistant": ( |
| 127 | + "sub-agent of adk_answering_agent, not independently loadable" |
| 128 | + ), |
| 129 | + "integrations/oauth_calendar_agent": ( |
| 130 | + "fetches the Calendar API (calendar v3) discovery doc at import" |
| 131 | + ), |
| 132 | +} |
| 133 | + |
| 134 | +# Samples whose own code is currently broken against the ADK API. Loading them |
| 135 | +# fails today; remove the entry once the sample is fixed. |
| 136 | +XFAIL_LOAD = { |
| 137 | + "integrations/jira_agent": ( |
| 138 | + "ApplicationIntegrationToolset no longer accepts tool_name" |
| 139 | + ), |
| 140 | + "workflows/loop_config": ( |
| 141 | + "root_agent.yaml references the nonexistent agent_class Workflow" |
| 142 | + ), |
| 143 | + "models/hello_world_litellm_add_function_to_prompt": ( |
| 144 | + "langchain_core requires an explicit import of langchain_core.tools" |
| 145 | + ), |
| 146 | + "adk_team/adk_triaging_agent": ( |
| 147 | + "agent.py imports adk_triaging_agent.settings, which is not present" |
| 148 | + ), |
| 149 | +} |
| 150 | + |
| 151 | +_DUMMY_ENV = { |
| 152 | + "GOOGLE_API_KEY": "dummy-key", |
| 153 | + "GEMINI_API_KEY": "dummy-key", |
| 154 | + "GOOGLE_CLOUD_PROJECT": "dummy-project", |
| 155 | + "GOOGLE_CLOUD_LOCATION": "us-central1", |
| 156 | + "OPENAI_API_KEY": "dummy-key", |
| 157 | + "ANTHROPIC_API_KEY": "dummy-key", |
| 158 | + "GITHUB_TOKEN": "dummy-token", |
| 159 | + "VERTEXAI_DATASTORE_ID": "dummy-datastore", |
| 160 | +} |
| 161 | + |
| 162 | + |
| 163 | +def get_sample_dirs(): |
| 164 | + """Yields a pytest param per loadable sample directory.""" |
| 165 | + if not SAMPLES_DIR.exists(): |
| 166 | + return |
| 167 | + sample_dirs = [] |
| 168 | + for dirpath, dirnames, filenames in os.walk(SAMPLES_DIR): |
| 169 | + path = Path(dirpath) |
| 170 | + if path.name == "tests": |
| 171 | + dirnames[:] = [] |
| 172 | + continue |
| 173 | + if any( |
| 174 | + f in filenames for f in ("agent.py", "__init__.py", "root_agent.yaml") |
| 175 | + ): |
| 176 | + sample_dirs.append(path) |
| 177 | + for sample_dir in sorted(sample_dirs): |
| 178 | + rel = sample_dir.relative_to(SAMPLES_DIR).as_posix() |
| 179 | + if rel in SKIP_LOAD: |
| 180 | + marks = pytest.mark.skip(reason=SKIP_LOAD[rel]) |
| 181 | + elif rel in XFAIL_LOAD: |
| 182 | + marks = pytest.mark.xfail(reason=XFAIL_LOAD[rel], strict=False) |
| 183 | + else: |
| 184 | + marks = () |
| 185 | + yield pytest.param(sample_dir, id=rel, marks=marks) |
| 186 | + |
| 187 | + |
| 188 | +def _load_root_agent(sample_dir: Path): |
| 189 | + """Loads a sample the way `adk run` does, isolating module side effects.""" |
| 190 | + saved_modules = set(sys.modules) |
| 191 | + saved_path = list(sys.path) |
| 192 | + sys.path.insert(0, str(sample_dir.parent)) |
| 193 | + try: |
| 194 | + loader = AgentLoader(str(sample_dir.parent)) |
| 195 | + loader.remove_agent_from_cache(sample_dir.name) |
| 196 | + agent_or_app = loader.load_agent(sample_dir.name) |
| 197 | + return ( |
| 198 | + agent_or_app.root_agent |
| 199 | + if isinstance(agent_or_app, App) |
| 200 | + else agent_or_app |
| 201 | + ) |
| 202 | + finally: |
| 203 | + sys.path[:] = saved_path |
| 204 | + # Evict only the sample's own modules so the next sample reloads cleanly, |
| 205 | + # while leaving third-party libraries cached (re-importing libraries with |
| 206 | + # global registries, e.g. opentelemetry, breaks on reload). |
| 207 | + prefix = sample_dir.name |
| 208 | + for name in set(sys.modules) - saved_modules: |
| 209 | + module = sys.modules.get(name) |
| 210 | + file = getattr(module, "__file__", None) |
| 211 | + if ( |
| 212 | + name == prefix |
| 213 | + or name.startswith(prefix + ".") |
| 214 | + or ( |
| 215 | + file |
| 216 | + and Path(file).resolve().is_relative_to(SAMPLES_DIR.resolve()) |
| 217 | + ) |
| 218 | + ): |
| 219 | + del sys.modules[name] |
| 220 | + |
| 221 | + |
| 222 | +@pytest.mark.parametrize("sample_dir", list(get_sample_dirs())) |
| 223 | +def test_sample_loads(sample_dir: Path, monkeypatch): |
| 224 | + """Smoke test: every sample's agent imports and constructs a root agent.""" |
| 225 | + for key, value in _DUMMY_ENV.items(): |
| 226 | + monkeypatch.setenv(key, value) |
| 227 | + root_agent = _load_root_agent(sample_dir) |
| 228 | + assert root_agent is not None, f"{sample_dir} loaded no root agent" |
| 229 | + assert getattr( |
| 230 | + root_agent, "name", None |
| 231 | + ), f"{sample_dir} root agent has no name" |
0 commit comments