Skip to content
Open
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
45 changes: 45 additions & 0 deletions docs/FUNCTION_METADATA.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update ARCHITECTURE.md instead of adding this.

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Function declaration metadata

`FunctionDeclaration` accepts an optional fourth constructor argument containing
generic metadata (annotations):

```php
$function = new FunctionDeclaration(
'get_weather',
'Gets the weather',
null,
['deferredLoading' => true]
);

$metadata = $function->getMetadata();
```

Metadata is an `array<string, mixed>` whose values should be JSON-serializable.
The SDK preserves it during declaration and model-configuration serialization
without interpreting annotation names or values. Empty metadata is omitted from
serialized declarations, preserving the existing shape for callers that do not
use this argument. Missing metadata is restored as an empty array.

Providers and other consumers define which annotations they recognize, their value
types, and their interaction with request-level custom options. Unknown annotations
can be ignored. Provider-specific annotations should use a namespaced key or nested
provider-specific map to avoid collisions. Metadata must not be blindly merged into
a provider request or the function's JSON parameter schema.

For example, a provider can interpret a `deferredLoading` annotation together with
`ModelConfig::setCustomOptions(['deferredLoading' => true])`. This example does not
establish a core deferred-loading capability, guarantee provider support, or add a
model-selection requirement. Automatic tool-count thresholds remain provider policy.
Annotations are not authorization; applications must still validate tool execution.

This extension concerns outgoing function definitions only. It adds no message
types or native response replay mechanism. An OpenAI provider experiment can use
existing `previous_response_id` custom options and send only new input for
server-managed continuation; stateless replay of discovery items remains separate
work requiring further evidence.

The [Vercel AI SDK OpenAI provider](https://ai-sdk.dev/providers/ai-sdk-providers/openai)
uses per-tool `providerOptions` for comparable provider-owned configuration. Its
documented deferred-loading API is explicit, rather than count-triggered. Generic
metadata provides an extension point for exploring such features without adding
feature-specific properties or fluent builder methods to this SDK.
46 changes: 41 additions & 5 deletions src/Tools/DTO/FunctionDeclaration.php

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The descriptions throughout this are inconsistent. Let's clean that up.

Original file line number Diff line number Diff line change
Expand Up @@ -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 metadata.
*
* @since 0.1.0
*
* @phpstan-type FunctionDeclarationArrayShape array{
* name: string,
* description: string,
* parameters?: array<string, mixed>
* parameters?: array<string, mixed>,
* metadata?: array<string, mixed>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the word annotations and like that it's consistent with the AI SDK. Let's go with that as our word.

* }
*
* @extends AbstractDataTransferObject<FunctionDeclarationArrayShape>
Expand All @@ -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_METADATA = 'metadata';
/**
* @var string The name of the function.
*/
Expand All @@ -42,20 +44,32 @@ class FunctionDeclaration extends AbstractDataTransferObject
*/
private ?array $parameters;

/**
* @var array<string, mixed> Optional annotations interpreted by consumers, not the core SDK.
*/
private array $metadata;

/**
* Constructor.
*
* @since 0.1.0
* @since n.e.x.t Adds the optional $metadata parameter.
*
* @param string $name The name of the function.
* @param string $description A description of what the function does.
* @param array<string, mixed>|null $parameters The JSON schema for the function parameters.
* @param array<string, mixed> $metadata Optional metadata 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 $metadata = []
) {
$this->name = $name;
$this->description = $description;
$this->parameters = $parameters;
$this->metadata = $metadata;
}

/**
Expand Down Expand Up @@ -94,6 +108,18 @@ public function getParameters(): ?array
return $this->parameters;
}

/**
* Gets the function metadata without interpreting its annotations.
*
* @since n.e.x.t
*
* @return array<string, mixed> The metadata, or an empty array if none was provided.
*/
public function getMetadata(): array
{
return $this->metadata;
}

/**
* {@inheritDoc}
*
Expand All @@ -117,6 +143,11 @@ public static function getJsonSchema(): array
'description' => 'The JSON schema for the function parameters.',
'additionalProperties' => true,
],
self::KEY_METADATA => [
'type' => 'object',
'description' => 'Optional metadata whose annotations are interpreted by consumers.',
'additionalProperties' => true,
],
],
'required' => [self::KEY_NAME, self::KEY_DESCRIPTION],
];
Expand All @@ -140,6 +171,10 @@ public function toArray(): array
$data[self::KEY_PARAMETERS] = $this->parameters;
}

if ($this->metadata !== []) {
$data[self::KEY_METADATA] = $this->metadata;
}

return $data;
}

Expand All @@ -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_METADATA] ?? []
);
}
}
79 changes: 79 additions & 0 deletions tests/unit/Tools/DTO/FunctionDeclarationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 metadata.
*
* @return void
*/
public function testMetadataDefaultsPreserveCompatibility(): 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->getMetadata());
$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 testMetadataRoundTrip(): void
{
$metadata = [
'deferredLoading' => true,
'readOnlyHint' => false,
'vendor' => ['labels' => ['weather', 'public'], 'priority' => 0, 'optional' => null],
];
$declaration = new FunctionDeclaration('get_weather', 'Gets the weather', null, $metadata);
$this->assertSame($metadata, $declaration->getMetadata());
$this->assertNull($declaration->getParameters());
$this->assertSame($metadata, $declaration->toArray()['metadata']);
$this->assertSame($metadata, FunctionDeclaration::fromArray($declaration->toArray())->getMetadata());

$json = json_decode((string) json_encode($declaration), true);
$this->assertSame($metadata, FunctionDeclaration::fromArray($json)->getMetadata());
}

/**
* Tests metadata is optional and unconstrained in the declaration schema.
*
* @return void
*/
public function testMetadataSchema(): void
{
$schema = FunctionDeclaration::getJsonSchema();
$this->assertSame('object', $schema['properties']['metadata']['type']);
$this->assertTrue($schema['properties']['metadata']['additionalProperties']);
$this->assertNotContains('metadata', $schema['required']);
}

/**
* Tests model configuration preserves metadata and clones declarations independently.
*
* @return void
*/
public function testMetadataSurvivesModelConfigRoundTripAndClone(): void
{
$metadata = ['vendor' => ['enabled' => false]];
$declaration = new FunctionDeclaration('lookup', 'Looks up a record', ['type' => 'object'], $metadata);
$config = new ModelConfig();
$config->setFunctionDeclarations([$declaration]);
$restored = ModelConfig::fromArray($config->toArray());
$cloned = clone $config;
$this->assertSame($metadata, $restored->getFunctionDeclarations()[0]->getMetadata());
$this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata());
$this->assertNotSame($declaration, $cloned->getFunctionDeclarations()[0]);

$copy = $cloned->getFunctionDeclarations()[0]->getMetadata();
$copy['vendor']['enabled'] = true;
$this->assertSame($metadata, $declaration->getMetadata());
$this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata());
}
}
Loading