Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions fixtures/Tool/EnumMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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';
}
19 changes: 19 additions & 0 deletions fixtures/Tool/EnumPriority.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
33 changes: 33 additions & 0 deletions fixtures/Tool/ToolWithBackedEnums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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<string> $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,
];
}
}
47 changes: 46 additions & 1 deletion src/agent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -139,19 +141,62 @@ 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<string> $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 {
// ...
}
}

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<string> $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.
Expand Down
55 changes: 53 additions & 2 deletions src/platform/src/Contract/JsonSchema/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +53,7 @@
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
* @author Oskar Stark <oskarstark@googlemail.com>
*/
final readonly class Factory
{
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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
Expand All @@ -182,4 +201,36 @@ private function getTypeSchema(Type $type): array
return ['type' => 'string'];
}
}

/**
* @return array<string, mixed>
*/
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,
];
}
}
35 changes: 35 additions & 0 deletions src/platform/tests/Contract/JsonSchema/FactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}