Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Commit 728d6f6

Browse files
committed
feat: introduce chain and memory tool metadata factory
1 parent 47d9097 commit 728d6f6

File tree

13 files changed

+492
-59
lines changed

13 files changed

+492
-59
lines changed

src/Chain/ToolBox/Exception/ToolConfigurationException.php

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,12 @@
44

55
namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;
66

7-
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
87
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
98

109
final class ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface
1110
{
12-
public static function invalidReference(mixed $reference): self
11+
public static function invalidMethod(string $toolClass, string $methodName, \ReflectionException $previous): self
1312
{
14-
return new self(sprintf('The reference "%s" is not a valid as tool.', $reference));
15-
}
16-
17-
public static function missingAttribute(string $className): self
18-
{
19-
return new self(sprintf('The class "%s" is not a tool, please add %s attribute.', $className, AsTool::class));
20-
}
21-
22-
public static function invalidMethod(string $toolClass, string $methodName): self
23-
{
24-
return new self(sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass));
13+
return new self(sprintf('Method "%s" not found in tool "%s".', $methodName, $toolClass), previous: $previous);
2514
}
2615
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;
6+
7+
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
8+
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
9+
10+
final class ToolMetadataException extends InvalidArgumentException implements ExceptionInterface
11+
{
12+
public static function invalidReference(mixed $reference): self
13+
{
14+
return new self(sprintf('The reference "%s" is not a valid as tool.', $reference));
15+
}
16+
17+
public static function missingAttribute(string $className): self
18+
{
19+
return new self(sprintf('The class "%s" is not a tool, please add %s attribute.', $className, AsTool::class));
20+
}
21+
}

src/Chain/ToolBox/MetadataFactory.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
namespace PhpLlm\LlmChain\Chain\ToolBox;
66

