mirror of
https://github.com/symfony/ai-agent.git
synced 2026-03-23 23:12:11 +01:00
[Agent] Add ToolCallRequested event for human-in-the-loop tool confirmation
This commit is contained in:
committed by
Oskar Stark
parent
d1d97f1b86
commit
e359cd5429
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.7
|
||||
---
|
||||
|
||||
* Add `ToolCallRequested` event dispatched before tool execution
|
||||
|
||||
0.4
|
||||
---
|
||||
|
||||
|
||||
85
src/Toolbox/Event/ToolCallRequested.php
Normal file
85
src/Toolbox/Event/ToolCallRequested.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Agent\Toolbox\Event;
|
||||
|
||||
use Psr\EventDispatcher\StoppableEventInterface;
|
||||
use Symfony\AI\Agent\Toolbox\ToolResult;
|
||||
use Symfony\AI\Platform\Result\ToolCall;
|
||||
use Symfony\AI\Platform\Tool\Tool;
|
||||
|
||||
/**
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class ToolCallRequested implements StoppableEventInterface
|
||||
{
|
||||
private bool $denied = false;
|
||||
private ?string $denialReason = null;
|
||||
private ?ToolResult $result = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ToolCall $toolCall,
|
||||
private readonly Tool $metadata,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getToolCall(): ToolCall
|
||||
{
|
||||
return $this->toolCall;
|
||||
}
|
||||
|
||||
public function getMetadata(): Tool
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny the tool execution with an optional reason.
|
||||
*/
|
||||
public function deny(?string $reason = null): void
|
||||
{
|
||||
$this->denied = true;
|
||||
$this->denialReason = $reason;
|
||||
}
|
||||
|
||||
public function isDenied(): bool
|
||||
{
|
||||
return $this->denied;
|
||||
}
|
||||
|
||||
public function getDenialReason(): ?string
|
||||
{
|
||||
return $this->denialReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom result to skip the actual tool execution.
|
||||
*/
|
||||
public function setResult(ToolResult $result): void
|
||||
{
|
||||
$this->result = $result;
|
||||
}
|
||||
|
||||
public function hasResult(): bool
|
||||
{
|
||||
return null !== $this->result;
|
||||
}
|
||||
|
||||
public function getResult(): ?ToolResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
public function isPropagationStopped(): bool
|
||||
{
|
||||
return $this->denied || null !== $this->result;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallFailed;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallSucceeded;
|
||||
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException;
|
||||
use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionExceptionInterface;
|
||||
@@ -69,6 +70,20 @@ final class Toolbox implements ToolboxInterface
|
||||
public function execute(ToolCall $toolCall): ToolResult
|
||||
{
|
||||
$metadata = $this->getMetadata($toolCall);
|
||||
|
||||
$event = new ToolCallRequested($toolCall, $metadata);
|
||||
$this->eventDispatcher?->dispatch($event);
|
||||
|
||||
if ($event->isDenied()) {
|
||||
$this->logger->debug(\sprintf('Tool "%s" denied: %s', $toolCall->getName(), $event->getDenialReason()));
|
||||
|
||||
return new ToolResult($toolCall, $event->getDenialReason() ?? 'Tool execution denied.');
|
||||
}
|
||||
|
||||
if ($event->hasResult()) {
|
||||
return $event->getResult();
|
||||
}
|
||||
|
||||
$tool = $this->getExecutable($metadata);
|
||||
|
||||
try {
|
||||
|
||||
107
tests/Toolbox/Event/ToolCallRequestedTest.php
Normal file
107
tests/Toolbox/Event/ToolCallRequestedTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Agent\Tests\Toolbox\Event;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
|
||||
use Symfony\AI\Agent\Toolbox\ToolResult;
|
||||
use Symfony\AI\Platform\Result\ToolCall;
|
||||
use Symfony\AI\Platform\Tool\ExecutionReference;
|
||||
use Symfony\AI\Platform\Tool\Tool;
|
||||
|
||||
final class ToolCallRequestedTest extends TestCase
|
||||
{
|
||||
private ToolCall $toolCall;
|
||||
private Tool $metadata;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->toolCall = new ToolCall('call_123', 'my_tool', ['arg' => 'value']);
|
||||
$this->metadata = new Tool(new ExecutionReference(self::class, '__invoke'), 'my_tool', 'A test tool');
|
||||
}
|
||||
|
||||
public function testGetToolCall()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$this->assertSame($this->toolCall, $event->getToolCall());
|
||||
}
|
||||
|
||||
public function testGetMetadata()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$this->assertSame($this->metadata, $event->getMetadata());
|
||||
}
|
||||
|
||||
public function testInitialStateIsNotDenied()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$this->assertFalse($event->isDenied());
|
||||
$this->assertNull($event->getDenialReason());
|
||||
}
|
||||
|
||||
public function testDeny()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$event->deny('Not allowed');
|
||||
|
||||
$this->assertTrue($event->isDenied());
|
||||
$this->assertSame('Not allowed', $event->getDenialReason());
|
||||
}
|
||||
|
||||
public function testInitialStateHasNoResult()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$this->assertFalse($event->hasResult());
|
||||
$this->assertNull($event->getResult());
|
||||
}
|
||||
|
||||
public function testSetResult()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
$result = new ToolResult($this->toolCall, 'custom result');
|
||||
|
||||
$event->setResult($result);
|
||||
|
||||
$this->assertTrue($event->hasResult());
|
||||
$this->assertSame($result, $event->getResult());
|
||||
}
|
||||
|
||||
public function testPropagationNotStoppedInitially()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$this->assertFalse($event->isPropagationStopped());
|
||||
}
|
||||
|
||||
public function testPropagationStoppedWhenDenied()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$event->deny('Denied');
|
||||
|
||||
$this->assertTrue($event->isPropagationStopped());
|
||||
}
|
||||
|
||||
public function testPropagationStoppedWhenResultSet()
|
||||
{
|
||||
$event = new ToolCallRequested($this->toolCall, $this->metadata);
|
||||
|
||||
$event->setResult(new ToolResult($this->toolCall, 'result'));
|
||||
|
||||
$this->assertTrue($event->isPropagationStopped());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use Symfony\AI\Agent\Tests\Fixtures\Tool\ToolException;
|
||||
use Symfony\AI\Agent\Tests\Fixtures\Tool\ToolNoParams;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallArgumentsResolved;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallFailed;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
|
||||
use Symfony\AI\Agent\Toolbox\Event\ToolCallSucceeded;
|
||||
use Symfony\AI\Agent\Toolbox\Toolbox;
|
||||
use Symfony\AI\Platform\Result\ToolCall;
|
||||
@@ -68,6 +69,7 @@ final class ToolboxEventDispatcherTest extends TestCase
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
$this->assertSame([
|
||||
ToolCallRequested::class,
|
||||
ToolCallArgumentsResolved::class,
|
||||
ToolCallFailed::class,
|
||||
], $this->dispatchedEvents);
|
||||
@@ -80,6 +82,7 @@ final class ToolboxEventDispatcherTest extends TestCase
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
$this->assertSame([
|
||||
ToolCallRequested::class,
|
||||
ToolCallArgumentsResolved::class,
|
||||
ToolCallFailed::class,
|
||||
], $this->dispatchedEvents);
|
||||
@@ -92,6 +95,7 @@ final class ToolboxEventDispatcherTest extends TestCase
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
$this->assertSame([
|
||||
ToolCallRequested::class,
|
||||
ToolCallArgumentsResolved::class,
|
||||
ToolCallSucceeded::class,
|
||||
], $this->dispatchedEvents);
|
||||
|
||||
Reference in New Issue
Block a user