[Agent] Add ToolCallRequested event for human-in-the-loop tool confirmation

This commit is contained in:
Christopher Hertel
2026-03-14 17:53:07 +01:00
committed by Oskar Stark
parent d1d97f1b86
commit e359cd5429
5 changed files with 216 additions and 0 deletions

View File

@@ -1,6 +1,11 @@
CHANGELOG
=========
0.7
---
* Add `ToolCallRequested` event dispatched before tool execution
0.4
---

View 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;
}
}

View File

@@ -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 {

View 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());
}
}

View File

@@ -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);