Skip to content

Commit ec4cb80

Browse files
sarathfrancis90GWeale
authored andcommitted
fix(tools): bind JSONDecodeError in ToolConnectionAnalyzer.analyze
Merge google#5942 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **2. Or, if no issue exists, describe the change:** **Problem:** `ToolConnectionAnalyzer.analyze()` in `src/google/adk/tools/environment_simulation/tool_connection_analyzer.py` crashes whenever the analyzed LLM returns a response that is not valid JSON. The JSON parse is wrapped in a `try/except`, but the `except` clause does not bind the exception: ```python except json.JSONDecodeError: logging.warning( "Failed to parse tool connection analysis from LLM. Proceeding" " without connection map. Error: %s\nLLM Output:\n%s", e, # <-- 'e' was never defined response_text, ) return ToolConnectionMap(stateful_parameters=[]) ``` The warning log references `e`, but the `except` clause binds nothing. So the moment a non-JSON response is parsed, the handler itself raises `NameError: name 'e' is not defined`. This masks the real parse error and crashes `analyze()` instead of degrading gracefully to an empty `ToolConnectionMap` as the surrounding code clearly intends. Reproduced traceback (LLM mocked to return `"this is not json at all"`): ```text File ".../tool_connection_analyzer.py", line 136, in analyze response_json = json.loads(clean_json_text.strip()) ... json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) During handling of the above exception, another exception occurred: File ".../tool_connection_analyzer.py", line 141, in analyze e, ^ NameError: name 'e' is not defined ``` **Solution:** Bind the caught exception with `as e` so the handler can log the real parse error and return an empty `ToolConnectionMap` as designed: ```python except json.JSONDecodeError as e: logging.warning(...) return ToolConnectionMap(stateful_parameters=[]) ``` This is a one-character fix; the surrounding logging and fallback behaviour are unchanged. After the fix, the same input logs the underlying parse error and returns `ToolConnectionMap(stateful_parameters=[])` without raising. ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. This branch of `analyze()` previously had zero coverage, so I added `tests/unittests/tools/environment_simulation/test_tool_connection_analyzer.py` covering: - the malformed-JSON path (regression guard — fails with `NameError` on the pre-fix code, passes after the fix), - the valid-JSON path, and - the Markdown code-fence stripping path. `pytest` summary (low parallelism, as run locally): ```text $ pytest tests/unittests/tools/environment_simulation/ -n2 ======================= 12 passed, 11 warnings in 1.64s ======================== ``` ```text $ pytest tests/unittests/tools/environment_simulation/test_tool_connection_analyzer.py -n0 -v ...test_malformed_json_returns_empty_map_without_crashing PASSED ...test_valid_json_is_parsed_into_connection_map PASSED ...test_fenced_json_is_stripped_before_parsing PASSED ======================== 3 passed, 5 warnings in 0.57s ========================= ``` Verified the regression test fails on the unfixed code with the exact `NameError: name 'e' is not defined` and passes after the fix. `isort` and `pyink` report no changes on the modified files. **Manual End-to-End (E2E) Tests:** Reproduced directly against the analyzer with a mocked LLM: ```python analyzer = ToolConnectionAnalyzer(llm_name=..., llm_config=...) # LLM mocked to return a non-JSON string. result = await analyzer.analyze([some_tool]) ``` - Before the fix: raises `NameError: name 'e' is not defined`. - After the fix: logs `Failed to parse tool connection analysis from LLM... Error: Expecting value: line 1 column 1 (char 0)` and returns `ToolConnectionMap(stateful_parameters=[])`. ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context The affected module is marked `@experimental(FeatureName.ENVIRONMENT_SIMULATION)`. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=google#5942 from sarathfrancis90:fix-tool-connection-analyzer-jsondecodeerror 536dd56 PiperOrigin-RevId: 955373698
1 parent f070262 commit ec4cb80

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

