Skip to content

Commit 12c954c

Browse files
authored
Merge branch 'main' into codex/repro-adk-run-cancel-cleanup
2 parents 8b20804 + ecef5f8 commit 12c954c

9 files changed

Lines changed: 603 additions & 70 deletions

File tree

src/google/adk/artifacts/artifact_util.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,32 @@ def validate_artifact_reference_scope(
134134
"Session-scoped artifact references must stay within the same"
135135
" session scope."
136136
)
137+
138+
139+
def validate_path_segment(value: str, field_name: str) -> None:
140+
"""Rejects values that could alter the constructed path.
141+
142+
Args:
143+
value: The caller-supplied identifier (e.g. user_id or session_id).
144+
field_name: Human-readable name used in the error message.
145+
146+
Raises:
147+
InputValidationError: If the value contains path separators, traversal
148+
segments, or null bytes.
149+
"""
150+
if not value:
151+
raise input_validation_error.InputValidationError(
152+
f"{field_name} must not be empty."
153+
)
154+
if "\x00" in value:
155+
raise input_validation_error.InputValidationError(
156+
f"{field_name} must not contain null bytes."
157+
)
158+
if "/" in value or "\\" in value:
159+
raise input_validation_error.InputValidationError(
160+
f"{field_name} {value!r} must not contain path separators."
161+
)
162+
if value in (".", "..") or ".." in value.split("/"):
163+
raise input_validation_error.InputValidationError(
164+
f"{field_name} {value!r} must not contain traversal segments."
165+
)

src/google/adk/artifacts/file_artifact_service.py

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from pydantic import ValidationError
3535
from typing_extensions import override
3636

37+
from . import artifact_util
3738
from ..errors.input_validation_error import InputValidationError
3839
from .base_artifact_service import ArtifactVersion
3940
from .base_artifact_service import BaseArtifactService
@@ -142,39 +143,14 @@ def _is_user_scoped(session_id: Optional[str], filename: str) -> bool:
142143
return session_id is None or _file_has_user_namespace(filename)
143144

144145

145-
def _validate_path_segment(value: str, field_name: str) -> None:
146-
"""Rejects values that could alter the constructed filesystem path.
147-
148-
Args:
149-
value: The caller-supplied identifier (e.g. user_id or session_id).
150-
field_name: Human-readable name used in the error message.
151-
152-
Raises:
153-
InputValidationError: If the value contains path separators, traversal
154-
segments, or null bytes.
155-
"""
156-
if not value:
157-
raise InputValidationError(f"{field_name} must not be empty.")
158-
if "\x00" in value:
159-
raise InputValidationError(f"{field_name} must not contain null bytes.")
160-
if "/" in value or "\\" in value:
161-
raise InputValidationError(
162-
f"{field_name} {value!r} must not contain path separators."
163-
)
164-
if value in (".", "..") or ".." in value.split("/"):
165-
raise InputValidationError(
166-
f"{field_name} {value!r} must not contain traversal segments."
167-
)
168-
169-
170146
def _user_artifacts_dir(base_root: Path) -> Path:
171147
"""Returns the path that stores user-scoped artifacts."""
172148
return base_root / "artifacts"
173149

174150

175151
def _session_artifacts_dir(base_root: Path, session_id: str) -> Path:
176152
"""Returns the path that stores session-scoped artifacts."""
177-
_validate_path_segment(session_id, "session_id")
153+
artifact_util.validate_path_segment(session_id, "session_id")
178154
return base_root / "sessions" / session_id / "artifacts"
179155

180156

@@ -256,7 +232,7 @@ def __init__(self, root_dir: Path | str):
256232

257233
def _base_root(self, user_id: str, /) -> Path:
258234
"""Returns the artifacts root directory for a user."""
259-
_validate_path_segment(user_id, "user_id")
235+
artifact_util.validate_path_segment(user_id, "user_id")
260236
return self.root_dir / "users" / user_id
261237

