Skip to content

Commit a709f6d

Browse files
committed
feature #478 [Agent] Allow exposing tool exceptions to AI (valtzu)
This PR was merged into the main branch. Discussion ---------- [Agent] Allow exposing tool exceptions to AI | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | Fix #255 | License | MIT Make it possible to throw custom exceptions that expose the error to the LLM when using `FaultTolerantToolbox`. Example use case: an event listener for `ToolCallArgumentsResolved` that validates the tool call arguments and throws if any violations found. Then the LLM can auto-fix the input. Commits ------- a06e551 [Agent] Allow exposing exceptions to AI
2 parents f2a5327 + a06e551 commit a709f6d

File tree

9 files changed

+138
-7
lines changed

9 files changed

+138
-7
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Fixtures\Tool;
13+
14+
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
15+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
16+
17+
#[AsTool('tool_custom_exception', description: 'This tool is broken and it exposes the error', method: 'bar')]
18+
final class ToolCustomException
19+
{
20+
public function bar(): string
21+
{
22+
throw new class('Custom error.') extends \RuntimeException implements ToolExecutionExceptionInterface {
23+
public function getToolCallResult(): array
24+
{
25+
return [
26+
'error' => true,
27+
'error_code' => 'ERR42',
28+
'error_description' => 'Temporary error, try again later.',
29+
];
30+
}
31+
};
32+
}
33+
}

src/agent/doc/index.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,37 @@ to the LLM::
268268

269269
$agent = new Agent($platform, $model, inputProcessor: [$toolProcessor], outputProcessor: [$toolProcessor]);
270270

271+
If you want to expose the underlying error to the LLM, you can throw a custom exception that implements `ToolExecutionExceptionInterface`::
272+
273+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
274+
275+
class EntityNotFoundException extends \RuntimeException implements ToolExecutionExceptionInterface
276+
{
277+
public function __construct(private string $entityName, private int $id)
278+
{
279+
}
280+
281+
public function getToolCallResult(): mixed
282+
{
283+
return \sprintf('No %s found with id %d', $this->entityName, $this->id);
284+
}
285+
}
286+
287+
#[AsTool('get_user_age', 'Get age by user id')]
288+
class GetUserAge
289+
{
290+
public function __construct(private UserRepository $userRepository)
291+
{
292+
}
293+
294+
public function __invoke(int $id): int
295+
{
296+
$user = $this->userRepository->find($id) ?? throw new EntityNotFoundException('user', $id);
297+
298+
return $user->getAge();
299+
}
300+
}
301+
271302
**Tool Filtering**
272303

273304
To limit the tools provided to the LLM in a specific agent call to a subset of the configured tools, you can use the

src/agent/src/Toolbox/Exception/ToolExecutionException.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
/**
1717
* @author Christopher Hertel <mail@christopher-hertel.de>
1818
*/
19-
final class ToolExecutionException extends \RuntimeException implements ExceptionInterface
19+
final class ToolExecutionException extends \RuntimeException implements ToolExecutionExceptionInterface
2020
{
2121
public ?ToolCall $toolCall = null;
2222

@@ -27,4 +27,9 @@ public static function executionFailed(ToolCall $toolCall, \Throwable $previous)
2727

2828
return $exception;
2929
}
30+
31+
public function getToolCallResult(): string
32+
{
33+
return \sprintf('An error occurred while executing tool "%s".', $this->toolCall->name);
34+
}
3035
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Agent\Toolbox\Exception;
13+
14+
/**
15+
* @author Valtteri R <valtzu@gmail.com>
16+
*/
17+
interface ToolExecutionExceptionInterface extends ExceptionInterface
18+
{
19+
public function getToolCallResult(): mixed;
20+
}

src/agent/src/Toolbox/FaultTolerantToolbox.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace Symfony\AI\Agent\Toolbox;
1313

