Skip to content

Commit 9912716

Browse files
authored
Merge branch 'main' into feat/lifecycle-error-callbacks-4774
2 parents 8b4f75b + 5020954 commit 9912716

File tree

13 files changed

+347
-120
lines changed

13 files changed

+347
-120
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ eval = [
111111
# go/keep-sorted start
112112
"Jinja2>=3.1.4,<4.0.0", # For eval template rendering
113113
"gepa>=0.1.0",
114-
"google-cloud-aiplatform[evaluation]>=1.140.0",
114+
"google-cloud-aiplatform[evaluation]>=1.143.0",
115115
"pandas>=2.2.3",
116116
"rouge-score>=0.1.2",
117117
"tabulate>=0.9.0",

src/google/adk/cli/cli_deploy.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import sys
2323
import traceback
2424
from typing import Final
25+
from typing import Literal
2526
from typing import Optional
2627
import warnings
2728

@@ -1170,6 +1171,9 @@ def to_gke(
11701171
memory_service_uri: Optional[str] = None,
11711172
use_local_storage: bool = False,
11721173
a2a: bool = False,
1174+
service_type: Literal[
1175+
'ClusterIP', 'NodePort', 'LoadBalancer'
1176+
] = 'ClusterIP',
11731177
):
11741178
"""Deploys an agent to Google Kubernetes Engine(GKE).
11751179
@@ -1197,6 +1201,7 @@ def to_gke(
11971201
artifact_service_uri: The URI of the artifact service.
11981202
memory_service_uri: The URI of the memory service.
11991203
use_local_storage: Whether to use local .adk storage in the container.
1204+
service_type: The Kubernetes Service type (default: ClusterIP).
12001205
"""
12011206
click.secho(
12021207
'\n🚀 Starting ADK Agent Deployment to GKE...', fg='cyan', bold=True
@@ -1334,7 +1339,7 @@ def to_gke(
13341339
metadata:
13351340
name: {service_name}
13361341
spec:
1337-
type: LoadBalancer
1342+
type: {service_type}
13381343
selector:
13391344
app: {service_name}
13401345
ports:
@@ -1388,3 +1393,11 @@ def to_gke(
13881393
click.secho(
13891394
'\n🎉 Deployment to GKE finished successfully!', fg='cyan', bold=True
13901395
)
1396+
if service_type == 'ClusterIP':
1397+
click.echo(
1398+
'\nThe service is only reachable from within the cluster.'
1399+
' To access it locally, run:'
1400+
f'\n kubectl port-forward svc/{service_name} {port}:{port}'
1401+
'\n\nTo expose the service externally, add a Gateway or'
1402+
' re-deploy with --service_type=LoadBalancer.'
1403+
)

src/google/adk/cli/cli_tools_click.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,17 @@ def cli_deploy_agent_engine(
22382238
default="INFO",
22392239
help="Optional. Set the logging level",
22402240
)
2241+
@click.option(
2242+
"--service_type",
2243+
type=click.Choice(["ClusterIP", "LoadBalancer"], case_sensitive=True),
2244+
default="ClusterIP",
2245+
show_default=True,
2246+
help=(
2247+
"Optional. The Kubernetes Service type for the deployed agent."
2248+
" ClusterIP (default) keeps the service cluster-internal;"
2249+
" use LoadBalancer to expose a public IP."
2250+
),
2251+
)
22412252
@click.option(
22422253
"--temp_folder",
22432254
type=str,
@@ -2281,6 +2292,7 @@ def cli_deploy_gke(
22812292
otel_to_cloud: bool,
22822293
with_ui: bool,
22832294
adk_version: str,
2295+
service_type: str,
22842296
log_level: Optional[str] = None,
22852297
session_service_uri: Optional[str] = None,
22862298
artifact_service_uri: Optional[str] = None,
@@ -2312,6 +2324,7 @@ def cli_deploy_gke(
23122324
with_ui=with_ui,
23132325
log_level=log_level,
23142326
adk_version=adk_version,
2327+
service_type=service_type,
23152328
session_service_uri=session_service_uri,
23162329
artifact_service_uri=artifact_service_uri,
23172330
memory_service_uri=memory_service_uri,

src/google/adk/cli/fast_api.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ def _normalize_relative_path(path: str) -> str:
291291
def _has_parent_reference(path: str) -> bool:
292292
return any(part == ".." for part in path.split("/"))
293293

294-
_ALLOWED_UPLOAD_EXTENSIONS = frozenset({".yaml", ".yml"})
294+
_ALLOWED_EXTENSIONS = frozenset({".yaml", ".yml"})
295295

296296
def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
297297
if not filename:
@@ -307,10 +307,10 @@ def _parse_upload_filename(filename: Optional[str]) -> tuple[str, str]:
307307
if _has_parent_reference(rel_path):
308308
raise ValueError(f"Path traversal rejected: {filename!r}")
309309
ext = os.path.splitext(rel_path)[1].lower()
310-
if ext not in _ALLOWED_UPLOAD_EXTENSIONS:
310+
if ext not in _ALLOWED_EXTENSIONS:
311311
raise ValueError(
312312
f"File type not allowed: {rel_path!r}"
313-
f" (allowed: {', '.join(sorted(_ALLOWED_UPLOAD_EXTENSIONS))})"
313+
f" (allowed: {', '.join(sorted(_ALLOWED_EXTENSIONS))})"
314314
)
315315
return app_name, rel_path
316316

@@ -322,6 +322,12 @@ def _parse_file_path(file_path: str) -> str:
322322
raise ValueError(f"Absolute file_path rejected: {file_path!r}")
323323
if _has_parent_reference(file_path):
324324
raise ValueError(f"Path traversal rejected: {file_path!r}")
325+
ext = os.path.splitext(file_path)[1].lower()
326+
if ext not in _ALLOWED_EXTENSIONS:
327+
raise ValueError(
328+
f"File type not allowed: {file_path!r}"
329+
f" (allowed: {', '.join(sorted(_ALLOWED_EXTENSIONS))})"
330+
)
325331
return file_path
326332

327333
def _resolve_under_dir(root_dir: Path, rel_path: str) -> Path:

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import logging
2020
import os
2121
from pathlib import Path
22+
import re
2223
import sys
2324
from typing import Any
2425
from typing import Literal
@@ -187,8 +188,36 @@ def _load_from_yaml_config(
187188
) + e.args[1:]
188189
raise e
189190

191+
_VALID_AGENT_NAME_RE = re.compile(r"^[a-zA-Z0-9_]+$")
192+
193+
def _validate_agent_name(self, agent_name: str) -> None:
194+
"""Validate agent name to prevent arbitrary module imports."""
195+
# Strip the special agent prefix for validation
196+
if agent_name.startswith("__"):
197+
name_to_check = agent_name[2:]
198+
check_dir = os.path.abspath(SPECIAL_AGENTS_DIR)
199+
else:
200+
name_to_check = agent_name
201+
check_dir = self.agents_dir
202+
203+
if not self._VALID_AGENT_NAME_RE.match(name_to_check):
204+
raise ValueError(
205+
f"Invalid agent name: {agent_name!r}. Agent names must be valid"
206+
" Python identifiers (letters, digits, and underscores only)."
207+
)
208+
209+
# Verify the agent exists on disk before allowing import
210+
agent_path = Path(check_dir) / name_to_check
211+
agent_file = Path(check_dir) / f"{name_to_check}.py"
212+
if not (agent_path.is_dir() or agent_file.is_file()):
213+
raise ValueError(
214+
f"Agent not found: {agent_name!r}. No matching directory or module"
215+
f" exists in '{os.path.join(check_dir, name_to_check)}'."
216+
)
217+
190218
def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
191219
"""Internal logic to load an agent"""
220+
self._validate_agent_name(agent_name)
192221
# Determine the directory to use for loading
193222
if agent_name.startswith("__"):
194223
# Special agent: use special agents directory
@@ -335,18 +364,13 @@ def load_agent(self, agent_name: str) -> Union[BaseAgent, App]:
335364
def list_agents(self) -> list[str]:
336365
"""Lists all agents available in the agent loader (sorted alphabetically)."""
337366
base_path = Path.cwd() / self.agents_dir
338-
agent_names = []
339-
for x in os.listdir(base_path):
340-
if (
341-
os.path.isdir(os.path.join(base_path, x))
342-
and not x.startswith(".")
343-
and x != "__pycache__"
344-
):
345-
try:
346-
self._determine_agent_language(x)
347-
agent_names.append(x)
348-
except ValueError:
349-
continue
367+
agent_names = [
368+
x
369+
for x in os.listdir(base_path)
370+
if os.path.isdir(os.path.join(base_path, x))
371+
and not x.startswith(".")
372+
and x != "__pycache__"
373+
]
350374
agent_names.sort()
351375
return agent_names
352376

src/google/adk/events/event.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Event(LlmResponse):
3636
"""
3737

3838
model_config = ConfigDict(
39-
extra='forbid',
39+
extra='ignore',
4040
ser_json_bytes='base64',
4141
val_json_bytes='base64',
4242
alias_generator=alias_generators.to_camel,

src/google/adk/sessions/database_session_service.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from sqlalchemy.engine import Connection
3434
from sqlalchemy.engine import make_url
3535
from sqlalchemy.exc import ArgumentError
36+
from sqlalchemy.exc import IntegrityError
3637
from sqlalchemy.ext.asyncio import async_sessionmaker
3738
from sqlalchemy.ext.asyncio import AsyncEngine
3839
from sqlalchemy.ext.asyncio import AsyncSession as DatabaseSessionFactory
@@ -103,6 +104,35 @@ async def _select_required_state(
103104
return state_row
104105

105106

107+
async def _get_or_create_state(
108+
*,
109+
sql_session: DatabaseSessionFactory,
110+
state_model: type[_StorageStateT],
111+
primary_key: Any,
112+
defaults: dict[str, Any],
113+
) -> _StorageStateT:
114+
"""Returns an existing state row or creates one, handling concurrent inserts.
115+
116+
Uses a SAVEPOINT so that an IntegrityError from a racing INSERT does not
117+
invalidate the outer transaction.
118+
"""
119+
row = await sql_session.get(state_model, primary_key)
120+
if row is not None:
121+
return row
122+
try:
123+
async with sql_session.begin_nested():
124+
row = state_model(**defaults)
125+
sql_session.add(row)
126+
return row
127+
except IntegrityError:
128+
# Another concurrent caller inserted the row first.
129+
# The savepoint was rolled back, so re-fetch the winner's row.
130+
row = await sql_session.get(state_model, primary_key)
131+
if row is None:
132+
raise
133+
return row
134+
135+
106136
def _set_sqlite_pragma(dbapi_connection, connection_record):
107137
cursor = dbapi_connection.cursor()
108138
cursor.execute("PRAGMA foreign_keys=ON")
@@ -401,24 +431,20 @@ async def create_session(
401431
raise AlreadyExistsError(
402432
f"Session with id {session_id} already exists."
403433
)
404-
# Fetch app and user states from storage
405-
storage_app_state = await sql_session.get(
406-
schema.StorageAppState, (app_name)
434+
# Get or create state rows, handling concurrent insert races.
435+
storage_app_state = await _get_or_create_state(
436+
sql_session=sql_session,
437+
state_model=schema.StorageAppState,
438+
primary_key=app_name,
439+
defaults={"app_name": app_name, "state": {}},
407440
)
408-
storage_user_state = await sql_session.get(
409-
schema.StorageUserState, (app_name, user_id)
441+
storage_user_state = await _get_or_create_state(
442+
sql_session=sql_session,
443+
state_model=schema.StorageUserState,
444+
primary_key=(app_name, user_id),
445+
defaults={"app_name": app_name, "user_id": user_id, "state": {}},
410446
)
411447

412-
# Create state tables if not exist
413-
if not storage_app_state:
414-
storage_app_state = schema.StorageAppState(app_name=app_name, state={})
415-
sql_session.add(storage_app_state)
416-
if not storage_user_state:
417-
storage_user_state = schema.StorageUserState(
418-
app_name=app_name, user_id=user_id, state={}
419-
)
420-
sql_session.add(storage_user_state)
421-
422448
# Extract state deltas
423449
state_deltas = _session_util.extract_state_delta(state)
424450
app_state_delta = state_deltas["app"]

src/google/adk/skills/_utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import pathlib
2121
from typing import Union
2222

23+
from google.auth import credentials as auth
2324
from google.cloud import storage
2425
from pydantic import ValidationError
2526
import yaml
@@ -301,6 +302,8 @@ def _list_skills_in_dir(
301302
def _list_skills_in_gcs_dir(
302303
bucket_name: str,
303304
skills_base_path: str = "",
305+
project_id: str | None = None,
306+
credentials: auth.Credentials | None = None,
304307
) -> Dict[str, models.Frontmatter]:
305308
"""List skills in a GCS directory.
306309
@@ -311,7 +314,7 @@ def _list_skills_in_gcs_dir(
311314
Returns:
312315
Dictionary mapping skill IDs to their frontmatter.
313316
"""
314-
client = storage.Client()
317+
client = storage.Client(project=project_id, credentials=credentials)
315318
bucket = client.bucket(bucket_name)
316319

317320
base_prefix = skills_base_path.strip("/")
@@ -350,13 +353,17 @@ def _load_skill_from_gcs_dir(
350353
bucket_name: str,
351354
skill_id: str,
352355
skills_base_path: str = "",
356+
project_id: str | None = None,
357+
credentials: auth.Credentials | None = None,
353358
) -> models.Skill:
354359
"""Load a complete skill from a GCS directory.
355360
356361
Args:
357362
bucket_name: Name of the GCS bucket.
358363
skill_id: The ID of the skill (directory name).
359364
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
365+
project_id: Project ID to use for GCS client.
366+
credentials: Credentials to use for GCS client.
360367
361368
Returns:
362369
Skill object with all components loaded.
@@ -366,7 +373,8 @@ def _load_skill_from_gcs_dir(
366373
ValueError: If SKILL.md is invalid or the skill name does not match
367374
the directory name.
368375
"""
369-
client = storage.Client()
376+
377+
client = storage.Client(project=project_id, credentials=credentials)
370378
bucket = client.bucket(bucket_name)
371379

372380
base_prefix = skills_base_path.strip("/")

src/google/adk/tools/bash_tool.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,12 @@ async def run_async(
141141
try:
142142
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
143143
return {
144-
"stdout": stdout.decode(),
145-
"stderr": stderr.decode(),
144+
"stdout": (
145+
stdout.decode() if stdout is not None else "<No stdout captured>"
146+
),
147+
"stderr": (
148+
stderr.decode() if stderr is not None else "<No stderr captured>"
149+
),
146150
"returncode": process.returncode,
147151
}
148152
except asyncio.TimeoutError:

tests/unittests/cli/test_fast_api.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,40 @@ def test_builder_save_allows_yaml_files(builder_test_client, tmp_path):
17591759
assert response.json() is True
17601760

17611761

1762+
def test_builder_get_rejects_non_yaml_file_paths(builder_test_client, tmp_path):
1763+
"""GET /builder/app/{app_name}?file_path=... rejects non-YAML extensions."""
1764+
app_root = tmp_path / "app"
1765+
app_root.mkdir(parents=True, exist_ok=True)
1766+
(app_root / ".env").write_text("SECRET=supersecret\n")
1767+
(app_root / "agent.py").write_text("root_agent = None\n")
1768+
(app_root / "config.json").write_text("{}\n")
1769+
1770+
for file_path in [".env", "agent.py", "config.json"]:
1771+
response = builder_test_client.get(
1772+
f"/builder/app/app?file_path={file_path}"
1773+
)
1774+
assert response.status_code == 200, f"Expected 200 for {file_path}"
1775+
assert response.text == "", f"Expected empty response for {file_path}"
1776+
1777+
1778+
def test_builder_get_allows_yaml_file_paths(builder_test_client, tmp_path):
1779+
"""GET /builder/app/{app_name}?file_path=... allows YAML extensions."""
1780+
app_root = tmp_path / "app"
1781+
app_root.mkdir(parents=True, exist_ok=True)
1782+
(app_root / "sub_agent.yaml").write_text("name: sub\n")
1783+
(app_root / "tool.yml").write_text("name: tool\n")
1784+
1785+
response = builder_test_client.get(
1786+
"/builder/app/app?file_path=sub_agent.yaml"
1787+
)
1788+
assert response.status_code == 200
1789+
assert response.text == "name: sub\n"
1790+
1791+
response = builder_test_client.get("/builder/app/app?file_path=tool.yml")
1792+
assert response.status_code == 200
1793+
assert response.text == "name: tool\n"
1794+
1795+
17621796
def test_builder_endpoints_not_registered_without_web(
17631797
mock_session_service,
17641798
mock_artifact_service,

0 commit comments

Comments
 (0)