From 4d264e016a00737917f123963cb8e9b134faabf6 Mon Sep 17 00:00:00 2001 From: Yogesh Vaishnav Date: Sun, 25 Jan 2026 23:59:07 +0530 Subject: [PATCH 1/2] feat: concurrent tools execution --- phpunit.xml.dist | 3 + src/Concerns/CallsTools.php | 183 +++++++++---- src/Facades/Tool.php | 1 + src/Tool.php | 14 + tests/Concerns/CallsToolsConcurrentTest.php | 274 ++++++++++++++++++++ tests/ConcurrentToolExecutionTest.php | 81 ++++++ 6 files changed, 510 insertions(+), 46 deletions(-) create mode 100644 tests/Concerns/CallsToolsConcurrentTest.php create mode 100644 tests/ConcurrentToolExecutionTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0c12bb9fa..928ff17b7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,4 +14,7 @@ ./src + + + diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php index 078001e60..d07566e5e 100644 --- a/src/Concerns/CallsTools.php +++ b/src/Concerns/CallsTools.php @@ -5,6 +5,7 @@ namespace Prism\Prism\Concerns; use Generator; +use Illuminate\Support\Facades\Concurrency; use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\MultipleItemsFoundException; use Prism\Prism\Exceptions\PrismException; @@ -47,67 +48,157 @@ protected function callTools(array $tools, array $toolCalls): array */ protected function callToolsAndYieldEvents(array $tools, array $toolCalls, string $messageId, array &$toolResults): Generator { - foreach ($toolCalls as $toolCall) { + $groupedToolCalls = $this->groupToolCallsByConcurrency($tools, $toolCalls); + + $executionResults = $this->executeToolsWithConcurrency($tools, $groupedToolCalls, $messageId); + + foreach (array_keys($toolCalls) as $index) { + $result = $executionResults[$index]; + + $toolResults[] = $result['toolResult']; + + foreach ($result['events'] as $event) { + yield $event; + } + } + } + + /** + * Group tool calls by whether they should run concurrently or sequentially. + * + * @param Tool[] $tools + * @param ToolCall[] $toolCalls + * @return array{concurrent: array, sequential: array} + */ + protected function groupToolCallsByConcurrency(array $tools, array $toolCalls): array + { + $concurrent = []; + $sequential = []; + + foreach ($toolCalls as $index => $toolCall) { try { $tool = $this->resolveTool($toolCall->name, $tools); - $output = call_user_func_array( - $tool->handle(...), - $toolCall->arguments() - ); - if (is_string($output)) { - $output = new ToolOutput(result: $output); + if ($tool->isConcurrent()) { + $concurrent[$index] = $toolCall; + } else { + $sequential[$index] = $toolCall; } + } catch (PrismException) { + // If tool not found, treat as sequential for error handling + $sequential[$index] = $toolCall; + } + } - $toolResult = new ToolResult( - toolCallId: $toolCall->id, - toolName: $toolCall->name, - args: $toolCall->arguments(), - result: $output->result, - toolCallResultId: $toolCall->resultId, - artifacts: $output->artifacts, - ); + return [ + 'concurrent' => $concurrent, + 'sequential' => $sequential, + ]; + } - $toolResults[] = $toolResult; + /** + * Execute tools with concurrency support and return indexed results. + * + * @param Tool[] $tools + * @param array{concurrent: array, sequential: array} $groupedToolCalls + * @return array}> + */ + protected function executeToolsWithConcurrency(array $tools, array $groupedToolCalls, string $messageId): array + { + $results = []; - yield new ToolResultEvent( - id: EventID::generate(), - timestamp: time(), - toolResult: $toolResult, - messageId: $messageId, - success: true - ); + $concurrentClosures = []; - foreach ($toolResult->artifacts as $artifact) { - yield new ArtifactEvent( - id: EventID::generate(), - timestamp: time(), - artifact: $artifact, - toolCallId: $toolCall->id, - toolName: $toolCall->name, - messageId: $messageId, - ); - } - } catch (PrismException $e) { - $toolResult = new ToolResult( - toolCallId: $toolCall->id, - toolName: $toolCall->name, - args: $toolCall->arguments(), - result: $e->getMessage(), - toolCallResultId: $toolCall->resultId, - ); + foreach ($groupedToolCalls['concurrent'] as $index => $toolCall) { + $concurrentClosures[$index] = fn () => $this->executeToolCall($tools, $toolCall, $messageId); + } - $toolResults[] = $toolResult; + foreach (Concurrency::run($concurrentClosures) as $index => $result) { + $results[$index] = $result; + } - yield new ToolResultEvent( + foreach ($groupedToolCalls['sequential'] as $index => $toolCall) { + $results[$index] = $this->executeToolCall($tools, $toolCall, $messageId); + } + + return $results; + } + + /** + * Execute a single tool call and return result with events. + * + * @param Tool[] $tools + * @return array{toolResult: ToolResult, events: array} + */ + protected function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array + { + $events = []; + + try { + $tool = $this->resolveTool($toolCall->name, $tools); + $output = call_user_func_array( + $tool->handle(...), + $toolCall->arguments() + ); + + if (is_string($output)) { + $output = new ToolOutput(result: $output); + } + + $toolResult = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: $output->result, + toolCallResultId: $toolCall->resultId, + artifacts: $output->artifacts, + ); + + $events[] = new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $toolResult, + messageId: $messageId, + success: true + ); + + foreach ($toolResult->artifacts as $artifact) { + $events[] = new ArtifactEvent( id: EventID::generate(), timestamp: time(), - toolResult: $toolResult, + artifact: $artifact, + toolCallId: $toolCall->id, + toolName: $toolCall->name, messageId: $messageId, - success: false, - error: $e->getMessage() ); } + + return [ + 'toolResult' => $toolResult, + 'events' => $events, + ]; + } catch (PrismException $e) { + $toolResult = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: $e->getMessage(), + toolCallResultId: $toolCall->resultId, + ); + + $events[] = new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $toolResult, + messageId: $messageId, + success: false, + error: $e->getMessage() + ); + + return [ + 'toolResult' => $toolResult, + 'events' => $events, + ]; } } diff --git a/src/Facades/Tool.php b/src/Facades/Tool.php index 947fa1c9a..f458aa4f3 100644 --- a/src/Facades/Tool.php +++ b/src/Facades/Tool.php @@ -14,6 +14,7 @@ * @method static BaseTool for(string $description) * @method static BaseTool using(Closure|callable $fn) * @method static BaseTool make(string|Tool|\Laravel\Mcp\Server\Tool $tool) + * @method static BaseTool concurrent(bool $concurrent = true) * @method static BaseTool withParameter(Schema $parameter, bool $required = true) * @method static BaseTool withStringParameter(string $name, string $description, bool $required = true) * @method static BaseTool withNumberParameter(string $name, string $description, bool $required = true) diff --git a/src/Tool.php b/src/Tool.php index fdebfe6c9..8a24203ce 100644 --- a/src/Tool.php +++ b/src/Tool.php @@ -44,6 +44,8 @@ class Tool /** @var null|false|Closure(Throwable,array):string */ protected null|false|Closure $failedHandler = null; + protected bool $concurrent = false; + public function __construct() { // @@ -111,6 +113,18 @@ public function withErrorHandling(?Closure $handler = null): self return $this; } + public function concurrent(bool $concurrent = true): self + { + $this->concurrent = $concurrent; + + return $this; + } + + public function isConcurrent(): bool + { + return $this->concurrent; + } + public function withParameter(Schema $parameter, bool $required = true): self { $this->parameters[$parameter->name()] = $parameter; diff --git a/tests/Concerns/CallsToolsConcurrentTest.php b/tests/Concerns/CallsToolsConcurrentTest.php new file mode 100644 index 000000000..3b3664f16 --- /dev/null +++ b/tests/Concerns/CallsToolsConcurrentTest.php @@ -0,0 +1,274 @@ +caller = new TestToolCaller; +}); + +it('executes sequential tools in order', function (): void { + $executionOrder = []; + + $tool1 = (new Tool) + ->as('tool1') + ->for('First tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(function (string $input) use (&$executionOrder): string { + $executionOrder[] = 'tool1'; + + return "Result from tool1: $input"; + }); + + $tool2 = (new Tool) + ->as('tool2') + ->for('Second tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(function (string $input) use (&$executionOrder): string { + $executionOrder[] = 'tool2'; + + return "Result from tool2: $input"; + }); + + $toolCalls = [ + new ToolCall('call1', 'tool1', ['input' => 'test1']), + new ToolCall('call2', 'tool2', ['input' => 'test2']), + ]; + + $toolResults = []; + $events = iterator_to_array($this->caller->callToolsAndYieldEvents( + [$tool1, $tool2], + $toolCalls, + 'msg123', + $toolResults + )); + + expect($toolResults)->toHaveCount(2); + expect($toolResults[0]->toolName)->toBe('tool1'); + expect($toolResults[0]->result)->toBe('Result from tool1: test1'); + expect($toolResults[1]->toolName)->toBe('tool2'); + expect($toolResults[1]->result)->toBe('Result from tool2: test2'); + + // Verify events are in order + expect($events)->toHaveCount(2); + expect($events[0])->toBeInstanceOf(ToolResultEvent::class); + expect($events[0]->toolResult->toolName)->toBe('tool1'); + expect($events[1])->toBeInstanceOf(ToolResultEvent::class); + expect($events[1]->toolResult->toolName)->toBe('tool2'); +}); + +it('executes concurrent tools in parallel but maintains event order', function (): void { + $tool1 = (new Tool) + ->as('tool1') + ->for('First tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Result from tool1: $input") + ->concurrent(); + + $tool2 = (new Tool) + ->as('tool2') + ->for('Second tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Result from tool2: $input") + ->concurrent(); + + $tool3 = (new Tool) + ->as('tool3') + ->for('Third tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Result from tool3: $input") + ->concurrent(); + + $toolCalls = [ + new ToolCall('call1', 'tool1', ['input' => 'test1']), + new ToolCall('call2', 'tool2', ['input' => 'test2']), + new ToolCall('call3', 'tool3', ['input' => 'test3']), + ]; + + $toolResults = []; + $events = iterator_to_array($this->caller->callToolsAndYieldEvents( + [$tool1, $tool2, $tool3], + $toolCalls, + 'msg123', + $toolResults + )); + + // Verify results are in original order despite parallel execution + expect($toolResults)->toHaveCount(3); + expect($toolResults[0]->toolName)->toBe('tool1'); + expect($toolResults[1]->toolName)->toBe('tool2'); + expect($toolResults[2]->toolName)->toBe('tool3'); + + // Verify events are in original order + expect($events)->toHaveCount(3); + expect($events[0]->toolResult->toolName)->toBe('tool1'); + expect($events[1]->toolResult->toolName)->toBe('tool2'); + expect($events[2]->toolResult->toolName)->toBe('tool3'); +}); + +it('handles mixed concurrent and sequential tools correctly', function (): void { + $executionLog = []; + + $concurrentTool1 = (new Tool) + ->as('concurrent1') + ->for('Concurrent tool 1') + ->withParameter(new StringSchema('input', 'input')) + ->using(function (string $input) use (&$executionLog): string { + $executionLog[] = ['tool' => 'concurrent1', 'time' => microtime(true)]; + + return "Concurrent result 1: $input"; + }) + ->concurrent(); + + $sequentialTool = (new Tool) + ->as('sequential') + ->for('Sequential tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(function (string $input) use (&$executionLog): string { + $executionLog[] = ['tool' => 'sequential', 'time' => microtime(true)]; + + return "Sequential result: $input"; + }); + + $concurrentTool2 = (new Tool) + ->as('concurrent2') + ->for('Concurrent tool 2') + ->withParameter(new StringSchema('input', 'input')) + ->using(function (string $input) use (&$executionLog): string { + $executionLog[] = ['tool' => 'concurrent2', 'time' => microtime(true)]; + + return "Concurrent result 2: $input"; + }) + ->concurrent(); + + $toolCalls = [ + new ToolCall('call1', 'concurrent1', ['input' => 'test1']), + new ToolCall('call2', 'sequential', ['input' => 'test2']), + new ToolCall('call3', 'concurrent2', ['input' => 'test3']), + ]; + + $toolResults = []; + $events = iterator_to_array($this->caller->callToolsAndYieldEvents( + [$concurrentTool1, $sequentialTool, $concurrentTool2], + $toolCalls, + 'msg123', + $toolResults + )); + + // Verify all tools executed + expect($toolResults)->toHaveCount(3); + expect($executionLog)->toHaveCount(3); + + // Verify results maintain original order + expect($toolResults[0]->toolName)->toBe('concurrent1'); + expect($toolResults[1]->toolName)->toBe('sequential'); + expect($toolResults[2]->toolName)->toBe('concurrent2'); + + // Verify events maintain order + expect($events[0]->toolResult->toolName)->toBe('concurrent1'); + expect($events[1]->toolResult->toolName)->toBe('sequential'); + expect($events[2]->toolResult->toolName)->toBe('concurrent2'); +}); + +it('handles errors in concurrent tools while maintaining order', function (): void { + $tool1 = (new Tool) + ->as('success_tool') + ->for('Success tool') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Success: $input") + ->concurrent(); + + $tool2 = (new Tool) + ->as('nonexistent_tool') + ->for('This tool won\'t be found') + ->using(fn (): string => 'should not run') + ->concurrent(); + + $tool3 = (new Tool) + ->as('another_success') + ->for('Another success') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Another success: $input") + ->concurrent(); + + $toolCalls = [ + new ToolCall('call1', 'success_tool', ['input' => 'test1']), + new ToolCall('call2', 'error_tool', ['input' => 'test2']), // Tool not in array - will fail + new ToolCall('call3', 'another_success', ['input' => 'test3']), + ]; + + $toolResults = []; + $events = iterator_to_array($this->caller->callToolsAndYieldEvents( + [$tool1, $tool3], + $toolCalls, + 'msg123', + $toolResults + )); + + // All tool calls should have results (including error) + expect($toolResults)->toHaveCount(3); + + expect($toolResults[0]->toolName)->toBe('success_tool'); + expect($toolResults[0]->result)->toBe('Success: test1'); + + expect($toolResults[1]->toolName)->toBe('error_tool'); + expect($toolResults[1]->result)->toContain('not found'); + + expect($toolResults[2]->toolName)->toBe('another_success'); + expect($toolResults[2]->result)->toBe('Another success: test3'); + + // Verify events maintain order + expect($events[0]->toolResult->toolName)->toBe('success_tool'); + expect($events[0]->success)->toBeTrue(); + + expect($events[1]->toolResult->toolName)->toBe('error_tool'); + expect($events[1]->success)->toBeFalse(); + expect($events[1]->error)->toContain('not found'); + + expect($events[2]->toolResult->toolName)->toBe('another_success'); + expect($events[2]->success)->toBeTrue(); +}); + +it('groups tools correctly by concurrency status', function (): void { + $concurrentTool = (new Tool) + ->as('concurrent') + ->for('Concurrent') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Concurrent: $input") + ->concurrent(); + + $sequentialTool = (new Tool) + ->as('sequential') + ->for('Sequential') + ->withParameter(new StringSchema('input', 'input')) + ->using(fn (string $input): string => "Sequential: $input"); + + $toolCalls = [ + new ToolCall('call1', 'concurrent', ['input' => 'test1']), + new ToolCall('call2', 'sequential', ['input' => 'test2']), + ]; + + $grouped = $this->caller->groupToolCallsByConcurrency( + [$concurrentTool, $sequentialTool], + $toolCalls + ); + + expect($grouped)->toHaveKeys(['concurrent', 'sequential']); + expect($grouped['concurrent'])->toHaveCount(1); + expect($grouped['sequential'])->toHaveCount(1); + expect($grouped['concurrent'][0]->name)->toBe('concurrent'); + expect($grouped['sequential'][1]->name)->toBe('sequential'); +}); diff --git a/tests/ConcurrentToolExecutionTest.php b/tests/ConcurrentToolExecutionTest.php new file mode 100644 index 000000000..30bf557e0 --- /dev/null +++ b/tests/ConcurrentToolExecutionTest.php @@ -0,0 +1,81 @@ +as('search') + ->for('useful for searching') + ->withParameter(new StringSchema('query', 'the search query')) + ->using(fn (string $query): string => "Result for $query") + ->concurrent(); + + expect($tool->isConcurrent())->toBeTrue(); +}); + +it('tools are not concurrent by default', function (): void { + $tool = (new Tool) + ->as('search') + ->for('useful for searching') + ->withParameter(new StringSchema('query', 'the search query')) + ->using(fn (string $query): string => "Result for $query"); + + expect($tool->isConcurrent())->toBeFalse(); +}); + +it('can explicitly set tool as non-concurrent', function (): void { + $tool = (new Tool) + ->as('search') + ->for('useful for searching') + ->withParameter(new StringSchema('query', 'the search query')) + ->using(fn (string $query): string => "Result for $query") + ->concurrent(false); + + expect($tool->isConcurrent())->toBeFalse(); +}); + +it('can use concurrent via facade', function (): void { + $tool = ToolFacade::as('search') + ->for('useful for searching') + ->withParameter(new StringSchema('query', 'the search query')) + ->using(fn (string $query): string => "Result for $query") + ->concurrent(); + + expect($tool->isConcurrent())->toBeTrue(); +}); + +it('concurrent method can be chained with other methods', function (): void { + $tool = (new Tool) + ->as('search') + ->for('useful for searching') + ->concurrent() + ->withParameter(new StringSchema('query', 'the search query')) + ->using(fn (string $query): string => "Result for $query"); + + expect($tool->isConcurrent())->toBeTrue(); + expect($tool->name())->toBe('search'); + expect($tool->description())->toBe('useful for searching'); +}); + +it('handles errors in concurrent tools gracefully', function (): void { + $tool = (new Tool) + ->as('error_tool') + ->for('tool that throws error') + ->withParameter(new StringSchema('query', 'the search query')) + ->using(function (string $query): string { + throw new \RuntimeException('Tool execution failed'); + }) + ->concurrent(); + + $result = $tool->handle('test'); + + expect($result) + ->toContain('Tool execution error') + ->toContain('Tool execution failed'); +}); From 57ec64f94b2a65ed586d1d79760b882f8b5a6bda Mon Sep 17 00:00:00 2001 From: TJ Miller Date: Mon, 26 Jan 2026 16:58:43 -0500 Subject: [PATCH 2/2] Cleanup & docs --- docs/core-concepts/tools-function-calling.md | 83 ++++++++++++++++++++ src/Concerns/CallsTools.php | 13 +-- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/docs/core-concepts/tools-function-calling.md b/docs/core-concepts/tools-function-calling.md index f8a6f55d2..0fdf9dff1 100644 --- a/docs/core-concepts/tools-function-calling.md +++ b/docs/core-concepts/tools-function-calling.md @@ -295,6 +295,89 @@ use Prism\Prism\Facades\Tool; $tool = Tool::make(SearchTool::class); ``` +## Concurrent Tool Execution + +When the AI calls multiple tools in a single step, Prism normally executes them sequentially. For I/O-bound operations like API calls or database queries, you can enable concurrent execution to run tools in parallel, reducing total wait time. + +### Marking Tools as Concurrent + +Use the `concurrent()` method to mark a tool as safe for parallel execution: + +```php +use Prism\Prism\Facades\Tool; + +$weatherTool = Tool::as('weather') + ->for('Get current weather conditions') + ->withStringParameter('city', 'The city to get weather for') + ->using(function (string $city): string { + // API call that takes ~500ms + return Http::get("https://api.weather.com/{$city}")->json('conditions'); + }) + ->concurrent(); + +$stockTool = Tool::as('stock_price') + ->for('Get current stock price') + ->withStringParameter('symbol', 'The stock ticker symbol') + ->using(function (string $symbol): string { + // Another API call that takes ~500ms + return Http::get("https://api.stocks.com/{$symbol}")->json('price'); + }) + ->concurrent(); +``` + +When the AI calls both tools in a single step, they'll execute in parallel instead of sequentially - taking ~500ms total instead of ~1000ms. + +### How It Works + +Prism uses [Laravel's Concurrency facade](https://laravel.com/docs/12.x/concurrency) to execute concurrent tools. Under the hood, tools marked as concurrent are grouped and run in parallel, while sequential tools run one at a time. + +The execution flow: +1. Prism groups tool calls by their concurrency setting +2. Concurrent tools execute in parallel via `Concurrency::run()` +3. Sequential tools execute one at a time +4. Results are returned in the original order, regardless of execution order + +### When to Use Concurrent Tools + +**Good candidates for concurrent execution:** +- External API calls (weather, stocks, search) +- Database queries that don't depend on each other +- File reads from different sources +- Any I/O-bound operation + +**Keep sequential (don't mark as concurrent):** +- Tools that modify shared state +- Tools where execution order matters +- Tools with side effects that could conflict +- CPU-bound operations (concurrency won't help) + +### Mixed Execution + +You can mix concurrent and sequential tools in the same request: + +```php +$searchTool = Tool::as('search') + ->for('Search the web') + ->withStringParameter('query', 'Search query') + ->using(fn (string $query): string => $this->search($query)) + ->concurrent(); // Safe to run in parallel + +$saveResultTool = Tool::as('save_result') + ->for('Save a result to the database') + ->withStringParameter('data', 'Data to save') + ->using(fn (string $data): string => $this->save($data)); + // Sequential - modifies database state +``` + +Prism handles the grouping automatically. Concurrent tools run in parallel, then sequential tools run in order. + +### Error Handling + +Errors in concurrent tools are handled the same way as sequential tools. If one concurrent tool fails, other concurrent tools still complete, and all results (including errors) are returned in the original order. + +> [!NOTE] +> Concurrent execution requires Laravel's Concurrency feature, available in Laravel 11+. Make sure you have the appropriate concurrency driver configured. See [Laravel's Concurrency documentation](https://laravel.com/docs/12.x/concurrency) for setup details. + ## Using Laravel MCP Tools You can use existing [Laravel MCP](https://github.com/laravel/mcp) Tools in Prism directly, without using the Laravel MCP Server: diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php index d07566e5e..9ad211a82 100644 --- a/src/Concerns/CallsTools.php +++ b/src/Concerns/CallsTools.php @@ -64,8 +64,6 @@ protected function callToolsAndYieldEvents(array $tools, array $toolCalls, strin } /** - * Group tool calls by whether they should run concurrently or sequentially. - * * @param Tool[] $tools * @param ToolCall[] $toolCalls * @return array{concurrent: array, sequential: array} @@ -85,7 +83,6 @@ protected function groupToolCallsByConcurrency(array $tools, array $toolCalls): $sequential[$index] = $toolCall; } } catch (PrismException) { - // If tool not found, treat as sequential for error handling $sequential[$index] = $toolCall; } } @@ -97,8 +94,6 @@ protected function groupToolCallsByConcurrency(array $tools, array $toolCalls): } /** - * Execute tools with concurrency support and return indexed results. - * * @param Tool[] $tools * @param array{concurrent: array, sequential: array} $groupedToolCalls * @return array}> @@ -113,8 +108,10 @@ protected function executeToolsWithConcurrency(array $tools, array $groupedToolC $concurrentClosures[$index] = fn () => $this->executeToolCall($tools, $toolCall, $messageId); } - foreach (Concurrency::run($concurrentClosures) as $index => $result) { - $results[$index] = $result; + if ($concurrentClosures !== []) { + foreach (Concurrency::run($concurrentClosures) as $index => $result) { + $results[$index] = $result; + } } foreach ($groupedToolCalls['sequential'] as $index => $toolCall) { @@ -125,8 +122,6 @@ protected function executeToolsWithConcurrency(array $tools, array $groupedToolC } /** - * Execute a single tool call and return result with events. - * * @param Tool[] $tools * @return array{toolResult: ToolResult, events: array} */