7+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
8+
79
interface MetadataFactory
810
{
911
/**
1012
* @return iterable<Metadata>
13+
*
14+
* @throws ToolMetadataException if the metadata for the given reference is not found
1115
*/
12-
public function getMetadata(mixed $reference): iterable;
16+
public function getMetadata(string $reference): iterable;
1317
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
6+
7+
use PhpLlm\LlmChain\Chain\JsonSchema\Factory;
8+
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
9+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
10+
use PhpLlm\LlmChain\Chain\ToolBox\ExecutionReference;
11+
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
12+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
13+
14+
abstract class AbstractFactory implements MetadataFactory
15+
{
16+
public function __construct(
17+
private readonly Factory $factory = new Factory(),
18+
) {
19+
}
20+
21+
protected function convertAttribute(string $className, AsTool $attribute): Metadata
22+
{
23+
try {
24+
return new Metadata(
25+
new ExecutionReference($className, $attribute->method),
26+
$attribute->name,
27+
$attribute->description,
28+
$this->factory->buildParameters($className, $attribute->method)
29+
);
30+
} catch (\ReflectionException $e) {
31+
throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e);
32+
}
33+
}
34+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
6+
7+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
8+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
9+
10+
final readonly class ChainFactory implements MetadataFactory
11+
{
12+
/**
13+
* @var list<MetadataFactory>
14+
*/
15+
private array $factories;
16+
17+
/**
18+
* @param iterable<MetadataFactory> $factories
19+
*/
20+
public function __construct(iterable $factories)
21+
{
22+
$this->factories = $factories instanceof \Traversable ? iterator_to_array($factories) : $factories;
23+
}
24+
25+
public function getMetadata(string $reference): iterable
26+
{
27+
$invalid = 0;
28+
foreach ($this->factories as $factory) {
29+
try {
30+
yield from $factory->getMetadata($reference);
31+
} catch (ToolMetadataException) {
32+
++$invalid;
33+
continue;
34+
}
35+
36+
// If the factory does not throw an exception, we don't need to check the others
37+
return;
38+
}
39+
40+
if ($invalid === count($this->factories)) {
41+
throw ToolMetadataException::invalidReference($reference);
42+
}
43+
}
44+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
6+
7+
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
8+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
9+
10+
final class MemoryFactory extends AbstractFactory
11+
{
12+
/**
13+
* @var array<string, AsTool[]>
14+
*/
15+
private array $tools = [];
16+
17+
public function addTool(string $className, string $name, string $description, string $method = '__invoke'): self
18+
{
19+
$this->tools[$className][] = new AsTool($name, $description, $method);
20+
21+
return $this;
22+
}
23+
24+
/**
25+
* @param class-string $reference
26+
*/
27+
public function getMetadata(string $reference): iterable
28+
{
29+
if (!isset($this->tools[$reference])) {
30+
throw ToolMetadataException::invalidReference($reference);
31+
}
32+
33+
foreach ($this->tools[$reference] as $tool) {
34+
yield $this->convertAttribute($reference, $tool);
35+
}
36+
}
37+
}

src/Chain/ToolBox/MetadataFactory/ReflectionFactory.php

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,32 @@
66

77
use PhpLlm\LlmChain\Chain\JsonSchema\Factory;
88
use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
9-
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
10-
use PhpLlm\LlmChain\Chain\ToolBox\ExecutionReference;
9+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
1110
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
12-
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
1311

1412
/**
1513
* Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools.
1614
*/
17-
final readonly class ReflectionFactory implements MetadataFactory
15+
final class ReflectionFactory extends AbstractFactory
1816
{
19-
public function __construct(
20-
private Factory $factory = new Factory(),
21-
) {
22-
}
23-
2417
/**
25-
* @return iterable<Metadata>
18+
* @param class-string $reference
2619
*/
27-
public function getMetadata(mixed $reference): iterable
20+
public function getMetadata(string $reference): iterable
2821
{
29-
if (!is_object($reference) && !is_string($reference) || is_string($reference) && !class_exists($reference)) {
30-
throw ToolConfigurationException::invalidReference($reference);
22+
if (!class_exists($reference)) {
23+
throw ToolMetadataException::invalidReference($reference);
3124
}
3225

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

3629
if (0 === count($attributes)) {
37-
throw ToolConfigurationException::missingAttribute($reflectionClass->getName());
30+
throw ToolMetadataException::missingAttribute($reference);
3831
}
3932

4033
foreach ($attributes as $attribute) {
41-
yield $this->convertAttribute($reflectionClass->getName(), $attribute->newInstance());
42-
}
43-
}
44-
45-
private function convertAttribute(string $className, AsTool $attribute): Metadata
46-
{
47-
try {
48-
return new Metadata(
49-
new ExecutionReference($className, $attribute->method),
50-
$attribute->name,
51-
$attribute->description,
52-
$this->factory->buildParameters($className, $attribute->method)
53-
);
54-
} catch (\ReflectionException) {
55-
throw ToolConfigurationException::invalidMethod($className, $attribute->method);
34+
yield $this->convertAttribute($reference, $attribute->newInstance());
5635
}
5736
}
5837
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Chain\ToolBox\MetadataFactory;
6+
7+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;
8+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
9+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ChainFactory;
10+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\MemoryFactory;
11+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory\ReflectionFactory;
12+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMisconfigured;
13+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple;
14+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoAttribute1;
15+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolOptionalParam;
16+
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
17+
use PHPUnit\Framework\Attributes\CoversClass;
18+
use PHPUnit\Framework\Attributes\Medium;
19+
use PHPUnit\Framework\Attributes\Test;
20+
use PHPUnit\Framework\Attributes\UsesClass;
21+
use PHPUnit\Framework\TestCase;
22+
23+
#[CoversClass(ChainFactory::class)]
24+
#[Medium]
25+
#[UsesClass(MemoryFactory::class)]
26+
#[UsesClass(ReflectionFactory::class)]
27+
#[UsesClass(ToolMetadataException::class)]
28+
final class ChainFactoryTest extends TestCase
29+
{
30+
private ChainFactory $factory;
31+
32+
protected function setUp(): void
33+
{
34+
$factory1 = (new MemoryFactory())
35+
->addTool(ToolNoAttribute1::class, 'reference', 'A reference tool')
36+
->addTool(ToolOptionalParam::class, 'optional_param', 'Tool with optional param', 'bar');
37+
$factory2 = new ReflectionFactory();
38+
39+
$this->factory = new ChainFactory([$factory1, $factory2]);
40+
}
41+
42+
#[Test]
43+
public function testGetMetadataNotExistingClass(): void
44+
{
45+
$this->expectException(ToolMetadataException::class);
46+
$this->expectExceptionMessage('The reference "NoClass" is not a valid as tool.');
47+
48+
iterator_to_array($this->factory->getMetadata('NoClass'));
49+
}
50+
51+
#[Test]
52+
public function testGetMetadataNotConfiguredClass(): void
53+
{
54+
$this->expectException(ToolConfigurationException::class);
55+
$this->expectExceptionMessage(sprintf('Method "foo" not found in tool "%s".', ToolMisconfigured::class));
56+
57+
iterator_to_array($this->factory->getMetadata(ToolMisconfigured::class));
58+
}
59+
60+
#[Test]
61+
public function testGetMetadataWithAttributeSingleHit(): void
62+
{
63+
$metadata = iterator_to_array($this->factory->getMetadata(ToolRequiredParams::class));
64+
65+
self::assertCount(1, $metadata);
66+
}
67+
68+
#[Test]
69+
public function testGetMetadataOverwrite(): void
70+
{
71+
$metadata = iterator_to_array($this->factory->getMetadata(ToolOptionalParam::class));
72+
73+
self::assertCount(1, $metadata);
74+
self::assertSame('optional_param', $metadata[0]->name);
75+
self::assertSame('Tool with optional param', $metadata[0]->description);
76+
self::assertSame('bar', $metadata[0]->reference->method);
77+
}
78+
79+
#[Test]
80+
public function testGetMetadataWithAttributeDoubleHit(): void
81+
{
82+
$metadata = iterator_to_array($this->factory->getMetadata(ToolMultiple::class));
83+
84+
self::assertCount(2, $metadata);
85+
}
86+
87+
#[Test]
88+
public function testGetMetadataWithMemorySingleHit(): void
89+
{
90+
$metadata = iterator_to_array($this->factory->getMetadata(ToolNoAttribute1::class));
91+
92+
self::assertCount(1, $metadata);
93+
}
94+
}

0 commit comments

Comments
 (0)