From 6e613e983d7cee35b3f8778b9e00329350efbfad Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:29:49 -0500 Subject: [PATCH 01/13] 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 e88cc6624c9cf44682ca0e14e75a4801569ca715 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:30:11 -0500 Subject: [PATCH 02/13] 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 e59422575f457ec21ca269ba3a784be2ea0207fd Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:33:49 -0500 Subject: [PATCH 03/13] 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 8301cf744334b1afe877a5f82abbb7931a158fd9 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:33:57 -0500 Subject: [PATCH 04/13] 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 3f57c2ab1eeb34ab200f77a7bceaa341e96b37be Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:05 -0500 Subject: [PATCH 05/13] 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 90de4c1c48280290e8d4a796e16c49d9d5c0e1c2 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:12 -0500 Subject: [PATCH 06/13] 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 287bdb053b7df1412057db611f4cf9f366507052 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:19 -0500 Subject: [PATCH 07/13] 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 620752c90e648e9707e45de5fe59045ccc1a9678 Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:27 -0500 Subject: [PATCH 08/13] 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 ca8c5bb67f27d44e3c214189552ebb48603c433e Mon Sep 17 00:00:00 2001 From: Jonathan Hill Date: Tue, 26 May 2026 12:34:34 -0500 Subject: [PATCH 09/13] 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 36bc932c8ddb43277ba428fbdfddf61a98825f27 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 14:16:34 -0500 Subject: [PATCH 10/13] 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 f210b599aa1b1500c45ac4e64b318db0bdd53a28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 16:45:28 -0500 Subject: [PATCH 11/13] style: apply isort and pyink formatting to PR files --- src/google/adk/evaluation/agent_evaluator.py | 4 +++- .../adk/integrations/vmaas/sandbox_client.py | 4 +++- src/google/adk/models/anthropic_llm.py | 4 +++- src/google/adk/models/apigee_llm.py | 4 +++- src/google/adk/sessions/schemas/shared.py | 2 +- .../adk/sessions/sqlite_session_service.py | 18 ++++++++++++++---- src/google/adk/utils/_schema_utils.py | 3 ++- tests/unittests/utils/test_json_utils.py | 12 ++++++------ 8 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/google/adk/evaluation/agent_evaluator.py b/src/google/adk/evaluation/agent_evaluator.py index c8ca7c09cb..8548093778 100644 --- a/src/google/adk/evaluation/agent_evaluator.py +++ b/src/google/adk/evaluation/agent_evaluator.py @@ -325,7 +325,9 @@ 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_utils.safe_json_loads(f.read(), context=initial_session_file) + initial_session = json_utils.safe_json_loads( + f.read(), context=initial_session_file + ) return initial_session @staticmethod diff --git a/src/google/adk/integrations/vmaas/sandbox_client.py b/src/google/adk/integrations/vmaas/sandbox_client.py index d3fbd4ca0d..84394b40d3 100644 --- a/src/google/adk/integrations/vmaas/sandbox_client.py +++ b/src/google/adk/integrations/vmaas/sandbox_client.py @@ -131,7 +131,9 @@ def _parse_response(self, response: Any) -> dict[str, Any]: The parsed JSON response as a dict. """ if hasattr(response, "body") and response.body: - return json_utils.safe_json_loads(response.body, context='sandbox response') + return json_utils.safe_json_loads( + response.body, context="sandbox response" + ) return {} def update_access_token(self, access_token: str) -> None: diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 8b102a8369..63f89ed6c8 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -682,7 +682,9 @@ 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_utils.safe_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) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index a26444d794..d5c50792a4 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -1162,7 +1162,9 @@ def _upsert_tool_call(self, tool_call: dict[str, Any]) -> types.Part: args_delta = func.get('arguments', '') if args_delta: try: - args = json_utils.safe_json_loads(args_delta, context='streaming response') + 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) diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index da2ef32e8c..582c95c6d7 100644 --- a/src/google/adk/sessions/schemas/shared.py +++ b/src/google/adk/sessions/schemas/shared.py @@ -52,7 +52,7 @@ def process_result_value(self, value, dialect: Dialect): if dialect.name == "postgresql": return value # JSONB returns dict directly else: - return json_utils.safe_json_loads(value, context='session state') + return json_utils.safe_json_loads(value, context="session state") return value diff --git a/src/google/adk/sessions/sqlite_session_service.py b/src/google/adk/sessions/sqlite_session_service.py index 73fd9484b3..6ed19d7ccc 100644 --- a/src/google/adk/sessions/sqlite_session_service.py +++ b/src/google/adk/sessions/sqlite_session_service.py @@ -246,7 +246,9 @@ async def get_session( session_row = await cursor.fetchone() if session_row is None: return None - session_state = json_utils.safe_json_loads(session_row["state"], context='session state') + session_state = json_utils.safe_json_loads( + session_row["state"], context="session state" + ) last_update_time = session_row["update_time"] # Build events query @@ -329,12 +331,16 @@ async def list_sessions( (app_name,), ) as cursor: async for row in cursor: - user_states_map[row["user_id"]] = json_utils.safe_json_loads(row["state"], context='session 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_utils.safe_json_loads(row["state"], context='session 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( @@ -476,7 +482,11 @@ 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_utils.safe_json_loads(row["state"], context='session 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 diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index db5ed3e47a..b65b1640ae 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -27,10 +27,11 @@ from typing import Optional from google.genai import types -from . import json_utils from pydantic import BaseModel from pydantic import TypeAdapter +from . import json_utils + # Use SchemaUnion from google.genai.types to support all schema types # that the underlying API supports. SchemaType = types.SchemaUnion diff --git a/tests/unittests/utils/test_json_utils.py b/tests/unittests/utils/test_json_utils.py index 5f6a9a81eb..97b5983ca4 100644 --- a/tests/unittests/utils/test_json_utils.py +++ b/tests/unittests/utils/test_json_utils.py @@ -14,14 +14,13 @@ """Tests for JSON utility functions.""" -import pytest - from google.adk.utils.json_utils import safe_json_loads +import pytest def test_parses_object(): result = safe_json_loads('{"key": "value"}') - assert result == {"key": "value"} + assert result == {'key': 'value'} def test_parses_array(): @@ -31,12 +30,12 @@ def test_parses_array(): def test_parses_nested_structure(): result = safe_json_loads('{"a": {"b": [1, null, true]}}') - assert result == {"a": {"b": [1, None, True]}} + assert result == {'a': {'b': [1, None, True]}} def test_parses_string_value(): result = safe_json_loads('"hello"') - assert result == "hello" + assert result == 'hello' def test_parses_number(): @@ -74,6 +73,7 @@ def test_error_wraps_json_decode_error(): safe_json_loads('{bad}', context='test') assert exc_info.value.__cause__ is not None import json + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) @@ -85,4 +85,4 @@ def test_context_none_no_suffix(): def test_unicode_content(): result = safe_json_loads('{"emoji": "🎉", "chinese": "你好"}') - assert result == {"emoji": "🎉", "chinese": "你好"} + assert result == {'emoji': '🎉', 'chinese': '你好'} From 796f1443baf070046eef7a0f7c0382c0dc588d13 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 16:49:20 -0500 Subject: [PATCH 12/13] fix: restore schema_to_json_schema accidentally removed during refactor The json_utils refactor replaced json.loads with safe_json_loads in validate_schema, but also accidentally deleted schema_to_json_schema. This function is used by _workflow_hitl_utils.py (added in main after the branch was created) causing ImportError across the entire test suite. Also fix test_context_none_no_suffix: the assertion checked for any ' in ' substring, which appears in the json.JSONDecodeError body text itself. Now checks the message prefix format directly. --- src/google/adk/utils/_schema_utils.py | 14 ++++++++++++++ tests/unittests/utils/test_json_utils.py | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index b65b1640ae..0bca75343f 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -94,6 +94,20 @@ 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. diff --git a/tests/unittests/utils/test_json_utils.py b/tests/unittests/utils/test_json_utils.py index 97b5983ca4..9e256764d8 100644 --- a/tests/unittests/utils/test_json_utils.py +++ b/tests/unittests/utils/test_json_utils.py @@ -80,7 +80,9 @@ def test_error_wraps_json_decode_error(): 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) + msg = str(exc_info.value) + assert msg.startswith('Invalid JSON:'), msg + assert not msg.startswith('Invalid JSON in '), msg def test_unicode_content(): From f485b3c72d15ae7f0773d9f2a5d4947cd0ec4157 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 16:53:40 -0500 Subject: [PATCH 13/13] fix(apigee_llm): update exception handlers to catch ValueError from safe_json_loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json.JSONDecodeError is a subclass of ValueError, but safe_json_loads raises ValueError directly. Two handlers were catching json.JSONDecodeError and would silently stop catching errors after the refactor: - Streaming chunk loop (line ~576): changed except json.JSONDecodeError → ValueError - _parse_streaming_chunk args block: removed dead try/except since safe_json_loads already raises ValueError with context='streaming response' --- src/google/adk/models/apigee_llm.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/google/adk/models/apigee_llm.py b/src/google/adk/models/apigee_llm.py index d5c50792a4..8966bfb221 100644 --- a/src/google/adk/models/apigee_llm.py +++ b/src/google/adk/models/apigee_llm.py @@ -573,7 +573,7 @@ async def _handle_streaming( try: for res in self._parse_streaming_line(line, accumulator): yield res - except json.JSONDecodeError: + except ValueError: logger.warning('Failed to parse JSON chunk: %s', line) continue @@ -1161,17 +1161,14 @@ def _upsert_tool_call(self, tool_call: dict[str, Any]) -> types.Part: func = tool_call.get('function', {}) args_delta = func.get('arguments', '') if args_delta: - try: - 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) - else: - part.function_call.args.update(args) - except json.JSONDecodeError as e: - raise ValueError(f'Failed to parse arguments: {args_delta}') from e + 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) + else: + part.function_call.args.update(args) func_name = func.get('name') if func_name: