diff --git a/src/google/adk/evaluation/agent_evaluator.py b/src/google/adk/evaluation/agent_evaluator.py index f52a367950..8548093778 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,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.loads(f.read()) + 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 1a264a1146..84394b40d3 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,10 @@ 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: diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 9658b85a5f..63f89ed6c8 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,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.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 a1575bdce6..8966bfb221 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 @@ -572,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 @@ -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 @@ -1160,15 +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.loads(args_delta) - 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: diff --git a/src/google/adk/sessions/schemas/shared.py b/src/google/adk/sessions/schemas/shared.py index 25d4ea9e95..582c95c6d7 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 diff --git a/src/google/adk/sessions/sqlite_session_service.py b/src/google/adk/sessions/sqlite_session_service.py index 798befcedc..6ed19d7ccc 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,9 @@ 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 +331,16 @@ 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 +398,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 +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.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 diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index e83431bd61..0bca75343f 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -30,6 +30,8 @@ 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 @@ -130,4 +132,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') diff --git a/src/google/adk/utils/json_utils.py b/src/google/adk/utils/json_utils.py new file mode 100644 index 0000000000..b0a4c57068 --- /dev/null +++ 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 diff --git a/tests/unittests/utils/test_json_utils.py b/tests/unittests/utils/test_json_utils.py new file mode 100644 index 0000000000..9e256764d8 --- /dev/null +++ b/tests/unittests/utils/test_json_utils.py @@ -0,0 +1,90 @@ +# 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.""" + +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'} + + +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) + msg = str(exc_info.value) + assert msg.startswith('Invalid JSON:'), msg + assert not msg.startswith('Invalid JSON in '), msg + + +def test_unicode_content(): + result = safe_json_loads('{"emoji": "🎉", "chinese": "你好"}') + assert result == {'emoji': '🎉', 'chinese': '你好'}