14-
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
14+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
1515
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
1616
use Symfony\AI\Platform\Result\ToolCall;
1717
use Symfony\AI\Platform\Tool\Tool;
@@ -37,8 +37,8 @@ public function execute(ToolCall $toolCall): mixed
3737
{
3838
try {
3939
return $this->innerToolbox->execute($toolCall);
40-
} catch (ToolExecutionException $e) {
41-
return \sprintf('An error occurred while executing tool "%s".', $e->toolCall->name);
40+
} catch (ToolExecutionExceptionInterface $e) {
41+
return $e->getToolCallResult();
4242
} catch (ToolNotFoundException) {
4343
$names = array_map(fn (Tool $metadata) => $metadata->name, $this->getTools());
4444

src/agent/src/Toolbox/Toolbox.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Psr\Log\NullLogger;
1616
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
1717
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
18+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
1819
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
1920
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
2021
use Symfony\AI\Platform\Result\ToolCall;
@@ -81,6 +82,8 @@ public function execute(ToolCall $toolCall): mixed
8182
$this->eventDispatcher?->dispatch(new ToolCallArgumentsResolved($tool, $metadata, $arguments));
8283

8384
$result = $tool->{$metadata->reference->method}(...$arguments);
85+
} catch (ToolExecutionExceptionInterface $e) {
86+
throw $e;
8487
} catch (\Throwable $e) {
8588
$this->logger->warning(\sprintf('Failed to execute tool "%s".', $toolCall->name), ['exception' => $e]);
8689
throw ToolExecutionException::executionFailed($toolCall, $e);

src/agent/src/Toolbox/ToolboxInterface.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace Symfony\AI\Agent\Toolbox;
1313

14-
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
14+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
1515
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
1616
use Symfony\AI\Platform\Result\ToolCall;
1717
use Symfony\AI\Platform\Tool\Tool;
@@ -27,8 +27,8 @@ interface ToolboxInterface
2727
public function getTools(): array;
2828

2929
/**
30-
* @throws ToolExecutionException if the tool execution fails
31-
* @throws ToolNotFoundException if the tool is not found
30+
* @throws ToolExecutionExceptionInterface if the tool execution fails
31+
* @throws ToolNotFoundException if the tool is not found
3232
*/
3333
public function execute(ToolCall $toolCall): mixed;
3434
}

src/agent/tests/Toolbox/FaultTolerantToolboxTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use PHPUnit\Framework\Attributes\UsesClass;
1616
use PHPUnit\Framework\TestCase;
1717
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
18+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
1819
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
1920
use Symfony\AI\Agent\Toolbox\FaultTolerantToolbox;
2021
use Symfony\AI\Agent\Toolbox\ToolboxInterface;
@@ -62,6 +63,26 @@ public function testFaultyToolCall()
6263
$this->assertSame($expected, $actual);
6364
}
6465

66+
public function testCustomToolExecutionException()
67+
{
68+
$faultyToolbox = $this->createFaultyToolbox(
69+
static fn () => new class extends \RuntimeException implements ToolExecutionExceptionInterface {
70+
public function getToolCallResult(): array
71+
{
72+
return ['error' => 'custom'];
73+
}
74+
},
75+
);
76+
77+
$faultTolerantToolbox = new FaultTolerantToolbox($faultyToolbox);
78+
$expected = ['error' => 'custom'];
79+
80+
$toolCall = new ToolCall('123456789', 'tool_xyz');
81+
$actual = $faultTolerantToolbox->execute($toolCall);
82+
83+
$this->assertSame($expected, $actual);
84+
}
85+
6586
private function createFaultyToolbox(\Closure $exceptionFactory): ToolboxInterface
6687
{
6788
return new class($exceptionFactory) implements ToolboxInterface {

src/agent/tests/Toolbox/ToolboxTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
1919
use Symfony\AI\Agent\Toolbox\Exception\ToolConfigurationException;
2020
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
21+
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
2122
use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException;
2223
use Symfony\AI\Agent\Toolbox\Toolbox;
2324
use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory;
2425
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
2526
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
27+
use Symfony\AI\Fixtures\Tool\ToolCustomException;
2628
use Symfony\AI\Fixtures\Tool\ToolDate;
2729
use Symfony\AI\Fixtures\Tool\ToolException;
2830
use Symfony\AI\Fixtures\Tool\ToolMisconfigured;
@@ -60,6 +62,7 @@ protected function setUp(): void
6062
new ToolOptionalParam(),
6163
new ToolNoParams(),
6264
new ToolException(),
65+
new ToolCustomException(),
6366
new ToolDate(),
6467
], new ReflectionToolFactory());
6568
}
@@ -122,6 +125,12 @@ public function testGetTools()
122125
'This tool is broken',
123126
);
124127

128+
$toolCustomException = new Tool(
129+
new ExecutionReference(ToolCustomException::class, 'bar'),
130+
'tool_custom_exception',
131+
'This tool is broken and it exposes the error',
132+
);
133+
125134
$toolDate = new Tool(
126135
new ExecutionReference(ToolDate::class, '__invoke'),
127136
'tool_date',
@@ -145,6 +154,7 @@ public function testGetTools()
145154
$toolOptionalParam,
146155
$toolNoParams,
147156
$toolException,
157+
$toolCustomException,
148158
$toolDate,
149159
];
150160

@@ -177,6 +187,14 @@ public function testExecuteWithException()
177187
$this->toolbox->execute(new ToolCall('call_1234', 'tool_exception'));
178188
}
179189

190+
public function testExecuteWithCustomException()
191+
{
192+
$this->expectException(ToolExecutionExceptionInterface::class);
193+
$this->expectExceptionMessage('Custom error.');
194+
195+
$this->toolbox->execute(new ToolCall('call_1234', 'tool_custom_exception'));
196+
}
197+
180198
#[DataProvider('executeProvider')]
181199
public function testExecute(string $expected, string $toolName, array $toolPayload = [])
182200
{

0 commit comments

Comments
 (0)