Skip to content

Commit 0e7cd0e

Browse files
committed
feat: client executed tools
1 parent aa0f29d commit 0e7cd0e

File tree

72 files changed

+1395
-61
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+1395
-61
lines changed

docs/core-concepts/tools-function-calling.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,75 @@ use Prism\Prism\Facades\Tool;
388388
$tool = Tool::make(CurrentWeatherTool::class);
389389
```
390390

391+
## Client-Executed Tools
392+
393+
Sometimes you need tools that are executed by the client (e.g., frontend application) rather than on the server. Client-executed tools are defined without a handler function.
394+
395+
### Explicit Declaration (Recommended)
396+
397+
Use the `clientExecuted()` method to explicitly mark a tool as client-executed:
398+
399+
```php
400+
use Prism\Prism\Facades\Tool;
401+
402+
$clientTool = Tool::as('browser_action')
403+
->for('Perform an action in the user\'s browser')
404+
->withStringParameter('action', 'The action to perform')
405+
->clientExecuted();
406+
```
407+
408+
This makes your intent clear and self-documenting.
409+
410+
### Implicit Declaration
411+
412+
You can also create a client-executed tool by simply omitting the `using()` call:
413+
414+
```php
415+
use Prism\Prism\Facades\Tool;
416+
417+
$clientTool = Tool::as('browser_action')
418+
->for('Perform an action in the user\'s browser')
419+
->withStringParameter('action', 'The action to perform');
420+
// No using() call - tool is implicitly client-executed
421+
```
422+
423+
When the AI calls a client-executed tool, Prism will:
424+
1. Stop execution and return control to your application
425+
2. Set the response's `finishReason` to `FinishReason::ToolCalls`
426+
3. Include the tool calls in the response for your client to execute
427+
428+
### Handling Client-Executed Tools
429+
430+
```php
431+
use Prism\Prism\Facades\Prism;
432+
use Prism\Prism\Enums\FinishReason;
433+
434+
$response = Prism::text()
435+
->using('anthropic', 'claude-3-5-sonnet-latest')
436+
->withTools([$clientTool])
437+
->withMaxSteps(3)
438+
->withPrompt('Click the submit button')
439+
->asText();
440+
441+
```
442+
443+
### Streaming with Client-Executed Tools
444+
445+
When streaming, client-executed tools emit a `ToolCallEvent` but no `ToolResultEvent`:
446+
447+
```php
448+
449+
$response = Prism::text()
450+
->using('anthropic', 'claude-3-5-sonnet-latest')
451+
->withTools([$clientTool])
452+
->withMaxSteps(3)
453+
->withPrompt('Click the submit button')
454+
->asStream();
455+
```
456+
457+
> [!NOTE]
458+
> Client-executed tools are useful for scenarios like browser automation, UI interactions, or any operation that must run on the user's device rather than the server.
459+
391460
## Tool Choice Options
392461

393462
You can control how the AI uses tools with the `withToolChoice` method:

src/Concerns/CallsTools.php

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
use Illuminate\Support\Facades\Concurrency;
99
use Illuminate\Support\ItemNotFoundException;
1010
use Illuminate\Support\MultipleItemsFoundException;
11+
use JsonException;
12+
use Prism\Prism\Enums\FinishReason;
1113
use Prism\Prism\Exceptions\PrismException;
1214
use Prism\Prism\Streaming\EventID;
1315
use Prism\Prism\Streaming\Events\ArtifactEvent;
16+
use Prism\Prism\Streaming\Events\StepFinishEvent;
17+
use Prism\Prism\Streaming\Events\StreamEndEvent;
1418
use Prism\Prism\Streaming\Events\ToolResultEvent;
19+
use Prism\Prism\Streaming\StreamState;
1520
use Prism\Prism\Tool;
1621
use Prism\Prism\ValueObjects\ToolCall;
1722
use Prism\Prism\ValueObjects\ToolOutput;
@@ -25,13 +30,15 @@ trait CallsTools
2530
* @param Tool[] $tools
2631
* @param ToolCall[] $toolCalls
2732
* @return ToolResult[]
33+
*
34+
* @throws PrismException|JsonException
2835
*/
29-
protected function callTools(array $tools, array $toolCalls): array
36+
protected function callTools(array $tools, array $toolCalls, bool &$hasPendingToolCalls): array
3037
{
3138
$toolResults = [];
3239

3340
// Consume generator to execute all tools and collect results
34-
foreach ($this->callToolsAndYieldEvents($tools, $toolCalls, EventID::generate(), $toolResults) as $event) {
41+
foreach ($this->callToolsAndYieldEvents($tools, $toolCalls, EventID::generate(), $toolResults, $hasPendingToolCalls) as $event) {
3542
// Events are discarded for non-streaming handlers
3643
}
3744

@@ -46,13 +53,15 @@ protected function callTools(array $tools, array $toolCalls): array
4653
* @param ToolResult[] $toolResults Results are collected into this array by reference
4754
* @return Generator<ToolResultEvent|ArtifactEvent>
4855
*/
49-
protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator
56+
protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults, bool &$hasPendingToolCalls): Generator
5057
{
51-
$groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $toolCalls);
58+
$serverToolCalls = $this->filterServerExecutedToolCalls($tools, $toolCalls, $hasPendingToolCalls);
59+
60+
$groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $serverToolCalls);
5261

5362
$executionResults = $this->executeToolsWithConcurrency($tools, $groupedToolCalls, $messageId);
5463

55-
foreach (array_keys($toolCalls) as $index) {
64+
foreach (collect($executionResults)->keys()->sort() as $index) {
5665
$result = $executionResults[$index];
5766

5867
$toolResults[] = $result['toolResult'];
@@ -64,8 +73,39 @@ protected function callToolsAndYieldEvents(array $tools, array $toolCalls, strin
6473
}
6574

6675
/**
76+
* Filter out client-executed tool calls, setting the pending flag if any are found.
77+
*
6778
* @param Tool[] $tools
6879
* @param ToolCall[] $toolCalls
80+
* @return array<int, ToolCall> Server-executed tool calls with original indices preserved
81+
*/
82+
protected function filterServerExecutedToolCalls(array $tools, array $toolCalls, bool &$hasPendingToolCalls): array
83+
{
84+
$serverToolCalls = [];
85+
86+
foreach ($toolCalls as $index => $toolCall) {
87+
try {
88+
$tool = $this->resolveTool($toolCall->name, $tools);
89+
90+
if ($tool->isClientExecuted()) {
91+
$hasPendingToolCalls = true;
92+
93+
continue;
94+
}
95+
96+
$serverToolCalls[$index] = $toolCall;
97+
} catch (PrismException) {
98+
// Unknown tool - keep it so error handling works in executeToolCall
99+
$serverToolCalls[$index] = $toolCall;
100+
}
101+
}
102+
103+
return $serverToolCalls;
104+
}
105+
106+
/**
107+
* @param Tool[] $tools
108+
* @param array<int, ToolCall> $toolCalls
69109
* @return array{concurrent: array<int, ToolCall>, sequential: array<int, ToolCall>}
70110
*/
71111
protected function groupToolCallsByConcurrency(array $tools, array $toolCalls): array
@@ -197,8 +237,31 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes
197237
}
198238
}
199239

240+
/**
241+
* Yield stream completion events when client-executed tools are pending.
242+
*
243+
* @return Generator<StepFinishEvent|StreamEndEvent>
244+
*/
245+
protected function yieldToolCallsFinishEvents(StreamState $state): Generator
246+
{
247+
yield new StepFinishEvent(
248+
id: EventID::generate(),
249+
timestamp: time()
250+
);
251+
252+
yield new StreamEndEvent(
253+
id: EventID::generate(),
254+
timestamp: time(),
255+
finishReason: FinishReason::ToolCalls,
256+
usage: $state->usage(),
257+
citations: $state->citations(),
258+
);
259+
}
260+
200261
/**
201262
* @param Tool[] $tools
263+
*
264+
* @throws PrismException
202265
*/
203266
protected function resolveTool(string $name, array $tools): Tool
204267
{

src/Exceptions/PrismException.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,11 @@ public static function unsupportedProviderAction(string $method, string $provide
9494
$provider,
9595
));
9696
}
97+
98+
public static function toolHandlerNotDefined(string $toolName): self
99+
{
100+
return new self(
101+
sprintf('Tool (%s) has no handler defined', $toolName)
102+
);
103+
}
97104
}

src/Providers/Anthropic/Handlers/Stream.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,15 @@ protected function handleToolCalls(Request $request, int $depth): Generator
499499

500500
// Execute tools and emit results
501501
$toolResults = [];
502-
yield from $this->callToolsAndYieldEvents($request->tools(), $toolCalls, $this->state->messageId(), $toolResults);
502+
$hasPendingToolCalls = false;
503+
yield from $this->callToolsAndYieldEvents($request->tools(), $toolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
504+
505+
if ($hasPendingToolCalls) {
506+
$this->state->markStepFinished();
507+
yield from $this->yieldToolCallsFinishEvents($this->state);
508+
509+
return;
510+
}
503511

504512
// Add messages to request for next turn
505513
if ($toolResults !== []) {

src/Providers/Anthropic/Handlers/Structured.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ protected function handleToolCalls(array $toolCalls, Response $tempResponse): Re
150150
protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tempResponse): Response
151151
{
152152
$customToolCalls = $this->filterCustomToolCalls($toolCalls);
153-
$toolResults = $this->callTools($this->request->tools(), $customToolCalls);
153+
$hasPendingToolCalls = false;
154+
$toolResults = $this->callTools($this->request->tools(), $customToolCalls, $hasPendingToolCalls);
154155
$this->addStep($toolCalls, $tempResponse, $toolResults);
155156

156157
return $this->responseBuilder->toResponse();
@@ -162,7 +163,8 @@ protected function executeCustomToolsAndFinalize(array $toolCalls, Response $tem
162163
protected function executeCustomToolsAndContinue(array $toolCalls, Response $tempResponse): Response
163164
{
164165
$customToolCalls = $this->filterCustomToolCalls($toolCalls);
165-
$toolResults = $this->callTools($this->request->tools(), $customToolCalls);
166+
$hasPendingToolCalls = false;
167+
$toolResults = $this->callTools($this->request->tools(), $customToolCalls, $hasPendingToolCalls);
166168

167169
$message = new ToolResultMessage($toolResults);
168170
if ($toolResultCacheType = $this->request->providerOptions('tool_result_cache_type')) {
@@ -173,7 +175,7 @@ protected function executeCustomToolsAndContinue(array $toolCalls, Response $tem
173175
$this->request->resetToolChoice();
174176
$this->addStep($toolCalls, $tempResponse, $toolResults);
175177

176-
if ($this->canContinue()) {
178+
if (! $hasPendingToolCalls && $this->canContinue()) {
177179
return $this->handle();
178180
}
179181

src/Providers/Anthropic/Handlers/Text.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ public static function buildHttpRequestPayload(PrismRequest $request): array
9393

9494
protected function handleToolCalls(): Response
9595
{
96-
$toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls);
96+
$hasPendingToolCalls = false;
97+
$toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls, $hasPendingToolCalls);
9798

9899
$this->addStep($toolResults);
99100

@@ -113,7 +114,7 @@ protected function handleToolCalls(): Response
113114
$this->request->addMessage($toolResultMessage);
114115
$this->request->resetToolChoice();
115116

116-
if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) {
117+
if (! $hasPendingToolCalls && $this->responseBuilder->steps->count() < $this->request->maxSteps()) {
117118
return $this->handle();
118119
}
119120

src/Providers/DeepSeek/Handlers/Stream.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,15 @@ protected function handleToolCalls(Request $request, string $text, array $toolCa
381381
}
382382

383383
$toolResults = [];
384-
yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
384+
$hasPendingToolCalls = false;
385+
yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
386+
387+
if ($hasPendingToolCalls) {
388+
$this->state->markStepFinished();
389+
yield from $this->yieldToolCallsFinishEvents($this->state);
390+
391+
return;
392+
}
385393

386394
$this->state->markStepFinished();
387395
yield new StepFinishEvent(

src/Providers/DeepSeek/Handlers/Text.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ protected function handleToolCalls(array $data, Request $request): TextResponse
5959
{
6060
$toolCalls = ToolCallMap::map(data_get($data, 'choices.0.message.tool_calls', []));
6161

62-
$toolResults = $this->callTools($request->tools(), $toolCalls);
62+
$hasPendingToolCalls = false;
63+
$toolResults = $this->callTools(
64+
$request->tools(),
65+
$toolCalls,
66+
$hasPendingToolCalls,
67+
);
6368

6469
$this->addStep($data, $request, $toolResults);
6570

@@ -71,7 +76,7 @@ protected function handleToolCalls(array $data, Request $request): TextResponse
7176
$request = $request->addMessage(new ToolResultMessage($toolResults));
7277
$request->resetToolChoice();
7378

74-
if ($this->shouldContinue($request)) {
79+
if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
7580
return $this->handle($request);
7681
}
7782

src/Providers/Gemini/Handlers/Stream.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ protected function handleToolCalls(
326326
array $data = []
327327
): Generator {
328328
$mappedToolCalls = [];
329+
$hasPendingToolCalls = false;
329330

330331
// Convert tool calls to ToolCall objects
331332
foreach ($this->state->toolCalls() as $toolCallData) {
@@ -334,8 +335,16 @@ protected function handleToolCalls(
334335

335336
// Execute tools and emit results
336337
$toolResults = [];
337-
yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults);
338+
yield from $this->callToolsAndYieldEvents($request->tools(), $mappedToolCalls, $this->state->messageId(), $toolResults, $hasPendingToolCalls);
338339

340+
if ($hasPendingToolCalls) {
341+
$this->state->markStepFinished();
342+
yield from $this->yieldToolCallsFinishEvents($this->state);
343+
344+
return;
345+
}
346+
347+
// Add messages for next turn and continue streaming
339348
if ($toolResults !== []) {
340349
// Emit step finish after tool calls
341350
$this->state->markStepFinished();

src/Providers/Gemini/Handlers/Structured.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,17 +202,19 @@ protected function handleStop(array $data, Request $request, FinishReason $finis
202202
*/
203203
protected function handleToolCalls(array $data, Request $request): StructuredResponse
204204
{
205+
$hasPendingToolCalls = false;
205206
$toolResults = $this->callTools(
206207
$request->tools(),
207-
ToolCallMap::map(data_get($data, 'candidates.0.content.parts', []))
208+
ToolCallMap::map(data_get($data, 'candidates.0.content.parts', [])),
209+
$hasPendingToolCalls,
208210
);
209211

210212
$request->addMessage(new ToolResultMessage($toolResults));
211213
$request->resetToolChoice();
212214

213215
$this->addStep($data, $request, FinishReason::ToolCalls, $toolResults);
214216

215-
if ($this->shouldContinue($request)) {
217+
if (! $hasPendingToolCalls && $this->shouldContinue($request)) {
216218
return $this->handle($request);
217219
}
218220

0 commit comments

Comments
 (0)