[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 d9fb5ef744
commit c476ed32f4
9 changed files with 517 additions and 0 deletions

View File

@@ -389,10 +389,15 @@ Tool Call Lifecycle Events
~~~~~~~~~~~~~~~~~~~~~~~~~~
If you need to react more granularly to the lifecycle of individual tool calls, you can listen to the
:class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallRequested`,
:class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallArgumentsResolved`,
:class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallSucceeded` and
:class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallFailed` events. These are dispatched at different stages::
$eventDispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event): void {
// Intercept a tool call before execution, e.g. to deny it or set a custom result
});
$eventDispatcher->addListener(ToolCallArgumentsResolved::class, function (ToolCallArgumentsResolved $event): void {
// Let the client know, that the tool $event->toolCall->name was executed
});
@@ -405,6 +410,11 @@ If you need to react more granularly to the lifecycle of individual tool calls,
// Let the client know, that the tool $event->toolCall->name failed with the exception: $event->exception
});
See the :doc:`/cookbook/human-in-the-loop` cookbook article for a complete guide on building a human-in-the-loop
confirmation system using the ``ToolCallRequested`` event.
* `Human-in-the-Loop Confirmation`_
Excluding Tool Messages from MessageBag
---------------------------------------
@@ -755,3 +765,4 @@ Code Examples
.. _`RAG with Pinecone`: https://github.com/symfony/ai/blob/main/examples/rag/pinecone.php
.. _`Chat with static memory`: https://github.com/symfony/ai/blob/main/examples/memory/static.php
.. _`Chat with embedding search memory`: https://github.com/symfony/ai/blob/main/examples/memory/mariadb.php
.. _`Human-in-the-Loop Confirmation`: https://github.com/symfony/ai/blob/main/examples/toolbox/confirmation.php

View File

