Skip to content

Commit

Permalink
feat: add tool call event (#187)
Browse files Browse the repository at this point in the history
  • Loading branch information
chr-hertel authored Jan 5, 2025
1 parent 4adb1a2 commit fc8db39
Show file tree
Hide file tree
Showing 16 changed files with 169 additions and 179 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,27 @@ 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.
#### Tool Result Interception

To react to the result of a tool, you can implement an EventListener or EventSubscriber, that listens to the
`ToolCallsExecuted` event. This event is dispatched after the `ToolBox` executed all current tool calls and enables
you to skip the next LLM call by setting a response yourself:

```php
$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void {
foreach ($event->toolCallResults as $toolCallResult) {
if (str_starts_with($toolCallResult->toolCall->name, 'weather_')) {
$event->response = new StructuredResponse($toolCallResult->result);
}
}
});
```

#### Code Examples (with built-in tools)

1. **Clock Tool**: [toolbox-clock.php](examples/toolbox-clock.php)
1. **SerpAPI Tool**: [toolbox-serpapi.php](examples/toolbox-serpapi.php)
1. **Weather Tool**: [toolbox-weather.php](examples/toolbox-weather.php)
1. **Weather Tool with Event Listener**: [toolbox-weather-event.php](examples/toolbox-weather-event.php)
1. **Wikipedia Tool**: [toolbox-wikipedia.php](examples/toolbox-wikipedia.php)
1. **YouTube Transcriber Tool**: [toolbox-youtube.php](examples/toolbox-youtube.php) (with streaming)

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"symfony/css-selector": "^6.4 || ^7.1",
"symfony/dom-crawler": "^6.4 || ^7.1",
"symfony/dotenv": "^6.4 || ^7.1",
"symfony/event-dispatcher": "^6.4 || ^7.1",
"symfony/finder": "^6.4 || ^7.1",
"symfony/process": "^6.4 || ^7.1",
"symfony/var-dumper": "^6.4 || ^7.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Chain\ToolBox\ChainProcessor;
use PhpLlm\LlmChain\Chain\ToolBox\Event\ToolCallsExecuted;
use PhpLlm\LlmChain\Chain\ToolBox\Tool\OpenMeteo;
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
use PhpLlm\LlmChain\Chain\ToolBox\ToolBox;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Message\MessageBag;
use PhpLlm\LlmChain\Model\Response\StructuredResponse;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpClient\HttpClient;

require_once dirname(__DIR__).'/vendor/autoload.php';
Expand All @@ -23,12 +26,22 @@
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
$llm = new GPT(GPT::GPT_4O_MINI);

$wikipedia = new OpenMeteo(HttpClient::create());
$toolBox = new ToolBox(new ToolAnalyzer(), [$wikipedia]);
$processor = new ChainProcessor($toolBox);
$openMeteo = new OpenMeteo(HttpClient::create());
$toolBox = new ToolBox(new ToolAnalyzer(), [$openMeteo]);
$eventDispatcher = new EventDispatcher();
$processor = new ChainProcessor($toolBox, eventDispatcher: $eventDispatcher);
$chain = new Chain($platform, $llm, [$processor], [$processor]);

$messages = new MessageBag(Message::ofUser('How is the weather currently in Berlin? And how about tomorrow?'));
// Add tool call result listener to enforce chain exits direct with structured response for weather tools
$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void {
foreach ($event->toolCallResults as $toolCallResult) {
if (str_starts_with($toolCallResult->toolCall->name, 'weather_')) {
$event->response = new StructuredResponse($toolCallResult->result);
}
}
});

$messages = new MessageBag(Message::ofUser('How is the weather currently in Berlin?'));
$response = $chain->call($messages);

echo $response->getContent().PHP_EOL;
dump($response->getContent());
15 changes: 12 additions & 3 deletions src/Chain/ToolBox/ChainProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@
use PhpLlm\LlmChain\Chain\InputProcessor;
use PhpLlm\LlmChain\Chain\Output;
use PhpLlm\LlmChain\Chain\OutputProcessor;
use PhpLlm\LlmChain\Chain\ToolBox\Event\ToolCallsExecuted;
use PhpLlm\LlmChain\Exception\MissingModelSupport;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class ChainProcessor implements InputProcessor, OutputProcessor, ChainAwareProcessor
{
use ChainAwareTrait;

public function __construct(
private ToolBoxInterface $toolBox,
private readonly ToolBoxInterface $toolBox,
private ToolResultConverter $resultConverter = new ToolResultConverter(),
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
}

Expand Down Expand Up @@ -47,12 +51,17 @@ public function processOutput(Output $output): void
$toolCalls = $output->response->getContent();
$messages->add(Message::ofAssistant(toolCalls: $toolCalls));

$results = [];
foreach ($toolCalls as $toolCall) {
$result = $this->toolBox->execute($toolCall);
$messages->add(Message::ofToolCall($toolCall, $result));
$results[] = new ToolCallResult($toolCall, $result);
$messages->add(Message::ofToolCall($toolCall, $this->resultConverter->convert($result)));
}

$output->response = $this->chain->call($messages, $output->options);
$event = new ToolCallsExecuted(...$results);
$this->eventDispatcher?->dispatch($event);

$output->response = $event->hasResponse() ? $event->response : $this->chain->call($messages, $output->options);
}
}
}
27 changes: 27 additions & 0 deletions src/Chain/ToolBox/Event/ToolCallsExecuted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox\Event;

use PhpLlm\LlmChain\Chain\ToolBox\ToolCallResult;
use PhpLlm\LlmChain\Model\Response\ResponseInterface;

