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:
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:
- 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.
- 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.
- 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
Summary
ToolCallArgumentResolverfails 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 aTypeErrorbecause the parameter expects?Foo, not whateverjson_decodeproduced (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):The raw
arrayreaches__invokeinstead of aFooinstance. The same shape of failure occurs for?BackedEnum(stringreaches__invokeinstead of the enum case),?\DateTimeImmutable(stringinstead 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 $foodoes 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
For
?Foothe resolved type is aNullableType(which extendsUnionType). Its__toString()produces:The Symfony Serializer's class-keyed
supportsDenormalization()lookups all expect a clean FQCN as the type key —class_exists("Foo|null")isfalse,is_subclass_of("Foo|null", BackedEnum::class)isfalse,is_a("Foo|null", \DateTimeInterface::class, true)isfalse. 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 ofFoo.For the non-nullable case
Foothe type stringifies cleanly toApp\Foo, the matching normalizer matches, the conversion succeeds — which is why the existing non-nullable cases (#492 etc.) are unaffected.Minimal reproducer
Replace
Foowith aBackedEnum(and the array value with the enum's backing string) to reproduce the same failure shape againstBackedEnumNormalizer; the underlying root cause is identical.Possible fix directions
In order of invasiveness:
Typeobject instead of the stringified form tosupportsDenormalization()/denormalize()and rely on the Symfony Serializer's native handling ofNullableType. TheType::__toString()round-trip inresolveArguments()is the actual root cause; using the structuredTypeeverywhere downstream is the cleanest path and covers every wrapped class type uniformly.NullableTypein the resolver — unwrap to the inner type for the denormalization call (null/missing values are already handled by the early-return onarray_key_exists()higher up in the same method). One unwrap fixes every?Fooregardless of the underlying class.|nullsuffix 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 throughToolCallArgumentResolver::resolveArguments()would prevent this from coming back. Ideally it covers both an arbitrary user class and aBackedEnumto exercise the two mainsupportsDenormalization()paths.Environment
symfony/ai-agent: v0.8.0symfony/ai-platform: v0.8.1symfony/ai-bundle: v0.8.0Related
BackedEnumsupport for tool arguments and structured output. Reproducer in [Platform] Backed Enums Support for Tool Arguments and Structured Output #492 used a non-nullableMode $modeparameter; the nullable case was not exercised.ToolNormalizerhad a comparable nullable-handling gap on the schema-emission side; closed for the Gemini bridge but the OpenAI/argument-resolver path was not in scope.