Skip to content

Commit 5e237d9

Browse files
OskarStarkchr-hertel
authored andcommitted
[Platform] Add automatic enum validation for backed enums
1 parent 5b1d0c9 commit 5e237d9

File tree

6 files changed

+205
-3
lines changed

6 files changed

+205
-3
lines changed

fixtures/Tool/EnumMode.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\Tool;
13+
14+
enum EnumMode: string
15+
{
16+
case AND = 'and';
17+
case OR = 'or';
18+
case NOT = 'not';
19+
}

fixtures/Tool/EnumPriority.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\Tool;
13+
14+
enum EnumPriority: int
15+
{
16+
case LOW = 1;
17+
case MEDIUM = 5;
18+
case HIGH = 10;
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\Tool;
13+
14+
class ToolWithBackedEnums
15+
{
16+
/**
17+
* Search using enum parameters without attributes.
18+
*
19+
* @param array<string> $searchTerms The search terms
20+
* @param EnumMode $mode The search mode
21+
* @param EnumPriority $priority The search priority
22+
* @param EnumMode|null $fallback Optional fallback mode
23+
*/
24+
public function __invoke(array $searchTerms, EnumMode $mode, EnumPriority $priority, ?EnumMode $fallback = null): array
25+
{
26+
return [
27+
'terms' => $searchTerms,
28+
'mode' => $mode->value,
29+
'priority' => $priority->value,
30+
'fallback' => $fallback?->value,
31+
];
32+
}
33+
}

src/agent/doc/index.rst

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ Symfony AI generates a JSON Schema representation for all tools in the Toolbox b
128128
method arguments and param comments in the doc block. Additionally, JSON Schema support validation rules, which are
129129
partially support by LLMs like GPT.
130130

131-
To leverage this, configure the ``#[With]`` attribute on the method arguments of your tool::
131+
**Parameter Validation with #[With] Attribute**
132+
133+
To leverage JSON Schema validation rules, configure the ``#[With]`` attribute on the method arguments of your tool::
132134

133135
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
134136
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
@@ -139,19 +141,62 @@ To leverage this, configure the ``#[With]`` attribute on the method arguments of
139141
/**
140142
* @param string $name The name of an object
141143
* @param int $number The number of an object
144+
* @param array<string> $categories List of valid categories
142145
*/
143146
public function __invoke(
144147
#[With(pattern: '/([a-z0-1]){5}/')]
145148
string $name,
146149
#[With(minimum: 0, maximum: 10)]
147150
int $number,
151+
#[With(enum: ['tech', 'business', 'science'])]
152+
array $categories,
148153
): string {
149154
// ...
150155
}
151156
}
152157

153158
See attribute class ``Symfony\AI\Platform\Contract\JsonSchema\Attribute\With`` for all available options.
154159

160+
**Automatic Enum Validation**
161+
162+
For PHP backed enums, Symfony AI provides automatic validation without requiring any ``#[With]`` attributes::
163+
164+
enum Priority: int
165+
{
166+
case LOW = 1;
167+
case NORMAL = 5;
168+
case HIGH = 10;
169+
}
170+
171+
enum ContentType: string
172+
{
173+
case ARTICLE = 'article';
174+
case TUTORIAL = 'tutorial';
175+
case NEWS = 'news';
176+
}
177+
178+
#[AsTool('content_search', 'Search for content with automatic enum validation.')]
179+
final class ContentSearchTool
180+
{
181+
/**
182+
* @param array<string> $keywords The search keywords
183+
* @param ContentType $type The content type to search for
184+
* @param Priority $priority Minimum priority level
185+
* @param ContentType|null $fallback Optional fallback content type
186+
*/
187+
public function __invoke(
188+
array $keywords,
189+
ContentType $type,
190+
Priority $priority,
191+
?ContentType $fallback = null,
192+
): array {
193+
// Enums are automatically validated - no #[With] attribute needed!
194+
// ...
195+
}
196+
}
197+
198+
This eliminates the need for manual ``#[With(enum: [...])]`` attributes when using PHP's native backed enum types.
199+
155200
.. note::
156201

157202
Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by Symfony AI.

