Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/pr-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
3 changes: 2 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,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
Expand Down
5 changes: 2 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,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:
Expand Down
41 changes: 15 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,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)
Expand Down
5 changes: 3 additions & 2 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 @@ -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 @@ -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)
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
11 changes: 6 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,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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 2 additions & 15 deletions src/google/adk/utils/_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -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')
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
88 changes: 88 additions & 0 deletions tests/unittests/utils/test_json_utils.py
Original file line number Diff line number Diff line change
@@ -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": "你好"}
Loading