From b6f38bc90cda8cbd264e98a1aec99116728bbc2f Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Wed, 14 Oct 2026 21:17:10 +0000 Subject: [PATCH] fix(models): treat NaN/Infinity tool-call arguments as malformed --- strix/config/tool_call_arguments.py | 12 ++++++++++-- tests/test_agent_factory_tool_arguments.py | 5 +++-- tests/test_tool_call_arguments.py | 18 ++++++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/strix/config/tool_call_arguments.py b/strix/config/tool_call_arguments.py index 9169dcdc..d7c2f8d9 100644 --- a/strix/config/tool_call_arguments.py +++ b/strix/config/tool_call_arguments.py @@ -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): diff --git a/tests/test_agent_factory_tool_arguments.py b/tests/test_agent_factory_tool_arguments.py index 4330ca4a..aa2efa85 100644 --- a/tests/test_agent_factory_tool_arguments.py +++ b/tests/test_agent_factory_tool_arguments.py @@ -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 diff --git a/tests/test_tool_call_arguments.py b/tests/test_tool_call_arguments.py index 55631a78..fb251c8f 100644 --- a/tests/test_tool_call_arguments.py +++ b/tests/test_tool_call_arguments.py @@ -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)