Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions src/Gateway/Anthropic/Concerns/MapsTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
use Laravel\Ai\Contracts\Providers\SupportsWebFetch;
use Laravel\Ai\Contracts\Providers\SupportsWebSearch;
use Laravel\Ai\Contracts\Tool;
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;
Expand All @@ -23,22 +25,51 @@ trait MapsTools
protected function mapTools(array $tools, Provider $provider): array
{
$mapped = [];
$nonDeferredCount = 0;
$hasToolSearch = false;

foreach ($tools as $tool) {
if ($tool instanceof ProviderTool) {
if ($tool instanceof ToolSearch) {
if (blank($tool->tools)) {
continue;
}

$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);
$nonDeferredCount++;
} elseif ($tool instanceof Tool) {
$mapped[] = $this->mapTool($tool);
$nonDeferredCount++;
}
}

if ($hasToolSearch && $nonDeferredCount === 0) {
throw new LogicException(
'Anthropic tool search requires at least one non-deferred tool.'
);
}

return $mapped;
}

/**
* 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);

Expand All @@ -51,11 +82,17 @@ 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;
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/Gateway/AzureOpenAi/AzureOpenAiGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,15 @@ 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);

$schemaArray = filled($schema)
? (new ObjectSchema($schema))->toSchema()
: [];

return array_filter([
$definition = array_filter([
'type' => 'function',
'name' => ToolNameResolver::resolve($tool),
'description' => (string) $tool->description(),
Expand All @@ -245,5 +245,11 @@ protected function mapTool(Tool $tool): array
'required' => $schemaArray['required'] ?? [],
] : null,
]);

if ($defer) {
$definition['defer_loading'] = true;
}

return $definition;
}
}
9 changes: 9 additions & 0 deletions src/Gateway/Concerns/InvokesTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/Gateway/OpenAi/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
24 changes: 21 additions & 3 deletions src/Gateway/OpenAi/Concerns/MapsTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
use Laravel\Ai\Contracts\Providers\SupportsFileSearch;
use Laravel\Ai\Contracts\Providers\SupportsWebSearch;
use Laravel\Ai\Contracts\Tool;
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;
Expand All @@ -25,7 +27,17 @@ protected function mapTools(array $tools, Provider $provider): array
$mapped = [];

foreach ($tools as $tool) {
if ($tool instanceof ProviderTool) {
if ($tool instanceof ToolSearch) {
if (blank($tool->tools)) {
continue;
}

$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) {
$mapped[] = $this->mapTool($tool);
Expand All @@ -38,7 +50,7 @@ protected function mapTools(array $tools, Provider $provider): array
/**
* 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);

Expand All @@ -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(),
Expand All @@ -60,6 +72,12 @@ protected function mapTool(Tool $tool): array
'additionalProperties' => false,
],
];

if ($defer) {
$definition['defer_loading'] = true;
}

return $definition;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/Providers/Concerns/GeneratesText.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/Providers/Tools/ToolSearch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Laravel\Ai\Providers\Tools;

use Laravel\Ai\Contracts\Tool;

class ToolSearch extends ProviderTool
{
/**
* @param array<Tool> $tools
*/
public function __construct(public array $tools = [])
{
//
}

/**
* Set the deferred tools discovered through search.
*
* @param array<Tool> $tools
*/
public function withTools(array $tools): self
{
$this->tools = $tools;

return $this;
}
}
5 changes: 5 additions & 0 deletions tests/Datasets/Providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
23 changes: 23 additions & 0 deletions tests/Feature/Providers/Anthropic/ToolSearchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Illuminate\Support\Facades\Http;
use Tests\Fixtures\Agents\AnthropicToolSearchAgent;

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'),
]);

(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']);
});
});
65 changes: 65 additions & 0 deletions tests/Feature/Providers/OpenAi/ToolSearchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

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 () {
config(['ai.providers.openai' => [
...config('ai.providers.openai'),
'key' => 'test-key',
]]);
});

test('an agent with a ToolSearch tool emits a tool_search entry and defers its nested 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']);
});
});

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);
});
});
29 changes: 29 additions & 0 deletions tests/Fixtures/Agents/AnthropicToolSearchAgent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Tests\Fixtures\Agents;

use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
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')]
class AnthropicToolSearchAgent implements Agent, HasTools
{
use Promptable;

public function instructions(): string
{
return 'You are a helpful assistant.';
}

public function tools(): iterable
{
return [new NonStrictTool, new ToolSearch(tools: [new DeferredTool])];
}
}
Loading