Skip to content
Merged
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
63 changes: 56 additions & 7 deletions src/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 !== '') {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -227,22 +244,28 @@ 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);
}
$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()) {
Expand All @@ -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();
Expand All @@ -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<string, mixed> 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.
*/
Expand Down
34 changes: 34 additions & 0 deletions src/Contracts/NamedToolSelectableInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* 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' => '<tool>']`, 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
{
}
45 changes: 45 additions & 0 deletions src/Contracts/ToolSelectableInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* 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
{
}
Loading
Loading