Skip to content
Merged
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
11 changes: 9 additions & 2 deletions backend/app/services/report_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'<tool_call>\s*(\{.*?\})\s*</tool_call>'
# Capture the full <tool_call> 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'<tool_call>(.*?)</tool_call>'
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
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_report_agent_tool_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 </tool_call> anchor backtracks the old lazy regex to the
# outer brace -- kept so the balanced-extraction rewrite doesn't regress it.)
response = '<tool_call>{"name": "insight_forge", "parameters": {"query": "pricing"}}</tool_call>'
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
# `<tool_call>\s*(\{...\})` regex required the object to start right after the
# tag and dropped this; balanced extraction scans past the prose.
response = '<tool_call>\nHere you go: {"name": "quick_search", "parameters": {"q": "x"}}</tool_call>'
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 </tool_call> and dropped this entirely.
response = '<tool_call>{"name": "insight_forge", "parameters": {"query": "p"}}\nNote: done</tool_call>'
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 = '<tool_call>```json\n{"name": "quick_search", "parameters": {"q": 1}}\n```</tool_call>'
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)
Expand Down
Loading