src/platform/src/Contract/JsonSchema/Factory.php

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
1515
use Symfony\AI\Platform\Exception\InvalidArgumentException;
1616
use Symfony\Component\TypeInfo\Type;
17+
use Symfony\Component\TypeInfo\Type\BackedEnumType;
1718
use Symfony\Component\TypeInfo\Type\BuiltinType;
1819
use Symfony\Component\TypeInfo\Type\CollectionType;
20+
use Symfony\Component\TypeInfo\Type\NullableType;
1921
use Symfony\Component\TypeInfo\Type\ObjectType;
2022
use Symfony\Component\TypeInfo\TypeIdentifier;
2123
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
@@ -51,6 +53,7 @@
5153
* }
5254
*
5355
* @author Christopher Hertel <mail@christopher-hertel.de>
56+
* @author Oskar Stark <oskarstark@googlemail.com>
5457
*/
5558
final readonly class Factory
5659
{
@@ -135,6 +138,19 @@ private function convertTypes(array $elements): ?array
135138
*/
136139
private function getTypeSchema(Type $type): array
137140
{
141+
// Handle BackedEnumType directly
142+
if ($type instanceof BackedEnumType) {
143+
return $this->buildEnumSchema($type->getClassName());
144+
}
145+
146+
// Handle NullableType that wraps a BackedEnumType
147+
if ($type instanceof NullableType) {
148+
$wrappedType = $type->getWrappedType();
149+
if ($wrappedType instanceof BackedEnumType) {
150+
return $this->buildEnumSchema($wrappedType->getClassName());
151+
}
152+
}
153+
138154
switch (true) {
139155
case $type->isIdentifiedBy(TypeIdentifier::INT):
140156
return ['type' => 'integer'];
@@ -168,11 +184,14 @@ private function getTypeSchema(Type $type): array
168184
throw new InvalidArgumentException('Cannot build schema from plain object type.');
169185
}
170186
\assert($type instanceof ObjectType);
171-
if (\in_array($type->getClassName(), ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) {
187+
188+
$className = $type->getClassName();
189+
190+
if (\in_array($className, ['DateTime', 'DateTimeImmutable', 'DateTimeInterface'], true)) {
172191
return ['type' => 'string', 'format' => 'date-time'];
173192
} else {
174193
// Recursively build the schema for an object type
175-
return $this->buildProperties($type->getClassName()) ?? ['type' => 'object'];
194+
return $this->buildProperties($className) ?? ['type' => 'object'];
176195
}
177196

178197
// no break
@@ -182,4 +201,36 @@ private function getTypeSchema(Type $type): array
182201
return ['type' => 'string'];
183202
}
184203
}
204+
205+
/**
206+
* @return array<string, mixed>
207+
*/
208+
private function buildEnumSchema(string $enumClassName): array
209+
{
210+
$reflection = new \ReflectionEnum($enumClassName);
211+
212+
if (!$reflection->isBacked()) {
213+
throw new InvalidArgumentException(\sprintf('Enum "%s" is not backed.', $enumClassName));
214+
}
215+
216+
$cases = $reflection->getCases();
217+
$values = [];
218+
$backingType = $reflection->getBackingType();
219+
220+
foreach ($cases as $case) {
221+
$values[] = $case->getBackingValue();
222+
}
223+
224+
if (null === $backingType) {
225+
throw new InvalidArgumentException(\sprintf('Backed enum "%s" has no backing type.', $enumClassName));
226+
}
227+
228+
$typeName = $backingType->getName();
229+
$jsonType = 'string' === $typeName ? 'string' : ('int' === $typeName ? 'integer' : 'string');
230+
231+
return [
232+
'type' => $jsonType,
233+
'enum' => $values,
234+
];
235+
}
185236
}

src/platform/tests/Contract/JsonSchema/FactoryTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\AI\Fixtures\Tool\ToolNoParams;
2222
use Symfony\AI\Fixtures\Tool\ToolOptionalParam;
2323
use Symfony\AI\Fixtures\Tool\ToolRequiredParams;
24+
use Symfony\AI\Fixtures\Tool\ToolWithBackedEnums;
2425
use Symfony\AI\Fixtures\Tool\ToolWithToolParameterAttribute;
2526
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\With;
2627
use Symfony\AI\Platform\Contract\JsonSchema\DescriptionParser;
@@ -265,4 +266,38 @@ public function testBuildPropertiesForExampleDto()
265266

266267
$this->assertSame($expected, $actual);
267268
}
269+
270+
public function testBuildParametersWithBackedEnums()
271+
{
272+
$actual = $this->factory->buildParameters(ToolWithBackedEnums::class, '__invoke');
273+
$expected = [
274+
'type' => 'object',
275+
'properties' => [
276+
'searchTerms' => [
277+
'type' => 'array',
278+
'items' => ['type' => 'string'],
279+
'description' => 'The search terms',
280+
],
281+
'mode' => [
282+
'type' => 'string',
283+
'enum' => ['and', 'or', 'not'],
284+
'description' => 'The search mode',
285+
],
286+
'priority' => [
287+
'type' => 'integer',
288+
'enum' => [1, 5, 10],
289+
'description' => 'The search priority',
290+
],
291+
'fallback' => [
292+
'type' => ['string', 'null'],
293+
'enum' => ['and', 'or', 'not'],
294+
'description' => 'Optional fallback mode',
295+
],
296+
],
297+
'required' => ['searchTerms', 'mode', 'priority'],
298+
'additionalProperties' => false,
299+
];
300+
301+
$this->assertSame($expected, $actual);
302+
}
268303
}

0 commit comments

Comments
 (0)