From 8bd857731b02412a00adbaf885781d8c26e13748 Mon Sep 17 00:00:00 2001 From: Behzad Soltanpour Date: Wed, 10 Jun 2026 12:41:51 +0330 Subject: [PATCH 1/3] Add deferred tool loading and hosted tool search for OpenAI and Anthropic Lets agents defer tool definitions so supporting providers load them on demand via hosted tool search, reducing input tokens and improving tool-selection accuracy for agents with many tools. - #[Deferred] marks a tool for on-demand loading - #[ToolSearch(strategy:)] enables the hosted search entry on an agent (strategy selects Anthropic's regex or bm25 variant; OpenAI ignores it) - SupportsToolSearch gates by provider and model (gpt-5.4+; Sonnet/Opus 4.0+, Haiku 4.5+); unsupported providers and models silently emit tools as before - Anthropic guards against an all-deferred toolset, which the provider rejects with a 400 Backward compatible: with no opt-in, request output is unchanged. --- src/Attributes/Deferred.php | 16 +++ src/Attributes/ToolSearch.php | 14 +++ .../Providers/SupportsToolSearch.php | 11 ++ .../Anthropic/Concerns/BuildsTextRequests.php | 2 +- src/Gateway/Anthropic/Concerns/MapsTools.php | 49 +++++++- .../AzureOpenAi/AzureOpenAiGateway.php | 10 +- .../OpenAi/Concerns/BuildsTextRequests.php | 2 +- src/Gateway/OpenAi/Concerns/MapsTools.php | 36 +++++- src/Gateway/TextGenerationOptions.php | 13 ++ src/Providers/AnthropicProvider.php | 21 +++- src/Providers/OpenAiProvider.php | 18 ++- .../Providers/Anthropic/ToolSearchTest.php | 23 ++++ .../Providers/OpenAi/ToolSearchTest.php | 31 +++++ .../Agents/AnthropicToolSearchAgent.php | 34 +++++ .../Fixtures/Agents/OpenAiToolSearchAgent.php | 34 +++++ tests/Fixtures/Tools/DeferredTool.php | 27 ++++ tests/Unit/Attributes/DeferredTest.php | 17 +++ .../Anthropic/ToolSearchMappingTest.php | 108 ++++++++++++++++ .../Gateway/OpenAi/ToolSearchMappingTest.php | 117 ++++++++++++++++++ tests/Unit/Gateway/ToolSearchOptionTest.php | 48 +++++++ .../Unit/Providers/ToolSearchSupportTest.php | 49 ++++++++ 21 files changed, 666 insertions(+), 14 deletions(-) create mode 100644 src/Attributes/Deferred.php create mode 100644 src/Attributes/ToolSearch.php create mode 100644 src/Contracts/Providers/SupportsToolSearch.php create mode 100644 tests/Feature/Providers/Anthropic/ToolSearchTest.php create mode 100644 tests/Feature/Providers/OpenAi/ToolSearchTest.php create mode 100644 tests/Fixtures/Agents/AnthropicToolSearchAgent.php create mode 100644 tests/Fixtures/Agents/OpenAiToolSearchAgent.php create mode 100644 tests/Fixtures/Tools/DeferredTool.php create mode 100644 tests/Unit/Attributes/DeferredTest.php create mode 100644 tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php create mode 100644 tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php create mode 100644 tests/Unit/Gateway/ToolSearchOptionTest.php create mode 100644 tests/Unit/Providers/ToolSearchSupportTest.php diff --git a/src/Attributes/Deferred.php b/src/Attributes/Deferred.php new file mode 100644 index 000000000..e58830b06 --- /dev/null +++ b/src/Attributes/Deferred.php @@ -0,0 +1,16 @@ +getAttributes(self::class) !== []; + } +} diff --git a/src/Attributes/ToolSearch.php b/src/Attributes/ToolSearch.php new file mode 100644 index 000000000..835db7147 --- /dev/null +++ b/src/Attributes/ToolSearch.php @@ -0,0 +1,14 @@ +mapTools($tools, $provider) : []; + $mappedTools = filled($tools) ? $this->mapTools($tools, $provider, $model, $options) : []; $providerOptions = $options?->providerOptions($provider->driver()) ?? []; diff --git a/src/Gateway/Anthropic/Concerns/MapsTools.php b/src/Gateway/Anthropic/Concerns/MapsTools.php index 2051abb37..0303d0173 100644 --- a/src/Gateway/Anthropic/Concerns/MapsTools.php +++ b/src/Gateway/Anthropic/Concerns/MapsTools.php @@ -3,9 +3,12 @@ namespace Laravel\Ai\Gateway\Anthropic\Concerns; use Illuminate\JsonSchema\JsonSchemaTypeFactory; +use Laravel\Ai\Attributes\Deferred; +use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebFetch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Tool; +use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\Tools\ProviderTool; @@ -20,16 +23,38 @@ trait MapsTools /** * Map the given tools to Anthropic tool definitions. */ - protected function mapTools(array $tools, Provider $provider): array + protected function mapTools(array $tools, Provider $provider, string $model = '', ?TextGenerationOptions $options = null): array { + $searchActive = $this->toolSearchActive($provider, $model, $options); + $mapped = []; + $toolCount = 0; + $deferredCount = 0; foreach ($tools as $tool) { if ($tool instanceof ProviderTool) { $mapped[] = $this->mapProviderTool($tool, $provider); } elseif ($tool instanceof Tool) { - $mapped[] = $this->mapTool($tool); + $defer = $searchActive && Deferred::isAppliedTo($tool); + $mapped[] = $this->mapTool($tool, $defer); + $toolCount++; + $deferredCount += $defer ? 1 : 0; + } + } + + if ($searchActive && $deferredCount > 0) { + if ($deferredCount === $toolCount) { + throw new LogicException( + 'Anthropic tool search requires at least one non-deferred tool.' + ); } + + $strategy = $options?->toolSearchStrategy === 'bm25' ? 'bm25' : 'regex'; + + array_unshift($mapped, [ + 'type' => "tool_search_tool_{$strategy}_20251119", + 'name' => "tool_search_tool_{$strategy}", + ]); } return $mapped; @@ -38,7 +63,7 @@ protected function mapTools(array $tools, Provider $provider): array /** * Map a regular tool to an Anthropic tool definition. */ - protected function mapTool(Tool $tool): array + protected function mapTool(Tool $tool, bool $defer = false): array { $schema = $tool->schema(new JsonSchemaTypeFactory); @@ -51,11 +76,27 @@ protected function mapTool(Tool $tool): array $inputSchema['required'] = $schemaArray['required'] ?? []; } - return [ + $definition = [ 'name' => ToolNameResolver::resolve($tool), 'description' => (string) $tool->description(), 'input_schema' => $inputSchema, ]; + + if ($defer) { + $definition['defer_loading'] = true; + } + + return $definition; + } + + /** + * Determine whether hosted tool search is active for this request. + */ + protected function toolSearchActive(Provider $provider, string $model, ?TextGenerationOptions $options): bool + { + return $options?->toolSearchStrategy !== null + && $provider instanceof SupportsToolSearch + && $provider->supportsToolSearch($model); } /** diff --git a/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php b/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php index 9396edafe..37297728a 100644 --- a/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php +++ b/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php @@ -227,7 +227,7 @@ protected function extractImageUsage(array $data): Usage /** * {@inheritdoc} */ - protected function mapTool(Tool $tool): array + protected function mapTool(Tool $tool, bool $defer = false): array { $schema = $tool->schema(new JsonSchemaTypeFactory); @@ -235,7 +235,7 @@ protected function mapTool(Tool $tool): array ? (new ObjectSchema($schema))->toSchema() : []; - return array_filter([ + $definition = array_filter([ 'type' => 'function', 'name' => ToolNameResolver::resolve($tool), 'description' => (string) $tool->description(), @@ -245,5 +245,11 @@ protected function mapTool(Tool $tool): array 'required' => $schemaArray['required'] ?? [], ] : null, ]); + + if ($defer) { + $definition['defer_loading'] = true; + } + + return $definition; } } diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index 5cada0717..90103e5d3 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -28,7 +28,7 @@ protected function buildTextRequestBody( if (filled($tools)) { $body['tool_choice'] = 'auto'; - $body['tools'] = $this->mapTools($tools, $provider); + $body['tools'] = $this->mapTools($tools, $provider, $model, $options); } if (filled($schema)) { diff --git a/src/Gateway/OpenAi/Concerns/MapsTools.php b/src/Gateway/OpenAi/Concerns/MapsTools.php index 85d905f81..eb6e4b59f 100644 --- a/src/Gateway/OpenAi/Concerns/MapsTools.php +++ b/src/Gateway/OpenAi/Concerns/MapsTools.php @@ -3,10 +3,13 @@ namespace Laravel\Ai\Gateway\OpenAi\Concerns; use Illuminate\JsonSchema\JsonSchemaTypeFactory; +use Laravel\Ai\Attributes\Deferred; use Laravel\Ai\Attributes\Strict; use Laravel\Ai\Contracts\Providers\SupportsFileSearch; +use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Tool; +use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\Tools\FileSearch; @@ -20,25 +23,34 @@ trait MapsTools /** * Map the given tools to OpenAI function definitions. */ - protected function mapTools(array $tools, Provider $provider): array + protected function mapTools(array $tools, Provider $provider, string $model = '', ?TextGenerationOptions $options = null): array { + $searchActive = $this->toolSearchActive($provider, $model, $options); + $mapped = []; + $deferredCount = 0; foreach ($tools as $tool) { if ($tool instanceof ProviderTool) { $mapped[] = $this->mapProviderTool($tool, $provider); } elseif ($tool instanceof Tool) { - $mapped[] = $this->mapTool($tool); + $defer = $searchActive && Deferred::isAppliedTo($tool); + $mapped[] = $this->mapTool($tool, $defer); + $deferredCount += $defer ? 1 : 0; } } + if ($searchActive && $deferredCount > 0) { + array_unshift($mapped, ['type' => 'tool_search']); + } + return $mapped; } /** * Map a regular tool to an OpenAI function definition. */ - protected function mapTool(Tool $tool): array + protected function mapTool(Tool $tool, bool $defer = false): array { $strict = Strict::isAppliedTo($tool); @@ -48,7 +60,7 @@ protected function mapTool(Tool $tool): array ? (new ObjectSchema($schema, strict: $strict))->toSchema() : []; - return [ + $definition = [ 'type' => 'function', 'name' => ToolNameResolver::resolve($tool), 'description' => (string) $tool->description(), @@ -60,6 +72,22 @@ protected function mapTool(Tool $tool): array 'additionalProperties' => false, ], ]; + + if ($defer) { + $definition['defer_loading'] = true; + } + + return $definition; + } + + /** + * Determine whether hosted tool search is active for this request. + */ + protected function toolSearchActive(Provider $provider, string $model, ?TextGenerationOptions $options): bool + { + return $options?->toolSearchStrategy !== null + && $provider instanceof SupportsToolSearch + && $provider->supportsToolSearch($model); } /** diff --git a/src/Gateway/TextGenerationOptions.php b/src/Gateway/TextGenerationOptions.php index 4c782c14f..6cda4636b 100644 --- a/src/Gateway/TextGenerationOptions.php +++ b/src/Gateway/TextGenerationOptions.php @@ -5,6 +5,7 @@ use Laravel\Ai\Attributes\MaxSteps; use Laravel\Ai\Attributes\MaxTokens; use Laravel\Ai\Attributes\Temperature; +use Laravel\Ai\Attributes\ToolSearch; use Laravel\Ai\Attributes\TopP; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasProviderOptions; @@ -19,6 +20,7 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?Agent $agent = null, public readonly ?float $topP = null, + public readonly ?string $toolSearchStrategy = null, ) { // } @@ -52,9 +54,20 @@ public static function forAgent(Agent $agent): self temperature: self::resolve($agent, $reflection, 'temperature', Temperature::class), agent: $agent, topP: self::resolve($agent, $reflection, 'topP', TopP::class), + toolSearchStrategy: self::resolveToolSearchStrategy($reflection), ); } + /** + * Resolve the tool search strategy from the agent's ToolSearch attribute. + */ + private static function resolveToolSearchStrategy(ReflectionClass $reflection): ?string + { + $attributes = $reflection->getAttributes(ToolSearch::class); + + return ! empty($attributes) ? $attributes[0]->newInstance()->strategy : null; + } + /** * Resolve an option from the agent's method, falling back to the attribute. * diff --git a/src/Providers/AnthropicProvider.php b/src/Providers/AnthropicProvider.php index aff86c845..61c4f3175 100644 --- a/src/Providers/AnthropicProvider.php +++ b/src/Providers/AnthropicProvider.php @@ -4,6 +4,7 @@ use Laravel\Ai\Contracts\Gateway\FileGateway; use Laravel\Ai\Contracts\Providers\FileProvider; +use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebFetch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Providers\TextProvider; @@ -12,7 +13,7 @@ use Laravel\Ai\Providers\Tools\WebFetch; use Laravel\Ai\Providers\Tools\WebSearch; -class AnthropicProvider extends Provider implements FileProvider, SupportsWebFetch, SupportsWebSearch, TextProvider +class AnthropicProvider extends Provider implements FileProvider, SupportsToolSearch, SupportsWebFetch, SupportsWebSearch, TextProvider { use Concerns\GeneratesText; use Concerns\HasFileGateway; @@ -20,6 +21,24 @@ class AnthropicProvider extends Provider implements FileProvider, SupportsWebFet use Concerns\ManagesFiles; use Concerns\StreamsText; + /** + * Determine whether the given model supports hosted tool search. + */ + public function supportsToolSearch(string $model): bool + { + if (! preg_match('/claude-(opus|sonnet|haiku)-(\d+)-(\d+)/', $model, $matches)) { + return false; + } + + $version = (int) $matches[2] + ((int) $matches[3]) / 10; + + return match ($matches[1]) { + 'opus', 'sonnet' => $version >= 4.0, + 'haiku' => $version >= 4.5, + default => false, + }; + } + /** * Get the web fetch tool options for the provider. */ diff --git a/src/Providers/OpenAiProvider.php b/src/Providers/OpenAiProvider.php index 9afce1f9c..b77c41afa 100644 --- a/src/Providers/OpenAiProvider.php +++ b/src/Providers/OpenAiProvider.php @@ -11,6 +11,7 @@ use Laravel\Ai\Contracts\Providers\ImageProvider; use Laravel\Ai\Contracts\Providers\StoreProvider; use Laravel\Ai\Contracts\Providers\SupportsFileSearch; +use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Providers\TextProvider; use Laravel\Ai\Contracts\Providers\TranscriptionProvider; @@ -20,7 +21,7 @@ use Laravel\Ai\Providers\Tools\FileSearch; use Laravel\Ai\Providers\Tools\WebSearch; -class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvider, FileProvider, ImageProvider, StoreProvider, SupportsFileSearch, SupportsWebSearch, TextProvider, TranscriptionProvider +class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvider, FileProvider, ImageProvider, StoreProvider, SupportsFileSearch, SupportsToolSearch, SupportsWebSearch, TextProvider, TranscriptionProvider { use Concerns\GeneratesAudio; use Concerns\GeneratesEmbeddings; @@ -38,6 +39,21 @@ class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvide use Concerns\ManagesStores; use Concerns\StreamsText; + /** + * Determine whether the given model supports hosted tool search. + */ + public function supportsToolSearch(string $model): bool + { + if (! preg_match('/^gpt-(\d+)(?:\.(\d+))?/', $model, $matches)) { + return false; + } + + $major = (int) $matches[1]; + $minor = (int) ($matches[2] ?? 0); + + return $major > 5 || ($major === 5 && $minor >= 4); + } + /** * Get the file search tool options for the provider. */ diff --git a/tests/Feature/Providers/Anthropic/ToolSearchTest.php b/tests/Feature/Providers/Anthropic/ToolSearchTest.php new file mode 100644 index 000000000..cd805d51a --- /dev/null +++ b/tests/Feature/Providers/Anthropic/ToolSearchTest.php @@ -0,0 +1,23 @@ + $this->fakeTextResponse('ok'), + ]); + + (new AnthropicToolSearchAgent)->prompt('Hi'); + + Http::assertSent(function ($request) { + $tools = collect($request->data()['tools'] ?? []); + + $deferred = $tools->firstWhere('name', 'DeferredTool'); + $plain = $tools->firstWhere('name', 'NonStrictTool'); + + return $tools->contains(fn ($t) => ($t['type'] ?? null) === 'tool_search_tool_regex_20251119') + && ($deferred['defer_loading'] ?? false) === true + && ! isset($plain['defer_loading']); + }); +}); diff --git a/tests/Feature/Providers/OpenAi/ToolSearchTest.php b/tests/Feature/Providers/OpenAi/ToolSearchTest.php new file mode 100644 index 000000000..bcd407b23 --- /dev/null +++ b/tests/Feature/Providers/OpenAi/ToolSearchTest.php @@ -0,0 +1,31 @@ + [ + ...config('ai.providers.openai'), + 'key' => 'test-key', + ]]); +}); + +test('an agent with the ToolSearch attribute emits a tool_search entry and defers marked tools', function () { + Http::fake([ + '*' => fakeOpenAiResponse('ok'), + ]); + + (new OpenAiToolSearchAgent)->prompt('Hi'); + + Http::assertSent(function (Request $request) { + $tools = collect(data_get(json_decode($request->body(), true), 'tools')); + + $deferred = $tools->firstWhere('name', 'DeferredTool'); + $plain = $tools->firstWhere('name', 'NonStrictTool'); + + return $tools->contains(fn ($t) => ($t['type'] ?? null) === 'tool_search') + && ($deferred['defer_loading'] ?? false) === true + && ! isset($plain['defer_loading']); + }); +}); diff --git a/tests/Fixtures/Agents/AnthropicToolSearchAgent.php b/tests/Fixtures/Agents/AnthropicToolSearchAgent.php new file mode 100644 index 000000000..856c3dfe8 --- /dev/null +++ b/tests/Fixtures/Agents/AnthropicToolSearchAgent.php @@ -0,0 +1,34 @@ + + */ + public function tools(): iterable + { + return [new DeferredTool, new NonStrictTool]; + } +} diff --git a/tests/Fixtures/Agents/OpenAiToolSearchAgent.php b/tests/Fixtures/Agents/OpenAiToolSearchAgent.php new file mode 100644 index 000000000..8c9e759c3 --- /dev/null +++ b/tests/Fixtures/Agents/OpenAiToolSearchAgent.php @@ -0,0 +1,34 @@ + + */ + public function tools(): iterable + { + return [new DeferredTool, new NonStrictTool]; + } +} diff --git a/tests/Fixtures/Tools/DeferredTool.php b/tests/Fixtures/Tools/DeferredTool.php new file mode 100644 index 000000000..877341773 --- /dev/null +++ b/tests/Fixtures/Tools/DeferredTool.php @@ -0,0 +1,27 @@ +toBeTrue(); +}); + +test('isAppliedTo returns false when target does not have the attribute', function () { + expect(Deferred::isAppliedTo(new NonStrictTool))->toBeFalse(); +}); + +test('isAppliedTo returns false when target is null', function () { + expect(Deferred::isAppliedTo(null))->toBeFalse(); +}); diff --git a/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php new file mode 100644 index 000000000..c0b697f57 --- /dev/null +++ b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php @@ -0,0 +1,108 @@ +mapTools($tools, $provider, $model, $options); + } + }; +} + +function anthropicSupportingProvider(bool $supports = true): Provider +{ + return new class($supports) extends Provider implements SupportsToolSearch + { + public function __construct(private bool $supports) + { + // + } + + public function supportsToolSearch(string $model): bool + { + return $this->supports; + } + }; +} + +test('prepends the regex tool search entry by default and defers marked tools', function () { + $mapped = anthropicToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + anthropicSupportingProvider(), + 'claude-opus-4-8', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped[0])->toBe([ + 'type' => 'tool_search_tool_regex_20251119', + 'name' => 'tool_search_tool_regex', + ]); + expect($mapped[0])->not->toHaveKey('defer_loading'); + + $tools = array_slice($mapped, 1); + $deferred = collect($tools)->firstWhere('defer_loading', true); + $nonDeferred = collect($tools)->filter(fn ($t) => ! isset($t['defer_loading'])); + + expect($deferred)->not->toBeNull() + ->and($deferred['description'])->toContain('deferred') + ->and($nonDeferred)->toHaveCount(1); +}); + +test('prepends the bm25 tool search entry when that strategy is selected', function () { + $mapped = anthropicToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + anthropicSupportingProvider(), + 'claude-opus-4-8', + new TextGenerationOptions(toolSearchStrategy: 'bm25'), + ); + + expect($mapped[0])->toBe([ + 'type' => 'tool_search_tool_bm25_20251119', + 'name' => 'tool_search_tool_bm25', + ]); +}); + +test('emits tools unchanged when the agent has not opted in', function () { + $mapped = anthropicToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + anthropicSupportingProvider(), + 'claude-opus-4-8', + new TextGenerationOptions, + ); + + expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search_tool_regex_20251119') + ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); +}); + +test('silently skips deferral when the model does not support tool search', function () { + $mapped = anthropicToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + anthropicSupportingProvider(supports: false), + 'claude-haiku-3', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); +}); + +test('throws when every tool is deferred because Anthropic requires a non-deferred tool', function () { + anthropicToolSearchMapper()->map( + [new DeferredTool, new DeferredTool], + anthropicSupportingProvider(), + 'claude-opus-4-8', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); +})->throws(LogicException::class, 'at least one non-deferred tool'); diff --git a/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php new file mode 100644 index 000000000..ff5bca2c6 --- /dev/null +++ b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php @@ -0,0 +1,117 @@ +mapTools($tools, $provider, $model, $options); + } + }; +} + +function openAiSupportingProvider(bool $supports = true): Provider +{ + return new class($supports) extends Provider implements SupportsToolSearch + { + public function __construct(private bool $supports) + { + // + } + + public function supportsToolSearch(string $model): bool + { + return $this->supports; + } + }; +} + +function openAiPlainProvider(): Provider +{ + return new class extends Provider + { + public function __construct() + { + // + } + }; +} + +test('deferred tool gets defer_loading and a tool_search entry is prepended when search is active', function () { + $mapped = openAiToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + openAiSupportingProvider(), + 'gpt-5.4', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped[0])->toBe(['type' => 'tool_search']); + + $tools = array_slice($mapped, 1); + $deferred = collect($tools)->firstWhere('defer_loading', true); + $nonDeferred = collect($tools)->filter(fn ($t) => ! isset($t['defer_loading'])); + + expect($deferred)->not->toBeNull() + ->and($deferred['description'])->toContain('deferred') + ->and($nonDeferred)->toHaveCount(1); +}); + +test('no tool_search entry or defer_loading when the agent has not opted in', function () { + $mapped = openAiToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + openAiSupportingProvider(), + 'gpt-5.4', + new TextGenerationOptions, // toolSearchStrategy === null + ); + + expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search') + ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); +}); + +test('silently skips deferral when the provider model does not support tool search', function () { + $mapped = openAiToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + openAiSupportingProvider(supports: false), + 'gpt-5.3', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search') + ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); +}); + +test('silently skips deferral when the provider does not implement SupportsToolSearch', function () { + $mapped = openAiToolSearchMapper()->map( + [new DeferredTool, new NonStrictTool], + openAiPlainProvider(), + 'gpt-5.4', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); +}); + +test('no tool_search entry is added when no tools are deferred even if search is active', function () { + $mapped = openAiToolSearchMapper()->map( + [new NonStrictTool], + openAiSupportingProvider(), + 'gpt-5.4', + new TextGenerationOptions(toolSearchStrategy: 'regex'), + ); + + expect($mapped)->toHaveCount(1) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search'); +}); diff --git a/tests/Unit/Gateway/ToolSearchOptionTest.php b/tests/Unit/Gateway/ToolSearchOptionTest.php new file mode 100644 index 000000000..7d3667f42 --- /dev/null +++ b/tests/Unit/Gateway/ToolSearchOptionTest.php @@ -0,0 +1,48 @@ +toolSearchStrategy)->toBeNull(); +}); + +test('resolves regex strategy by default when ToolSearch attribute is present', function () { + $agent = new #[ToolSearch] class implements Agent + { + use Promptable; + + public function instructions(): string + { + return 'x'; + } + }; + + expect(TextGenerationOptions::forAgent($agent)->toolSearchStrategy)->toBe('regex'); +}); + +test('resolves the overridden bm25 strategy', function () { + $agent = new #[ToolSearch(strategy: 'bm25')] class implements Agent + { + use Promptable; + + public function instructions(): string + { + return 'x'; + } + }; + + expect(TextGenerationOptions::forAgent($agent)->toolSearchStrategy)->toBe('bm25'); +}); diff --git a/tests/Unit/Providers/ToolSearchSupportTest.php b/tests/Unit/Providers/ToolSearchSupportTest.php new file mode 100644 index 000000000..d6948ae8d --- /dev/null +++ b/tests/Unit/Providers/ToolSearchSupportTest.php @@ -0,0 +1,49 @@ + 'test', 'driver' => 'test', 'key' => 'test'], + Mockery::mock(Dispatcher::class), + ); +} + +test('the OpenAI provider implements the tool search capability', function () { + expect(makeProvider(OpenAiProvider::class))->toBeInstanceOf(SupportsToolSearch::class); +}); + +test('the OpenAI provider supports tool search on gpt-5.4 and later', function (string $model, bool $expected) { + expect(makeProvider(OpenAiProvider::class)->supportsToolSearch($model))->toBe($expected); +})->with([ + ['gpt-5.4', true], + ['gpt-5.4-2026-01-01', true], + ['gpt-5.5', true], + ['gpt-6', true], + ['gpt-5.3', false], + ['gpt-5', false], + ['gpt-4o', false], +]); + +test('the Anthropic provider implements the tool search capability', function () { + expect(makeProvider(AnthropicProvider::class))->toBeInstanceOf(SupportsToolSearch::class); +}); + +test('the Anthropic provider supports tool search on Sonnet/Opus 4.0+ and Haiku 4.5+', function (string $model, bool $expected) { + expect(makeProvider(AnthropicProvider::class)->supportsToolSearch($model))->toBe($expected); +})->with([ + ['claude-opus-4-8', true], + ['claude-sonnet-4-6', true], + ['claude-sonnet-4-0', true], + ['claude-haiku-4-5', true], + ['claude-haiku-4-5-20251001', true], + ['claude-haiku-4-0', false], + ['claude-opus-3-5', false], + ['claude-3-5-sonnet-20241022', false], +]); From 8487085ed40d8fac1b06a8b48615933357040b20 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 16 Jun 2026 16:32:50 +0530 Subject: [PATCH 2/3] Rework hosted tool search into a ToolSearch provider tool --- src/Attributes/Deferred.php | 16 --- src/Attributes/ToolSearch.php | 14 --- .../Providers/SupportsToolSearch.php | 11 -- .../Anthropic/Concerns/BuildsTextRequests.php | 2 +- src/Gateway/Anthropic/Concerns/MapsTools.php | 63 +++++------ src/Gateway/Concerns/InvokesTools.php | 9 ++ .../OpenAi/Concerns/BuildsTextRequests.php | 2 +- src/Gateway/OpenAi/Concerns/MapsTools.php | 36 ++----- src/Gateway/TextGenerationOptions.php | 13 --- src/Providers/AnthropicProvider.php | 21 +--- src/Providers/Concerns/GeneratesText.php | 4 + src/Providers/OpenAiProvider.php | 18 +--- src/Providers/Tools/ToolSearch.php | 28 +++++ tests/Datasets/Providers.php | 5 + .../Providers/Anthropic/ToolSearchTest.php | 2 +- .../Providers/OpenAi/ToolSearchTest.php | 2 +- .../Agents/AnthropicToolSearchAgent.php | 9 +- .../Fixtures/Agents/OpenAiToolSearchAgent.php | 9 +- tests/Fixtures/Tools/DeferredTool.php | 2 - tests/Fixtures/Tools/SecretCodeGenerator.php | 34 ++++++ tests/Integration/ToolSearchTest.php | 52 +++++++++ tests/Unit/Attributes/DeferredTest.php | 17 --- .../Anthropic/ToolSearchMappingTest.php | 101 ++++++++++-------- tests/Unit/Gateway/FindToolTest.php | 45 ++++++++ .../Gateway/OpenAi/ToolSearchMappingTest.php | 96 +++++------------ tests/Unit/Gateway/ToolSearchOptionTest.php | 48 --------- .../Unit/Providers/ToolSearchSupportTest.php | 49 --------- tests/Unit/Providers/Tools/ToolSearchTest.php | 28 +++++ 28 files changed, 332 insertions(+), 404 deletions(-) delete mode 100644 src/Attributes/Deferred.php delete mode 100644 src/Attributes/ToolSearch.php delete mode 100644 src/Contracts/Providers/SupportsToolSearch.php create mode 100644 src/Providers/Tools/ToolSearch.php create mode 100644 tests/Fixtures/Tools/SecretCodeGenerator.php create mode 100644 tests/Integration/ToolSearchTest.php delete mode 100644 tests/Unit/Attributes/DeferredTest.php create mode 100644 tests/Unit/Gateway/FindToolTest.php delete mode 100644 tests/Unit/Gateway/ToolSearchOptionTest.php delete mode 100644 tests/Unit/Providers/ToolSearchSupportTest.php create mode 100644 tests/Unit/Providers/Tools/ToolSearchTest.php diff --git a/src/Attributes/Deferred.php b/src/Attributes/Deferred.php deleted file mode 100644 index e58830b06..000000000 --- a/src/Attributes/Deferred.php +++ /dev/null @@ -1,16 +0,0 @@ -getAttributes(self::class) !== []; - } -} diff --git a/src/Attributes/ToolSearch.php b/src/Attributes/ToolSearch.php deleted file mode 100644 index 835db7147..000000000 --- a/src/Attributes/ToolSearch.php +++ /dev/null @@ -1,14 +0,0 @@ -mapTools($tools, $provider, $model, $options) : []; + $mappedTools = filled($tools) ? $this->mapTools($tools, $provider) : []; $providerOptions = $options?->providerOptions($provider->driver()) ?? []; diff --git a/src/Gateway/Anthropic/Concerns/MapsTools.php b/src/Gateway/Anthropic/Concerns/MapsTools.php index 0303d0173..e0010c1fd 100644 --- a/src/Gateway/Anthropic/Concerns/MapsTools.php +++ b/src/Gateway/Anthropic/Concerns/MapsTools.php @@ -3,15 +3,14 @@ namespace Laravel\Ai\Gateway\Anthropic\Concerns; use Illuminate\JsonSchema\JsonSchemaTypeFactory; -use Laravel\Ai\Attributes\Deferred; -use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebFetch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Tool; -use Laravel\Ai\Gateway\TextGenerationOptions; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\Tools\ProviderTool; +use Laravel\Ai\Providers\Tools\ToolSearch; use Laravel\Ai\Providers\Tools\WebFetch; use Laravel\Ai\Providers\Tools\WebSearch; use Laravel\Ai\Tools\ToolNameResolver; @@ -23,38 +22,40 @@ trait MapsTools /** * Map the given tools to Anthropic tool definitions. */ - protected function mapTools(array $tools, Provider $provider, string $model = '', ?TextGenerationOptions $options = null): array + protected function mapTools(array $tools, Provider $provider): array { - $searchActive = $this->toolSearchActive($provider, $model, $options); - $mapped = []; - $toolCount = 0; - $deferredCount = 0; + $nonDeferredCount = 0; + $hasToolSearch = false; foreach ($tools as $tool) { - if ($tool instanceof ProviderTool) { + if ($tool instanceof ToolSearch) { + $hasToolSearch = true; + $options = $tool->providerOptions(Lab::Anthropic); + $strategy = ($options['strategy'] ?? 'regex') === 'bm25' ? 'bm25' : 'regex'; + unset($options['strategy']); + + $mapped[] = [ + 'type' => "tool_search_tool_{$strategy}_20251119", + 'name' => "tool_search_tool_{$strategy}", + ...$options, + ]; + + foreach ($tool->tools as $deferred) { + $mapped[] = $this->mapTool($deferred, defer: true); + } + } elseif ($tool instanceof ProviderTool) { $mapped[] = $this->mapProviderTool($tool, $provider); } elseif ($tool instanceof Tool) { - $defer = $searchActive && Deferred::isAppliedTo($tool); - $mapped[] = $this->mapTool($tool, $defer); - $toolCount++; - $deferredCount += $defer ? 1 : 0; + $mapped[] = $this->mapTool($tool); + $nonDeferredCount++; } } - if ($searchActive && $deferredCount > 0) { - if ($deferredCount === $toolCount) { - throw new LogicException( - 'Anthropic tool search requires at least one non-deferred tool.' - ); - } - - $strategy = $options?->toolSearchStrategy === 'bm25' ? 'bm25' : 'regex'; - - array_unshift($mapped, [ - 'type' => "tool_search_tool_{$strategy}_20251119", - 'name' => "tool_search_tool_{$strategy}", - ]); + if ($hasToolSearch && $nonDeferredCount === 0) { + throw new LogicException( + 'Anthropic tool search requires at least one non-deferred tool.' + ); } return $mapped; @@ -89,16 +90,6 @@ protected function mapTool(Tool $tool, bool $defer = false): array return $definition; } - /** - * Determine whether hosted tool search is active for this request. - */ - protected function toolSearchActive(Provider $provider, string $model, ?TextGenerationOptions $options): bool - { - return $options?->toolSearchStrategy !== null - && $provider instanceof SupportsToolSearch - && $provider->supportsToolSearch($model); - } - /** * Map a provider tool to an Anthropic provider tool definition. */ diff --git a/src/Gateway/Concerns/InvokesTools.php b/src/Gateway/Concerns/InvokesTools.php index 272848c05..e40b5ae70 100644 --- a/src/Gateway/Concerns/InvokesTools.php +++ b/src/Gateway/Concerns/InvokesTools.php @@ -4,6 +4,7 @@ use Closure; use Laravel\Ai\Contracts\Tool; +use Laravel\Ai\Providers\Tools\ToolSearch; use Laravel\Ai\Tools\Request; use Laravel\Ai\Tools\ToolNameResolver; @@ -54,6 +55,14 @@ protected function executeTool(Tool $tool, array $arguments): string protected function findTool(string $name, array $tools): ?Tool { foreach ($tools as $tool) { + if ($tool instanceof ToolSearch) { + if ($nested = $this->findTool($name, $tool->tools)) { + return $nested; + } + + continue; + } + if ($tool instanceof Tool && ToolNameResolver::resolve($tool) === $name) { return $tool; } diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index 90103e5d3..5cada0717 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -28,7 +28,7 @@ protected function buildTextRequestBody( if (filled($tools)) { $body['tool_choice'] = 'auto'; - $body['tools'] = $this->mapTools($tools, $provider, $model, $options); + $body['tools'] = $this->mapTools($tools, $provider); } if (filled($schema)) { diff --git a/src/Gateway/OpenAi/Concerns/MapsTools.php b/src/Gateway/OpenAi/Concerns/MapsTools.php index eb6e4b59f..7fe1dbe9f 100644 --- a/src/Gateway/OpenAi/Concerns/MapsTools.php +++ b/src/Gateway/OpenAi/Concerns/MapsTools.php @@ -3,17 +3,16 @@ namespace Laravel\Ai\Gateway\OpenAi\Concerns; use Illuminate\JsonSchema\JsonSchemaTypeFactory; -use Laravel\Ai\Attributes\Deferred; use Laravel\Ai\Attributes\Strict; use Laravel\Ai\Contracts\Providers\SupportsFileSearch; -use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Tool; -use Laravel\Ai\Gateway\TextGenerationOptions; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\Tools\FileSearch; use Laravel\Ai\Providers\Tools\ProviderTool; +use Laravel\Ai\Providers\Tools\ToolSearch; use Laravel\Ai\Providers\Tools\WebSearch; use Laravel\Ai\Tools\ToolNameResolver; use RuntimeException; @@ -23,27 +22,24 @@ trait MapsTools /** * Map the given tools to OpenAI function definitions. */ - protected function mapTools(array $tools, Provider $provider, string $model = '', ?TextGenerationOptions $options = null): array + protected function mapTools(array $tools, Provider $provider): array { - $searchActive = $this->toolSearchActive($provider, $model, $options); - $mapped = []; - $deferredCount = 0; foreach ($tools as $tool) { - if ($tool instanceof ProviderTool) { + if ($tool instanceof ToolSearch) { + $mapped[] = ['type' => 'tool_search', ...$tool->providerOptions(Lab::OpenAI)]; + + foreach ($tool->tools as $deferred) { + $mapped[] = $this->mapTool($deferred, defer: true); + } + } elseif ($tool instanceof ProviderTool) { $mapped[] = $this->mapProviderTool($tool, $provider); } elseif ($tool instanceof Tool) { - $defer = $searchActive && Deferred::isAppliedTo($tool); - $mapped[] = $this->mapTool($tool, $defer); - $deferredCount += $defer ? 1 : 0; + $mapped[] = $this->mapTool($tool); } } - if ($searchActive && $deferredCount > 0) { - array_unshift($mapped, ['type' => 'tool_search']); - } - return $mapped; } @@ -80,16 +76,6 @@ protected function mapTool(Tool $tool, bool $defer = false): array return $definition; } - /** - * Determine whether hosted tool search is active for this request. - */ - protected function toolSearchActive(Provider $provider, string $model, ?TextGenerationOptions $options): bool - { - return $options?->toolSearchStrategy !== null - && $provider instanceof SupportsToolSearch - && $provider->supportsToolSearch($model); - } - /** * Map a provider tool to an OpenAI provider tool definition. */ diff --git a/src/Gateway/TextGenerationOptions.php b/src/Gateway/TextGenerationOptions.php index 6cda4636b..4c782c14f 100644 --- a/src/Gateway/TextGenerationOptions.php +++ b/src/Gateway/TextGenerationOptions.php @@ -5,7 +5,6 @@ use Laravel\Ai\Attributes\MaxSteps; use Laravel\Ai\Attributes\MaxTokens; use Laravel\Ai\Attributes\Temperature; -use Laravel\Ai\Attributes\ToolSearch; use Laravel\Ai\Attributes\TopP; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasProviderOptions; @@ -20,7 +19,6 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?Agent $agent = null, public readonly ?float $topP = null, - public readonly ?string $toolSearchStrategy = null, ) { // } @@ -54,20 +52,9 @@ public static function forAgent(Agent $agent): self temperature: self::resolve($agent, $reflection, 'temperature', Temperature::class), agent: $agent, topP: self::resolve($agent, $reflection, 'topP', TopP::class), - toolSearchStrategy: self::resolveToolSearchStrategy($reflection), ); } - /** - * Resolve the tool search strategy from the agent's ToolSearch attribute. - */ - private static function resolveToolSearchStrategy(ReflectionClass $reflection): ?string - { - $attributes = $reflection->getAttributes(ToolSearch::class); - - return ! empty($attributes) ? $attributes[0]->newInstance()->strategy : null; - } - /** * Resolve an option from the agent's method, falling back to the attribute. * diff --git a/src/Providers/AnthropicProvider.php b/src/Providers/AnthropicProvider.php index 61c4f3175..aff86c845 100644 --- a/src/Providers/AnthropicProvider.php +++ b/src/Providers/AnthropicProvider.php @@ -4,7 +4,6 @@ use Laravel\Ai\Contracts\Gateway\FileGateway; use Laravel\Ai\Contracts\Providers\FileProvider; -use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebFetch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Providers\TextProvider; @@ -13,7 +12,7 @@ use Laravel\Ai\Providers\Tools\WebFetch; use Laravel\Ai\Providers\Tools\WebSearch; -class AnthropicProvider extends Provider implements FileProvider, SupportsToolSearch, SupportsWebFetch, SupportsWebSearch, TextProvider +class AnthropicProvider extends Provider implements FileProvider, SupportsWebFetch, SupportsWebSearch, TextProvider { use Concerns\GeneratesText; use Concerns\HasFileGateway; @@ -21,24 +20,6 @@ class AnthropicProvider extends Provider implements FileProvider, SupportsToolSe use Concerns\ManagesFiles; use Concerns\StreamsText; - /** - * Determine whether the given model supports hosted tool search. - */ - public function supportsToolSearch(string $model): bool - { - if (! preg_match('/claude-(opus|sonnet|haiku)-(\d+)-(\d+)/', $model, $matches)) { - return false; - } - - $version = (int) $matches[2] + ((int) $matches[3]) / 10; - - return match ($matches[1]) { - 'opus', 'sonnet' => $version >= 4.0, - 'haiku' => $version >= 4.5, - default => false, - }; - } - /** * Get the web fetch tool options for the provider. */ diff --git a/src/Providers/Concerns/GeneratesText.php b/src/Providers/Concerns/GeneratesText.php index a2a9c6e0d..00e7e6124 100644 --- a/src/Providers/Concerns/GeneratesText.php +++ b/src/Providers/Concerns/GeneratesText.php @@ -22,6 +22,7 @@ use Laravel\Ai\Messages\UserMessage; use Laravel\Ai\Middleware\RememberConversation; use Laravel\Ai\Prompts\AgentPrompt; +use Laravel\Ai\Providers\Tools\ToolSearch; use Laravel\Ai\Responses\AgentResponse; use Laravel\Ai\Responses\StructuredAgentResponse; use Laravel\Ai\Tools\AgentTool; @@ -134,6 +135,9 @@ protected function resolveTool(mixed $tool): mixed return match (true) { $tool instanceof Agent => new AgentTool($tool), $tool instanceof Tool => $tool, + $tool instanceof ToolSearch => $tool->withTools( + array_map(fn ($nested) => $this->resolveTool($nested), $tool->tools), + ), McpTool::supports($tool) => new McpTool($tool), McpServerTool::supports($tool) => new McpServerTool($tool), default => $tool, diff --git a/src/Providers/OpenAiProvider.php b/src/Providers/OpenAiProvider.php index b77c41afa..9afce1f9c 100644 --- a/src/Providers/OpenAiProvider.php +++ b/src/Providers/OpenAiProvider.php @@ -11,7 +11,6 @@ use Laravel\Ai\Contracts\Providers\ImageProvider; use Laravel\Ai\Contracts\Providers\StoreProvider; use Laravel\Ai\Contracts\Providers\SupportsFileSearch; -use Laravel\Ai\Contracts\Providers\SupportsToolSearch; use Laravel\Ai\Contracts\Providers\SupportsWebSearch; use Laravel\Ai\Contracts\Providers\TextProvider; use Laravel\Ai\Contracts\Providers\TranscriptionProvider; @@ -21,7 +20,7 @@ use Laravel\Ai\Providers\Tools\FileSearch; use Laravel\Ai\Providers\Tools\WebSearch; -class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvider, FileProvider, ImageProvider, StoreProvider, SupportsFileSearch, SupportsToolSearch, SupportsWebSearch, TextProvider, TranscriptionProvider +class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvider, FileProvider, ImageProvider, StoreProvider, SupportsFileSearch, SupportsWebSearch, TextProvider, TranscriptionProvider { use Concerns\GeneratesAudio; use Concerns\GeneratesEmbeddings; @@ -39,21 +38,6 @@ class OpenAiProvider extends Provider implements AudioProvider, EmbeddingProvide use Concerns\ManagesStores; use Concerns\StreamsText; - /** - * Determine whether the given model supports hosted tool search. - */ - public function supportsToolSearch(string $model): bool - { - if (! preg_match('/^gpt-(\d+)(?:\.(\d+))?/', $model, $matches)) { - return false; - } - - $major = (int) $matches[1]; - $minor = (int) ($matches[2] ?? 0); - - return $major > 5 || ($major === 5 && $minor >= 4); - } - /** * Get the file search tool options for the provider. */ diff --git a/src/Providers/Tools/ToolSearch.php b/src/Providers/Tools/ToolSearch.php new file mode 100644 index 000000000..c4ecedac5 --- /dev/null +++ b/src/Providers/Tools/ToolSearch.php @@ -0,0 +1,28 @@ + $tools + */ + public function __construct(public array $tools = []) + { + // + } + + /** + * Set the deferred tools discovered through search. + * + * @param array $tools + */ + public function withTools(array $tools): self + { + $this->tools = $tools; + + return $this; + } +} diff --git a/tests/Datasets/Providers.php b/tests/Datasets/Providers.php index 5fbb65d45..0a3a191b2 100644 --- a/tests/Datasets/Providers.php +++ b/tests/Datasets/Providers.php @@ -55,6 +55,11 @@ 'xai' => ['xai', 'XAI_API_KEY', 'grok-4-1-fast-reasoning'], ]); +dataset('tool-search-providers', [ + 'anthropic' => ['anthropic', 'ANTHROPIC_API_KEY', 'claude-haiku-4-5-20251001'], + 'openai' => ['openai', 'OPENAI_API_KEY', 'gpt-5.4'], +]); + dataset('agent-document-providers', [ 'anthropic' => ['anthropic', 'ANTHROPIC_API_KEY', 'claude-haiku-4-5-20251001'], 'openai' => ['openai', 'OPENAI_API_KEY', 'gpt-5.4-nano'], diff --git a/tests/Feature/Providers/Anthropic/ToolSearchTest.php b/tests/Feature/Providers/Anthropic/ToolSearchTest.php index cd805d51a..5f98a5c6e 100644 --- a/tests/Feature/Providers/Anthropic/ToolSearchTest.php +++ b/tests/Feature/Providers/Anthropic/ToolSearchTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AnthropicToolSearchAgent; -test('an agent with the ToolSearch attribute emits the regex tool search entry and defers marked tools', function () { +test('an agent with a ToolSearch tool emits the regex tool search entry and defers its nested tools', function () { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), ]); diff --git a/tests/Feature/Providers/OpenAi/ToolSearchTest.php b/tests/Feature/Providers/OpenAi/ToolSearchTest.php index bcd407b23..724ba6318 100644 --- a/tests/Feature/Providers/OpenAi/ToolSearchTest.php +++ b/tests/Feature/Providers/OpenAi/ToolSearchTest.php @@ -11,7 +11,7 @@ ]]); }); -test('an agent with the ToolSearch attribute emits a tool_search entry and defers marked tools', function () { +test('an agent with a ToolSearch tool emits a tool_search entry and defers its nested tools', function () { Http::fake([ '*' => fakeOpenAiResponse('ok'), ]); diff --git a/tests/Fixtures/Agents/AnthropicToolSearchAgent.php b/tests/Fixtures/Agents/AnthropicToolSearchAgent.php index 856c3dfe8..dc6e741e2 100644 --- a/tests/Fixtures/Agents/AnthropicToolSearchAgent.php +++ b/tests/Fixtures/Agents/AnthropicToolSearchAgent.php @@ -4,17 +4,15 @@ use Laravel\Ai\Attributes\Model; use Laravel\Ai\Attributes\Provider; -use Laravel\Ai\Attributes\ToolSearch; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasTools; -use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Promptable; +use Laravel\Ai\Providers\Tools\ToolSearch; use Tests\Fixtures\Tools\DeferredTool; use Tests\Fixtures\Tools\NonStrictTool; #[Provider('anthropic')] #[Model('claude-opus-4-8')] -#[ToolSearch] class AnthropicToolSearchAgent implements Agent, HasTools { use Promptable; @@ -24,11 +22,8 @@ public function instructions(): string return 'You are a helpful assistant.'; } - /** - * @return iterable - */ public function tools(): iterable { - return [new DeferredTool, new NonStrictTool]; + return [new NonStrictTool, new ToolSearch(tools: [new DeferredTool])]; } } diff --git a/tests/Fixtures/Agents/OpenAiToolSearchAgent.php b/tests/Fixtures/Agents/OpenAiToolSearchAgent.php index 8c9e759c3..992fbfbf1 100644 --- a/tests/Fixtures/Agents/OpenAiToolSearchAgent.php +++ b/tests/Fixtures/Agents/OpenAiToolSearchAgent.php @@ -4,17 +4,15 @@ use Laravel\Ai\Attributes\Model; use Laravel\Ai\Attributes\Provider; -use Laravel\Ai\Attributes\ToolSearch; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasTools; -use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Promptable; +use Laravel\Ai\Providers\Tools\ToolSearch; use Tests\Fixtures\Tools\DeferredTool; use Tests\Fixtures\Tools\NonStrictTool; #[Provider('openai')] #[Model('gpt-5.4')] -#[ToolSearch] class OpenAiToolSearchAgent implements Agent, HasTools { use Promptable; @@ -24,11 +22,8 @@ public function instructions(): string return 'You are a helpful assistant.'; } - /** - * @return iterable - */ public function tools(): iterable { - return [new DeferredTool, new NonStrictTool]; + return [new NonStrictTool, new ToolSearch(tools: [new DeferredTool])]; } } diff --git a/tests/Fixtures/Tools/DeferredTool.php b/tests/Fixtures/Tools/DeferredTool.php index 877341773..ff824e781 100644 --- a/tests/Fixtures/Tools/DeferredTool.php +++ b/tests/Fixtures/Tools/DeferredTool.php @@ -3,11 +3,9 @@ namespace Tests\Fixtures\Tools; use Illuminate\Contracts\JsonSchema\JsonSchema; -use Laravel\Ai\Attributes\Deferred; use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Tools\Request; -#[Deferred] class DeferredTool implements Tool { public function description(): string diff --git a/tests/Fixtures/Tools/SecretCodeGenerator.php b/tests/Fixtures/Tools/SecretCodeGenerator.php new file mode 100644 index 000000000..731ffbe10 --- /dev/null +++ b/tests/Fixtures/Tools/SecretCodeGenerator.php @@ -0,0 +1,34 @@ +prompt( + 'What is the secret authorization code for this session?', + provider: $provider, + model: $model, + ); + + expect($response->text)->toContain('ZEBRA-4417') + ->and($response->toolCalls->pluck('name'))->toContain('SecretCodeGenerator'); +})->with('tool-search-providers'); diff --git a/tests/Unit/Attributes/DeferredTest.php b/tests/Unit/Attributes/DeferredTest.php deleted file mode 100644 index a0fa2654a..000000000 --- a/tests/Unit/Attributes/DeferredTest.php +++ /dev/null @@ -1,17 +0,0 @@ -toBeTrue(); -}); - -test('isAppliedTo returns false when target does not have the attribute', function () { - expect(Deferred::isAppliedTo(new NonStrictTool))->toBeFalse(); -}); - -test('isAppliedTo returns false when target is null', function () { - expect(Deferred::isAppliedTo(null))->toBeFalse(); -}); diff --git a/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php index c0b697f57..f1e036c6f 100644 --- a/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php +++ b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php @@ -1,9 +1,9 @@ mapTools($tools, $provider, $model, $options); + return $this->mapTools($tools, $provider); } }; } -function anthropicSupportingProvider(bool $supports = true): Provider +function anthropicProvider(): Provider { - return new class($supports) extends Provider implements SupportsToolSearch + return new class extends Provider { - public function __construct(private bool $supports) + public function __construct() { // } - - public function supportsToolSearch(string $model): bool - { - return $this->supports; - } }; } -test('prepends the regex tool search entry by default and defers marked tools', function () { +test('emits the regex tool search entry and defers the tools nested in the ToolSearch tool', function () { $mapped = anthropicToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - anthropicSupportingProvider(), - 'claude-opus-4-8', - new TextGenerationOptions(toolSearchStrategy: 'regex'), + [new NonStrictTool, new ToolSearch(tools: [new DeferredTool])], + anthropicProvider(), ); - expect($mapped[0])->toBe([ + $search = collect($mapped)->firstWhere('type', 'tool_search_tool_regex_20251119'); + + expect($search)->toBe([ 'type' => 'tool_search_tool_regex_20251119', 'name' => 'tool_search_tool_regex', ]); - expect($mapped[0])->not->toHaveKey('defer_loading'); + expect($search)->not->toHaveKey('defer_loading'); - $tools = array_slice($mapped, 1); - $deferred = collect($tools)->firstWhere('defer_loading', true); - $nonDeferred = collect($tools)->filter(fn ($t) => ! isset($t['defer_loading'])); + $deferred = collect($mapped)->firstWhere('defer_loading', true); + $nonDeferred = collect($mapped)->filter( + fn ($t) => isset($t['input_schema']) && ! isset($t['defer_loading']) + ); expect($deferred)->not->toBeNull() ->and($deferred['description'])->toContain('deferred') ->and($nonDeferred)->toHaveCount(1); }); -test('prepends the bm25 tool search entry when that strategy is selected', function () { +test('emits the bm25 tool search entry when that strategy is set via provider options', function () { + $search = (new ToolSearch(tools: [new DeferredTool])) + ->withProviderOptions(Lab::Anthropic, ['strategy' => 'bm25']); + $mapped = anthropicToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - anthropicSupportingProvider(), - 'claude-opus-4-8', - new TextGenerationOptions(toolSearchStrategy: 'bm25'), + [new NonStrictTool, $search], + anthropicProvider(), ); - expect($mapped[0])->toBe([ + expect(collect($mapped)->firstWhere('type', 'tool_search_tool_bm25_20251119'))->toBe([ 'type' => 'tool_search_tool_bm25_20251119', 'name' => 'tool_search_tool_bm25', ]); }); -test('emits tools unchanged when the agent has not opted in', function () { +test('does not leak the strategy option onto the tool search entry', function () { + $search = (new ToolSearch(tools: [new DeferredTool])) + ->withProviderOptions(Lab::Anthropic, ['strategy' => 'regex']); + $mapped = anthropicToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - anthropicSupportingProvider(), - 'claude-opus-4-8', - new TextGenerationOptions, + [new NonStrictTool, $search], + anthropicProvider(), ); - expect($mapped)->toHaveCount(2) - ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search_tool_regex_20251119') - ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); + expect(collect($mapped)->firstWhere('type', 'tool_search_tool_regex_20251119')) + ->not->toHaveKey('strategy'); +}); + +test('forwards provider options onto the tool search entry', function () { + $search = (new ToolSearch(tools: [new DeferredTool])) + ->withProviderOptions(Lab::Anthropic, ['cache_control' => ['type' => 'ephemeral']]); + + $mapped = anthropicToolSearchMapper()->map([new NonStrictTool, $search], anthropicProvider()); + + expect(collect($mapped)->firstWhere('type', 'tool_search_tool_regex_20251119')) + ->toBe([ + 'type' => 'tool_search_tool_regex_20251119', + 'name' => 'tool_search_tool_regex', + 'cache_control' => ['type' => 'ephemeral'], + ]); }); -test('silently skips deferral when the model does not support tool search', function () { +test('does not emit a tool_search entry when no ToolSearch tool is present', function () { $mapped = anthropicToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - anthropicSupportingProvider(supports: false), - 'claude-haiku-3', - new TextGenerationOptions(toolSearchStrategy: 'regex'), + [new NonStrictTool], + anthropicProvider(), ); - expect($mapped)->toHaveCount(2) + expect($mapped)->toHaveCount(1) ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); }); -test('throws when every tool is deferred because Anthropic requires a non-deferred tool', function () { +test('throws when no non-deferred tool accompanies the ToolSearch tool because Anthropic requires one', function () { anthropicToolSearchMapper()->map( - [new DeferredTool, new DeferredTool], - anthropicSupportingProvider(), - 'claude-opus-4-8', - new TextGenerationOptions(toolSearchStrategy: 'regex'), + [new ToolSearch(tools: [new DeferredTool, new DeferredTool])], + anthropicProvider(), ); })->throws(LogicException::class, 'at least one non-deferred tool'); diff --git a/tests/Unit/Gateway/FindToolTest.php b/tests/Unit/Gateway/FindToolTest.php new file mode 100644 index 000000000..369515276 --- /dev/null +++ b/tests/Unit/Gateway/FindToolTest.php @@ -0,0 +1,45 @@ +findTool($name, $tools); + } + }; +} + +test('finds a top-level tool by name', function () { + $tool = new NonStrictTool; + + expect(toolFinder()->find('NonStrictTool', [$tool]))->toBe($tool); +}); + +test('finds a tool nested inside a ToolSearch tool', function () { + $nested = new DeferredTool; + + $found = toolFinder()->find('DeferredTool', [ + new NonStrictTool, + new ToolSearch(tools: [$nested]), + ]); + + expect($found)->toBe($nested); +}); + +test('returns null when the tool is not present anywhere', function () { + $found = toolFinder()->find('Missing', [ + new NonStrictTool, + new ToolSearch(tools: [new DeferredTool]), + ]); + + expect($found)->toBeNull(); +}); diff --git a/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php index ff5bca2c6..1df3dbd72 100644 --- a/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php +++ b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php @@ -1,9 +1,9 @@ mapTools($tools, $provider, $model, $options); + return $this->mapTools($tools, $provider); } }; } -function openAiSupportingProvider(bool $supports = true): Provider -{ - return new class($supports) extends Provider implements SupportsToolSearch - { - public function __construct(private bool $supports) - { - // - } - - public function supportsToolSearch(string $model): bool - { - return $this->supports; - } - }; -} - -function openAiPlainProvider(): Provider +function openAiProvider(): Provider { return new class extends Provider { @@ -47,71 +31,41 @@ public function __construct() }; } -test('deferred tool gets defer_loading and a tool_search entry is prepended when search is active', function () { +test('emits the tool_search entry and defers the tools nested in the ToolSearch tool', function () { $mapped = openAiToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - openAiSupportingProvider(), - 'gpt-5.4', - new TextGenerationOptions(toolSearchStrategy: 'regex'), + [new NonStrictTool, new ToolSearch(tools: [new DeferredTool])], + openAiProvider(), ); - expect($mapped[0])->toBe(['type' => 'tool_search']); - - $tools = array_slice($mapped, 1); - $deferred = collect($tools)->firstWhere('defer_loading', true); - $nonDeferred = collect($tools)->filter(fn ($t) => ! isset($t['defer_loading'])); + $search = collect($mapped)->firstWhere('type', 'tool_search'); + $deferred = collect($mapped)->firstWhere('defer_loading', true); + $nonDeferred = collect($mapped)->filter( + fn ($t) => ($t['type'] ?? null) === 'function' && ! isset($t['defer_loading']) + ); - expect($deferred)->not->toBeNull() + expect($search)->toBe(['type' => 'tool_search']) + ->and($deferred)->not->toBeNull() ->and($deferred['description'])->toContain('deferred') ->and($nonDeferred)->toHaveCount(1); }); -test('no tool_search entry or defer_loading when the agent has not opted in', function () { - $mapped = openAiToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - openAiSupportingProvider(), - 'gpt-5.4', - new TextGenerationOptions, // toolSearchStrategy === null - ); +test('forwards provider options onto the tool_search entry', function () { + $search = (new ToolSearch(tools: [new DeferredTool])) + ->withProviderOptions(Lab::OpenAI, ['foo' => 'bar']); - expect($mapped)->toHaveCount(2) - ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search') - ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); -}); - -test('silently skips deferral when the provider model does not support tool search', function () { - $mapped = openAiToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - openAiSupportingProvider(supports: false), - 'gpt-5.3', - new TextGenerationOptions(toolSearchStrategy: 'regex'), - ); + $mapped = openAiToolSearchMapper()->map([new NonStrictTool, $search], openAiProvider()); - expect($mapped)->toHaveCount(2) - ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search') - ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); + expect(collect($mapped)->firstWhere('type', 'tool_search')) + ->toBe(['type' => 'tool_search', 'foo' => 'bar']); }); -test('silently skips deferral when the provider does not implement SupportsToolSearch', function () { +test('does not emit a tool_search entry when no ToolSearch tool is present', function () { $mapped = openAiToolSearchMapper()->map( - [new DeferredTool, new NonStrictTool], - openAiPlainProvider(), - 'gpt-5.4', - new TextGenerationOptions(toolSearchStrategy: 'regex'), + [new NonStrictTool, new DeferredTool], + openAiProvider(), ); expect($mapped)->toHaveCount(2) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search') ->and(collect($mapped)->contains(fn ($t) => isset($t['defer_loading'])))->toBeFalse(); }); - -test('no tool_search entry is added when no tools are deferred even if search is active', function () { - $mapped = openAiToolSearchMapper()->map( - [new NonStrictTool], - openAiSupportingProvider(), - 'gpt-5.4', - new TextGenerationOptions(toolSearchStrategy: 'regex'), - ); - - expect($mapped)->toHaveCount(1) - ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search'); -}); diff --git a/tests/Unit/Gateway/ToolSearchOptionTest.php b/tests/Unit/Gateway/ToolSearchOptionTest.php deleted file mode 100644 index 7d3667f42..000000000 --- a/tests/Unit/Gateway/ToolSearchOptionTest.php +++ /dev/null @@ -1,48 +0,0 @@ -toolSearchStrategy)->toBeNull(); -}); - -test('resolves regex strategy by default when ToolSearch attribute is present', function () { - $agent = new #[ToolSearch] class implements Agent - { - use Promptable; - - public function instructions(): string - { - return 'x'; - } - }; - - expect(TextGenerationOptions::forAgent($agent)->toolSearchStrategy)->toBe('regex'); -}); - -test('resolves the overridden bm25 strategy', function () { - $agent = new #[ToolSearch(strategy: 'bm25')] class implements Agent - { - use Promptable; - - public function instructions(): string - { - return 'x'; - } - }; - - expect(TextGenerationOptions::forAgent($agent)->toolSearchStrategy)->toBe('bm25'); -}); diff --git a/tests/Unit/Providers/ToolSearchSupportTest.php b/tests/Unit/Providers/ToolSearchSupportTest.php deleted file mode 100644 index d6948ae8d..000000000 --- a/tests/Unit/Providers/ToolSearchSupportTest.php +++ /dev/null @@ -1,49 +0,0 @@ - 'test', 'driver' => 'test', 'key' => 'test'], - Mockery::mock(Dispatcher::class), - ); -} - -test('the OpenAI provider implements the tool search capability', function () { - expect(makeProvider(OpenAiProvider::class))->toBeInstanceOf(SupportsToolSearch::class); -}); - -test('the OpenAI provider supports tool search on gpt-5.4 and later', function (string $model, bool $expected) { - expect(makeProvider(OpenAiProvider::class)->supportsToolSearch($model))->toBe($expected); -})->with([ - ['gpt-5.4', true], - ['gpt-5.4-2026-01-01', true], - ['gpt-5.5', true], - ['gpt-6', true], - ['gpt-5.3', false], - ['gpt-5', false], - ['gpt-4o', false], -]); - -test('the Anthropic provider implements the tool search capability', function () { - expect(makeProvider(AnthropicProvider::class))->toBeInstanceOf(SupportsToolSearch::class); -}); - -test('the Anthropic provider supports tool search on Sonnet/Opus 4.0+ and Haiku 4.5+', function (string $model, bool $expected) { - expect(makeProvider(AnthropicProvider::class)->supportsToolSearch($model))->toBe($expected); -})->with([ - ['claude-opus-4-8', true], - ['claude-sonnet-4-6', true], - ['claude-sonnet-4-0', true], - ['claude-haiku-4-5', true], - ['claude-haiku-4-5-20251001', true], - ['claude-haiku-4-0', false], - ['claude-opus-3-5', false], - ['claude-3-5-sonnet-20241022', false], -]); diff --git a/tests/Unit/Providers/Tools/ToolSearchTest.php b/tests/Unit/Providers/Tools/ToolSearchTest.php new file mode 100644 index 000000000..8129ef135 --- /dev/null +++ b/tests/Unit/Providers/Tools/ToolSearchTest.php @@ -0,0 +1,28 @@ +tools)->toBe([]); +}); + +test('accepts a deferred tool set', function () { + $tool = new DeferredTool; + + expect((new ToolSearch(tools: [$tool]))->tools)->toBe([$tool]); +}); + +test('withTools replaces the deferred tool set', function () { + $tool = new DeferredTool; + + expect((new ToolSearch)->withTools([$tool])->tools)->toBe([$tool]); +}); + +test('carries provider options for a specific provider', function () { + $search = (new ToolSearch)->withProviderOptions(Lab::Anthropic, ['strategy' => 'bm25']); + + expect($search->providerOptions(Lab::Anthropic))->toBe(['strategy' => 'bm25']) + ->and($search->providerOptions(Lab::OpenAI))->toBe([]); +}); From 1a63a55e98b090a8d4fa388eaab808dfa6e757f8 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 16 Jun 2026 18:09:26 +0530 Subject: [PATCH 3/3] Formatting --- src/Gateway/Anthropic/Concerns/MapsTools.php | 5 +++ .../OpenAi/Concerns/BuildsTextRequests.php | 8 +++-- src/Gateway/OpenAi/Concerns/MapsTools.php | 4 +++ .../Providers/OpenAi/ToolSearchTest.php | 34 +++++++++++++++++++ .../Anthropic/ToolSearchMappingTest.php | 32 +++++++++++++++++ .../Gateway/OpenAi/ToolSearchMappingTest.php | 10 ++++++ 6 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/Gateway/Anthropic/Concerns/MapsTools.php b/src/Gateway/Anthropic/Concerns/MapsTools.php index e0010c1fd..60182f79c 100644 --- a/src/Gateway/Anthropic/Concerns/MapsTools.php +++ b/src/Gateway/Anthropic/Concerns/MapsTools.php @@ -30,6 +30,10 @@ protected function mapTools(array $tools, Provider $provider): array foreach ($tools as $tool) { if ($tool instanceof ToolSearch) { + if (blank($tool->tools)) { + continue; + } + $hasToolSearch = true; $options = $tool->providerOptions(Lab::Anthropic); $strategy = ($options['strategy'] ?? 'regex') === 'bm25' ? 'bm25' : 'regex'; @@ -46,6 +50,7 @@ protected function mapTools(array $tools, Provider $provider): array } } elseif ($tool instanceof ProviderTool) { $mapped[] = $this->mapProviderTool($tool, $provider); + $nonDeferredCount++; } elseif ($tool instanceof Tool) { $mapped[] = $this->mapTool($tool); $nonDeferredCount++; diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index 5cada0717..dbf6ab120 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -27,8 +27,12 @@ protected function buildTextRequestBody( $body = ['model' => $model, 'input' => $input]; if (filled($tools)) { - $body['tool_choice'] = 'auto'; - $body['tools'] = $this->mapTools($tools, $provider); + $mappedTools = $this->mapTools($tools, $provider); + + if (filled($mappedTools)) { + $body['tool_choice'] = 'auto'; + $body['tools'] = $mappedTools; + } } if (filled($schema)) { diff --git a/src/Gateway/OpenAi/Concerns/MapsTools.php b/src/Gateway/OpenAi/Concerns/MapsTools.php index 7fe1dbe9f..e3d716f0e 100644 --- a/src/Gateway/OpenAi/Concerns/MapsTools.php +++ b/src/Gateway/OpenAi/Concerns/MapsTools.php @@ -28,6 +28,10 @@ protected function mapTools(array $tools, Provider $provider): array foreach ($tools as $tool) { if ($tool instanceof ToolSearch) { + if (blank($tool->tools)) { + continue; + } + $mapped[] = ['type' => 'tool_search', ...$tool->providerOptions(Lab::OpenAI)]; foreach ($tool->tools as $deferred) { diff --git a/tests/Feature/Providers/OpenAi/ToolSearchTest.php b/tests/Feature/Providers/OpenAi/ToolSearchTest.php index 724ba6318..4e2105571 100644 --- a/tests/Feature/Providers/OpenAi/ToolSearchTest.php +++ b/tests/Feature/Providers/OpenAi/ToolSearchTest.php @@ -2,6 +2,10 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Laravel\Ai\Contracts\Agent; +use Laravel\Ai\Contracts\HasTools; +use Laravel\Ai\Promptable; +use Laravel\Ai\Providers\Tools\ToolSearch; use Tests\Fixtures\Agents\OpenAiToolSearchAgent; beforeEach(function () { @@ -29,3 +33,33 @@ && ! isset($plain['defer_loading']); }); }); + +test('an agent whose only tool is an empty ToolSearch omits the tool fields', function () { + Http::fake([ + '*' => fakeOpenAiResponse('ok'), + ]); + + $agent = new class implements Agent, HasTools + { + use Promptable; + + public function instructions(): string + { + return 'You are a helpful assistant.'; + } + + public function tools(): iterable + { + return [new ToolSearch]; + } + }; + + $agent->prompt('Hi', provider: 'openai', model: 'gpt-5.4'); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + + return ! array_key_exists('tools', $body) + && ! array_key_exists('tool_choice', $body); + }); +}); diff --git a/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php index f1e036c6f..ccacec23a 100644 --- a/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php +++ b/tests/Unit/Gateway/Anthropic/ToolSearchMappingTest.php @@ -1,9 +1,11 @@ throws(LogicException::class, 'at least one non-deferred tool'); + +test('counts a server tool as non-deferred so a ToolSearch alongside web search is allowed', function () { + $provider = new class extends Provider implements SupportsWebSearch + { + public function __construct() {} + + public function webSearchToolOptions(WebSearch $search): array + { + return []; + } + }; + + $mapped = anthropicToolSearchMapper()->map( + [new WebSearch, new ToolSearch(tools: [new DeferredTool])], + $provider, + ); + + expect(collect($mapped)->firstWhere('type', 'tool_search_tool_regex_20251119'))->not->toBeNull() + ->and(collect($mapped)->firstWhere('type', 'web_search_20250305'))->not->toBeNull(); +}); + +test('skips an empty ToolSearch tool without emitting a search entry', function () { + $mapped = anthropicToolSearchMapper()->map( + [new NonStrictTool, new ToolSearch], + anthropicProvider(), + ); + + expect($mapped)->toHaveCount(1) + ->and(collect($mapped)->contains(fn ($t) => str_starts_with($t['type'] ?? '', 'tool_search')))->toBeFalse(); +}); diff --git a/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php index 1df3dbd72..46379b05a 100644 --- a/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php +++ b/tests/Unit/Gateway/OpenAi/ToolSearchMappingTest.php @@ -59,6 +59,16 @@ public function __construct() ->toBe(['type' => 'tool_search', 'foo' => 'bar']); }); +test('skips an empty ToolSearch tool without emitting a tool_search entry', function () { + $mapped = openAiToolSearchMapper()->map( + [new NonStrictTool, new ToolSearch], + openAiProvider(), + ); + + expect($mapped)->toHaveCount(1) + ->and(collect($mapped)->pluck('type'))->not->toContain('tool_search'); +}); + test('does not emit a tool_search entry when no ToolSearch tool is present', function () { $mapped = openAiToolSearchMapper()->map( [new NonStrictTool, new DeferredTool],