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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,6 @@ Tool calling can be enabled by registering the processors in the chain:
use PhpLlm\LlmChain\Chain\ToolBox\ChainProcessor;
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
use PhpLlm\LlmChain\Chain\ToolBox\ToolBox;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

// Platform & LLM instantiation

Expand Down Expand Up @@ -180,7 +177,6 @@ You can configure the method to be called by the LLM with the `#[AsTool]` attrib
```php
use PhpLlm\LlmChain\ToolBox\Attribute\AsTool;


#[AsTool(name: 'weather_current', description: 'get current weather for a location', method: 'current')]
#[AsTool(name: 'weather_forecast', description: 'get weather forecast for a location', method: 'forecast')]
final readonly class OpenMeteo
Expand Down Expand Up @@ -231,6 +227,24 @@ See attribute class [ToolParameter](src/Chain/ToolBox/Attribute/ToolParameter.ph
> [!NOTE]
> Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by LLM Chain.

#### Fault Tolerance

To gracefully handle errors that occur during tool calling, e.g. wrong tool names or runtime errors, you can use the
`FaultTolerantToolBox` as a decorator for the `ToolBox`. It will catch the exceptions and return readable error messages
to the LLM.

```php
use PhpLlm\LlmChain\Chain\ToolBox\ChainProcessor;
use PhpLlm\LlmChain\Chain\ToolBox\FaultTolerantToolBox;

// Platform, LLM & ToolBox instantiation

$toolBox = new FaultTolerantToolBox($innerToolBox);
$toolProcessor = new ChainProcessor($toolBox);

