diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07cf44b6..f6635276 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -340,6 +340,25 @@ $embeddings = AiClient::generateEmbeddings( ); ``` +### Function declaration annotations + +`FunctionDeclaration` accepts optional annotations for consumer-specific hints that do not belong to the function's parameter schema: + +```php +$function = new FunctionDeclaration( + 'get_weather', + 'Gets the weather', + null, + ['deferredLoading' => true] +); + +$annotations = $function->getAnnotations(); +``` + +Annotations are an `array` whose values should be JSON-serializable. The core SDK preserves annotations without interpreting their names or values. Providers and other consumers define the annotations they recognize and ignore unknown annotations. Provider-specific annotations should use namespaced keys or a nested provider-specific map to avoid collisions, and must not be blindly merged into provider requests. + +Empty annotations are omitted from serialized declarations, preserving the existing shape for callers that do not use them. Missing annotations are restored as an empty array. Annotations are not authorization; applications must still validate tool execution. + ## Class diagrams This section shows comprehensive class diagrams for the proposed architecture. For explanation on specific terms, see the [glossary](./GLOSSARY.md). @@ -806,6 +825,7 @@ direction LR +getName() string +getDescription() string +getParameters() mixed + +getAnnotations() array< string, mixed > +getJsonSchema() array< string, mixed >$ } class FunctionResponse { @@ -1229,6 +1249,7 @@ direction LR +getName() string +getDescription() string +getParameters() mixed + +getAnnotations() array< string, mixed > +getJsonSchema() array< string, mixed >$ } class Tool { diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 5b08ce80..2b148636 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -10,14 +10,15 @@ * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, - * including its name, description, and parameter schema. + * including its name, description, parameter schema, and optional annotations. * * @since 0.1.0 * * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array + * parameters?: array, + * annotations?: array * } * * @extends AbstractDataTransferObject @@ -27,6 +28,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; + public const KEY_ANNOTATIONS = 'annotations'; /** * @var string The name of the function. */ @@ -42,20 +44,32 @@ class FunctionDeclaration extends AbstractDataTransferObject */ private ?array $parameters; + /** + * @var array The function annotations. + */ + private array $annotations; + /** * Constructor. * * @since 0.1.0 + * @since n.e.x.t Adds the optional $annotations parameter. * * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. + * @param array $annotations Optional annotations with JSON-serializable values. */ - public function __construct(string $name, string $description, ?array $parameters = null) - { + public function __construct( + string $name, + string $description, + ?array $parameters = null, + array $annotations = [] + ) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; + $this->annotations = $annotations; } /** @@ -94,6 +108,18 @@ public function getParameters(): ?array return $this->parameters; } + /** + * Gets the function annotations. + * + * @since n.e.x.t + * + * @return array The annotations, or an empty array if none were provided. + */ + public function getAnnotations(): array + { + return $this->annotations; + } + /** * {@inheritDoc} * @@ -117,6 +143,11 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => true, ], + self::KEY_ANNOTATIONS => [ + 'type' => 'object', + 'description' => 'Optional annotations interpreted by consumers.', + 'additionalProperties' => true, + ], ], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION], ]; @@ -140,6 +171,10 @@ public function toArray(): array $data[self::KEY_PARAMETERS] = $this->parameters; } + if ($this->annotations !== []) { + $data[self::KEY_ANNOTATIONS] = $this->annotations; + } + return $data; } @@ -155,7 +190,8 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], - $array[self::KEY_PARAMETERS] ?? null + $array[self::KEY_PARAMETERS] ?? null, + $array[self::KEY_ANNOTATIONS] ?? [] ); } } diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index b2782bdb..f6f8bb85 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; @@ -320,4 +321,82 @@ public function testImplementsWithArrayTransformationInterface(): void $declaration = new FunctionDeclaration('test', 'test function'); $this->assertImplementsArrayTransformation($declaration); } + + /** + * Tests legacy declarations keep their serialized shape without empty annotations. + * + * @return void + */ + public function testAnnotationsDefaultsPreserveCompatibility(): void + { + $legacy = ['name' => 'get_weather', 'description' => 'Gets the weather']; + $declarations = [ + new FunctionDeclaration('get_weather', 'Gets the weather'), + new FunctionDeclaration('get_weather', 'Gets the weather', null, []), + FunctionDeclaration::fromArray($legacy), + ]; + foreach ($declarations as $declaration) { + $this->assertSame([], $declaration->getAnnotations()); + $this->assertSame($legacy, $declaration->toArray()); + $this->assertSame($legacy, json_decode((string) json_encode($declaration), true)); + } + } + + /** + * Tests arbitrary annotations survive array and JSON serialization unchanged. + * + * @return void + */ + public function testAnnotationsRoundTrip(): void + { + $annotations = [ + 'deferredLoading' => true, + 'readOnlyHint' => false, + 'vendor' => ['labels' => ['weather', 'public'], 'priority' => 0, 'optional' => null], + ]; + $declaration = new FunctionDeclaration('get_weather', 'Gets the weather', null, $annotations); + $this->assertSame($annotations, $declaration->getAnnotations()); + $this->assertNull($declaration->getParameters()); + $this->assertSame($annotations, $declaration->toArray()['annotations']); + $this->assertSame($annotations, FunctionDeclaration::fromArray($declaration->toArray())->getAnnotations()); + + $json = json_decode((string) json_encode($declaration), true); + $this->assertSame($annotations, FunctionDeclaration::fromArray($json)->getAnnotations()); + } + + /** + * Tests annotations are optional and unconstrained in the declaration schema. + * + * @return void + */ + public function testAnnotationsSchema(): void + { + $schema = FunctionDeclaration::getJsonSchema(); + $this->assertSame('object', $schema['properties']['annotations']['type']); + $this->assertTrue($schema['properties']['annotations']['additionalProperties']); + $this->assertNotContains('annotations', $schema['required']); + } + + /** + * Tests model configuration preserves annotations and clones declarations independently. + * + * @return void + */ + public function testAnnotationsSurviveModelConfigRoundTripAndClone(): void + { + $annotations = ['vendor' => ['enabled' => false]]; + $declaration = new FunctionDeclaration('lookup', 'Looks up a record', ['type' => 'object'], $annotations); + $config = new ModelConfig(); + $config->setFunctionDeclarations([$declaration]); + $restored = ModelConfig::fromArray($config->toArray()); + $cloned = clone $config; + $this->assertSame($annotations, $restored->getFunctionDeclarations()[0]->getAnnotations()); + $this->assertSame($annotations, $cloned->getFunctionDeclarations()[0]->getAnnotations()); + $this->assertNotSame($declaration, $cloned->getFunctionDeclarations()[0]); + + $copy = $cloned->getFunctionDeclarations()[0]->getAnnotations(); + $copy['vendor']['enabled'] = true; + $this->assertSame($annotations, $declaration->getAnnotations()); + $this->assertSame($annotations, $cloned->getFunctionDeclarations()[0]->getAnnotations()); + } }