262238
def _scope_root(
@@ -269,7 +245,7 @@ def _scope_root(
269245
base = self._base_root(user_id)
270246
if _is_user_scoped(session_id, filename):
271247
return _user_artifacts_dir(base)
272-
if not session_id:
248+
if session_id is None:
273249
raise InputValidationError(
274250
"Session ID must be provided for session-scoped artifacts."
275251
)
@@ -543,7 +519,7 @@ def _list_artifact_keys_sync(
543519

544520
base_root = self._base_root(user_id)
545521

546-
if session_id:
522+
if session_id is not None:
547523
session_root = _session_artifacts_dir(base_root, session_id)
548524
for artifact_dir in _iter_artifact_dirs(session_root):
549525
metadata = self._latest_metadata(artifact_dir)

src/google/adk/artifacts/gcs_artifact_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,16 @@ def _get_blob_prefix(
167167
session_id: Optional[str] = None,
168168
) -> str:
169169
"""Constructs the blob name prefix in GCS for a given artifact."""
170+
artifact_util.validate_path_segment(app_name, "app_name")
171+
artifact_util.validate_path_segment(user_id, "user_id")
170172
if self._file_has_user_namespace(filename):
171173
return f"{app_name}/{user_id}/user/{filename}"
172174

173175
if session_id is None:
174176
raise InputValidationError(
175177
"Session ID must be provided for session-scoped artifacts."
176178
)
179+
artifact_util.validate_path_segment(session_id, "session_id")
177180
return f"{app_name}/{user_id}/{session_id}/{filename}"
178181

179182
def _get_blob_name(
@@ -368,6 +371,10 @@ def _load_artifact(
368371
def _list_artifact_keys(
369372
self, app_name: str, user_id: str, session_id: Optional[str]
370373
) -> list[str]:
374+
artifact_util.validate_path_segment(app_name, "app_name")
375+
artifact_util.validate_path_segment(user_id, "user_id")
376+
if session_id is not None:
377+
artifact_util.validate_path_segment(session_id, "session_id")
371378
filenames = set()
372379

373380
if session_id:

src/google/adk/artifacts/in_memory_artifact_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,16 @@ def _artifact_path(
8585
Returns:
8686
The constructed artifact path.
8787
"""
88+
artifact_util.validate_path_segment(app_name, "app_name")
89+
artifact_util.validate_path_segment(user_id, "user_id")
8890
if self._file_has_user_namespace(filename):
8991
return f"{app_name}/{user_id}/user/{filename}"
9092

9193
if session_id is None:
9294
raise InputValidationError(
9395
"Session ID must be provided for session-scoped artifacts."
9496
)
97+
artifact_util.validate_path_segment(session_id, "session_id")
9598
return f"{app_name}/{user_id}/{session_id}/{filename}"
9699

97100
@override
@@ -215,6 +218,10 @@ async def load_artifact(
215218
async def list_artifact_keys(
216219
self, *, app_name: str, user_id: str, session_id: Optional[str] = None
217220
) -> list[str]:
221+
artifact_util.validate_path_segment(app_name, "app_name")
222+
artifact_util.validate_path_segment(user_id, "user_id")
223+
if session_id is not None:
224+
artifact_util.validate_path_segment(session_id, "session_id")
218225
usernamespace_prefix = f"{app_name}/{user_id}/user/"
219226
session_prefix = (
220227
f"{app_name}/{user_id}/{session_id}/" if session_id else None

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,84 @@ def _get_tool_origin(
306306
return "UNKNOWN"
307307

308308

309+
def _extract_tool_declarations(
310+
tools_dict: dict[str, "BaseTool"],
311+
) -> list[dict[str, Any]]:
312+
"""Extracts structured tool metadata for the ``LLM_REQUEST`` event.
313+
314+
Earlier versions logged only the tool names (``list(tools_dict.keys())``).
315+
Downstream consumers such as online evaluation need the tool *description* and
316+
*parameter schema* to judge whether the model selected and invoked the right
317+
tool, so this returns one structured entry per tool instead of a bare name.
318+
319+
Each entry always carries ``name`` and, when available, ``description`` and
320+
``parameters`` (the OpenAPI parameter schema from the tool's
321+
``FunctionDeclaration``). Extraction is best-effort and per-tool: a tool whose
322+
declaration cannot be resolved still contributes its name and description, so
323+
one misbehaving tool never drops the whole ``tools`` attribute.
324+
325+
Args:
326+
tools_dict: Mapping of tool name to ``BaseTool`` from ``LlmRequest``.
327+
328+
Returns:
329+
A list of ``{"name", "description"?, "parameters"?}`` dicts.
330+
"""
331+
tools: list[dict[str, Any]] = []
332+
for name, tool in tools_dict.items():
333+
# Fall back to the dict key when the tool has no (or a falsy) name.
334+
entry: dict[str, Any] = {"name": getattr(tool, "name", None) or name}
335+
description = getattr(tool, "description", None)
336+
if description:
337+
entry["description"] = description
338+
339+
# The parameter schema lives on the tool's FunctionDeclaration, which some
340+
# tools (e.g. built-in tools) do not provide. Resolve defensively so a
341+
# single failing tool does not discard the whole tools list.
342+
#
343+
# Note: FunctionTool._get_declaration() rebuilds the declaration from the
344+
# function signature on each call (no caching), so this repeats work the
345+
# framework already did when assembling the request. Acceptable for typical
346+
# toolsets; revisit with a cache if it shows up on the hot path.
347+
declaration = None
348+
try:
349+
get_declaration = getattr(tool, "_get_declaration", None)
350+
if callable(get_declaration):
351+
declaration = get_declaration()
352+
except Exception: # pylint: disable=broad-except
353+
logger.debug("Failed to get declaration for tool %s", name, exc_info=True)
354+
355+
if declaration is not None:
356+
if "description" not in entry:
357+
decl_description = getattr(declaration, "description", None)
358+
if decl_description:
359+
entry["description"] = decl_description
360+
# A declaration carries its parameter schema in one of two shapes: the
361+
# structured `parameters` Schema, or a raw JSON-schema dict in
362+
# `parameters_json_schema`. Several tools (MCP, OpenAPI, skill, node, and
363+
# environment tools) populate only the latter, and model adapters prefer
364+
# it, so prefer it here too and fall back to `parameters` otherwise.
365+
json_schema = getattr(declaration, "parameters_json_schema", None)
366+
if json_schema is not None:
367+
entry["parameters"] = json_schema
368+
else:
369+
parameters = getattr(declaration, "parameters", None)
370+
if parameters is not None:
371+
try:
372+
entry["parameters"] = parameters.model_dump(
373+
exclude_none=True, mode="json"
374+
)
375+
except Exception: # pylint: disable=broad-except
376+
# Leave parameters off if the schema is not JSON-serializable.
377+
logger.debug(
378+
"Failed to serialize parameters for tool %s",
379+
name,
380+
exc_info=True,
381+
)
382+
383+
tools.append(entry)
384+
return tools
385+
386+
309387
_SENSITIVE_KEYS = frozenset({
310388
"client_secret",
311389
"access_token",
@@ -4028,7 +4106,8 @@ async def before_model_callback(
40284106
"""
40294107

40304108
# 5. Attributes (Config & Tools)
4031-
attributes = {}
4109+
attributes: dict[str, Any] = {}
4110+
tools_truncated = False
40324111
if llm_request.config:
40334112
config_dict = {}
40344113
for field_name in [
@@ -4057,13 +4136,21 @@ async def before_model_callback(
40574136
attributes["labels"] = labels
40584137

40594138
if hasattr(llm_request, "tools_dict") and llm_request.tools_dict:
4060-
attributes["tools"] = list(llm_request.tools_dict.keys())
4139+
# Route tool declarations through the shared safety pipeline so unbounded
4140+
# descriptions / parameter schemas are size-capped and sensitive keys are
4141+
# redacted, consistent with every other captured attribute.
4142+
tools, tools_truncated = _recursive_smart_truncate(
4143+
_extract_tool_declarations(llm_request.tools_dict),
4144+
self.config.max_content_length,
4145+
)
4146+
attributes["tools"] = tools
40614147

40624148
TraceManager.push_span(callback_context, "llm_request")
40634149
await self._log_event(
40644150
"LLM_REQUEST",
40654151
callback_context,
40664152
raw_content=llm_request,
4153+
is_truncated=tools_truncated,
40674154
event_data=EventData(
40684155
model=llm_request.model,
40694156
extra_attributes=attributes,

src/google/adk/telemetry/_metrics.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
unit="1",
100100
description="Number of inference (model) calls per agent invocation.",
101101
explicit_bucket_boundaries_advisory=[
102+
0,
102103
1,
103104
2,
104105
3,
@@ -118,6 +119,7 @@
118119
unit="1",
119120
description="Number of tool calls per agent invocation.",
120121
explicit_bucket_boundaries_advisory=[
122+
0,
121123
1,
122124
2,
123125
3,

0 commit comments

Comments
 (0)