diff --git a/fixtures/Tool/EnumMode.php b/fixtures/Tool/EnumMode.php new file mode 100644 index 000000000..117fe13d3 --- /dev/null +++ b/fixtures/Tool/EnumMode.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +enum EnumMode: string +{ + case AND = 'and'; + case OR = 'or'; + case NOT = 'not'; +} diff --git a/fixtures/Tool/EnumPriority.php b/fixtures/Tool/EnumPriority.php new file mode 100644 index 000000000..50206d7f1 --- /dev/null +++ b/fixtures/Tool/EnumPriority.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +enum EnumPriority: int +{ + case LOW = 1; + case MEDIUM = 5; + case HIGH = 10; +} diff --git a/fixtures/Tool/ToolWithBackedEnums.php b/fixtures/Tool/ToolWithBackedEnums.php new file mode 100644 index 000000000..0f62cf2f0 --- /dev/null +++ b/fixtures/Tool/ToolWithBackedEnums.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Fixtures\Tool; + +class ToolWithBackedEnums +{ + /** + * Search using enum parameters without attributes. + * + * @param array $searchTerms The search terms + * @param EnumMode $mode The search mode + * @param EnumPriority $priority The search priority + * @param EnumMode|null $fallback Optional fallback mode + */ + public function __invoke(array $searchTerms, EnumMode $mode, EnumPriority $priority, ?EnumMode $fallback = null): array + { + return [ + 'terms' => $searchTerms, + 'mode' => $mode->value, + 'priority' => $priority->value, + 'fallback' => $fallback?->value, + ]; + } +} diff --git a/src/agent/doc/index.rst b/src/agent/doc/index.rst index 377cf4575..bf392296d 100644 --- a/src/agent/doc/index.rst +++ b/src/agent/doc/index.rst @@ -128,7 +128,9 @@ Symfony AI generates a JSON Schema representation for all tools in the Toolbox b method arguments and param comments in the doc block. Additionally, JSON Schema support validation rules, which are partially support by LLMs like GPT. -To leverage this, configure the ``#[With]`` attribute on the method arguments of your tool:: +**Parameter Validation with #[With] Attribute** + +To leverage JSON Schema validation rules, configure the ``#[With]`` attribute on the method arguments of your tool:: use Symfony\AI\Agent\Toolbox\Attribute\AsTool; use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; @@ -139,12 +141,15 @@ To leverage this, configure the ``#[With]`` attribute on the method arguments of /** * @param string $name The name of an object * @param int $number The number of an object + * @param array $categories List of valid categories */ public function __invoke( #[With(pattern: '/([a-z0-1]){5}/')] string $name, #[With(minimum: 0, maximum: 10)] int $number, + #[With(enum: ['tech', 'business', 'science'])] + array $categories, ): string { // ... } @@ -152,6 +157,46 @@ To leverage this, configure the ``#[With]`` attribute on the method arguments of See attribute class ``Symfony\AI\Platform\Contract\JsonSchema\Attribute\With`` for all available options. +**Automatic Enum Validation** + +For PHP backed enums, Symfony AI provides automatic validation without requiring any ``#[With]`` attributes:: + + enum Priority: int + { + case LOW = 1; + case NORMAL = 5; + case HIGH = 10; + } + + enum ContentType: string + { + case ARTICLE = 'article'; + case TUTORIAL = 'tutorial'; + case NEWS = 'news'; + } + + #[AsTool('content_search', 'Search for content with automatic enum validation.')] + final class ContentSearchTool + { + /** + * @param array $keywords The search keywords + * @param ContentType $type The content type to search for + * @param Priority $priority Minimum priority level + * @param ContentType|null $fallback Optional fallback content type + */ + public function __invoke( + array $keywords, + ContentType $type, + Priority $priority, + ?ContentType $fallback = null, + ): array { + // Enums are automatically validated - no #[With] attribute needed! + // ... + } + } + +This eliminates the need for manual ``#[With(enum: [...])]`` attributes when using PHP's native backed enum types. + .. note:: Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by Symfony AI. diff --git a/src/platform/src/Contract/JsonSchema/Factory.php b/src/platform/src/Contract/JsonSchema/Factory.php index a1349209b..2a1e5b2ad 100644 --- a/src/platform/src/Contract/JsonSchema/Factory.php +++ b/src/platform/src/Contract/JsonSchema/Factory.php @@ -14,8 +14,10 @@ use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BackedEnumType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; @@ -51,6 +53,7 @@ * } * * @author Christopher Hertel + * @author Oskar Stark */ final readonly class Factory { @@ -135,6 +138,19 @@ private function convertTypes(array $elements): ?array */ private function getTypeSchema(Type $type): array { + // Handle BackedEnumType directly + if ($type instanceof BackedEnumType) { + return $this->buildEnumSchema($type->getClassName()); + } + + // Handle NullableType that wraps a BackedEnumType + if ($type instanceof NullableType) { + $wrappedType = $type->getWrappedType(); + if ($wrappedType instanceof BackedEnumType) { + return $this->buildEnumSchema($wrappedType->getClassName()); + } + } + switch (true) { case $type->isIdentifiedBy(TypeIdentifier::INT): return ['type' => 'integer']; @@ -168,11 +184,14 @@ private function getTypeSchema(Type $type): array throw new InvalidArgumentException('Cannot build schema from plain object type.'); } \assert($type instanceof ObjectType); - if (\in_array($type->getClassName(), ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) { + + $className = $type->getClassName(); + + if (\in_array($className, ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) { return ['type' => 'string', 'format' => 'date-time']; } else { // Recursively build the schema for an object type - return $this->buildProperties($type->getClassName()) ?? ['type' => 'object']; + return $this->buildProperties($className) ?? ['type' => 'object']; } // no break @@ -182,4 +201,36 @@ private function getTypeSchema(Type $type): array return ['type' => 'string']; } } + + /** + * @return array + */ + private function buildEnumSchema(string $enumClassName): array + { + $reflection = new \ReflectionEnum($enumClassName); + + if (!$reflection->isBacked()) { + throw new InvalidArgumentException(\sprintf('Enum "%s" is not backed.', $enumClassName)); + } + + $cases = $reflection->getCases(); + $values = []; + $backingType = $reflection->getBackingType(); + + foreach ($cases as $case) { + $values[] = $case->getBackingValue(); + } + + if (null === $backingType) { + throw new InvalidArgumentException(\sprintf('Backed enum "%s" has no backing type.', $enumClassName)); + } + + $typeName = $backingType->getName(); + $jsonType = 'string' === $typeName ? 'string' : ('int' === $typeName ? 'integer' : 'string'); + + return [ + 'type' => $jsonType, + 'enum' => $values, + ]; + } } diff --git a/src/platform/tests/Contract/JsonSchema/FactoryTest.php b/src/platform/tests/Contract/JsonSchema/FactoryTest.php index 8ca6cb72b..3a58a2a89 100644 --- a/src/platform/tests/Contract/JsonSchema/FactoryTest.php +++ b/src/platform/tests/Contract/JsonSchema/FactoryTest.php @@ -21,6 +21,7 @@ use Symfony\AI\Fixtures\Tool\ToolNoParams; use Symfony\AI\Fixtures\Tool\ToolOptionalParam; use Symfony\AI\Fixtures\Tool\ToolRequiredParams; +use Symfony\AI\Fixtures\Tool\ToolWithBackedEnums; use Symfony\AI\Fixtures\Tool\ToolWithToolParameterAttribute; use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With; use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser; @@ -265,4 +266,38 @@ public function testBuildPropertiesForExampleDto() $this->assertSame($expected, $actual); } + + public function testBuildParametersWithBackedEnums() + { + $actual = $this->factory->buildParameters(ToolWithBackedEnums::class, '__invoke'); + $expected = [ + 'type' => 'object', + 'properties' => [ + 'searchTerms' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'The search terms', + ], + 'mode' => [ + 'type' => 'string', + 'enum' => ['and', 'or', 'not'], + 'description' => 'The search mode', + ], + 'priority' => [ + 'type' => 'integer', + 'enum' => [1, 5, 10], + 'description' => 'The search priority', + ], + 'fallback' => [ + 'type' => ['string', 'null'], + 'enum' => ['and', 'or', 'not'], + 'description' => 'Optional fallback mode', + ], + ], + 'required' => ['searchTerms', 'mode', 'priority'], + 'additionalProperties' => false, + ]; + + $this->assertSame($expected, $actual); + } }