Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.
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
15 changes: 2 additions & 13 deletions src/Chain/ToolBox/Exception/ToolConfigurationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,12 @@

namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;

use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Exception\InvalidArgumentException;

final class ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface
{
public static function invalidReference(mixed $reference): self
public static function invalidMethod(string $toolClass, string $methodName, \ReflectionException $previous): 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));
}

public static function invalidMethod(string $toolClass, string $methodName): self
{
return new self(sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass));
return new self(sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass), previous: $previous);
}
}
21 changes: 21 additions & 0 deletions src/Chain/ToolBox/Exception/ToolMetadataException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;

use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Exception\InvalidArgumentException;

final class ToolMetadataException 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));
}
}
6 changes: 5 additions & 1 deletion src/Chain/ToolBox/MetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;

interface MetadataFactory
{
/**
* @return iterable<Metadata>
*
* @throws ToolMetadataException if the metadata for the given reference is not found
*/
public function getMetadata(mixed $reference): iterable;
public function getMetadata(string $reference): iterable;
}
34 changes: 34 additions & 0 deletions src/Chain/ToolBox/MetadataFactory/AbstractFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

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\ExecutionReference;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

abstract class AbstractFactory implements MetadataFactory
{
public function __construct(
private readonly Factory $factory = new Factory(),
) {
}

protected function convertAttribute(string $className, AsTool $attribute): Metadata
{
try {
return new Metadata(
new ExecutionReference($className, $attribute->method),
$attribute->name,
$attribute->description,
$this->factory->buildParameters($className, $attribute->method)
);
} catch (\ReflectionException $e) {
throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e);
}
}
}
44 changes: 44 additions & 0 deletions src/Chain/ToolBox/MetadataFactory/ChainFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

final readonly class ChainFactory implements MetadataFactory
{
/**
* @var list<MetadataFactory>
*/
private array $factories;

/**
* @param iterable<MetadataFactory> $factories
*/
public function __construct(iterable $factories)
{
$this->factories = $factories instanceof \Traversable ? iterator_to_array($factories) : $factories;
}

public function getMetadata(string $reference): iterable
{
$invalid = 0;
foreach ($this->factories as $factory) {
try {
yield from $factory->getMetadata($reference);
} catch (ToolMetadataException) {
++$invalid;
continue;
}

// If the factory does not throw an exception, we don't need to check the others
return;
}

if ($invalid === count($this->factories)) {
throw ToolMetadataException::invalidReference($reference);
}
}
}
37 changes: 37 additions & 0 deletions src/Chain/ToolBox/MetadataFactory/MemoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;

final class MemoryFactory extends AbstractFactory
{
/**
* @var array<string, AsTool[]>
*/
private array $tools = [];

public function addTool(string $className, string $name, string $description, string $method = '__invoke'): self
{
$this->tools[$className][] = new AsTool($name, $description, $method);

return $this;
}

/**
* @param class-string $reference
*/
public function getMetadata(string $reference): iterable
{
if (!isset($this->tools[$reference])) {
throw ToolMetadataException::invalidReference($reference);
}

foreach ($this->tools[$reference] as $tool) {
yield $this->convertAttribute($reference, $tool);
}
}
}
37 changes: 8 additions & 29 deletions src/Chain/ToolBox/MetadataFactory/ReflectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,32 @@

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\ExecutionReference;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;

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

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

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

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

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

private function convertAttribute(string $className, AsTool $attribute): Metadata
{
try {
return new Metadata(
new ExecutionReference($className, $attribute->method),
$attribute->name,
$attribute->description,
$this->factory->buildParameters($className, $attribute->method)
);
} catch (\ReflectionException) {
throw ToolConfigurationException::invalidMethod($className, $attribute->method);
yield $this->convertAttribute($reference, $attribute->newInstance());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public function withStringableSystemPrompt(): void
public function getMap(): array
{
return [
new Metadata(ToolNoParams::class, 'tool_no_params', 'A tool without parameters', '__invoke', null),
new Metadata(new ExecutionReference(ToolNoParams::class), 'tool_no_params', 'A tool without parameters', null),
];
}

Expand Down
94 changes: 94 additions & 0 deletions tests/Chain/ToolBox/MetadataFactory/ChainFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Chain\ToolBox\MetadataFactory;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ChainFactory;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\MemoryFactory;
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ReflectionFactory;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMisconfigured;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoAttribute1;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolOptionalParam;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(ChainFactory::class)]
#[Medium]
#[UsesClass(MemoryFactory::class)]
#[UsesClass(ReflectionFactory::class)]
#[UsesClass(ToolMetadataException::class)]
final class ChainFactoryTest extends TestCase
{
private ChainFactory $factory;

protected function setUp(): void
{
$factory1 = (new MemoryFactory())
->addTool(ToolNoAttribute1::class, 'reference', 'A reference tool')
->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar');
$factory2 = new ReflectionFactory();

$this->factory = new ChainFactory([$factory1, $factory2]);
}

#[Test]
public function testGetMetadataNotExistingClass(): void
{
$this->expectException(ToolMetadataException::class);
$this->expectExceptionMessage('The reference "NoClass" is not a valid as tool.');

iterator_to_array($this->factory->getMetadata('NoClass'));
}

#[Test]
public function testGetMetadataNotConfiguredClass(): void
{
$this->expectException(ToolConfigurationException::class);
$this->expectExceptionMessage(sprintf('Method "foo" not found in tool "%s".', ToolMisconfigured::class));

iterator_to_array($this->factory->getMetadata(ToolMisconfigured::class));
}

#[Test]
public function testGetMetadataWithAttributeSingleHit(): void
{
$metadata = iterator_to_array($this->factory->getMetadata(ToolRequiredParams::class));

self::assertCount(1, $metadata);
}

#[Test]
public function testGetMetadataOverwrite(): void
{
$metadata = iterator_to_array($this->factory->getMetadata(ToolOptionalParam::class));

self::assertCount(1, $metadata);
self::assertSame('optional_param', $metadata[0]->name);
self::assertSame('Tool with optional param', $metadata[0]->description);
self::assertSame('bar', $metadata[0]->reference->method);
}

#[Test]
public function testGetMetadataWithAttributeDoubleHit(): void
{
$metadata = iterator_to_array($this->factory->getMetadata(ToolMultiple::class));

self::assertCount(2, $metadata);
}

#[Test]
public function testGetMetadataWithMemorySingleHit(): void
{
$metadata = iterator_to_array($this->factory->getMetadata(ToolNoAttribute1::class));

self::assertCount(1, $metadata);
}
}
Loading