Skip to content

Add human-in-the-loop tool approval#209

Open
zayedadel wants to merge 1 commit intolaravel:0.xfrom
zayedadel:feature/tool-approval
Open

Add human-in-the-loop tool approval#209
zayedadel wants to merge 1 commit intolaravel:0.xfrom
zayedadel:feature/tool-approval

Conversation

@zayedadel
Copy link

@zayedadel zayedadel commented Feb 25, 2026

[0.x] Introduce Human-in-the-Loop Tool Approval

Summary

This PR introduces a fully opt-in, stateless mechanism for requiring human approval before specific tools are executed.

As AI agents are given more destructive or sensitive capabilities (deleting records, sending emails, generating payments), developers need a straightforward way to interrupt the agent's execution loop, present the pending action to a human, and then easily resume execution upon approval or rejection.

How it Works

The implementation leans heavily into Laravel's existing conventions, avoiding completely the need for new dependencies or modifications to the underlying Prism package.

1. Tools opt-in via a requiresApproval() method:

class DeleteUserFiles implements Tool
{
    public function requiresApproval(): bool
    {
        return true; 
    }
    // ...
}

2. The Prompt returns a PendingApprovalResponse instead of executing:
If an agent attempts to call an opted-in tool, the execution loop is gracefully intercepted via an internal ToolApprovalRequiredException. The prompt call returns a PendingApprovalResponse containing the pending tool calls, rather than an executed text/structured response.

$response = (new FileManager)->prompt('Delete files for user 42');

if ($response instanceof PendingApprovalResponse) {
    // Present $response->pendingToolCalls to the user
}

3. Developers approve (or reject) to resume from where the agent paused:
Using the new HasApprovalFlow trait, agents gain approve() and reject() methods that execute the tool (if approved) and dynamically re-prompt the agent with the result or rejection reason.

$response = (new FileManager)->approve($pendingToolCall);

Known Limitations

Because laravel/ai currently leverages Prism's internal synchronous step loop, it cannot literally "pause" a process mid-flight and resume it from memory later. Thus, calling approve() or reject() relies on re-prompting the agent in a new API call with the tool's result injected into the conversation.

This works perfectly, but incurs an extra LLM API call. I believe this is an acceptable trade-off for the simplicity and power of human-in-the-loop flows without needing background workers natively.

Next Steps / Discussion:
Currently, the developer is responsible for storing the PendingToolCall between requests (e.g., in the session or cache). If you feel laravel/ai should prescribe a default persistence mechanism (similar to how RemembersConversations opinionates a DB schema), I would be happy to build that out in a follow-up commit or PR.

Tests

Added comprehensive Feature tests asserting the interception flow, event dispatching, and conditional toggling. Zero regressions on the existing unit and integration suite.

@zayedadel zayedadel marked this pull request as ready for review February 25, 2026 06:12
@MaestroError
Copy link

@zayedadel Does it mean that it re-prompts tool results as UserMessage? In case you store messages in DB, will there be 2 User Messages in sequence? Or the first user message (which invoked the tool call) get lost?

@zayedadel
Copy link
Author

zayedadel commented Feb 25, 2026

@MaestroError

@zayedadel Does it mean that it re-prompts tool results as UserMessage? In case you store messages in DB, will there be 2 User Messages in sequence? Or the first user message (which invoked the tool call) get lost?

yes it does unfortunately, as for the db storage quirk it can be mitigated or refactored into a better solution, however i wanted to get this pr up first to see if the maintainers approve of the general 'human-in-the-loop' concept and approach before over-engineering the message storage state or enforce my own opinions.

@grahamsutton
Copy link

@zayedadel This is would be a great addition. I certainly have a need for HITL for something I am currently working on.

Considering that Laravel has been really leaning into annotations, have you considered perhaps instead of doing:

class DeleteUserFiles implements Tool
{
    public function requiresApproval(): bool
    {
        return true; 
    }
    // ...
}

doing this instead?

#[RequiresApproval]
class DeleteUserFiles implements Tool
{
    // ...
}

@zayedadel
Copy link
Author

@grahamsutton yeah i actually went that way but i had to use reflection and i didn't like that so i chose to avoid it , none the less it'll be very easy to add , i just wanted the pr up to see if it is going anywhere before overthinking it , lets see what @taylorotwell have in mind for it then proceed with that.

@nicodevs
Copy link
Contributor

Hey @zayedadel,

How does this work in multiple tool call chains? For example, if you instruct the AI to “save an image, rename a folder, compress it,” how would it proceed if the second action requires confirmation?

If I understand this correctly, since this only re-prompts the LLM, the first action will be executed, the second one will be executed but return a “pending” status, and the third one will also be executed. After user confirmation, the second action will be executed. However, at that point, the folder has already been compressed (with an incorrect name).

In summary, I think this might become unreliable in the case of tool chains. To achieve true human-in-the-loop flows, I believe the tool should not return a message until confirmation is received. This would unfortunately require blocking the tool’s response until confirmation is obtained (for example, by checking for a given condition until a certain timeout).

@zayedadel
Copy link
Author

@nicodevs
the actual mechanics of how it breaks are slightly different due to how PHP handles the exception.

Because I am throwing a ToolApprovalRequiredException from inside the tool's closure, PHP immediately halts execution and unwinds the stack. So if the LLM requests [Tool 1, Tool 2 (needs approval), Tool 3]:

Tool 1 executes.
Tool 2 throws the exception, returning the PendingApprovalResponse.
Tool 3 is never executed. The loop is aborted.

You are absolutely right about the resulting unreliability, though. When the developer calls approve() for Tool 2 later, the agent is re-prompted. The LLM usually realizes it still needs to execute Tool 3 based on the conversation history, but we are essentially forcing the LLM to 're-plan' the rest of the chain, which is definitely fragile.

Given the current asynchronous/stateless constraints of typical PHP web requests, do you think this stateless 're-prompt' workaround is acceptable for a first iteration (perhaps with a note in the docs about tool chains), or we will have to hold off until a true blocking/pausing mechanism can be engineered at the Prism level.

@zayedadel zayedadel marked this pull request as draft March 1, 2026 08:58
@zayedadel zayedadel marked this pull request as ready for review March 1, 2026 08:58
@nicodevs
Copy link
Contributor

nicodevs commented Mar 6, 2026

I see, thank you so much for explaining @zayedadel

Given the current asynchronous/stateless constraints of typical PHP web requests, do you think this stateless 're-prompt' workaround is acceptable for a first iteration (perhaps with a note in the docs about tool chains), or we will have to hold off until a true blocking/pausing mechanism can be engineered at the Prism level.

In a side project, I am currently using Redis blpop to block execution until receiving a confirmation:

use Illuminate\Support\Facades\Redis;

$key = 'foo';
$timeout = 120;

askUserForConfirmation();

$reply = Redis::blpop($key, $timeout);

if (!$reply) {
    return 'Confirmation timed out';
}

if (strtolower((string) $reply[1]) === 'yes') {
    return executeSomething();
}

return 'User did not confirm execution';

In this example, askUserForConfirmation would send a message to the user and set their input in that Redis key, unblocking the execution of line 10 onward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants