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 ToolCallRequested event.
Prerequisites
- Symfony AI Platform component
- Symfony AI Agent component
- Symfony EventDispatcher component
Step 1: Install Packages
Install the Platform and Agent components together with the EventDispatcher:
1
composer require symfony/ai-platform symfony/ai-agent symfony/event-dispatcher
Step 2: Confirm Every Tool Call
The Toolbox dispatches a 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
The simplest approach asks for confirmation on every tool call:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
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 Toolbox:
1 2 3
use Symfony\AI\Agent\Toolbox\Toolbox;
$toolbox = new Toolbox($tools, eventDispatcher: $dispatcher);
Step 3: Add a Policy
In practice, you don't want to confirm every single call. A policy decides which tools need confirmation and which can run automatically:
1 2 3 4 5 6 7 8 9 10 11
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
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 4: Build a Confirmation Handler
The confirmation handler prompts the user and returns a decision. Its implementation depends on your application context — CLI, web, async, etc.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
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.
Tip
The event also exposes the tool's definition via $event->getDefinition(), which returns a
Tool object whose getDescription() and getParameters() methods expose the tool's
description and parameter schema. Use it to show the user more context before they decide.
Step 5: Wire the Policy and Handler Together
Combine the policy and handler in an event listener:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
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.');
}
});
Step 6: Remember User Decisions
To avoid asking the user repeatedly for the same tool, cache decisions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
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.');
}
});
Step 7: Register the Listener in Symfony
In a Symfony application, the
AsEventListener attribute registers the
listener automatically — no service configuration needed. The event is inferred from the
__invoke() argument:
1 2 3 4 5 6 7 8 9 10 11 12 13
namespace App\EventListener;
use Symfony\AI\Agent\Toolbox\Event\ToolCallRequested;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
class ToolConfirmationListener
{
public function __invoke(ToolCallRequested $event): void
{
// Your confirmation logic here
}
}
Learn More
- Human-in-the-Loop Confirmation Example
- Tool Calling with Agents - Build and register custom tools
- Symfony AI - Agent Component - Agent component documentation