From 262e592febc7e5e04ef38b8144f6ef6fb6e5e496 Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Sun, 21 Jun 2026 09:54:14 +0530 Subject: [PATCH] fix(report-agent): tolerate prose/fences around JSON Parse the body with the balanced-brace extractor (llm_sanitizer.extract_json_block) instead of a lazy `\s*(\{.*?\})\s*` regex. The old regex required the JSON object to start immediately after the tag and end immediately before , so it silently dropped tool calls where a reasoning model added leading prose, a trailing note, or a ```json code fence inside the tag. (Clean nested `parameters` already parsed fine -- the anchor backtracks the lazy regex to the outer brace -- so this is a robustness hardening, not a nested-params fix.) Adds tests for the leading-text, trailing-text, and fenced-JSON cases (which fail on the old regex) plus a nested-params guard. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/report_agent.py | 11 +++++-- .../tests/test_report_agent_tool_parsing.py | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index cba2a30..e76dcd5 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -20,6 +20,7 @@ from ..config import Config from ..utils.llm_client import LLMClient +from ..utils.llm_sanitizer import extract_json_block from ..utils.logger import get_logger from .zep_tools import ( ZepToolsService, @@ -1080,10 +1081,16 @@ def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]: tool_calls = [] # Format 1: XML-style (standard format) - xml_pattern = r'\s*(\{.*?\})\s*' + # Capture the full body, then pull the balanced {...} object + # out of it. A lazy `\{.*?\}` would stop at the first '}', dropping any + # tool call whose `parameters` is a non-empty nested object. + xml_pattern = r'(.*?)' for match in re.finditer(xml_pattern, response, re.DOTALL): + json_str = extract_json_block(match.group(1)) + if not json_str: + continue try: - call_data = json.loads(match.group(1)) + call_data = json.loads(json_str) tool_calls.append(call_data) except json.JSONDecodeError: pass diff --git a/backend/tests/test_report_agent_tool_parsing.py b/backend/tests/test_report_agent_tool_parsing.py index 5f534ed..420158c 100644 --- a/backend/tests/test_report_agent_tool_parsing.py +++ b/backend/tests/test_report_agent_tool_parsing.py @@ -42,6 +42,39 @@ def test_parse_xml_tool_call(agent): assert calls == [{"name": "quick_search"}] +def test_parse_xml_tool_call_with_nested_parameters(agent): + # Guard: a non-empty nested `parameters` object must survive. (This already + # worked -- the anchor backtracks the old lazy regex to the + # outer brace -- kept so the balanced-extraction rewrite doesn't regress it.) + response = '{"name": "insight_forge", "parameters": {"query": "pricing"}}' + calls = agent._parse_tool_calls(response) + assert calls == [{"name": "insight_forge", "parameters": {"query": "pricing"}}] + + +def test_parse_xml_tool_call_with_leading_text_in_tag(agent): + # Model prefixes the JSON with prose inside the tag. The old + # `\s*(\{...\})` regex required the object to start right after the + # tag and dropped this; balanced extraction scans past the prose. + response = '\nHere you go: {"name": "quick_search", "parameters": {"q": "x"}}' + calls = agent._parse_tool_calls(response) + assert calls == [{"name": "quick_search", "parameters": {"q": "x"}}] + + +def test_parse_xml_tool_call_with_trailing_text_in_tag(agent): + # A trailing note after the JSON inside the tag. The old regex required the + # object to end right before and dropped this entirely. + response = '{"name": "insight_forge", "parameters": {"query": "p"}}\nNote: done' + calls = agent._parse_tool_calls(response) + assert calls == [{"name": "insight_forge", "parameters": {"query": "p"}}] + + +def test_parse_xml_tool_call_with_fenced_json_in_tag(agent): + # Some models wrap the call in a ```json fence inside the tag. + response = '```json\n{"name": "quick_search", "parameters": {"q": 1}}\n```' + calls = agent._parse_tool_calls(response) + assert calls == [{"name": "quick_search", "parameters": {"q": 1}}] + + def test_parse_bare_json_with_nested_parameters(agent): response = '{"name": "insight_forge", "parameters": {"query": "pricing"}}' calls = agent._parse_tool_calls(response)