Skip to content

[Agent] ToolCallArgumentResolver does not widen int→float for top-level scalar float parameters (remaining gap after #2011) #2136

@sjahns

Description

@sjahns

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    AgentIssues & PRs about the AI Agent component

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions