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)