‎src/google/adk/tools/environment_simulation/tool_connection_analyzer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ async def analyze(self, tools: List[BaseTool]) -> ToolConnectionMap:
130130
clean_json_text = re.sub(r"^```[a-zA-Z]*\n", "", response_text)
131131
clean_json_text = re.sub(r"\n```$", "", clean_json_text)
132132
response_json = json.loads(clean_json_text.strip())
133-
except json.JSONDecodeError:
133+
except json.JSONDecodeError as e:
134134
logging.warning(
135135
"Failed to parse tool connection analysis from LLM. Proceeding"
136136
" without connection map. Error: %s\nLLM Output:\n%s",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
from unittest.mock import MagicMock
17+
from unittest.mock import patch
18+
19+
from google.adk.models.llm_response import LlmResponse
20+
from google.adk.tools.environment_simulation import tool_connection_analyzer
21+
from google.adk.tools.environment_simulation.tool_connection_analyzer import ToolConnectionAnalyzer
22+
from google.adk.tools.environment_simulation.tool_connection_map import ToolConnectionMap
23+
from google.genai import types
24+
import pytest
25+
26+
27+
def _make_analyzer(response_text: str) -> ToolConnectionAnalyzer:
28+
"""Builds a ToolConnectionAnalyzer whose LLM yields ``response_text``."""
29+
30+
async def fake_generate_content_async(request):
31+
yield LlmResponse(
32+
content=types.Content(
33+
role="model",
34+
parts=[types.Part(text=response_text)],
35+
)
36+
)
37+
38+
mock_llm = MagicMock()
39+
mock_llm.generate_content_async = fake_generate_content_async
40+
41+
with patch.object(
42+
tool_connection_analyzer, "LLMRegistry", autospec=True
43+
) as mock_registry:
44+
mock_registry.return_value.resolve.return_value = MagicMock(
45+
return_value=mock_llm
46+
)
47+
return ToolConnectionAnalyzer(
48+
llm_name="fake-model",
49+
llm_config=types.GenerateContentConfig(),
50+
)
51+
52+
53+
def _make_tool(name: str) -> MagicMock:
54+
"""Builds a tool whose declaration produces a non-empty schema."""
55+
tool = MagicMock()
56+
tool._get_declaration.return_value = types.FunctionDeclaration(name=name)
57+
return tool
58+
59+
60+
@pytest.mark.asyncio
61+
class TestToolConnectionAnalyzerAnalyze:
62+
"""Test cases for the analyze method of ToolConnectionAnalyzer."""
63+
64+
async def test_malformed_json_returns_empty_map_without_crashing(
65+
self, caplog
66+
):
67+
"""Regression test: a non-JSON LLM response must not raise NameError.
68+
69+
The JSONDecodeError handler logs the captured exception, so the ``except``
70+
clause must bind it (``as e``). Without the binding the handler itself
71+
raised ``NameError: name 'e' is not defined``, masking the real parse
72+
failure and crashing ``analyze()``.
73+
"""
74+
analyzer = _make_analyzer("this is not json at all")
75+
tool = _make_tool("create_ticket")
76+
77+
with caplog.at_level(logging.WARNING):
78+
result = await analyzer.analyze([tool])
79+
80+
assert isinstance(result, ToolConnectionMap)
81+
assert result.stateful_parameters == []
82+
assert "Failed to parse tool connection analysis" in caplog.text
83+
84+
async def test_valid_json_is_parsed_into_connection_map(self):
85+
"""A well-formed JSON response is parsed into a ToolConnectionMap."""
86+
response_text = (
87+
'{"stateful_parameters": [{"parameter_name": "ticket_id",'
88+
' "creating_tools": ["create_ticket"], "consuming_tools":'
89+
' ["get_ticket"]}]}'
90+
)
91+
analyzer = _make_analyzer(response_text)
92+
tool = _make_tool("create_ticket")
93+
94+
result = await analyzer.analyze([tool])
95+
96+
assert isinstance(result, ToolConnectionMap)
97+
assert len(result.stateful_parameters) == 1
98+
parameter = result.stateful_parameters[0]
99+
assert parameter.parameter_name == "ticket_id"
100+
assert parameter.creating_tools == ["create_ticket"]
101+
assert parameter.consuming_tools == ["get_ticket"]
102+
103+
async def test_fenced_json_is_stripped_before_parsing(self):
104+
"""A response wrapped in a Markdown code fence is parsed correctly."""
105+
response_text = (
106+
"```json\n"
107+
'{"stateful_parameters": [{"parameter_name": "order_id",'
108+
' "creating_tools": ["create_order"], "consuming_tools":'
109+
' ["get_order"]}]}\n'
110+
"```"
111+
)
112+
analyzer = _make_analyzer(response_text)
113+
tool = _make_tool("create_order")
114+
115+
result = await analyzer.analyze([tool])
116+
117+
assert isinstance(result, ToolConnectionMap)
118+
assert len(result.stateful_parameters) == 1
119+
assert result.stateful_parameters[0].parameter_name == "order_id"

0 commit comments

Comments
 (0)