@@ -0,0 +1,240 @@
Human-in-the-Loop Tool Confirmation
===================================
When AI agents execute tools, some actions — like deleting files, sending emails, or modifying
data — should require human approval. This guide shows how to build a confirmation system using
the :class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallRequested` event.
Prerequisites
-------------
* Symfony AI Platform component
* Symfony AI Agent component
* Symfony EventDispatcher component
How It Works
------------
The :class:`Symfony\\AI\\Agent\\Toolbox\\Toolbox` dispatches a
:class:`Symfony\\AI\\Agent\\Toolbox\\Event\\ToolCallRequested` event before each tool execution.
An event listener can inspect the tool call and either:
* **Allow it** — do nothing, the tool executes normally
* **Deny it** — call ``$event->deny($reason)`` to block execution and return the reason to the LLM
* **Replace it** — call ``$event->setResult($result)`` to skip execution and return a custom result
Basic Example: Confirm Every Tool Call
--------------------------------------
The simplest approach asks for confirmation on every tool call::
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event): void {
$toolCall = $event->getToolCall();
echo \sprintf(
"Tool '%s' wants to execute with args: %s\nAllow? [y/N] ",
$toolCall->getName(),
json_encode($toolCall->getArguments())
);
$input = strtolower(trim(fgets(\STDIN)));
if ('y' !== $input) {
$event->deny('User denied tool execution.');
}
});
Pass this dispatcher to the :class:`Symfony\\AI\\Agent\\Toolbox\\Toolbox`::
use Symfony\AI\Agent\Toolbox\Toolbox;
$toolbox = new Toolbox($tools, eventDispatcher: $dispatcher);
Adding a Policy Layer
---------------------
In practice, you don't want to confirm every single call. A policy decides which tools need
confirmation and which can run automatically. Here is an outline for a policy-based approach.
Step 1: Define a Policy
~~~~~~~~~~~~~~~~~~~~~~~
A policy inspects the tool call and returns a decision::
enum PolicyDecision
{
case Allow;
case Deny;
case AskUser;
}
interface PolicyInterface
{
public function decide(ToolCall $toolCall): PolicyDecision;
}
A simple policy could auto-allow read operations based on tool name patterns::
use Symfony\AI\Platform\Result\ToolCall;
class ReadAllowPolicy implements PolicyInterface
{
public function decide(ToolCall $toolCall): PolicyDecision
{
$name = strtolower($toolCall->getName());
foreach (['read', 'get', 'list', 'search', 'find', 'show'] as $pattern) {
if (str_contains($name, $pattern)) {
return PolicyDecision::Allow;
}
}
return PolicyDecision::AskUser;
}
}
Step 2: Build a Confirmation Handler
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The confirmation handler is responsible for prompting the user and returning a decision.
Its implementation depends on your application context — CLI, web, async, etc.::
use Symfony\AI\Platform\Result\ToolCall;
class CliConfirmationHandler
{
public function confirm(ToolCall $toolCall): bool
{
echo \sprintf(
"Allow tool '%s' with args %s? [y/N] ",
$toolCall->getName(),
json_encode($toolCall->getArguments())
);
return 'y' === strtolower(trim(fgets(\STDIN)));
}
}
For web applications, you might store pending confirmations in a database and wait for
a user response through an HTTP endpoint or WebSocket.
Step 3: Wire It Together
~~~~~~~~~~~~~~~~~~~~~~~~
Combine the policy and handler in an event listener::
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
$policy = new ReadAllowPolicy();
$handler = new CliConfirmationHandler();
$dispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event) use ($policy, $handler): void {
$decision = $policy->decide($event->getToolCall());
if (PolicyDecision::Allow === $decision) {
return; // Auto-approved, proceed with execution
}
if (PolicyDecision::Deny === $decision) {
$event->deny('Tool blocked by policy.');
return;
}
// PolicyDecision::AskUser
if (!$handler->confirm($event->getToolCall())) {
$event->deny('User denied tool execution.');
}
});
Remembering User Decisions
--------------------------
To avoid asking the user repeatedly for the same tool, you can cache decisions::
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
$decisions = [];
$dispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event) use (&$decisions): void {
$toolName = $event->getToolCall()->getName();
if (isset($decisions[$toolName])) {
if (!$decisions[$toolName]) {
$event->deny('Tool previously denied by user.');
}
return;
}
echo \sprintf(
"Allow tool '%s'? [y/N/always/never] ",
$toolName
);
$input = strtolower(trim(fgets(\STDIN)));
$allowed = \in_array($input, ['y', 'always'], true);
if (\in_array($input, ['always', 'never'], true)) {
$decisions[$toolName] = $allowed;
}
if (!$allowed) {
$event->deny('User denied tool execution.');
}
});
Using Tool Metadata
-------------------
The event also provides access to the tool's metadata via ``$event->getMetadata()``, which
includes the tool's description and parameter schema. This can be useful for displaying
more context to the user before they decide::
$dispatcher->addListener(ToolCallRequested::class, function (ToolCallRequested $event): void {
$metadata = $event->getMetadata();
echo \sprintf(
"Tool: %s\nDescription: %s\nArguments: %s\n",
$metadata->getName(),
$metadata->getDescription(),
json_encode($event->getToolCall()->getArguments())
);
// ... ask for confirmation
});
Integration with Symfony Framework
----------------------------------
In a Symfony application, register the listener as a service::
# config/services.yaml
services:
App\EventListener\ToolConfirmationListener:
tags:
- { name: kernel.event_listener, event: Symfony\AI\Agent\Toolbox\Event\ToolCallRequested }
And implement the listener::
namespace App\EventListener;
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
class ToolConfirmationListener
{
public function __invoke(ToolCallRequested $event): void
{
// Your confirmation logic here
}
}
Code Examples
-------------
* `Human-in-the-Loop Confirmation Example <https://github.com/symfony/ai/blob/main/examples/toolbox/confirmation.php>`_

View File

@@ -12,6 +12,7 @@ Getting Started Guides
chatbot-with-memory
dynamic-tools
human-in-the-loop
rag-implementation
structured-output-object-instances
@@ -24,6 +25,7 @@ Tools
-----
* :doc:`dynamic-tools` - Build a dynamic Toolbox for flexible tool management at runtime
* :doc:`human-in-the-loop` - Implement human-in-the-loop confirmation for tool execution
Retrieval Augmented Generation
------------------------------

View File

@@ -0,0 +1,48 @@
<?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.
*/
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Bridge\Filesystem\Filesystem;
use Symfony\AI\Agent\Toolbox\AgentProcessor;
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
use Symfony\AI\Agent\Toolbox\Toolbox;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addListener(ToolCallRequested::class, static function (ToolCallRequested $event): void {
output()->write(sprintf('Allow tool "%s"? [y/N] ', $event->getToolCall()->getName()));
if ('y' !== strtolower(trim(fgets(\STDIN)))) {
$event->deny('User denied tool execution.');
}
});
$toolbox = new Toolbox([new Filesystem(new SymfonyFilesystem(), __DIR__)], logger: logger(), eventDispatcher: $eventDispatcher);
$processor = new AgentProcessor($toolbox, eventDispatcher: $eventDispatcher);
$agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]);
$messages = new MessageBag(Message::ofUser('First, list the files in this folder. Then delete the file confirmation.php'));
$result = $agent->call($messages, ['stream' => true]);
foreach ($result->getContent() as $chunk) {
echo $chunk;
}
echo \PHP_EOL;

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