Feat: 🛠️ Add client-executed tools & human-in-the-loop tool approval flow#932
Feat: 🛠️ Add client-executed tools & human-in-the-loop tool approval flow#932vinitkadam03 wants to merge 8 commits intoprism-php:mainfrom
Conversation
7483218 to
c1840a7
Compare
|
fixes: #921 |
0dc55ee to
a04633b
Compare
a04633b to
ac64d0a
Compare
|
Can this work with a flow that uses |
@emiliopedrollo I haven't tested with $response = Prism::text()
->using('openai', 'gpt-4o')
->withTools([$tool])
->withMessages([
new AssistantMessage('', $previousResponse->toolCalls, [], $previousResponse->toolApprovalRequests),
new ToolResultMessage([], [new ToolApprovalResponse(approvalId: 'call_123', approved: true)]),
])
// id of previous response that ended in stop, etc i.e non tool-calls and streaming completed.
->withProviderOptions(['previous_response_id' => $lastCompleteResponse->meta->id])
->asText();The |
|
I had Claude do a first pass at this. Can you take a look and let me know what you think? I hype on this feature. BugAnthropic Structured handler missing
Architecture
new ToolApprovalRequest(approvalId: $tc->id, toolCallId: $tc->id)If they're always identical, why have two fields? The PR description mentions "Vercel AI SDK format" but Prism isn't the Vercel AI SDK. Is there a real use case where they diverge? I'd love to hear it. Otherwise we should collapse them into a single identifier.
The constructor now accepts public function __construct(
public readonly array $toolResults = [],
public readonly array $toolApprovalResponses = []
) {}This conflates two responsibilities. A
A mutable boolean threaded through readonly class ToolExecutionResult {
public function __construct(
public array $toolResults,
public array $approvalRequests,
public bool $hasPendingToolCalls,
) {}
}
I'd suggest using a dedicated Repeated boilerplate across all providers Every provider Text handler now has this identical block: $hasPendingToolCalls = false;
$approvalRequests = [];
$toolResults = $this->callTools($request->tools(), $toolCalls, $hasPendingToolCalls, $approvalRequests);
$toolApprovalRequests = array_map(
fn (ToolCall $tc): ToolApprovalRequest => new ToolApprovalRequest(
approvalId: $tc->id, toolCallId: $tc->id
),
$approvalRequests,
);This SecurityDynamic approval closures receive raw LLM arguments ->requiresApproval(fn (array $args): bool => $args['amount'] > 1000)The Style
Making Vercel SDK references in internal docblocks /**
* Vercel AI SDK compatible format: { approvalId, type: 'tool-approval-response', approved }
*/Prism's internal value objects shouldn't couple their documentation to third-party protocols. If Vercel compatibility is a goal, note it in the docs rather than the class docblock. TestingTests cover the happy paths well for client-executed tools, mixed tools, and streaming. I'd like to see coverage for:
Summary
|
|
Bug: Anthropic Structured handler missing resolveToolApprovals High: Replace High: Collapse High: Extract approval boilerplate from provider handlers into the trait Medium: Consider a dedicated approval message type instead of overloading Medium: Replace Medium: Add edge-case tests Low: Remove Vercel SDK references from internal docblocks Low: Document that approval closure args are untrusted LLM output |
…th approval, and request payload validation fix
ebf2088 to
aa3c746
Compare
Summary
This PR introduces support for client-executed tools that are intended to be executed by the client/caller rather than by Prism, and a tool approval flow for tools that require explicit user consent before server-side execution.
1. Client Executed Tools
Motivation
Client-executed tools enable scenarios where tool execution must happen on the client side, such as:
Behavior:
FinishReason::ToolCallsUsage Example
2. Tool Approval Flow
Motivation
Tool approval enables scenarios where the server can execute a tool but should only do so after explicit user consent:
The flow is stateless and operates in two phases.
Phase 1: Approval Request (stream stops)
When the LLM calls an approval-required tool, Prism emits a
ToolApprovalRequestEventand stops — returning control to the client.Event chain (streaming):
Phase 2: Approval Resolution (tool executes, LLM continues)
The client sends a new request with messages containing
ToolApprovalResponses. Before making the HTTP call to the LLM,resolveToolApprovalsAndYieldEvents()executes approved tools and creates denial results for denied ones. The LLM then continues the conversation with the tool results.Event chain (streaming):
Key behaviors:
StreamStartEventis emitted from approval resolution (before the HTTP call), so the client knows the stream is live before tool results arriveStreamStartEvent— once emitted, theStreamStatesuppresses it from the subsequent HTTP streamtool-output-availablearrives without a priortool-input-availablein Phase 2 (that was sent in Phase 1)ToolResultEventwith the denial reasonToolResultMessage(existing results + resolved results) is placed on the request before calling the LLMUsage Example
Phase 2 continuation (client sends approval responses):
Breaking Changes
None. This is a backward-compatible addition. Existing tools with handlers continue to work exactly as before. Tools without
requiresApproval()orclientExecuted()are unaffected.