From 87e6a63e2e5c066e777fcb0a2e06d2704ce2710f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:02:54 +0800 Subject: [PATCH 1/2] fix(core): accept whole-number floats for JSON Schema integer type JSON Schema 'integer' means 'a number without a fraction', so 3.0 is valid. The _matches_json_schema_type check only accepted Python int, rejecting 3.0 with a TypeError. Accept floats whose is_integer() is true alongside int. Fixes #9843 --- .../packages/core/agent_framework/_tools.py | 6 ++++- python/packages/core/tests/core/test_tools.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 53b64bce9d3..89f4d9168fa 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": diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index d7db2db4971..dff635b9c83 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -593,6 +593,29 @@ 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"], + } + + @tool(name="counter", description="Count tool", schema=json_schema) + def counter(count: int) -> str: + return str(count) + + # 3.0 is a valid JSON Schema integer; should not raise. + await counter.invoke(arguments={"count": 3.0}) + + # 3.5 is not a valid integer. + with pytest.raises(TypeError, match="Invalid type for 'count'"): + await counter.invoke(arguments={"count": 3.5}) + + def test_tool_decorator_with_json_schema_preserves_custom_properties(): """Test schema passthrough keeps custom JSON schema properties.""" From 9193ba7f6e7bdf3252c7e47e26eb53baa1133d2b Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:01:46 +0800 Subject: [PATCH 2/2] fix(core): hand schema-supplied tools an int when the schema asks for one Accepting 3.0 for a JSON Schema "integer" was only half of #9843. The value was validated and then passed through unchanged, so a tool that runs range(count) or indexes with it still raised TypeError on the float the model sent. The schema declares an integer, so the callable should receive one. _validate_arguments_against_schema now normalizes during the same top-level pass it validates in. A whole-number float becomes int when "integer" is the only numeric type allowed, which covers the plain case and the nullable one ("type": ["integer", "null"]). When "number" is allowed too the declaration permits fractions, so the value's own type is the answer and nothing is narrowed to fit a type the schema never exclusively asked for. This is the schema-supplied path only. Tools built from Python annotations go through the input model, which already converts to the annotated types, and that path returns before these checks run. The regression now asserts the type the callable receives rather than only that the call did not raise, and two more pin the nullable case and the number-alongside-integer one. --- .../packages/core/agent_framework/_tools.py | 25 +++++++-- python/packages/core/tests/core/test_tools.py | 52 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 89f4d9168fa..8c29daeb446 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1601,9 +1601,9 @@ def _matches_json_schema_type(value: Any, schema_type: str) -> bool: case "integer": # 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()) + 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": @@ -1618,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], @@ -1638,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 @@ -1653,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): @@ -1662,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 dff635b9c83..68030a53efb 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -603,19 +603,69 @@ async def test_tool_json_schema_integer_accepts_whole_number_float(): }, "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; should not raise. + # 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."""