diff --git a/src/llamafactory/data/tool_utils.py b/src/llamafactory/data/tool_utils.py index 772a21cb3..7dc0ebdfe 100644 --- a/src/llamafactory/data/tool_utils.py +++ b/src/llamafactory/data/tool_utils.py @@ -934,9 +934,7 @@ class Xing4ToolUtils(ToolUtils): if params_content[position:].strip(): return content - results.append( - FunctionCall(name_match.group(1), json.dumps(arguments, ensure_ascii=False)) - ) + results.append(FunctionCall(name_match.group(1), json.dumps(arguments, ensure_ascii=False))) return results @@ -1038,19 +1036,16 @@ class LFM2ToolUtils(ToolUtils): @staticmethod def _ast_to_value(node: ast.AST) -> Any: """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) - return ast.literal_eval(node) + class JsonLiteralTransformer(ast.NodeTransformer): + 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 @staticmethod diff --git a/tests/data/test_formatter.py b/tests/data/test_formatter.py index bfe095e98..33358b9e3 100644 --- a/tests/data/test_formatter.py +++ b/tests/data/test_formatter.py @@ -449,6 +449,30 @@ def test_lfm2_tool_round_trip(): 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"]) def test_minicpm5_function_formatter(): formatter = FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="minicpm5")