From 30e1ff79ec60ec23f85673b7323fcf2c5e1af6d8 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 4 Jan 2025 17:04:03 +0100 Subject: [PATCH] feat: add tool call event --- README.md | 18 ++++- composer.json | 1 + ...-weather.php => toolbox-weather-event.php} | 23 ++++-- src/Chain/ToolBox/ChainProcessor.php | 15 +++- src/Chain/ToolBox/Event/ToolCallsExecuted.php | 27 +++++++ src/Chain/ToolBox/ToolBox.php | 10 +-- src/Chain/ToolBox/ToolBoxInterface.php | 2 +- src/Chain/ToolBox/ToolCallResult.php | 16 +++++ src/Chain/ToolBox/ToolResultConverter.php | 21 ++++++ tests/Chain/ToolBox/ToolBoxTest.php | 70 ------------------- .../Chain/ToolBox/ToolResultConverterTest.php | 55 +++++++++++++++ tests/Fixture/Tool/ToolReturningArray.php | 16 ----- tests/Fixture/Tool/ToolReturningFloat.php | 16 ----- tests/Fixture/Tool/ToolReturningInteger.php | 16 ----- .../Tool/ToolReturningJsonSerializable.php | 21 ------ .../Fixture/Tool/ToolReturningStringable.php | 21 ------ 16 files changed, 169 insertions(+), 179 deletions(-) rename examples/{toolbox-weather.php => toolbox-weather-event.php} (52%) create mode 100644 src/Chain/ToolBox/Event/ToolCallsExecuted.php create mode 100644 src/Chain/ToolBox/ToolCallResult.php create mode 100644 src/Chain/ToolBox/ToolResultConverter.php create mode 100644 tests/Chain/ToolBox/ToolResultConverterTest.php delete mode 100644 tests/Fixture/Tool/ToolReturningArray.php delete mode 100644 tests/Fixture/Tool/ToolReturningFloat.php delete mode 100644 tests/Fixture/Tool/ToolReturningInteger.php delete mode 100644 tests/Fixture/Tool/ToolReturningJsonSerializable.php delete mode 100644 tests/Fixture/Tool/ToolReturningStringable.php diff --git a/README.md b/README.md index 2f72fc85..44d725c9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/composer.json b/composer.json index 0cfe67ba..aee73aa7 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/examples/toolbox-weather.php b/examples/toolbox-weather-event.php similarity index 52% rename from examples/toolbox-weather.php rename to examples/toolbox-weather-event.php index cbe1b06f..fbcb1762 100755 --- a/examples/toolbox-weather.php +++ b/examples/toolbox-weather-event.php @@ -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'; @@ -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()); diff --git a/src/Chain/ToolBox/ChainProcessor.php b/src/Chain/ToolBox/ChainProcessor.php index 4c25bf29..098b71ce 100644 --- a/src/Chain/ToolBox/ChainProcessor.php +++ b/src/Chain/ToolBox/ChainProcessor.php @@ -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, ) { } @@ -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); } } } diff --git a/src/Chain/ToolBox/Event/ToolCallsExecuted.php b/src/Chain/ToolBox/Event/ToolCallsExecuted.php new file mode 100644 index 00000000..d6f2fae7 --- /dev/null +++ b/src/Chain/ToolBox/Event/ToolCallsExecuted.php @@ -0,0 +1,27 @@ +toolCallResults = $toolCallResults; + } + + public function hasResponse(): bool + { + return isset($this->response); + } +} diff --git a/src/Chain/ToolBox/ToolBox.php b/src/Chain/ToolBox/ToolBox.php index 2c09a4b7..b99aa6f2 100644 --- a/src/Chain/ToolBox/ToolBox.php +++ b/src/Chain/ToolBox/ToolBox.php @@ -45,7 +45,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) { @@ -59,14 +59,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; } } diff --git a/src/Chain/ToolBox/ToolBoxInterface.php b/src/Chain/ToolBox/ToolBoxInterface.php index ed847653..4cb8c7df 100644 --- a/src/Chain/ToolBox/ToolBoxInterface.php +++ b/src/Chain/ToolBox/ToolBoxInterface.php @@ -13,5 +13,5 @@ interface ToolBoxInterface */ public function getMap(): array; - public function execute(ToolCall $toolCall): string; + public function execute(ToolCall $toolCall): mixed; } diff --git a/src/Chain/ToolBox/ToolCallResult.php b/src/Chain/ToolBox/ToolCallResult.php new file mode 100644 index 00000000..8c01f727 --- /dev/null +++ b/src/Chain/ToolBox/ToolCallResult.php @@ -0,0 +1,16 @@ + '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' => [ @@ -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', - ]; } } diff --git a/tests/Chain/ToolBox/ToolResultConverterTest.php b/tests/Chain/ToolBox/ToolResultConverterTest.php new file mode 100644 index 00000000..e4737491 --- /dev/null +++ b/tests/Chain/ToolBox/ToolResultConverterTest.php @@ -0,0 +1,55 @@ +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"}', + ]; + } +} diff --git a/tests/Fixture/Tool/ToolReturningArray.php b/tests/Fixture/Tool/ToolReturningArray.php deleted file mode 100644 index dff1856d..00000000 --- a/tests/Fixture/Tool/ToolReturningArray.php +++ /dev/null @@ -1,16 +0,0 @@ - 'bar']; - } -} diff --git a/tests/Fixture/Tool/ToolReturningFloat.php b/tests/Fixture/Tool/ToolReturningFloat.php deleted file mode 100644 index dddee8e9..00000000 --- a/tests/Fixture/Tool/ToolReturningFloat.php +++ /dev/null @@ -1,16 +0,0 @@ - 'bar']; - } - }; - } -} diff --git a/tests/Fixture/Tool/ToolReturningStringable.php b/tests/Fixture/Tool/ToolReturningStringable.php deleted file mode 100644 index 83c3cd78..00000000 --- a/tests/Fixture/Tool/ToolReturningStringable.php +++ /dev/null @@ -1,21 +0,0 @@ -