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

Commit 92d802e

Browse files
committed
feat: introduce chain and memory tool metadata factory
1 parent 6c9ecc5 commit 92d802e

File tree

14 files changed

+453
-59
lines changed

14 files changed

+453
-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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Metadata;
11+
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
12+
13+
abstract class AbstractFactory implements MetadataFactory
14+
{
15+
public function __construct(
16+
private readonly Factory $factory = new Factory(),
17+
) {
18+
}
19+
20+
protected function convertAttribute(string $className, AsTool $attribute): Metadata
21+
{
22+
try {
23+
return new Metadata(
24+
$attribute->name,
25+
$attribute->description,
26+
$attribute->method,
27+
$this->factory->buildParameters($className, $attribute->method)
28+
);
29+
} catch (\ReflectionException $e) {
30+
throw ToolConfigurationException::invalidMethod($className, $attribute->method, $e);
31+
}
32+
}
33+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
// If the factory does not throw an exception, we don't need to check the others
36+
return;
37+
}
38+
39+
if ($invalid === count($this->factories)) {
40+
throw ToolMetadataException::invalidReference($reference);
41+
}
42+
}
43+
}
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 & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +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;
9+
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolMetadataException;
1010
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
11-
use PhpLlm\LlmChain\Chain\ToolBox\MetadataFactory;
1211

1312
/**
1413
* Metadata factory that uses reflection in combination with `#[AsTool]` attribute to extract metadata from tools.
1514
*/
16-
final readonly class ReflectionFactory implements MetadataFactory
15+
final class ReflectionFactory extends AbstractFactory
1716
{
18-
public function __construct(
19-
private Factory $factory = new Factory(),
20-
) {
21-
}
22-
2317
/**
24-
* @return iterable<Metadata>
18+
* @param class-string $reference
2519
*/
26-
public function getMetadata(mixed $reference): iterable
20+
public function getMetadata(string $reference): iterable
2721
{
28-
if (!is_object($reference) && !is_string($reference) || is_string($reference) && !class_exists($reference)) {
29-
throw ToolConfigurationException::invalidReference($reference);
22+
if (!class_exists($reference)) {
23+
throw ToolMetadataException::invalidReference($reference);
3024
}
3125

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

3529
if (0 === count($attributes)) {
36-
throw ToolConfigurationException::missingAttribute($reflectionClass->getName());
30+
throw ToolMetadataException::missingAttribute($reference);
3731
}
3832

3933
foreach ($attributes as $attribute) {
40-
yield $this->convertAttribute($reflectionClass->getName(), $attribute->newInstance());
41-
}
42-
}
43-
44-
private function convertAttribute(string $className, AsTool $attribute): Metadata
45-
{
46-
try {
47-
return new Metadata(
48-
$attribute->name,
49-
$attribute->description,
50-
$attribute->method,
51-
$this->factory->buildParameters($className, $attribute->method)
52-
);
53-
} catch (\ReflectionException) {
54-
throw ToolConfigurationException::invalidMethod($className, $attribute->method);
34+
yield $this->convertAttribute($reference, $attribute->newInstance());
5535
}
5636
}
5737
}

src/Chain/ToolBox/ToolBox.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function getMap(): array
5858
public function execute(ToolCall $toolCall): mixed
5959
{
6060
foreach ($this->tools as $tool) {
61-
foreach ($this->metadataFactory->getMetadata($tool) as $metadata) {
61+
foreach ($this->metadataFactory->getMetadata($tool::class) as $metadata) {
6262
if ($metadata->name !== $toolCall->name) {
6363
continue;
6464
}
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]->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)