fix(models): treat NaN/Infinity tool-call arguments as malformed

This commit is contained in:
Alex Schapiro 2026-10-14 21:17:10 +00:00
parent 6753a5610a
commit b6f38bc90c
3 changed files with 29 additions and 6 deletions

View file

@ -22,6 +22,14 @@ from openai.types.responses import ResponseFunctionToolCall
MALFORMED_ARGUMENTS_KEY = "malformed_arguments"
def _reject_constant(name: str) -> Any:
raise ValueError(f"non-standard JSON constant {name}")
def _loads_strict(text: str) -> Any:
return json.loads(text, parse_constant=_reject_constant)
def describe_malformed_arguments(tool_name: str, arguments: str) -> str | None:
"""Return a model-facing recovery message if ``arguments`` is not a JSON object.
@ -33,7 +41,7 @@ def describe_malformed_arguments(tool_name: str, arguments: str) -> str | None:
if not arguments.strip():
return None
try:
parsed = json.loads(arguments)
parsed = _loads_strict(arguments)
except ValueError as exc:
detail = str(exc)
else:
@ -52,7 +60,7 @@ def repair_arguments(arguments: object) -> str | None:
if not isinstance(arguments, str) or not arguments.strip():
return "{}"
try:
parsed = json.loads(arguments)
parsed = _loads_strict(arguments)
except ValueError:
parsed = None
if isinstance(parsed, dict):

View file

@ -150,11 +150,12 @@ async def test_unknown_and_null_arguments_are_untouched() -> None:
@pytest.mark.asyncio
async def test_non_object_payloads_are_reported_instead_of_invoked() -> None:
@pytest.mark.parametrize("arguments", ["not json", '{"n": NaN}'])
async def test_non_object_payloads_are_reported_instead_of_invoked(arguments: str) -> None:
captured: dict[str, str] = {}
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _ARRAY))
result = await wrapped.on_invoke_tool(cast("Any", None), "not json")
result = await wrapped.on_invoke_tool(cast("Any", None), arguments)
assert result.startswith("probe: the tool call was not executed")
assert "raw_input" not in captured

View file

@ -25,6 +25,7 @@ from openai.types.responses import ResponseFunctionToolCall
from strix.config.models import _NonStreamingModel, _TurnGuardModel
from strix.config.tool_call_arguments import (
MALFORMED_ARGUMENTS_KEY,
_loads_strict,
describe_malformed_arguments,
repair_arguments,
repair_history_arguments,
@ -213,11 +214,21 @@ def test_repair_arguments_rewrites_anything_but_a_json_object(
assert repair_arguments(arguments) == expected
@pytest.mark.parametrize("arguments", ["{}", '{"cmd": "ls -la"}', '{"nested": {"a": [1]}}'])
@pytest.mark.parametrize(
"arguments", ["{}", '{"cmd": "ls -la"}', '{"nested": {"a": [1]}}', '{"n": "NaN"}']
)
def test_repair_arguments_leaves_json_objects_alone(arguments: str) -> None:
assert repair_arguments(arguments) is None
@pytest.mark.parametrize("arguments", ['{"n": NaN}', '{"n": Infinity}', '{"n": -Infinity}'])
def test_repair_arguments_wraps_non_standard_json_constants(arguments: str) -> None:
repaired = repair_arguments(arguments)
assert repaired is not None
assert _loads_strict(repaired) == {MALFORMED_ARGUMENTS_KEY: arguments}
def test_history_repair_rewrites_only_malformed_calls() -> None:
items = [
{"role": "user", "content": "go"},
@ -237,6 +248,7 @@ def test_history_repair_rewrites_only_malformed_calls() -> None:
assert rebuilt[3] is items[3]
assert isinstance(rebuilt[4], ResponseFunctionToolCall)
assert rebuilt[4].arguments == "{}"
assert isinstance(items[1], dict)
assert items[1]["arguments"] == '{"cmd": "ls'
@ -247,7 +259,9 @@ def test_repair_input_returns_same_object_when_nothing_changes() -> None:
assert repair_input("plain prompt") == "plain prompt"
@pytest.mark.parametrize("arguments", ['{"cmd": "ls -la', "[1, 2]", "null", "not json"])
@pytest.mark.parametrize(
"arguments", ['{"cmd": "ls -la', "[1, 2]", "null", "not json", '{"n": NaN}']
)
def test_describe_malformed_arguments_tells_the_model_to_reissue(arguments: str) -> None:
message = describe_malformed_arguments("exec_command", arguments)