From 734858f9bf5e7f20e1648ad79fda2584a1962c2b Mon Sep 17 00:00:00 2001 From: Your7Maxx Date: Wed, 19 Aug 2026 11:46:34 +0800 Subject: [PATCH] fix(lite_llm): add JSON-tolerant tool call argument parsing with strict/non-strict modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight models (e.g. DeepSeek V4 Flash) sometimes emit malformed JSON in tool call arguments — missing commas, unquoted keys, trailing garbage, or wrapped in markdown code fences. The existing `_parse_tool_call_arguments` had repair strategies (ast.literal_eval, unquoted-key quoting) but still raised `json.JSONDecodeError` as a last resort, and the only callsite in `_message_to_generate_content_response` did not catch it, causing a hard crash that terminated the entire agent pipeline. Changes: 1. Add a `strict` keyword parameter (default True) to `_parse_tool_call_arguments`. Existing callers keep the exception-raising behavior. The streaming `_finalize_tool_call_response` depends on the exception for truncation detection and is unaffected. 2. Add two new repair strategies before the final fallback: - Strip markdown code fences (```json ... ```) and retry parsing - Extract the first balanced {…} block via `JSONDecoder.raw_decode` to tolerate trailing garbage text 3. When strict=False (non-strict mode), log a warning and return {} instead of raising — this is used by `_message_to_generate_content_response` so the pipeline continues with default tool call arguments. 4. Add 15 unit tests covering valid JSON, empty input, unquoted keys, markdown fences, trailing text, trailing commas, single quotes, nested objects, array values, escaped strings, and strict/non-strict error modes. --- src/google/adk/models/lite_llm.py | 60 +++++++++++++++++++++++- tests/unittests/models/test_litellm.py | 65 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index a854c0104b..df9593ae90 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -187,7 +187,9 @@ def _quote_unquoted_json_object_keys(value: str) -> str: return "".join(result) -def _parse_tool_call_arguments(arguments: Any) -> Any: +def _parse_tool_call_arguments( + arguments: Any, *, strict: bool = True +) -> Any: """Parses LiteLLM tool call arguments. LiteLLM normally returns OpenAI-compatible tool call arguments as JSON @@ -195,6 +197,14 @@ def _parse_tool_call_arguments(arguments: Any) -> Any: argument payload is a Python dict literal or has unquoted object keys. Keep strict JSON as the primary path, then repair only those complete object-literal shapes so ADK can still surface the intended function call. + + When ``strict=False`` (non-strict mode), all repairs failing produces a + warning log and an empty dict ``{}`` fallback instead of raising. + + Args: + arguments: The tool call arguments to parse. + strict: If ``True`` (default), raises ``json.JSONDecodeError`` when all + repairs fail. If ``False``, logs a warning and returns ``{}``. """ if not arguments: return {} @@ -206,11 +216,13 @@ def _parse_tool_call_arguments(arguments: Any) -> Any: except json.JSONDecodeError as exc: json_error = exc + # Retry with Python literal eval (handles single-quoted keys, etc.). try: return ast.literal_eval(arguments) except (SyntaxError, ValueError): pass + # Retry after quoting unquoted JSON object keys. repaired_arguments = _quote_unquoted_json_object_keys(arguments) if repaired_arguments != arguments: try: @@ -221,6 +233,48 @@ def _parse_tool_call_arguments(arguments: Any) -> Any: except (SyntaxError, ValueError): pass + # Retry after stripping Markdown code fences (```json ... ```). + fence_match = re.search( + r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", arguments + ) + if fence_match: + candidate = fence_match.group(1).strip() + if candidate != arguments: + try: + return json.loads(candidate) + except json.JSONDecodeError: + try: + return ast.literal_eval(candidate) + except (SyntaxError, ValueError): + pass + # Also try with unquoted-key repair on the fence-stripped content. + repaired_candidate = _quote_unquoted_json_object_keys(candidate) + if repaired_candidate != candidate: + try: + return json.loads(repaired_candidate) + except json.JSONDecodeError: + try: + return ast.literal_eval(repaired_candidate) + except (SyntaxError, ValueError): + pass + + # Fall back to the first balanced {…} block (tolerates trailing content). + open_brace = arguments.find("{") + if open_brace != -1: + try: + candidate, _ = _JSON_DECODER.raw_decode(arguments, open_brace) + return candidate + except json.JSONDecodeError: + pass + + if not strict: + logger.warning( + "Failed to parse tool call arguments as JSON, falling back to empty" + " args. Arguments (first 200 chars): %s", + arguments[:200], + ) + return {} + raise json_error @@ -2388,7 +2442,9 @@ def _message_to_generate_content_response( thought_signature = _extract_thought_signature_from_tool_call(tool_call) part = types.Part.from_function_call( name=tool_call.function.name, - args=_parse_tool_call_arguments(tool_call.function.arguments), + args=_parse_tool_call_arguments( + tool_call.function.arguments, strict=False + ), ) function_call = part.function_call if function_call is None: diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 68b0e74c17..dbb72c25fe 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -54,6 +54,7 @@ from google.adk.models.lite_llm import _model_response_to_chunk from google.adk.models.lite_llm import _model_response_to_generate_content_response from google.adk.models.lite_llm import _parse_deepseek_tool_calls_from_text +from google.adk.models.lite_llm import _parse_tool_call_arguments from google.adk.models.lite_llm import _parse_tool_calls_from_text from google.adk.models.lite_llm import _redact_file_uri_for_log from google.adk.models.lite_llm import _redirect_litellm_loggers_to_stdout @@ -7076,3 +7077,67 @@ async def test_generate_content_async_omits_tool_choice_when_functions_override( _, kwargs = mock_acompletion.call_args assert kwargs.get("tools") is None assert "tool_choice" not in kwargs + + +class TestParseToolCallArguments: + """Tests for _parse_tool_call_arguments.""" + + def test_valid_json(self): + assert _parse_tool_call_arguments('{"a": 1}') == {"a": 1} + + def test_empty(self): + assert _parse_tool_call_arguments(None) == {} + assert _parse_tool_call_arguments("") == {} + + def test_already_dict(self): + assert _parse_tool_call_arguments({"a": 1}) == {"a": 1} + + def test_unquoted_keys(self): + result = _parse_tool_call_arguments('{a: 1}') + assert result == {"a": 1} + + def test_markdown_fence_valid(self): + result = _parse_tool_call_arguments( + "```json\n{\"a\": 1}\n```" + ) + assert result == {"a": 1} + + def test_markdown_fence_malformed(self): + result = _parse_tool_call_arguments("```\n{a: 1}\n```") + assert result == {"a": 1} + + def test_extra_trailing_text(self): + result = _parse_tool_call_arguments('{"a": 1} extra stuff') + assert result == {"a": 1} + + def test_truly_malformed_strict_raises(self): + with pytest.raises(json.JSONDecodeError): + _parse_tool_call_arguments("{bad") + + def test_truly_malformed_nonstrict_returns_empty(self): + result = _parse_tool_call_arguments("{bad", strict=False) + assert result == {} + + def test_trailing_comma(self): + result = _parse_tool_call_arguments('{"a": 1,}') + assert result == {"a": 1} + + def test_single_quotes(self): + result = _parse_tool_call_arguments("{'a': 1}") + assert result == {"a": 1} + + def test_empty_object(self): + result = _parse_tool_call_arguments("{}") + assert result == {} + + def test_nested_objects(self): + result = _parse_tool_call_arguments('{"a": {"b": 2}}') + assert result == {"a": {"b": 2}} + + def test_array_values(self): + result = _parse_tool_call_arguments('{"a": [1, 2, 3]}') + assert result == {"a": [1, 2, 3]} + + def test_string_with_escaped_chars(self): + result = _parse_tool_call_arguments('{"a": "he\\"llo"}') + assert result == {"a": 'he"llo'}