Skip to content

[Agent] ToolCallArgumentResolver cannot denormalize nullable class-typed parameters #2004

@sjahns

Description

@sjahns

Summary

ToolCallArgumentResolver fails to denormalize nullable class-typed parameters (?Foo $bar) on AsTool methods, regardless of the underlying class. The raw value from the LLM tool call is forwarded as-is, and PHP rejects it with a TypeError because the parameter expects ?Foo, not whatever json_decode produced (array, string, etc.).

The bug surfaces with any normalizer whose supportsDenormalization() requires the FQCN as the type key — BackedEnumNormalizer, ObjectNormalizer, DateTimeNormalizer, plus any custom class-keyed normalizer. Non-nullable class parameters work as expected.

Actual behavior

Tool call {"foo": {"name": "bar"}} against __invoke(?Foo $foo = null):

TypeError: SomeTool::__invoke():
  Argument #1 ($foo) must be of type ?Foo, array given,
  called in vendor/symfony/ai-agent/src/Toolbox/Toolbox.php on line 109

The raw array reaches __invoke instead of a Foo instance. The same shape of failure occurs for ?BackedEnum (string reaches __invoke instead of the enum case), ?\DateTimeImmutable (string instead of the instance), etc.

Expected behavior

The resolver should denormalize the LLM-supplied value to an instance of the declared class — symmetric to the non-nullable case, where Foo $foo does work.

Root cause

Symfony\AI\Agent\Toolbox\ToolCallArgumentResolver::resolveArguments() stringifies the parameter type before checking with the denormalizer:

https://github.com/symfony/ai/blob/v0.8.0/src/agent/src/Toolbox/ToolCallArgumentResolver.php#L84-L95

$parameterType = $this->typeResolver->resolve($reflectionParameter); // Type object
$dimensions = '';
while ($parameterType instanceof CollectionType) {
    $dimensions .= '[]';
    $parameterType = $parameterType->getCollectionValueType();
}
$parameterType .= $dimensions; // implicit (string) cast — Type implements Stringable

if ($this->denormalizer->supportsDenormalization($value, $parameterType)) {
    $value = $this->denormalizer->denormalize($value, $parameterType);
}

For ?Foo the resolved type is a NullableType (which extends UnionType). Its __toString() produces:

App\Foo|null

The Symfony Serializer's class-keyed supportsDenormalization() lookups all expect a clean FQCN as the type key — class_exists("Foo|null") is false, is_subclass_of("Foo|null", BackedEnum::class) is false, is_a("Foo|null", \DateTimeInterface::class, true) is false. So the denormalize branch is skipped, the original LLM-decoded value is forwarded into the method call, and PHP rejects it because the parameter expects an instance of Foo.

For the non-nullable case Foo the type stringifies cleanly to App\Foo, the matching normalizer matches, the conversion succeeds — which is why the existing non-nullable cases (#492 etc.) are unaffected.

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 Foo
{
    public function __construct(public string $name)
    {
    }
}

final class SomeTool
{
    public function __invoke(?Foo $foo = null): array
    {
        return [];
    }
}

$resolver = new ToolCallArgumentResolver();
$tool     = new Tool(new ExecutionReference(SomeTool::class), 'some_tool', 'desc');
$toolCall = new ToolCall('tc-1', 'some_tool', ['foo' => ['name' => 'bar']]);

$arguments = $resolver->resolveArguments($tool, $toolCall);
// expected: ['foo' => new Foo('bar')]
// actual:   ['foo' => ['name' => 'bar']]  (array left untouched)

(new SomeTool())(...$arguments);
// TypeError: Argument #1 ($foo) must be of type ?Foo, array given

Replace Foo with a BackedEnum (and the array value with the enum's backing string) to reproduce the same failure shape against BackedEnumNormalizer; the underlying root cause is identical.

Possible fix directions

In order of invasiveness:

  1. Pass the Type object instead of the stringified form to supportsDenormalization() / denormalize() and rely on the Symfony Serializer's native handling of NullableType. The Type::__toString() round-trip in resolveArguments() is the actual root cause; using the structured Type everywhere downstream is the cleanest path and covers every wrapped class type uniformly.
  2. Special-case NullableType in the resolver — unwrap to the inner type for the denormalization call (null/missing values are already handled by the early-return on array_key_exists() higher up in the same method). One unwrap fixes every ?Foo regardless of the underlying class.
  3. Strip the |null suffix from the stringified type before the supports check — quickest patch, but feels like papering over the underlying issue.

A regression test that round-trips a ?Foo-typed parameter through ToolCallArgumentResolver::resolveArguments() would prevent this from coming back. Ideally it covers both an arbitrary user class and a BackedEnum to exercise the two main supportsDenormalization() paths.

Environment

  • symfony/ai-agent: v0.8.0
  • symfony/ai-platform: v0.8.1
  • symfony/ai-bundle: v0.8.0
  • Symfony: 7.4
  • PHP: 8.4

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    AgentIssues & PRs about the AI Agent component

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions