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
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 @@ -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) {
Expand All @@ -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;
}
}
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
Loading