mirror of
https://github.com/usestrix/strix.git
synced 2026-10-16 04:28:23 +00:00
* fix(models): replay malformed tool-call arguments as valid JSON A model that emits a tool call with truncated or otherwise invalid JSON arguments fails that one call, but the raw string is recorded in the session. Strict OpenAI-compatible servers (vLLM, SGLang) validate every assistant tool call in the request, so each later turn is rejected with "Assistant tool call function.arguments must be valid JSON" and the agent never recovers. The turn guard now rewrites such arguments on the way out to a JSON object carrying the original text, alongside the existing duplicate call-id repair. * fix(agents): tell the model to re-issue tool calls whose arguments are not valid JSON A streamed turn that ends mid-tool-call leaves the client with a JSON prefix as the arguments. Instead of the SDK's generic parse-error result, the tool wrapper now short-circuits with a clear 'not executed, re-issue with complete arguments' message so the model recovers in one turn. * fix(models): treat NaN/Infinity tool-call arguments as malformed * test(cost): pin model resolution in the provider-prefixed cost test * fix(models): treat over-nested tool-call arguments as malformed
255 lines
8.5 KiB
Python
255 lines
8.5 KiB
Python
"""Tests for tool-argument shape coercion in the agent factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from agents.tool import FunctionTool
|
|
|
|
from strix.agents import factory
|
|
from strix.tools.notes.tools import list_notes
|
|
from strix.tools.reporting.tool import list_reports
|
|
|
|
|
|
def _capturing_tool(
|
|
captured: dict[str, str], schema: dict[str, Any], name: str = "probe"
|
|
) -> FunctionTool:
|
|
async def invoke(_ctx: Any, raw_input: str) -> str:
|
|
captured["raw_input"] = raw_input
|
|
return "ok"
|
|
|
|
return FunctionTool(
|
|
name=name,
|
|
description="test tool",
|
|
params_json_schema={"type": "object", "properties": schema},
|
|
on_invoke_tool=invoke,
|
|
)
|
|
|
|
|
|
async def _roundtrip(
|
|
schema: dict[str, Any], payload: dict[str, Any], name: str = "probe"
|
|
) -> dict[str, Any]:
|
|
captured: dict[str, str] = {}
|
|
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, schema, name))
|
|
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps(payload)) == "ok"
|
|
return cast("dict[str, Any]", json.loads(captured["raw_input"]))
|
|
|
|
|
|
_STRING = {"todos": {"type": "string"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncated_arguments_short_circuit_with_a_reissue_message() -> None:
|
|
captured: dict[str, str] = {}
|
|
wrapped = factory._with_coerced_arguments(_capturing_tool(captured, _STRING))
|
|
|
|
result = await wrapped.on_invoke_tool(cast("Any", None), '{"todos": "a, b')
|
|
|
|
assert "not executed" in result
|
|
assert "Re-issue the call" in result
|
|
assert "raw_input" not in captured
|
|
|
|
|
|
_ARRAY = {"tags": {"type": "array", "items": {"type": "string"}}}
|
|
_NULLABLE_ARRAY = {
|
|
"tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
|
|
}
|
|
_OBJECT = {"modifications": {"type": "object"}}
|
|
OVER_NESTED = '{"a":' + "[" * 100_000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_value_is_encoded_for_a_string_parameter() -> None:
|
|
parsed = await _roundtrip(_STRING, {"todos": [{"title": "Phase 1: recon"}]})
|
|
|
|
assert parsed["todos"] == '[{"title": "Phase 1: recon"}]'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_string_parameter_keeps_an_already_encoded_value() -> None:
|
|
parsed = await _roundtrip(_STRING, {"todos": '[{"title": "a"}]'})
|
|
|
|
assert parsed["todos"] == '[{"title": "a"}]'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("schema", [_ARRAY, _NULLABLE_ARRAY])
|
|
async def test_encoded_list_is_decoded_for_an_array_parameter(schema: dict[str, Any]) -> None:
|
|
parsed = await _roundtrip(schema, {"tags": '["auth", "idor"]'})
|
|
|
|
assert parsed["tags"] == ["auth", "idor"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"auth, idor",
|
|
"auth\nidor",
|
|
"auth",
|
|
"Endpoint /admin leaks user data, and session tokens never expire",
|
|
'"auth"',
|
|
],
|
|
)
|
|
async def test_free_form_strings_are_never_split_into_an_array(value: str) -> None:
|
|
parsed = await _roundtrip(_ARRAY, {"tags": value})
|
|
|
|
assert parsed["tags"] == value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("schema", [_ARRAY, _NULLABLE_ARRAY])
|
|
@pytest.mark.parametrize("value", ["", " "])
|
|
async def test_empty_string_becomes_an_empty_array(schema: dict[str, Any], value: str) -> None:
|
|
parsed = await _roundtrip(schema, {"tags": value})
|
|
|
|
assert parsed["tags"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_string_becomes_an_empty_object() -> None:
|
|
parsed = await _roundtrip(_OBJECT, {"modifications": ""})
|
|
|
|
assert parsed["modifications"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_string_for_a_string_parameter_is_untouched() -> None:
|
|
parsed = await _roundtrip(_STRING, {"todos": ""})
|
|
|
|
assert parsed["todos"] == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_encoded_mapping_is_decoded_for_an_object_parameter() -> None:
|
|
parsed = await _roundtrip(_OBJECT, {"modifications": '{"method": "POST"}'})
|
|
|
|
assert parsed["modifications"] == {"method": "POST"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_decoded_container_of_the_wrong_kind_is_not_substituted() -> None:
|
|
parsed = await _roundtrip(_OBJECT, {"modifications": '["POST"]'})
|
|
|
|
assert parsed["modifications"] == '["POST"]'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_values_matching_the_schema_are_left_alone() -> None:
|
|
parsed = await _roundtrip({**_ARRAY, **_OBJECT}, {"tags": ["auth"], "modifications": {"a": 1}})
|
|
|
|
assert parsed == {"tags": ["auth"], "modifications": {"a": 1}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_and_null_arguments_are_untouched() -> None:
|
|
parsed = await _roundtrip(_NULLABLE_ARRAY, {"tags": None, "other": ["x"]})
|
|
|
|
assert parsed == {"tags": None, "other": ["x"]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("arguments", ["not json", '{"n": NaN}', OVER_NESTED])
|
|
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), arguments)
|
|
|
|
assert result.startswith("probe: the tool call was not executed")
|
|
assert "raw_input" not in captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_coercion_is_applied_once_per_tool() -> None:
|
|
captured: dict[str, str] = {}
|
|
tool = factory._with_coerced_arguments(_capturing_tool(captured, _ARRAY))
|
|
|
|
assert factory._with_coerced_arguments(tool) is tool
|
|
|
|
|
|
_NULLABLE_STRING = {"category": {"anyOf": [{"type": "string"}, {"type": "null"}]}}
|
|
_NULLABLE_CONTENT = {"content": {"anyOf": [{"type": "string"}, {"type": "null"}]}}
|
|
_NULLABLE_STRING_TYPE_LIST = {"category": {"type": ["string", "null"]}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("schema", [_NULLABLE_STRING, _NULLABLE_STRING_TYPE_LIST])
|
|
@pytest.mark.parametrize("value", ["null", "none", "NULL", " None ", "nil", "undefined"])
|
|
async def test_nullish_string_on_a_nullable_parameter_becomes_none(
|
|
schema: dict[str, Any], value: str
|
|
) -> None:
|
|
parsed = await _roundtrip(schema, {"category": value}, "list_probes")
|
|
|
|
assert parsed["category"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nullish_string_on_a_required_parameter_is_untouched() -> None:
|
|
schema = {"content": {"type": "string"}}
|
|
captured: dict[str, str] = {}
|
|
tool = _capturing_tool(captured, schema, "list_probes")
|
|
tool.params_json_schema["required"] = ["content"]
|
|
wrapped = factory._with_coerced_arguments(tool)
|
|
|
|
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"content": "none"})) == "ok"
|
|
assert json.loads(captured["raw_input"])["content"] == "none"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_parameter_absent_from_required_is_treated_as_nullable() -> None:
|
|
captured: dict[str, str] = {}
|
|
tool = _capturing_tool(captured, {"category": {"type": "string"}}, "list_probes")
|
|
tool.params_json_schema["required"] = []
|
|
wrapped = factory._with_coerced_arguments(tool)
|
|
|
|
assert await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"category": "null"})) == "ok"
|
|
assert json.loads(captured["raw_input"])["category"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nullish_string_without_a_required_list_is_untouched() -> None:
|
|
parsed = await _roundtrip(_STRING, {"todos": "none"}, "list_probes")
|
|
|
|
assert parsed["todos"] == "none"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("name", ["update_note", "create_note", "record_coverage"])
|
|
@pytest.mark.parametrize("value", ["null", "none"])
|
|
async def test_a_nullish_value_survives_on_a_tool_that_writes(name: str, value: str) -> None:
|
|
parsed = await _roundtrip(_NULLABLE_CONTENT, {"content": value}, name)
|
|
|
|
assert parsed["content"] == value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nullish_looking_content_is_not_coerced() -> None:
|
|
parsed = await _roundtrip(
|
|
_NULLABLE_STRING, {"category": "none of the endpoints reflect input"}, "list_probes"
|
|
)
|
|
|
|
assert parsed["category"] == "none of the endpoints reflect input"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_string_on_a_nullable_string_parameter_is_untouched() -> None:
|
|
parsed = await _roundtrip(_NULLABLE_STRING, {"category": ""})
|
|
|
|
assert parsed["category"] == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nullish_string_on_a_nullable_array_parameter_becomes_none() -> None:
|
|
parsed = await _roundtrip(_NULLABLE_ARRAY, {"tags": "null"}, "list_probes")
|
|
|
|
assert parsed["tags"] is None
|
|
|
|
|
|
def test_real_tool_schemas_declare_optional_filters_as_nullable() -> None:
|
|
for tool, params in ((list_notes, ("category", "search")), (list_reports, ("target",))):
|
|
schema = tool.params_json_schema
|
|
for param in params:
|
|
assert factory._is_nullable(param, schema["properties"][param], schema)
|