Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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],
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -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

Expand Down
73 changes: 73 additions & 0 deletions python/packages/core/tests/core/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading