diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index c78267d34ac..ea6886e7768 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1599,7 +1599,11 @@ def _matches_json_schema_type(value: Any, schema_type: str) -> bool: case "string": return isinstance(value, str) case "integer": - return isinstance(value, int) and not isinstance(value, bool) + # JSON Schema "integer" is a number without a fraction, so 3.0 + # is valid (#9843). bool is not an integer here. + return (isinstance(value, int) and not isinstance(value, bool)) or ( + isinstance(value, float) and value.is_integer() + ) case "number": return (isinstance(value, int | float)) and not isinstance(value, bool) case "boolean": @@ -1614,6 +1618,21 @@ def _matches_json_schema_type(value: Any, schema_type: str) -> bool: return True +def _normalize_json_schema_value(value: Any, allowed_types: list[str]) -> Any: + """Coerce a value to the type the schema asked for, where JSON is looser. + + JSON has a single number type, so a model sends ``3.0`` for a parameter the + schema declares as ``integer``. Accepting it is only half the fix: a tool that + runs ``range(count)`` or indexes with it still raises on a float. Whole-number + floats become ``int`` when ``integer`` is the only numeric type allowed. When + ``number`` is allowed too the value's own type is the answer, so nothing is + narrowed to fit a declaration that also permits fractions. + """ + if isinstance(value, float) and value.is_integer() and "integer" in allowed_types and "number" not in allowed_types: + return int(value) + return value + + def _validate_arguments_against_schema( *, arguments: Mapping[str, Any], @@ -1634,7 +1653,7 @@ def _validate_arguments_against_schema( if unexpected_fields: raise TypeError(f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}") - for field_name, field_value in parsed_arguments.items(): + for field_name, field_value in list(parsed_arguments.items()): if not isinstance(properties.get(field_name), dict): continue @@ -1649,6 +1668,7 @@ def _validate_arguments_against_schema( f"Invalid type for '{field_name}' in '{tool_name}': " f"expected {schema_type}, got {type(field_value).__name__}" ) + parsed_arguments[field_name] = _normalize_json_schema_value(field_value, [schema_type]) continue if isinstance(schema_type, list): @@ -1658,6 +1678,7 @@ def _validate_arguments_against_schema( f"Invalid type for '{field_name}' in '{tool_name}': expected one of " f"{allowed_types}, got {type(field_value).__name__}" ) + parsed_arguments[field_name] = _normalize_json_schema_value(field_value, allowed_types) return parsed_arguments diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index d7db2db4971..68030a53efb 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -593,6 +593,79 @@ def search(query: str, max_results: int = 10) -> str: await search.invoke(arguments={"query": "hello", "max_results": "three"}) +async def test_tool_json_schema_integer_accepts_whole_number_float(): + """JSON Schema "integer" accepts whole-number floats like 3.0 (#9843).""" + + json_schema = { + "type": "object", + "properties": { + "count": {"type": "integer"}, + }, + "required": ["count"], + } + received: list[Any] = [] + + @tool(name="counter", description="Count tool", schema=json_schema) + def counter(count: int) -> str: + received.append(count) + return str(count) + + # 3.0 is a valid JSON Schema integer; it reaches the callable as the integer + # the schema asked for, so range(count) and friends work on it. + await counter.invoke(arguments={"count": 3.0}) + assert received == [3] + assert isinstance(received[0], int) + + # 3.5 is not a valid integer. + with pytest.raises(TypeError, match="Invalid type for 'count'"): + await counter.invoke(arguments={"count": 3.5}) + + +async def test_tool_json_schema_nullable_integer_normalizes_whole_number_float(): + """A nullable integer schema accepts 3.0 and still hands the callable an int.""" + + json_schema = { + "type": "object", + "properties": { + "count": {"type": ["integer", "null"]}, + }, + } + received: list[Any] = [] + + @tool(name="counter", description="Count tool", schema=json_schema) + def counter(count: int | None) -> str: + received.append(count) + return str(count) + + await counter.invoke(arguments={"count": 3.0}) + assert received == [3] + assert isinstance(received[0], int) + + await counter.invoke(arguments={"count": None}) + assert received == [3, None] + + +async def test_tool_json_schema_number_alongside_integer_keeps_the_float(): + """When the schema also allows "number", the value's own type is the answer.""" + + json_schema = { + "type": "object", + "properties": { + "value": {"type": ["integer", "number"]}, + }, + } + received: list[Any] = [] + + @tool(name="measure", description="Measure tool", schema=json_schema) + def measure(value: float) -> str: + received.append(value) + return str(value) + + await measure.invoke(arguments={"value": 3.0}) + assert received == [3.0] + assert isinstance(received[0], float) + + def test_tool_decorator_with_json_schema_preserves_custom_properties(): """Test schema passthrough keeps custom JSON schema properties."""