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(); + }); +});