Summary
#2011 fixed the JSON int→float case for float properties of object/DTO tool parameters by passing 'json' as the format to denormalize(). That coercion lives in AbstractObjectNormalizer and only fires for the JSON format, so passing the format was enough — on v0.9.0 object properties now denormalize correctly.
A top-level scalar float parameter (__invoke(float $amount)) is still rejected. Such a parameter is never denormalized by AbstractObjectNormalizer; it goes through the scalar branch of Serializer::denormalize(), which performs no int→float coercion and ignores $format entirely. Passing 'json' (the #2011 fix) therefore has no effect on this path.
Background — the JSON-specific cast lives only in AbstractObjectNormalizer
https://github.com/symfony/serializer/blob/v7.4.0/Normalizer/AbstractObjectNormalizer.php#L587-L595
// JSON only has a Number type corresponding to both int and float PHP types.
// PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert
// floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible).
// PHP's json_decode automatically converts Numbers without a decimal part to integers.
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
// a float is expected.
if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) {
return (float) $data;
}
This widening is implemented inside AbstractObjectNormalizer, i.e. it only runs while denormalizing the properties of an object. A top-level scalar argument never reaches it.
The call site already passes 'json' (v0.9.0, the #2011 fix)
https://github.com/symfony/ai/blob/v0.9.0/src/agent/src/Toolbox/ToolCallArgumentResolver.php#L104-L106
if ($this->denormalizer->supportsDenormalization($value, $parameterType, 'json')) {
$value = $this->denormalizer->denormalize($value, $parameterType, 'json');
}
For an object parameter this is sufficient: ObjectNormalizer/AbstractObjectNormalizer widens int→float for its float properties. For a top-level scalar float parameter it is not, because of the next section.
Why scalars still fail — the scalar branch in Serializer::denormalize()
https://github.com/symfony/serializer/blob/v7.4.10/Serializer.php#L197-L206
$normalizer = $this->getDenormalizer($data, $type, $format, $context);
// Check for a denormalizer first, e.g. the data is wrapped
if (!$normalizer && isset(self::SCALAR_TYPES[$type])) {
if (!('is_'.$type)($data)) {
throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)), $data, [$type], $context['deserialization_path'] ?? null, true);
}
return $data;
}
For denormalize(100, 'float', 'json'): none of the registered normalizers supports the scalar type float, so $normalizer is null and this branch runs. It evaluates is_float(100) (false) and throws. $format is never consulted here, so passing 'json' makes no difference. The JSON int↔float ambiguity is thus unhandled for top-level scalar float parameters.
Observed behavior
An LLM tool call {"amount": 100} (a JSON number without a fractional part — json_decode returns PHP int) against __invoke(float $amount) fails with:
NotNormalizableValueException:
Data expected to be of type "float" ("int" given).
The same value wrapped in an object — {"foo": {"amount": 100}} against __invoke(Foo $foo) where Foo::$amount is float — denormalizes successfully on v0.9.0 thanks to #2011. Only the top-level scalar case remains broken.
Minimal reproducer
<?php
use Symfony\AI\Agent\Toolbox\ToolCallArgumentResolver;
use Symfony\AI\Platform\Result\ToolCall;
use Symfony\AI\Platform\Tool\ExecutionReference;
use Symfony\AI\Platform\Tool\Tool;
final class SomeTool
{
public function __invoke(float $amount): array
{
return [];
}
}
$resolver = new ToolCallArgumentResolver();
$tool = new Tool(new ExecutionReference(SomeTool::class), 'some_tool', 'desc');
// LLM tool call payload: {"amount": 100}
// json_decode produces: ['amount' => 100] (int, not float)
$toolCall = new ToolCall('tc-1', 'some_tool', ['amount' => 100]);
$resolver->resolveArguments($tool, $toolCall);
// NotNormalizableValueException:
// Data expected to be of type "float" ("int" given).
Note
Could ToolCallArgumentResolver widen the int to float itself when the resolved top-level parameter type is float (JSON has no distinct int/float type)? That would mirror what AbstractObjectNormalizer already does for object properties.
Environment
symfony/ai-agent: v0.9.0
symfony/ai-platform: v0.9.0
symfony/ai-bundle: v0.9.0
symfony/serializer: 7.4.10
- Symfony: 7.4
- PHP: 8.4
Related
Summary
#2011 fixed the JSON int→float case for
floatproperties of object/DTO tool parameters by passing'json'as the format todenormalize(). That coercion lives inAbstractObjectNormalizerand only fires for the JSON format, so passing the format was enough — on v0.9.0 object properties now denormalize correctly.A top-level scalar
floatparameter (__invoke(float $amount)) is still rejected. Such a parameter is never denormalized byAbstractObjectNormalizer; it goes through the scalar branch ofSerializer::denormalize(), which performs no int→float coercion and ignores$formatentirely. Passing'json'(the #2011 fix) therefore has no effect on this path.Background — the JSON-specific cast lives only in
AbstractObjectNormalizerhttps://github.com/symfony/serializer/blob/v7.4.0/Normalizer/AbstractObjectNormalizer.php#L587-L595
This widening is implemented inside
AbstractObjectNormalizer, i.e. it only runs while denormalizing the properties of an object. A top-level scalar argument never reaches it.The call site already passes
'json'(v0.9.0, the #2011 fix)https://github.com/symfony/ai/blob/v0.9.0/src/agent/src/Toolbox/ToolCallArgumentResolver.php#L104-L106
For an object parameter this is sufficient:
ObjectNormalizer/AbstractObjectNormalizerwidens int→float for itsfloatproperties. For a top-level scalarfloatparameter it is not, because of the next section.Why scalars still fail — the scalar branch in
Serializer::denormalize()https://github.com/symfony/serializer/blob/v7.4.10/Serializer.php#L197-L206
For
denormalize(100, 'float', 'json'): none of the registered normalizers supports the scalar typefloat, so$normalizerisnulland this branch runs. It evaluatesis_float(100)(false) and throws.$formatis never consulted here, so passing'json'makes no difference. The JSON int↔float ambiguity is thus unhandled for top-level scalarfloatparameters.Observed behavior
An LLM tool call
{"amount": 100}(a JSON number without a fractional part —json_decodereturns PHPint) against__invoke(float $amount)fails with:The same value wrapped in an object —
{"foo": {"amount": 100}}against__invoke(Foo $foo)whereFoo::$amountisfloat— denormalizes successfully on v0.9.0 thanks to #2011. Only the top-level scalar case remains broken.Minimal reproducer
Note
Could
ToolCallArgumentResolverwiden theinttofloatitself when the resolved top-level parameter type isfloat(JSON has no distinct int/float type)? That would mirror whatAbstractObjectNormalizeralready does for object properties.Environment
symfony/ai-agent: v0.9.0symfony/ai-platform: v0.9.0symfony/ai-bundle: v0.9.0symfony/serializer: 7.4.10Related
'json'todenormalize(); fixed the object-propertyfloatcase. This issue is the remaining top-level scalar case the same fix did not cover.ToolCallArgumentResolver::resolveArguments()method, different root cause (NullableTypestringification).