unsloth/studio/backend/tests/test_tool_call_parser_strict.py
Wasim Yousef Said ccb471f5bf
Improve local chat tool call flow (#5962)
Unify the Studio local tool-call loop (GGUF + safetensors) behind a shared ToolLoopController: ordered preface-then-tool-card rendering, duplicate-call de-looping with a forced final answer, XML-leak containment, and a parser fix that accepts closed <function=...> calls followed by trailing prose. Includes backend tests for the controller, strict parser, and GGUF route cursor reset.
2026-06-09 07:28:44 -07:00

114 lines
4.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Strict-mode (Auto-Heal disabled) tool-call parsing.
With ``allow_incomplete=False`` the parser must accept a well-formed
``<function=...>...</function>`` call even when the model appends prose
after the closing tag -- matching the JSON-style ``<tool_call>...`` path,
which already tolerates trailing text -- while still rejecting genuinely
truncated calls that never close.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
from core.inference.tool_call_parser import parse_tool_calls_from_text
def _only(text: str) -> dict:
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1, f"expected exactly one call, got {len(calls)}: {calls!r}"
fn = calls[0]["function"]
return {"name": fn["name"], "arguments": json.loads(fn["arguments"])}
class TestFunctionStyleTrailingText:
def test_closed_function_with_trailing_prose_is_accepted(self):
text = (
"<function=web_search><parameter=query>weather london</parameter></function>"
" Let me check that for you."
)
call = _only(text)
assert call == {"name": "web_search", "arguments": {"query": "weather london"}}
def test_closed_function_with_trailing_whitespace_is_accepted(self):
text = "<function=web_search><parameter=query>cats</parameter></function> \n\n"
call = _only(text)
assert call == {"name": "web_search", "arguments": {"query": "cats"}}
def test_closed_function_without_trailing_text_still_parses(self):
text = "<function=web_search><parameter=query>cats</parameter></function>"
call = _only(text)
assert call == {"name": "web_search", "arguments": {"query": "cats"}}
def test_multi_param_with_trailing_prose(self):
text = (
"<function=terminal><parameter=command>ls -la</parameter>"
"<parameter=workdir>home</parameter></function> running it now"
)
call = _only(text)
assert call == {
"name": "terminal",
"arguments": {"command": "ls -la", "workdir": "home"},
}
def test_code_value_containing_literal_close_tag_is_preserved(self):
# The real closing </function> is the last one; the literal inside
# the code argument must survive (rfind, not the first match).
text = (
"<function=python><parameter=code>"
'print("</function>")'
"</parameter></function> all done"
)
call = _only(text)
assert call == {"name": "python", "arguments": {"code": 'print("</function>")'}}
def test_incomplete_function_without_close_is_still_rejected(self):
text = "<function=web_search><parameter=query>weather london"
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
def test_param_without_close_tag_is_rejected_in_strict_mode(self):
# Closing </function> present, but the single parameter never closes.
text = "<function=web_search><parameter=query>weather london</function>"
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
class TestParityWithJsonStyle:
def test_json_tool_call_with_trailing_prose_is_accepted(self):
text = (
'<tool_call>{"name":"web_search","arguments":{"query":"weather london"}}</tool_call>'
" Let me check that for you."
)
calls = parse_tool_calls_from_text(text, allow_incomplete = False)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "web_search"
def test_function_and_json_styles_agree_on_trailing_text(self):
q = "weather london"
func = parse_tool_calls_from_text(
f"<function=web_search><parameter=query>{q}</parameter></function> trailing",
allow_incomplete = False,
)
js = parse_tool_calls_from_text(
f'<tool_call>{{"name":"web_search","arguments":{{"query":"{q}"}}}}</tool_call> trailing',
allow_incomplete = False,
)
assert len(func) == len(js) == 1
assert json.loads(func[0]["function"]["arguments"]) == {"query": q}
assert json.loads(js[0]["function"]["arguments"]) == {"query": q}
class TestHealingPathUnaffected:
def test_auto_heal_still_repairs_unclosed_function(self):
text = "<function=web_search><parameter=query>cats"
calls = parse_tool_calls_from_text(text, allow_incomplete = True)
assert len(calls) == 1
assert calls[0]["function"]["name"] == "web_search"