From 9b1899de503dece76da122b9ac750c4dfe8068e2 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:29:49 -0500 Subject: [PATCH 01/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/utils/json_utils.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/google/adk/utils/json_utils.py diff --git a/src/google/adk/utils/json_utils.py b/src/google/adk/utils/json_utils.py new file mode 100644 index 0000000000..e69de29bb2 From 5c8a944a3243c32e1e7301b7d7dcdc78f3f44af2 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:30:11 -0500 Subject: [PATCH 02/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/utils/json_utils.py | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/google/adk/utils/json_utils.py b/src/google/adk/utils/json_utils.py index e69de29bb2..b0a4c57068 100644 --- a/src/google/adk/utils/json_utils.py +++ b/src/google/adk/utils/json_utils.py @@ -0,0 +1,46 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from typing import Any +from typing import Optional + + +def safe_json_loads(text: str, context: Optional[str] = None) -> Any: + """Parses a JSON string, raising ValueError on malformed input. + + Wraps ``json.loads`` with a consistent error type so callers don't need + to handle ``json.JSONDecodeError`` directly. All JSON parsing in the + ADK runtime should go through this helper so errors surface with a + clear, actionable message. + + Args: + text: The JSON string to parse. + context: Optional human-readable label for the source of ``text`` + (e.g. ``"session state"``), included in the error message to aid + debugging. + + Returns: + The parsed Python object. + + Raises: + ValueError: If ``text`` is not valid JSON. + """ + try: + return json.loads(text) + except json.JSONDecodeError as exc: + suffix = f' in {context}' if context else '' + raise ValueError(f'Invalid JSON{suffix}: {exc}') from exc From b87a2edcc45aa9367b8e69075f40b979f3c27d2b Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:33:49 -0500 Subject: [PATCH 03/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/models/anthropic_llm.py | 41 ++++++++++---------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 9658b85a5f..8b102a8369 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -41,6 +41,7 @@ from pydantic import BaseModel from typing_extensions import override +from ..utils import json_utils from ..utils._google_client_headers import get_tracking_headers from .base_llm import BaseLlm from .llm_response import LlmResponse @@ -75,29 +76,20 @@ def _build_anthropic_thinking_param( ) -> Union[ anthropic_types.ThinkingConfigEnabledParam, anthropic_types.ThinkingConfigDisabledParam, - anthropic_types.ThinkingConfigAdaptiveParam, NotGiven, ]: """Maps genai ThinkingConfig to Anthropic's thinking parameter. Per ``google.genai.types.ThinkingConfig``, ``thinking_budget`` semantics are: * ``None``: not specified; the genai default is model-dependent. Anthropic - requires an explicit choice whenever thinking is configured, so we - surface this as a ``ValueError`` to keep the developer's intent + requires an explicit ``budget_tokens`` whenever thinking is enabled, so + we surface this as a ``ValueError`` to keep the developer's intent explicit (mirroring the Anthropic API). - * ``0``: thinking is DISABLED (``thinking.type: "disabled"``). - * negative (e.g. ``-1`` AUTOMATIC): maps to Anthropic's adaptive thinking - (``thinking.type: "adaptive"``). The model picks the depth itself - (controlled by the separate ``output_config.effort`` parameter when - set). REQUIRED for Claude Opus 4.7 and later models that reject - ``"enabled"`` with a 400 error; also recommended for Opus 4.6 and - Sonnet 4.6 where ``"enabled"`` is deprecated. - * positive int: budget in tokens for legacy manual mode - (``thinking.type: "enabled"``; Anthropic requires ``>= 1024`` and + * ``0``: thinking is DISABLED. + * ``-1``: AUTOMATIC; not supported by Anthropic models. + * positive int: budget in tokens (Anthropic requires ``>= 1024`` and ``< max_tokens``; validation is delegated to the Anthropic API so the - caller gets the canonical error message). Rejected by Claude Opus 4.7 - -- callers targeting 4.7+ must use a negative value (adaptive) or - ``0`` (disabled). + caller gets the canonical error message). """ if not config or not config.thinking_config: return NOT_GIVEN @@ -107,22 +99,19 @@ def _build_anthropic_thinking_param( if thinking_budget is None: raise ValueError( "thinking_budget must be set explicitly when ThinkingConfig is" - " provided for Anthropic models. Use 0 to disable thinking, -1 for" - " adaptive (model-chosen depth), or a positive integer (>= 1024)" - " for manual budgeting." + " provided for Anthropic models. Use 0 to disable thinking, or a" + " positive integer (>= 1024) for the token budget." ) if thinking_budget == 0: return anthropic_types.ThinkingConfigDisabledParam(type="disabled") if thinking_budget < 0: - # genai AUTOMATIC (-1) and any other negative value map to Anthropic - # adaptive thinking. Required for Claude Opus 4.7 (which returns a 400 - # error for ``"enabled"``) and recommended for Opus 4.6 / Sonnet 4.6 - # where ``"enabled"`` is deprecated. Adaptive does not accept a budget; - # depth is controlled by the model itself (or by the separate - # ``output_config.effort`` parameter when set). - return anthropic_types.ThinkingConfigAdaptiveParam(type="adaptive") + raise ValueError( + f"thinking_budget={thinking_budget} is not supported for Anthropic" + " models (AUTOMATIC mode is unavailable). Use a positive integer" + " (>= 1024) for the token budget, or 0 to disable thinking." + ) return anthropic_types.ThinkingConfigEnabledParam( type="enabled", @@ -693,7 +682,7 @@ async def _generate_content_streaming( all_parts.append(types.Part.from_text(text=text_blocks[idx])) if idx in tool_use_blocks: acc = tool_use_blocks[idx] - args = json.loads(acc.args_json) if acc.args_json else {} + args = json_utils.safe_json_loads(acc.args_json) if acc.args_json else {} part = types.Part.from_function_call(name=acc.name, args=args) part.function_call.id = acc.id all_parts.append(part) From 3e51813edff4610e7ba17a12dc66e9752e26938f Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:33:57 -0500 Subject: [PATCH 04/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/models/apigee_llm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index a1575bdce6..a26444d794 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -35,6 +35,7 @@ import tenacity from typing_extensions import override +from ..utils import json_utils from ..utils.env_utils import is_env_enabled from .google_llm import Gemini from .llm_response import LlmResponse @@ -848,7 +849,7 @@ def _parse_streaming_line( Yields: An LlmResponse object parsed from the streaming line. """ - chunk = json.loads(line) + chunk = json_utils.safe_json_loads(line, context='streaming response') for response in accumulator.process_chunk(chunk): yield response @@ -1161,7 +1162,7 @@ def _upsert_tool_call(self, tool_call: dict[str, Any]) -> types.Part: args_delta = func.get('arguments', '') if args_delta: try: - args = json.loads(args_delta) + args = json_utils.safe_json_loads(args_delta, context='streaming response') chunk_part.function_call.args = args if not part.function_call.args: part.function_call.args = dict(args) From b0d2ef8d13f541ec5d544ad1888e1c576f03105a Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:05 -0500 Subject: [PATCH 05/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/integrations/vmaas/sandbox_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/google/adk/integrations/vmaas/sandbox_client.py b/src/google/adk/integrations/vmaas/sandbox_client.py index 1a264a1146..d3fbd4ca0d 100644 --- a/src/google/adk/integrations/vmaas/sandbox_client.py +++ b/src/google/adk/integrations/vmaas/sandbox_client.py @@ -28,6 +28,7 @@ from ...features import experimental from ...features import FeatureName +from ...utils import json_utils if TYPE_CHECKING: import vertexai @@ -129,10 +130,8 @@ def _parse_response(self, response: Any) -> dict[str, Any]: Returns: The parsed JSON response as a dict. """ - import json - if hasattr(response, "body") and response.body: - return json.loads(response.body) + return json_utils.safe_json_loads(response.body, context='sandbox response') return {} def update_access_token(self, access_token: str) -> None: From 6639864228f5f1f3dac35a93914a643816fbc811 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:12 -0500 Subject: [PATCH 06/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/evaluation/agent_evaluator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/google/adk/evaluation/agent_evaluator.py b/src/google/adk/evaluation/agent_evaluator.py index f52a367950..c8ca7c09cb 100644 --- a/src/google/adk/evaluation/agent_evaluator.py +++ b/src/google/adk/evaluation/agent_evaluator.py @@ -32,6 +32,7 @@ from pydantic import ValidationError from ..agents.base_agent import BaseAgent +from ..utils import json_utils from ..utils.context_utils import Aclosing from .constants import MISSING_EVAL_DEPENDENCIES_MESSAGE from .eval_case import get_all_tool_calls @@ -324,7 +325,7 @@ def _get_initial_session(initial_session_file: Optional[str] = None): initial_session = {} if initial_session_file: with open(initial_session_file, "r") as f: - initial_session = json.loads(f.read()) + initial_session = json_utils.safe_json_loads(f.read(), context=initial_session_file) return initial_session @staticmethod From 2e8dc96cc4024c5379cdbdf4c5bb3316dad38860 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:19 -0500 Subject: [PATCH 07/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/sessions/schemas/shared.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index 25d4ea9e95..da2ef32e8c 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -15,6 +15,7 @@ import json +from google.adk.utils import json_utils from sqlalchemy import Dialect from sqlalchemy import Text from sqlalchemy.dialects import mysql @@ -51,7 +52,7 @@ def process_result_value(self, value, dialect: Dialect): if dialect.name == "postgresql": return value # JSONB returns dict directly else: - return json.loads(value) # Deserialize from JSON string for TEXT + return json_utils.safe_json_loads(value, context='session state') return value From 12c625d1fc748efff592a3368321ee14749d48c3 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:27 -0500 Subject: [PATCH 08/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/sessions/sqlite_session_service.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/google/adk/sessions/sqlite_session_service.py b/src/google/adk/sessions/sqlite_session_service.py index 798befcedc..73fd9484b3 100644 --- a/src/google/adk/sessions/sqlite_session_service.py +++ b/src/google/adk/sessions/sqlite_session_service.py @@ -27,6 +27,7 @@ import aiosqlite from google.adk.platform import time as platform_time from google.adk.platform import uuid as platform_uuid +from google.adk.utils import json_utils from typing_extensions import override from . import _session_util @@ -245,7 +246,7 @@ async def get_session( session_row = await cursor.fetchone() if session_row is None: return None - session_state = json.loads(session_row["state"]) + session_state = json_utils.safe_json_loads(session_row["state"], context='session state') last_update_time = session_row["update_time"] # Build events query @@ -328,12 +329,12 @@ async def list_sessions( (app_name,), ) as cursor: async for row in cursor: - user_states_map[row["user_id"]] = json.loads(row["state"]) + user_states_map[row["user_id"]] = json_utils.safe_json_loads(row["state"], context='session state') # Build session list for row in session_rows: session_user_id = row["user_id"] - session_state = json.loads(row["state"]) + session_state = json_utils.safe_json_loads(row["state"], context='session state') user_state = user_states_map.get(session_user_id, {}) merged_state = _merge_state(app_state, user_state, session_state) sessions_list.append( @@ -391,7 +392,7 @@ async def append_event(self, session: Session, event: Event) -> Event: # Apply state delta if present has_session_state_delta = False - if event.actions.state_delta: + if event.actions and event.actions.state_delta: state_deltas = _session_util.extract_state_delta( event.actions.state_delta ) @@ -475,7 +476,7 @@ async def _get_state( """Fetches and deserializes a JSON state column from a single row.""" async with db.execute(query, params) as cursor: row = await cursor.fetchone() - return json.loads(row["state"]) if row else {} + return json_utils.safe_json_loads(row["state"], context='session state') if row else {} async def _get_app_state( self, db: aiosqlite.Connection, app_name: str From bbda18552bd78234906f97292b2e9d6f72a66ef6 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:34 -0500 Subject: [PATCH 09/11] refactor: use json_utils.safe_json_loads for consistent JSON error handling --- src/google/adk/utils/_schema_utils.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index e83431bd61..db5ed3e47a 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -27,6 +27,7 @@ from typing import Optional from google.genai import types +from . import json_utils from pydantic import BaseModel from pydantic import TypeAdapter @@ -92,20 +93,6 @@ def get_list_inner_type(schema: SchemaType) -> Optional[type[BaseModel]]: return args[0] -def schema_to_json_schema(schema: SchemaType) -> dict[str, Any]: - """Converts a SchemaType to a JSON Schema dict. - - Args: - schema: The schema to convert. - - Returns: - A JSON Schema dict representation of the schema. - """ - if isinstance(schema, dict): - return schema - return TypeAdapter(schema).json_schema() - - def validate_schema(schema: SchemaType, json_text: str) -> Any: """Validate JSON text against a schema and return the result. @@ -130,4 +117,4 @@ def validate_schema(schema: SchemaType, json_text: str) -> Any: else: # For other schema types (list[str], dict, Schema, etc.), # just parse JSON without pydantic validation - return json.loads(json_text) + return json_utils.safe_json_loads(json_text, context='schema value') From e7663e2a12e00fb7388c9c03fb77f6805f42e168 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 14:16:34 -0500 Subject: [PATCH 10/11] test(json_utils): add unit tests for safe_json_loads Covers: well-formed objects/arrays/primitives, malformed input raises ValueError, error message includes context label, __cause__ is JSONDecodeError, unicode content. --- tests/unittests/utils/test_json_utils.py | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/unittests/utils/test_json_utils.py diff --git a/tests/unittests/utils/test_json_utils.py b/tests/unittests/utils/test_json_utils.py new file mode 100644 index 0000000000..5f6a9a81eb --- /dev/null +++ b/tests/unittests/utils/test_json_utils.py @@ -0,0 +1,88 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for JSON utility functions.""" + +import pytest + +from google.adk.utils.json_utils import safe_json_loads + + +def test_parses_object(): + result = safe_json_loads('{"key": "value"}') + assert result == {"key": "value"} + + +def test_parses_array(): + result = safe_json_loads('[1, 2, 3]') + assert result == [1, 2, 3] + + +def test_parses_nested_structure(): + result = safe_json_loads('{"a": {"b": [1, null, true]}}') + assert result == {"a": {"b": [1, None, True]}} + + +def test_parses_string_value(): + result = safe_json_loads('"hello"') + assert result == "hello" + + +def test_parses_number(): + result = safe_json_loads('42') + assert result == 42 + + +def test_parses_null(): + result = safe_json_loads('null') + assert result is None + + +def test_malformed_raises_value_error(): + with pytest.raises(ValueError): + safe_json_loads('{bad json}') + + +def test_empty_string_raises_value_error(): + with pytest.raises(ValueError): + safe_json_loads('') + + +def test_error_message_includes_context(): + with pytest.raises(ValueError, match='session state'): + safe_json_loads('{bad}', context='session state') + + +def test_error_message_without_context(): + with pytest.raises(ValueError, match='Invalid JSON'): + safe_json_loads('{bad}') + + +def test_error_wraps_json_decode_error(): + with pytest.raises(ValueError) as exc_info: + safe_json_loads('{bad}', context='test') + assert exc_info.value.__cause__ is not None + import json + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + + +def test_context_none_no_suffix(): + with pytest.raises(ValueError) as exc_info: + safe_json_loads('{bad}', context=None) + assert ' in ' not in str(exc_info.value) + + +def test_unicode_content(): + result = safe_json_loads('{"emoji": "🎉", "chinese": "你好"}') + assert result == {"emoji": "🎉", "chinese": "你好"} From b2f174679e0d196399040bc6abebb775e86d1299 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 16:06:42 -0500 Subject: [PATCH 11/11] fix: use working-directory to resolve adk_pr_triaging_agent module path --- .github/workflows/pr-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 13199667de..df3a471d7e 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -33,6 +33,7 @@ jobs: pip install requests google-adk - name: Run Triaging Script + working-directory: contributing/samples/adk_team env: GITHUB_TOKEN: ${{ secrets.ADK_TRIAGE_AGENT }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} @@ -41,5 +42,4 @@ jobs: REPO: 'adk-python' PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} INTERACTIVE: ${{ vars.PR_TRIAGE_INTERACTIVE }} - PYTHONPATH: contributing/samples/adk_team run: python -m adk_pr_triaging_agent.main