final class ToolCallsExecuted
{
/**
* @var ToolCallResult[]
*/
public readonly array $toolCallResults;
public ResponseInterface $response;

public function __construct(ToolCallResult ...$toolCallResults)
{
$this->toolCallResults = $toolCallResults;
}

public function hasResponse(): bool
{
return isset($this->response);
}
}
10 changes: 1 addition & 9 deletions src/Chain/ToolBox/ToolBox.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function getMap(): array
return $this->map = $map;
}

public function execute(ToolCall $toolCall): string
public function execute(ToolCall $toolCall): mixed
{
foreach ($this->tools as $tool) {
foreach ($this->toolAnalyzer->getMetadata($tool::class) as $metadata) {
Expand All @@ -64,14 +64,6 @@ public function execute(ToolCall $toolCall): string
throw ToolBoxException::executionFailed($toolCall, $e);
}

if ($result instanceof \JsonSerializable || is_array($result)) {
return json_encode($result, flags: JSON_THROW_ON_ERROR);
}

if (is_integer($result) || is_float($result) || $result instanceof \Stringable) {
return (string) $result;
}

return $result;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Chain/ToolBox/ToolBoxInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ interface ToolBoxInterface
*/
public function getMap(): array;

public function execute(ToolCall $toolCall): string;
public function execute(ToolCall $toolCall): mixed;
}
16 changes: 16 additions & 0 deletions src/Chain/ToolBox/ToolCallResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Chain\ToolBox;

use PhpLlm\LlmChain\Model\Response\ToolCall;

final readonly class ToolCallResult
{
public function __construct(
public ToolCall $toolCall,
public mixed $result,
) {
}
}
21 changes: 21 additions & 0 deletions src/Chain/ToolBox/ToolResultConverter.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;

final readonly class ToolResultConverter
{
public function convert(mixed $result): string
{
if ($result instanceof \JsonSerializable || is_array($result)) {
return json_encode($result, flags: JSON_THROW_ON_ERROR);
}

if (is_integer($result) || is_float($result) || $result instanceof \Stringable) {
return (string) $result;
}

return $result;
}
}
70 changes: 0 additions & 70 deletions tests/Chain/ToolBox/ToolBoxTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolNoParams;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolOptionalParam;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolRequiredParams;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolReturningArray;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolReturningFloat;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolReturningInteger;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolReturningJsonSerializable;
use PhpLlm\LlmChain\Tests\Fixture\Tool\ToolReturningStringable;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
Expand All @@ -43,11 +38,6 @@ protected function setUp(): void
new ToolRequiredParams(),
new ToolOptionalParam(),
new ToolNoParams(),
new ToolReturningArray(),
new ToolReturningJsonSerializable(),
new ToolReturningInteger(),
new ToolReturningFloat(),
new ToolReturningStringable(),
new ToolException(),
]);
}
Expand Down Expand Up @@ -111,41 +101,6 @@ public function toolsMap(): void
'description' => 'A tool without parameters',
],
],
[
'type' => 'function',
'function' => [
'name' => 'tool_returning_array',
'description' => 'A tool returning an array',
],
],
[
'type' => 'function',
'function' => [
'name' => 'tool_returning_json_serializable',
'description' => 'A tool returning an object which implements \JsonSerializable',
],
],
[
'type' => 'function',
'function' => [
'name' => 'tool_returning_integer',
'description' => 'A tool returning an integer',
],
],
[
'type' => 'function',
'function' => [
'name' => 'tool_returning_float',
'description' => 'A tool returning a float',
],
],
[
'type' => 'function',
'function' => [
'name' => 'tool_returning_stringable',
'description' => 'A tool returning an object which implements \Stringable',
],
],
[
'type' => 'function',
'function' => [
Expand Down Expand Up @@ -207,30 +162,5 @@ public static function executeProvider(): iterable
'tool_required_params',
['text' => 'Hello', 'number' => 3],
];

yield 'tool_returning_array' => [
'{"foo":"bar"}',
'tool_returning_array',
];

yield 'tool_returning_json_serializable' => [
'{"foo":"bar"}',
'tool_returning_json_serializable',
];

yield 'tool_returning_integer' => [
'42',
'tool_returning_integer',
];

yield 'tool_returning_float' => [
'42.42',
'tool_returning_float',
];

yield 'tool_returning_stringable' => [
'Hi!',
'tool_returning_stringable',
];
}
}
55 changes: 55 additions & 0 deletions tests/Chain/ToolBox/ToolResultConverterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Chain\ToolBox;

use PhpLlm\LlmChain\Chain\ToolBox\ToolResultConverter;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(ToolResultConverter::class)]
final class ToolResultConverterTest extends TestCase
{
#[Test]
#[DataProvider('provideResults')]
public function testConvert(mixed $result, string $expected): void
{
$converter = new ToolResultConverter();

self::assertSame($expected, $converter->convert($result));
}

public static function provideResults(): \Generator
{
yield 'integer' => [42, '42'];

yield 'float' => [42.42, '42.42'];

yield 'array' => [['key' => 'value'], '{"key":"value"}'];

yield 'string' => ['plain string', 'plain string'];

yield 'stringable' => [
new class implements \Stringable {
public function __toString(): string
{
return 'stringable';
}
},
'stringable',
];

yield 'json_serializable' => [
new class implements \JsonSerializable {
public function jsonSerialize(): array
{
return ['key' => 'value'];
}
},
'{"key":"value"}',
];
}
}
16 changes: 0 additions & 16 deletions tests/Fixture/Tool/ToolReturningArray.php

This file was deleted.

Loading

0 comments on commit fc8db39

Please sign in to comment.