[data] fix: parse nested JSON literals in LFM2 tools (#10940)

Co-authored-by: LimbC-C <329404569+LimbC-C@users.noreply.github.com>
This commit is contained in:
LimbC-C
2026-11-06 15:41:40 +08:00
committed by GitHub
parent 702005ce52
commit 26a19a559e
2 changed files with 34 additions and 15 deletions

View File

@@ -934,9 +934,7 @@ class Xing4ToolUtils(ToolUtils):
if params_content[position:].strip(): if params_content[position:].strip():
return content return content
results.append( results.append(FunctionCall(name_match.group(1), json.dumps(arguments, ensure_ascii=False)))
FunctionCall(name_match.group(1), json.dumps(arguments, ensure_ascii=False))
)
return results return results
@@ -1038,19 +1036,16 @@ class LFM2ToolUtils(ToolUtils):
@staticmethod @staticmethod
def _ast_to_value(node: ast.AST) -> Any: def _ast_to_value(node: ast.AST) -> Any:
"""Convert an AST node to a Python value, handling JSON-style booleans/null.""" """Convert an AST node to a Python value, handling JSON-style booleans/null."""
# Handle JSON-style true/false/null as Name nodes
if isinstance(node, ast.Name):
if node.id == "true":
return True
elif node.id == "false":
return False
elif node.id == "null":
return None
else:
raise ValueError(f"Unknown identifier: {node.id}")
# Use literal_eval for other cases (strings, numbers, lists, dicts) class JsonLiteralTransformer(ast.NodeTransformer):
return ast.literal_eval(node) def visit_Name(self, name: ast.Name) -> ast.AST:
literals = {"true": True, "false": False, "null": None}
if name.id in literals:
return ast.copy_location(ast.Constant(value=literals[name.id]), name)
return name
# Normalize literals at every depth without evaluating names or expressions.
return ast.literal_eval(JsonLiteralTransformer().visit(node))
@override @override
@staticmethod @staticmethod

View File

@@ -449,6 +449,30 @@ def test_lfm2_tool_round_trip():
assert json.loads(extracted[0][1]) == original["arguments"] assert json.loads(extracted[0][1]) == original["arguments"]
@pytest.mark.runs_on(["cpu", "mps", "xpu"])
@pytest.mark.parametrize(
"arguments",
[
{"options": {"enabled": True, "fallback": None}},
{"items": [True, False, None]},
{"options": [{"active": [True, False, None], "label": "true false null"}]},
{"enabled": True, "fallback": None},
],
)
def test_lfm2_tool_round_trip_nested_json_literals(arguments):
formatter = FunctionFormatter(slots=["{{content}}"], tool_format="lfm2")
extractor = ToolFormatter(tool_format="lfm2")
rendered = formatter.apply(content=json.dumps({"name": "test_tool", "arguments": arguments}))[0]
assert extractor.extract(rendered) == [("test_tool", json.dumps(arguments, ensure_ascii=False))]
@pytest.mark.runs_on(["cpu", "mps", "xpu"])
@pytest.mark.parametrize("value", ['{"enabled": unknown}', "[1 + 2]"])
def test_lfm2_tool_extractor_rejects_nonliteral_values(value):
content = f"<|tool_call_start|>[test_tool(options={value})]<|tool_call_end|>"
assert ToolFormatter(tool_format="lfm2").extract(content) == content
@pytest.mark.runs_on(["cpu", "mps", "xpu"]) @pytest.mark.runs_on(["cpu", "mps", "xpu"])
def test_minicpm5_function_formatter(): def test_minicpm5_function_formatter():
formatter = FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5") formatter = FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5")