Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/google/adk/evaluation/agent_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/google/adk/integrations/vmaas/sandbox_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from ...features import experimental
from ...features import FeatureName
from ...utils import json_utils

if TYPE_CHECKING:
import vertexai
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 17 additions & 26 deletions src/google/adk/models/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 11 additions & 11 deletions src/google/adk/models/apigee_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/google/adk/sessions/schemas/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
21 changes: 16 additions & 5 deletions src/google/adk/sessions/sqlite_session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/google/adk/utils/_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
46 changes: 46 additions & 0 deletions src/google/adk/utils/json_utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading