Skip to content

refactor: introduce tool metadata factory interface #249

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 13, 2025
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
5 changes: 5 additions & 0 deletions src/Chain/ToolBox/Exception/ToolConfigurationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

final class ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface
{
public static function invalidReference(mixed $reference): self
{
return new self(sprintf('The reference "%s" is not a valid as tool.', $reference));
}

public static function missingAttribute(string $className): self
{
return new self(sprintf('The class "%s" is not a tool, please add %s attribute.', $className, AsTool::class));
Expand Down
13 changes: 13 additions & 0 deletions src/Chain/ToolBox/MetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox;

interface MetadataFactory
{
/**
* @return iterable<Metadata>
*/
public function getMetadata(mixed $reference): iterable;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,42 @@

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox;
namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

use PhpLlm\LlmChain\Chain\JsonSchema\Factory;
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

final readonly class ToolAnalyzer
/**
* Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools.
*/
final readonly class ReflectionFactory implements MetadataFactory
{
public function __construct(
private Factory $factory = new Factory(),
) {
}

/**
* @param class-string $className
*
* @return iterable<Metadata>
*/
public function getMetadata(string $className): iterable
public function getMetadata(mixed $reference): iterable
{
$reflectionClass = new \ReflectionClass($className);
if (!is_object($reference) && !is_string($reference) || is_string($reference) && !class_exists($reference)) {
throw ToolConfigurationException::invalidReference($reference);
}

$reflectionClass = new \ReflectionClass($reference);
$attributes = $reflectionClass->getAttributes(AsTool::class);

if (0 === count($attributes)) {
throw ToolConfigurationException::missingAttribute($className);
throw ToolConfigurationException::missingAttribute($reflectionClass->getName());
}

foreach ($attributes as $attribute) {
yield $this->convertAttribute($className, $attribute->newInstance());
yield $this->convertAttribute($reflectionClass->getName(), $attribute->newInstance());
}
}

Expand Down
13 changes: 7 additions & 6 deletions src/Chain/ToolBox/ToolBox.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ReflectionFactory;
use PhpLlm\LlmChain\Model\Response\ToolCall;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

final class ToolBox implements ToolBoxInterface
{
/**
* @var list<object>
* @var list<mixed>
*/
private readonly array $tools;

Expand All @@ -23,10 +24,10 @@ final class ToolBox implements ToolBoxInterface
private array $map;

/**
* @param iterable<object> $tools
* @param iterable<mixed> $tools
*/
public function __construct(
private readonly ToolAnalyzer $toolAnalyzer,
private readonly MetadataFactory $metadataFactory,
iterable $tools,
private readonly LoggerInterface $logger = new NullLogger(),
) {
Expand All @@ -35,7 +36,7 @@ public function __construct(

public static function create(object ...$tools): self
{
return new self(new ToolAnalyzer(), $tools);
return new self(new ReflectionFactory(), $tools);
}

public function getMap(): array
Expand All @@ -46,7 +47,7 @@ public function getMap(): array

$map = [];
foreach ($this->tools as $tool) {
foreach ($this->toolAnalyzer->getMetadata($tool::class) as $metadata) {
foreach ($this->metadataFactory->getMetadata($tool::class) as $metadata) {
$map[] = $metadata;
}
}
Expand All @@ -57,7 +58,7 @@ public function getMap(): array
public function execute(ToolCall $toolCall): mixed
{
foreach ($this->tools as $tool) {
foreach ($this->toolAnalyzer->getMetadata($tool::class) as $metadata) {
foreach ($this->metadataFactory->getMetadata($tool) as $metadata) {
if ($metadata->name !== $toolCall->name) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Chain\ToolBox;
namespace PhpLlm\LlmChain\Tests\Chain\ToolBox\MetadataFactory;

use PhpLlm\LlmChain\Chain\JsonSchema\DescriptionParser;
use PhpLlm\LlmChain\Chain\JsonSchema\Factory;
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ReflectionFactory;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWrong;
Expand All @@ -18,33 +18,54 @@
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(ToolAnalyzer::class)]
#[CoversClass(ReflectionFactory::class)]
#[UsesClass(AsTool::class)]
#[UsesClass(Metadata::class)]
#[UsesClass(Factory::class)]
#[UsesClass(DescriptionParser::class)]
#[UsesClass(ToolConfigurationException::class)]
final class ToolAnalyzerTest extends TestCase
final class ReflectionFactoryTest extends TestCase
{
private ToolAnalyzer $toolAnalyzer;
private ReflectionFactory $factory;

protected function setUp(): void
{
$this->toolAnalyzer = new ToolAnalyzer();
$this->factory = new ReflectionFactory();
}

#[Test]
public function invalidReferenceNonExistingClass(): void
{
$this->expectException(ToolConfigurationException::class);
iterator_to_array($this->factory->getMetadata('invalid'));
}

#[Test]
public function invalidReferenceNonInteger(): void
{
$this->expectException(ToolConfigurationException::class);
iterator_to_array($this->factory->getMetadata(1234));
}

#[Test]
public function invalidReferenceCallable(): void
{
$this->expectException(ToolConfigurationException::class);
iterator_to_array($this->factory->getMetadata(fn () => null));
}

#[Test]
public function withoutAttribute(): void
{
$this->expectException(ToolConfigurationException::class);
iterator_to_array($this->toolAnalyzer->getMetadata(ToolWrong::class));
iterator_to_array($this->factory->getMetadata(ToolWrong::class));
}

#[Test]
public function getDefinition(): void
{
/** @var Metadata[] $metadatas */
$metadatas = iterator_to_array($this->toolAnalyzer->getMetadata(ToolRequiredParams::class));
$metadatas = iterator_to_array($this->factory->getMetadata(ToolRequiredParams::class));

self::assertToolConfiguration(
metadata: $metadatas[0],
Expand Down Expand Up @@ -73,7 +94,7 @@ className: ToolRequiredParams::class,
#[Test]
public function getDefinitionWithMultiple(): void
{
$metadatas = iterator_to_array($this->toolAnalyzer->getMetadata(ToolMultiple::class));
$metadatas = iterator_to_array($this->factory->getMetadata(ToolMultiple::class));

self::assertCount(2, $metadatas);

Expand Down
8 changes: 4 additions & 4 deletions tests/Chain/ToolBox/ToolBoxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ReflectionFactory;
use PhpLlm\LlmChain\Chain\ToolBox\ToolBox;
use PhpLlm\LlmChain\Model\Response\ToolCall;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolException;
Expand All @@ -29,7 +29,7 @@
#[UsesClass(ToolCall::class)]
#[UsesClass(AsTool::class)]
#[UsesClass(Metadata::class)]
#[UsesClass(ToolAnalyzer::class)]
#[UsesClass(ReflectionFactory::class)]
#[UsesClass(Factory::class)]
#[UsesClass(DescriptionParser::class)]
#[UsesClass(ToolConfigurationException::class)]
Expand All @@ -41,7 +41,7 @@ final class ToolBoxTest extends TestCase

protected function setUp(): void
{
$this->toolBox = new ToolBox(new ToolAnalyzer(), [
$this->toolBox = new ToolBox(new ReflectionFactory(), [
new ToolRequiredParams(),
new ToolOptionalParam(),
new ToolNoParams(),
Expand Down Expand Up @@ -132,7 +132,7 @@ public function executeWithMisconfiguredTool(): void
self::expectException(ToolConfigurationException::class);
self::expectExceptionMessage('Method "foo" not found in tool "PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMisconfigured".');

$toolBox = new ToolBox(new ToolAnalyzer(), [new ToolMisconfigured()]);
$toolBox = new ToolBox(new ReflectionFactory(), [new ToolMisconfigured()]);

$toolBox->execute(new ToolCall('call_1234', 'tool_misconfigured'));
}
Expand Down