Skip to content

Commit

Permalink
feat: introduce optional fault tolerant toolbox
Browse files Browse the repository at this point in the history
  • Loading branch information
chr-hertel committed Feb 18, 2025
1 parent 706497c commit 57bad21
Show file tree
Hide file tree
Showing 15 changed files with 229 additions and 67 deletions.
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

0 comments on commit 57bad21

Please sign in to comment.