From 96d5b3daf459a44e0bec94e56b5f743835ceeef9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Fri, 31 Jul 2026 14:17:14 +0100 Subject: [PATCH] Declare tool selection as capability interfaces, and forward toolChoice and effort from Agent Adds ToolSelectableInterface and NamedToolSelectableInterface, so callers can ask what a provider can enforce instead of catching a ProviderException. NamedToolSelectableInterface extends the other because forcing a named tool is strictly more than forcing some tool, which makes the common case one check. Capability by type matches how Embedding, Image and Video already work. Agent can now pass toolChoice and effort, neither of which it could reach before. They differ on purpose: a forced tool applies to the opening call only, because forcing one every turn leaves the model unable to answer in plain text and the loop can only end by exhausting maxTurns. Effort applies throughout, which is what asking to think harder means. --- src/Agent.php | 63 +++++- .../NamedToolSelectableInterface.php | 34 +++ src/Contracts/ToolSelectableInterface.php | 45 ++++ tests/Unit/AgentToolChoiceTest.php | 199 ++++++++++++++++++ 4 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 src/Contracts/NamedToolSelectableInterface.php create mode 100644 src/Contracts/ToolSelectableInterface.php create mode 100644 tests/Unit/AgentToolChoiceTest.php diff --git a/src/Agent.php b/src/Agent.php index f65c28a..0f3e5b2 100644 --- a/src/Agent.php +++ b/src/Agent.php @@ -119,11 +119,19 @@ public function addMiddleware(MiddlewareInterface $middleware): self * Sends the prompt through any middleware, then iterates: call the LLM, * execute tool calls, feed results back, until a final response or max turns. * + * `toolChoice` forces the **opening** call only, after which the model is free again. Forcing + * every turn would leave it unable to answer in plain text, so the loop could only ever end by + * exhausting `maxTurns`. Check the provider implements + * {@see \PapiAI\Core\Contracts\ToolSelectableInterface} (or + * {@see \PapiAI\Core\Contracts\NamedToolSelectableInterface} to name a tool) before asking, or + * a provider that cannot honour it will throw. + * * @param string $prompt The user prompt * @param array{ * outputSchema?: Schema, * context?: mixed, * maxTurns?: int, + * toolChoice?: string|array{name: string}, * } $options Run options * * @return Response The final agent response @@ -166,6 +174,8 @@ private function executeRun(string $prompt, array $options = []): Response $maxTurns = $options['maxTurns'] ?? $this->maxTurns; $context = $options['context'] ?? null; $outputSchema = $options['outputSchema'] ?? null; + $toolChoice = $options['toolChoice'] ?? null; + $effort = $options['effort'] ?? null; // Add system message if ($this->instructions !== '') { @@ -177,7 +187,10 @@ private function executeRun(string $prompt, array $options = []): Response // Agentic loop for ($turn = 0; $turn < $maxTurns; $turn++) { - $response = $this->callProvider($messages, $outputSchema); + // Forced choice opens the conversation, then the model is free again. Forcing it every + // turn would leave the model unable to answer in plain text, so the loop could only ever + // end by exhausting maxTurns and throwing. + $response = $this->callProvider($messages, $outputSchema, $turn === 0 ? $toolChoice : null, $effort); // Add assistant message to history $messages[] = Message::assistant($response->text, $response->toolCalls ?: null); @@ -212,7 +225,11 @@ public function stream(string $prompt, array $options = []): iterable } $messages[] = Message::user($prompt); - foreach ($this->provider->stream($messages, $this->getProviderOptions()) as $chunk) { + // No agentic loop here, so a forced choice applies to the one call without any risk of + // trapping the model into calling a tool forever. + $providerOptions = $this->providerOptionsWith($options['toolChoice'] ?? null, $options['effort'] ?? null); + + foreach ($this->provider->stream($messages, $providerOptions) as $chunk) { yield $chunk; } } @@ -227,6 +244,8 @@ public function streamEvents(string $prompt, array $options = []): iterable $messages = []; $maxTurns = $options['maxTurns'] ?? $this->maxTurns; $context = $options['context'] ?? null; + $toolChoice = $options['toolChoice'] ?? null; + $effort = $options['effort'] ?? null; if ($this->instructions !== '') { $messages[] = Message::system($this->instructions); @@ -234,15 +253,19 @@ public function streamEvents(string $prompt, array $options = []): iterable $messages[] = Message::user($prompt); for ($turn = 0; $turn < $maxTurns; $turn++) { + // Opening turn only, for the same reason as run(): a permanently forced tool leaves the + // model unable to finish. + $turnChoice = $turn === 0 ? $toolChoice : null; + // Stream the response - foreach ($this->provider->stream($messages, $this->getProviderOptions()) as $chunk) { + foreach ($this->provider->stream($messages, $this->providerOptionsWith($turnChoice, $effort)) as $chunk) { if ($chunk->text !== '') { yield StreamEvent::text($chunk->text); } } // Get the complete response to check for tool calls - $response = $this->callProvider($messages); + $response = $this->callProvider($messages, null, $turnChoice, $effort); $messages[] = Message::assistant($response->text, $response->toolCalls ?: null); if (!$response->hasToolCalls()) { @@ -268,9 +291,13 @@ public function streamEvents(string $prompt, array $options = []): iterable /** * Call the provider with current messages. */ - private function callProvider(array $messages, ?Schema $outputSchema = null): Response - { - $options = $this->getProviderOptions(); + private function callProvider( + array $messages, + ?Schema $outputSchema = null, + string|array|null $toolChoice = null, + ?string $effort = null, + ): Response { + $options = $this->providerOptionsWith($toolChoice, $effort); if ($outputSchema !== null && $this->provider->supportsStructuredOutput()) { $options['outputSchema'] = $outputSchema->toJsonSchema(); @@ -279,6 +306,28 @@ private function callProvider(array $messages, ?Schema $outputSchema = null): Re return $this->provider->chat($messages, $options); } + /** + * Provider options, with a forced tool choice folded in when the caller asked for one. + * + * @param string|array{name: string}|null $toolChoice The caller's choice, or null to leave it to the model + * + * @return array Options ready for the provider + */ + private function providerOptionsWith(string|array|null $toolChoice, ?string $effort = null): array + { + $options = $this->getProviderOptions(); + + if ($toolChoice !== null) { + $options['toolChoice'] = $toolChoice; + } + + if ($effort !== null) { + $options['effort'] = $effort; + } + + return $options; + } + /** * Get provider options. */ diff --git a/src/Contracts/NamedToolSelectableInterface.php b/src/Contracts/NamedToolSelectableInterface.php new file mode 100644 index 0000000..03c97e7 --- /dev/null +++ b/src/Contracts/NamedToolSelectableInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PapiAI\Core\Contracts; + +/** + * Marks a provider that can be told to call one specific tool. + * + * Extends {@see ToolSelectableInterface} rather than sitting beside it, because forcing a named + * tool is strictly more than forcing "some tool": anything that can do the former can do the + * latter. That ordering is what lets a caller ask one question for the common case. + * + * On top of `"required"` and `"none"`, an implementer honours + * `['name' => '']`, calling exactly that tool. + * + * Most providers qualify. Cohere is the instructive exception: its API takes only REQUIRED or + * NONE, so it implements the parent interface and not this one. Silently turning "call + * get_weather" into "call something" would satisfy the request shape while breaking the guarantee + * the caller asked for, so it throws instead. + */ +interface NamedToolSelectableInterface extends ToolSelectableInterface +{ +} diff --git a/src/Contracts/ToolSelectableInterface.php b/src/Contracts/ToolSelectableInterface.php new file mode 100644 index 0000000..fc2e2eb --- /dev/null +++ b/src/Contracts/ToolSelectableInterface.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PapiAI\Core\Contracts; + +/** + * Marks a provider that can be told whether to call a tool. + * + * Implementing this means the provider honours `toolChoice` values of `"required"` (it must call + * one of the declared tools) and `"none"` (it must not call any). It says nothing about forcing a + * *specific* tool: see {@see NamedToolSelectableInterface} for that. + * + * Capability is expressed as a type rather than a `supports*()` probe so callers get a static + * answer, matching how {@see EmbeddingProviderInterface}, {@see ImageProviderInterface} and the + * rest already work. Do not confuse it with `ProviderInterface::supportsTool()`, which answers the + * different question of whether tools work at all: a provider can support tools and still be + * unable to force their use. + * + * A provider that does not implement this throws when asked to force a choice, rather than + * quietly downgrading to "the model decides". Check the type first and there is nothing to catch: + * + * if ($provider instanceof NamedToolSelectableInterface) { + * $agent->run($prompt, ['toolChoice' => ['name' => 'get_weather']]); + * } elseif ($provider instanceof ToolSelectableInterface) { + * $agent->run($prompt, ['toolChoice' => 'required']); + * } else { + * $agent->run($prompt); + * } + * + * `"auto"` needs no capability: every provider accepts it, and it is what omitting the option means. + */ +interface ToolSelectableInterface extends ProviderInterface +{ +} diff --git a/tests/Unit/AgentToolChoiceTest.php b/tests/Unit/AgentToolChoiceTest.php new file mode 100644 index 0000000..76581f1 --- /dev/null +++ b/tests/Unit/AgentToolChoiceTest.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use PapiAI\Core\Agent; +use PapiAI\Core\Contracts\NamedToolSelectableInterface; +use PapiAI\Core\Contracts\ProviderInterface; +use PapiAI\Core\Contracts\ToolSelectableInterface; +use PapiAI\Core\Response; +use PapiAI\Core\Tool; +use PapiAI\Core\ToolCall; + +/** + * Records the options of every call so the forced-choice lifetime can be asserted. + */ +class RecordingToolChoiceProvider implements NamedToolSelectableInterface +{ + /** @var list> */ + public array $calls = []; + + /** @var list */ + private array $responses; + + public function __construct(Response ...$responses) + { + $this->responses = $responses; + } + + public function chat(array $messages, array $options = []): Response + { + $this->calls[] = $options; + + return array_shift($this->responses) ?? new Response('done'); + } + + public function stream(array $messages, array $options = []): iterable + { + $this->calls[] = $options; + + return []; + } + + public function supportsTool(): bool + { + return true; + } + + public function supportsVision(): bool + { + return false; + } + + public function supportsStructuredOutput(): bool + { + return false; + } + + public function getName(): string + { + return 'recorder'; + } +} + +describe('Agent tool choice', function () { + beforeEach(function () { + $this->tool = Tool::make( + name: 'get_weather', + description: 'Weather', + parameters: [], + handler: fn () => 'sunny', + ); + }); + + it('forwards the choice to the provider', function () { + $provider = new RecordingToolChoiceProvider(new Response('done')); + $agent = new Agent(provider: $provider, model: 'm', tools: [$this->tool]); + + $agent->run('hi', ['toolChoice' => ['name' => 'get_weather']]); + + expect($provider->calls[0]['toolChoice'])->toBe(['name' => 'get_weather']); + }); + + it('forces only the opening call, so the loop can still finish', function () { + // The trap this exists to avoid: forcing every turn means the model must always call a + // tool, can never answer in plain text, and the loop can only end by exhausting maxTurns. + $provider = new RecordingToolChoiceProvider( + new Response('', toolCalls: [new ToolCall('1', 'get_weather', [])]), + new Response('sunny today'), + ); + $agent = new Agent(provider: $provider, model: 'm', tools: [$this->tool]); + + $response = $agent->run('hi', ['toolChoice' => 'required']); + + expect($response->text)->toBe('sunny today'); + expect($provider->calls)->toHaveCount(2); + expect($provider->calls[0]['toolChoice'])->toBe('required'); + expect($provider->calls[1])->not->toHaveKey('toolChoice'); + }); + + it('sends nothing when the caller does not ask (backward compatible)', function () { + $provider = new RecordingToolChoiceProvider(new Response('done')); + $agent = new Agent(provider: $provider, model: 'm', tools: [$this->tool]); + + $agent->run('hi'); + + expect($provider->calls[0])->not->toHaveKey('toolChoice'); + }); + + it('forwards the choice when streaming, where there is no loop to trap', function () { + $provider = new RecordingToolChoiceProvider(); + $agent = new Agent(provider: $provider, model: 'm', tools: [$this->tool]); + + iterator_to_array($agent->stream('hi', ['toolChoice' => 'required'])); + + expect($provider->calls[0]['toolChoice'])->toBe('required'); + }); +}); + +describe('Agent effort', function () { + beforeEach(function () { + $this->tool = Tool::make('get_weather', 'Weather', [], fn () => 'sunny'); + }); + + it('forwards the level to the provider', function () { + $provider = new RecordingToolChoiceProvider(new Response('done')); + + (new Agent(provider: $provider, model: 'm'))->run('hi', ['effort' => 'high']); + + expect($provider->calls[0]['effort'])->toBe('high'); + }); + + it('applies to every turn, unlike a forced tool', function () { + // Thinking hard on each turn is what the caller asked for. A forced tool is different: + // repeat it and the model can never stop calling tools. + $provider = new RecordingToolChoiceProvider( + new Response('', toolCalls: [new ToolCall('1', 'get_weather', [])]), + new Response('sunny'), + ); + $agent = new Agent(provider: $provider, model: 'm', tools: [$this->tool]); + + $agent->run('hi', ['effort' => 'high', 'toolChoice' => 'required']); + + expect($provider->calls)->toHaveCount(2); + expect($provider->calls[0]['effort'])->toBe('high'); + expect($provider->calls[1]['effort'])->toBe('high'); + expect($provider->calls[1])->not->toHaveKey('toolChoice'); + }); + + it('sends nothing when the caller does not ask', function () { + $provider = new RecordingToolChoiceProvider(new Response('done')); + + (new Agent(provider: $provider, model: 'm'))->run('hi'); + + expect($provider->calls[0])->not->toHaveKey('effort'); + }); + + it('forwards it when streaming too', function () { + $provider = new RecordingToolChoiceProvider(); + + iterator_to_array((new Agent(provider: $provider, model: 'm'))->stream('hi', ['effort' => 'low'])); + + expect($provider->calls[0]['effort'])->toBe('low'); + }); +}); + +describe('tool-selection capability interfaces', function () { + it('orders naming a tool as more than forcing one', function () { + // Anything that can force a *named* tool can force *a* tool, so the common case is one check. + expect(is_subclass_of(NamedToolSelectableInterface::class, ToolSelectableInterface::class))->toBeTrue(); + expect(is_subclass_of(ToolSelectableInterface::class, ProviderInterface::class))->toBeTrue(); + }); + + it('lets a caller ask instead of catching', function () { + $provider = new RecordingToolChoiceProvider(new Response('done')); + + expect($provider)->toBeInstanceOf(NamedToolSelectableInterface::class); + expect($provider)->toBeInstanceOf(ToolSelectableInterface::class); + expect($provider)->toBeInstanceOf(ProviderInterface::class); + }); + + it('leaves a plain provider outside both, which is the signal not to force', function () { + $plain = Mockery::mock(ProviderInterface::class); + + expect($plain)->not->toBeInstanceOf(ToolSelectableInterface::class); + expect($plain)->not->toBeInstanceOf(NamedToolSelectableInterface::class); + + Mockery::close(); + }); +});