$chain = new Chain($platform, $llm, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]);
```

#### Tool Result Interception

To react to the result of a tool, you can implement an EventListener or EventSubscriber, that listens to the
Expand Down
11 changes: 11 additions & 0 deletions src/Chain/ToolBox/Exception/ExceptionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;

use PhpLlm\LlmChain\Exception\ExceptionInterface as BaseExceptionInterface;

interface ExceptionInterface extends BaseExceptionInterface
{
}
21 changes: 21 additions & 0 deletions src/Chain/ToolBox/Exception/ToolConfigurationException.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 ToolConfigurationException extends InvalidArgumentException implements ExceptionInterface
{
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));
}
}
20 changes: 20 additions & 0 deletions src/Chain/ToolBox/Exception/ToolExecutionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;

use PhpLlm\LlmChain\Model\Response\ToolCall;

final class ToolExecutionException extends \RuntimeException implements ExceptionInterface
{
public ?ToolCall $toolCall = null;

public static function executionFailed(ToolCall $toolCall, \Throwable $previous): self
{
$exception = new self(sprintf('Execution of tool "%s" failed with error: %s', $toolCall->name, $previous->getMessage()), previous: $previous);
$exception->toolCall = $toolCall;

return $exception;
}
}
20 changes: 20 additions & 0 deletions src/Chain/ToolBox/Exception/ToolNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\Exception;

use PhpLlm\LlmChain\Model\Response\ToolCall;

final class ToolNotFoundException extends \RuntimeException implements ExceptionInterface
{
public ?ToolCall $toolCall = null;

public static function notFoundForToolCall(ToolCall $toolCall): self
{
$exception = new self(sprintf('Tool not found for call: %s.', $toolCall->name));
$exception->toolCall = $toolCall;

return $exception;
}
}
38 changes: 38 additions & 0 deletions src/Chain/ToolBox/FaultTolerantToolBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Model\Response\ToolCall;

/**
* Catches exceptions thrown by the inner tool box and returns error messages for the LLM instead.
*/
final readonly class FaultTolerantToolBox implements ToolBoxInterface
{
public function __construct(
private ToolBoxInterface $innerToolBox,
) {
}

public function getMap(): array
{
return $this->innerToolBox->getMap();
}

public function execute(ToolCall $toolCall): mixed
{
try {
return $this->innerToolBox->execute($toolCall);
} catch (ToolExecutionException $e) {
return sprintf('An error occurred while executing tool "%s".', $e->toolCall->name);
} catch (ToolNotFoundException) {
$names = array_map(fn (Metadata $metadata) => $metadata->name, $this->getMap());

return sprintf('Tool "%s" was not found, please use one of these: %s', $toolCall->name, implode(', ', $names));
}
}
}
4 changes: 2 additions & 2 deletions src/Chain/ToolBox/ParameterAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Attribute\ToolParameter;
use PhpLlm\LlmChain\Exception\ToolBoxException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;

/**
* @phpstan-type ParameterDefinition array{
Expand Down Expand Up @@ -46,7 +46,7 @@ public function getDefinition(string $className, string $methodName): ?array
try {
$reflection = new \ReflectionMethod($className, $methodName);
} catch (\ReflectionException) {
throw ToolBoxException::invalidMethod($className, $methodName);
throw ToolConfigurationException::invalidMethod($className, $methodName);
}
$parameters = $reflection->getParameters();

Expand Down
4 changes: 2 additions & 2 deletions src/Chain/ToolBox/ToolAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Attribute\AsTool;
use PhpLlm\LlmChain\Exception\InvalidToolImplementation;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolConfigurationException;

final readonly class ToolAnalyzer
{
Expand All @@ -25,7 +25,7 @@ public function getMetadata(string $className): iterable
$attributes = $reflectionClass->getAttributes(AsTool::class);

if (0 === count($attributes)) {
throw InvalidToolImplementation::missingAttribute($className);
throw ToolConfigurationException::missingAttribute($className);
}

foreach ($attributes as $attribute) {
Expand Down
7 changes: 4 additions & 3 deletions src/Chain/ToolBox/ToolBox.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Exception\ToolBoxException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Model\Response\ToolCall;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand Down Expand Up @@ -61,13 +62,13 @@ public function execute(ToolCall $toolCall): mixed
$result = $tool->{$metadata->method}(...$toolCall->arguments);
} catch (\Throwable $e) {
$this->logger->warning(sprintf('Failed to execute tool "%s".', $metadata->name), ['exception' => $e]);
throw ToolBoxException::executionFailed($toolCall, $e);
throw ToolExecutionException::executionFailed($toolCall, $e);
}

return $result;
}
}

throw ToolBoxException::notFoundForToolCall($toolCall);
throw ToolNotFoundException::notFoundForToolCall($toolCall);
}
}
6 changes: 6 additions & 0 deletions src/Chain/ToolBox/ToolBoxInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Model\Response\ToolCall;

interface ToolBoxInterface
Expand All @@ -13,5 +15,9 @@ interface ToolBoxInterface
*/
public function getMap(): array;

/**
* @throws ToolExecutionException if the tool execution fails
* @throws ToolNotFoundException if the tool is not found
*/
public function execute(ToolCall $toolCall): mixed;
}
15 changes: 0 additions & 15 deletions src/Exception/InvalidToolImplementation.php

This file was deleted.

33 changes: 0 additions & 33 deletions src/Exception/ToolBoxException.php

This file was deleted.

78 changes: 78 additions & 0 deletions tests/Chain/ToolBox/FaultTolerantToolBoxTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolExecutionException;
use PhpLlm\LlmChain\Chain\ToolBox\Exception\ToolNotFoundException;
use PhpLlm\LlmChain\Chain\ToolBox\FaultTolerantToolBox;
use PhpLlm\LlmChain\Chain\ToolBox\Metadata;
use PhpLlm\LlmChain\Chain\ToolBox\ToolBoxInterface;
use PhpLlm\LlmChain\Model\Response\ToolCall;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoParams;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(FaultTolerantToolBox::class)]
final class FaultTolerantToolBoxTest extends TestCase
{
#[Test]
public function faultyToolExecution(): void
{
$faultyToolBox = $this->createFaultyToolBox(
fn (ToolCall $toolCall) => ToolExecutionException::executionFailed($toolCall, new \Exception('error'))
);

$faultTolerantToolBox = new FaultTolerantToolBox($faultyToolBox);
$expected = 'An error occurred while executing tool "tool_foo".';

$toolCall = new ToolCall('987654321', 'tool_foo');
$actual = $faultTolerantToolBox->execute($toolCall);

self::assertSame($expected, $actual);
}

#[Test]
public function faultyToolCall(): void
{
$faultyToolBox = $this->createFaultyToolBox(
fn (ToolCall $toolCall) => ToolNotFoundException::notFoundForToolCall($toolCall)
);

$faultTolerantToolBox = new FaultTolerantToolBox($faultyToolBox);
$expected = 'Tool "tool_xyz" was not found, please use one of these: tool_no_params, tool_required_params';

$toolCall = new ToolCall('123456789', 'tool_xyz');
$actual = $faultTolerantToolBox->execute($toolCall);

self::assertSame($expected, $actual);
}

private function createFaultyToolBox(\Closure $exceptionFactory): ToolBoxInterface
{
return new class($exceptionFactory) implements ToolBoxInterface {
public function __construct(private readonly \Closure $exceptionFactory)
{
}

/**
* @return Metadata[]
*/
public function getMap(): array
{
return [
new Metadata(ToolNoParams::class, 'tool_no_params', 'A tool without parameters', '__invoke', null),
new Metadata(ToolRequiredParams::class, 'tool_required_params', 'A tool with required parameters', 'bar', null),
];
}

public function execute(ToolCall $toolCall): mixed
{
throw ($this->exceptionFactory)($toolCall);
}
};
}
}
6 changes: 3 additions & 3 deletions tests/Chain/ToolBox/ToolAnalyzerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
namespace PhpLlm\LlmChain\Tests\Chain\ToolBox;

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\ParameterAnalyzer;
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
use PhpLlm\LlmChain\Exception\InvalidToolImplementation;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolMultiple;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolWrong;
Expand All @@ -21,7 +21,7 @@
#[UsesClass(AsTool::class)]
#[UsesClass(Metadata::class)]
#[UsesClass(ParameterAnalyzer::class)]
#[UsesClass(InvalidToolImplementation::class)]
#[UsesClass(ToolConfigurationException::class)]
final class ToolAnalyzerTest extends TestCase
{
private ToolAnalyzer $toolAnalyzer;
Expand All @@ -34,7 +34,7 @@ protected function setUp(): void
#[Test]
public function withoutAttribute(): void
{
$this->expectException(InvalidToolImplementation::class);
$this->expectException(ToolConfigurationException::class);
iterator_to_array($this->toolAnalyzer->getMetadata(ToolWrong::class));
}

Expand Down
Loading