| """Tests for tool argument type coercion. |
| |
| When LLMs return tool call arguments, they frequently put numbers as strings |
| ("42" instead of 42) and booleans as strings ("true" instead of true). |
| coerce_tool_args() fixes these type mismatches by comparing argument values |
| against the tool's JSON Schema before dispatch. |
| """ |
|
|
| import pytest |
| from unittest.mock import patch |
|
|
| from model_tools import ( |
| coerce_tool_args, |
| _coerce_value, |
| _coerce_number, |
| _coerce_boolean, |
| ) |
|
|
|
|
| |
|
|
|
|
| class TestCoerceNumber: |
| """Unit tests for _coerce_number.""" |
|
|
| def test_integer_string(self): |
| assert _coerce_number("42") == 42 |
| assert isinstance(_coerce_number("42"), int) |
|
|
| def test_negative_integer(self): |
| assert _coerce_number("-7") == -7 |
|
|
| def test_zero(self): |
| assert _coerce_number("0") == 0 |
| assert isinstance(_coerce_number("0"), int) |
|
|
| def test_float_string(self): |
| assert _coerce_number("3.14") == 3.14 |
| assert isinstance(_coerce_number("3.14"), float) |
|
|
| def test_float_with_zero_fractional(self): |
| """3.0 should become int(3) since there's no fractional part.""" |
| assert _coerce_number("3.0") == 3 |
| assert isinstance(_coerce_number("3.0"), int) |
|
|
| def test_integer_only_rejects_float(self): |
| """When integer_only=True, "3.14" should stay as string.""" |
| result = _coerce_number("3.14", integer_only=True) |
| assert result == "3.14" |
| assert isinstance(result, str) |
|
|
| def test_integer_only_accepts_whole(self): |
| assert _coerce_number("42", integer_only=True) == 42 |
|
|
| def test_not_a_number(self): |
| assert _coerce_number("hello") == "hello" |
|
|
| def test_empty_string(self): |
| assert _coerce_number("") == "" |
|
|
| def test_large_number(self): |
| assert _coerce_number("1000000") == 1000000 |
|
|
| def test_scientific_notation(self): |
| assert _coerce_number("1e5") == 100000 |
|
|
| def test_inf_stays_string_for_integer_only(self): |
| """Infinity should not be converted to int.""" |
| result = _coerce_number("inf") |
| assert result == float("inf") |
|
|
| def test_negative_float(self): |
| assert _coerce_number("-2.5") == -2.5 |
|
|
|
|
| class TestCoerceBoolean: |
| """Unit tests for _coerce_boolean.""" |
|
|
| def test_true_lowercase(self): |
| assert _coerce_boolean("true") is True |
|
|
| def test_false_lowercase(self): |
| assert _coerce_boolean("false") is False |
|
|
| def test_true_mixed_case(self): |
| assert _coerce_boolean("True") is True |
|
|
| def test_false_mixed_case(self): |
| assert _coerce_boolean("False") is False |
|
|
| def test_true_with_whitespace(self): |
| assert _coerce_boolean(" true ") is True |
|
|
| def test_not_a_boolean(self): |
| assert _coerce_boolean("yes") == "yes" |
|
|
| def test_one_zero_not_coerced(self): |
| """'1' and '0' are not boolean values.""" |
| assert _coerce_boolean("1") == "1" |
| assert _coerce_boolean("0") == "0" |
|
|
| def test_empty_string(self): |
| assert _coerce_boolean("") == "" |
|
|
|
|
| class TestCoerceValue: |
| """Unit tests for _coerce_value.""" |
|
|
| def test_integer_type(self): |
| assert _coerce_value("5", "integer") == 5 |
|
|
| def test_number_type(self): |
| assert _coerce_value("3.14", "number") == 3.14 |
|
|
| def test_boolean_type(self): |
| assert _coerce_value("true", "boolean") is True |
|
|
| def test_string_type_passthrough(self): |
| """Strings expected as strings should not be coerced.""" |
| assert _coerce_value("hello", "string") == "hello" |
|
|
| def test_unknown_type_passthrough(self): |
| assert _coerce_value("stuff", "object") == "stuff" |
|
|
| def test_union_type_prefers_first_match(self): |
| """Union types try each in order.""" |
| assert _coerce_value("42", ["integer", "string"]) == 42 |
|
|
| def test_union_type_falls_through(self): |
| """If no type matches, return original string.""" |
| assert _coerce_value("hello", ["integer", "boolean"]) == "hello" |
|
|
| def test_union_with_string_preserves_original(self): |
| """A non-numeric string in [number, string] should stay a string.""" |
| assert _coerce_value("hello", ["number", "string"]) == "hello" |
|
|
|
|
| |
|
|
|
|
| class TestCoerceToolArgs: |
| """Integration tests for coerce_tool_args using the tool registry.""" |
|
|
| def _mock_schema(self, properties): |
| """Build a minimal tool schema with the given properties.""" |
| return { |
| "name": "test_tool", |
| "description": "test", |
| "parameters": { |
| "type": "object", |
| "properties": properties, |
| }, |
| } |
|
|
| def test_coerces_integer_arg(self): |
| schema = self._mock_schema({"limit": {"type": "integer"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"limit": "10"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["limit"] == 10 |
| assert isinstance(result["limit"], int) |
|
|
| def test_coerces_boolean_arg(self): |
| schema = self._mock_schema({"merge": {"type": "boolean"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"merge": "true"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["merge"] is True |
|
|
| def test_coerces_number_arg(self): |
| schema = self._mock_schema({"temperature": {"type": "number"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"temperature": "0.7"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["temperature"] == 0.7 |
|
|
| def test_leaves_string_args_alone(self): |
| schema = self._mock_schema({"path": {"type": "string"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"path": "/tmp/file.txt"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["path"] == "/tmp/file.txt" |
|
|
| def test_leaves_already_correct_types(self): |
| schema = self._mock_schema({"limit": {"type": "integer"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"limit": 10} |
| result = coerce_tool_args("test_tool", args) |
| assert result["limit"] == 10 |
|
|
| def test_unknown_tool_returns_args_unchanged(self): |
| with patch("model_tools.registry.get_schema", return_value=None): |
| args = {"limit": "10"} |
| result = coerce_tool_args("unknown_tool", args) |
| assert result["limit"] == "10" |
|
|
| def test_empty_args(self): |
| assert coerce_tool_args("test_tool", {}) == {} |
|
|
| def test_none_args(self): |
| assert coerce_tool_args("test_tool", None) is None |
|
|
| def test_preserves_non_string_values(self): |
| """Lists, dicts, and other non-string values are never touched.""" |
| schema = self._mock_schema({ |
| "items": {"type": "array"}, |
| "config": {"type": "object"}, |
| }) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"items": [1, 2, 3], "config": {"key": "val"}} |
| result = coerce_tool_args("test_tool", args) |
| assert result["items"] == [1, 2, 3] |
| assert result["config"] == {"key": "val"} |
|
|
| def test_extra_args_without_schema_left_alone(self): |
| """Args not in the schema properties are not touched.""" |
| schema = self._mock_schema({"limit": {"type": "integer"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"limit": "10", "extra": "42"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["limit"] == 10 |
| assert result["extra"] == "42" |
|
|
| def test_mixed_coercion(self): |
| """Multiple args coerced in the same call.""" |
| schema = self._mock_schema({ |
| "offset": {"type": "integer"}, |
| "limit": {"type": "integer"}, |
| "full": {"type": "boolean"}, |
| "path": {"type": "string"}, |
| }) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = { |
| "offset": "1", |
| "limit": "500", |
| "full": "false", |
| "path": "readme.md", |
| } |
| result = coerce_tool_args("test_tool", args) |
| assert result["offset"] == 1 |
| assert result["limit"] == 500 |
| assert result["full"] is False |
| assert result["path"] == "readme.md" |
|
|
| def test_failed_coercion_preserves_original(self): |
| """A non-parseable string stays as string even if schema says integer.""" |
| schema = self._mock_schema({"limit": {"type": "integer"}}) |
| with patch("model_tools.registry.get_schema", return_value=schema): |
| args = {"limit": "not_a_number"} |
| result = coerce_tool_args("test_tool", args) |
| assert result["limit"] == "not_a_number" |
|
|
| def test_real_read_file_schema(self): |
| """Test against the actual read_file schema from the registry.""" |
| |
| args = {"path": "foo.py", "offset": "10", "limit": "100"} |
| result = coerce_tool_args("read_file", args) |
| assert result["path"] == "foo.py" |
| assert result["offset"] == 10 |
| assert isinstance(result["offset"], int) |
| assert result["limit"] == 100 |
| assert isinstance(result["limit"], int) |
|
|