diff --git a/src/Agent.php b/src/Agent.php index 2111168..f65c28a 100644 --- a/src/Agent.php +++ b/src/Agent.php @@ -50,7 +50,10 @@ final class Agent implements AgentInterface * @param array $tools Available tools * @param array $hooks Event hooks * @param int $maxTokens Max tokens in response - * @param float $temperature Temperature for generation + * @param float|null $temperature Temperature for generation, or null to leave it to the model. + * Defaults to null on purpose: Anthropic returns a 400 for a non-default temperature on + * Claude 4.7 and later, and Google has deprecated it, so an agent that invents a value + * nobody asked for would break those models outright. * @param int $maxTurns Max agentic turns (tool call loops) * @param array $middleware Middleware pipeline */ @@ -61,7 +64,7 @@ public function __construct( array $tools = [], array $hooks = [], private readonly int $maxTokens = 4096, - private readonly float $temperature = 0.7, + private readonly ?float $temperature = null, private readonly int $maxTurns = 10, array $middleware = [], ) { @@ -281,10 +284,13 @@ private function callProvider(array $messages, ?Schema $outputSchema = null): Re */ private function getProviderOptions(): array { - $options = [ - 'maxTokens' => $this->maxTokens, - 'temperature' => $this->temperature, - ]; + $options = ['maxTokens' => $this->maxTokens]; + + // Only when the caller actually chose one. See the constructor docblock: several current + // models reject a temperature they did not ask for. + if ($this->temperature !== null) { + $options['temperature'] = $this->temperature; + } // Only forward the model when set; an empty string would otherwise defeat // the provider's `$options['model'] ?? $defaultModel` fallback (which only diff --git a/src/AgentBuilder.php b/src/AgentBuilder.php index 0fd4abc..16e67cb 100644 --- a/src/AgentBuilder.php +++ b/src/AgentBuilder.php @@ -41,7 +41,11 @@ final class AgentBuilder private int $maxTokens = 4096; - private float $temperature = 0.7; + /** + * Null until the caller chooses one, so nothing is sent that the model did not ask for. + * See {@see Agent::__construct()} for why inventing a default breaks current models. + */ + private ?float $temperature = null; private int $maxTurns = 10; diff --git a/src/Contracts/ProviderInterface.php b/src/Contracts/ProviderInterface.php index d88f3a5..07f96ec 100644 --- a/src/Contracts/ProviderInterface.php +++ b/src/Contracts/ProviderInterface.php @@ -30,6 +30,12 @@ * "none", "required", or `["name" => ""]` to force a specific tool. Providers validate it * (see PapiAI\Core\ToolChoice) and throw before any HTTP call when it cannot be met. * + * `effort` asks the model to think harder before answering: "low", "medium" or "high" (see + * PapiAI\Core\Effort). Every reasoning provider spells this differently, so each translates the + * level to its own knob. Providers with no such knob ignore it, documented per provider. That is + * deliberate and unlike `toolChoice`: effort is a hint about quality, so ignoring it degrades + * nothing the caller was promised, whereas ignoring a forced tool would break a guarantee. + * * @psalm-type ChatOptions = array{ * model?: string, * tools?: array, @@ -38,6 +44,7 @@ * stopSequences?: array, * outputSchema?: array, * toolChoice?: string|array{name: string}, + * effort?: string, * } */ interface ProviderInterface diff --git a/src/Effort.php b/src/Effort.php new file mode 100644 index 0000000..5a6b79e --- /dev/null +++ b/src/Effort.php @@ -0,0 +1,175 @@ + + * + * 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; + +/** + * How hard the model should think before answering. + * + * A neutral scale over a knob every reasoning provider spells differently: OpenAI takes a + * `reasoning_effort` string whose accepted values depend on the model, Anthropic wants a token + * budget for extended thinking, Gemini wants either a budget or one of two levels depending on the + * model generation. + * + * The scale is deliberately wider than any single provider offers, so a caller can say what they + * mean and each provider narrows it to what it actually has (see {@see nearestOf()}). Narrowing + * belongs to the provider because only it knows its own range; deciding what a level *costs* is + * shared, so that arithmetic lives here rather than being reinvented per provider. + * + * Providers with no reasoning knob ignore the option entirely, documented per provider. Effort is + * a hint about quality rather than a guarantee, so being ignored degrades nothing the caller was + * promised. That is the opposite of `toolChoice`, where being ignored would break a promise. + */ +enum Effort: string +{ + /** + * The smallest budget worth spending. Anthropic rejects anything under this, and it is a sane + * floor for everyone else. + */ + public const MINIMUM_BUDGET = 1024; + + /** + * Room the answer itself needs, over and above whatever thinking consumes. + */ + private const ANSWER_HEADROOM = 512; + + /** + * Do not think at all. Worth asking for explicitly on models that otherwise think by default. + */ + case None = 'none'; + + case Minimal = 'minimal'; + case Low = 'low'; + case Medium = 'medium'; + case High = 'high'; + case ExtraHigh = 'extra-high'; + + /** + * Everything the ceiling allows, leaving only room to answer. + */ + case Maximum = 'maximum'; + + /** + * Whether this level asks the model to think at all. + */ + public function thinks(): bool + { + return $this !== self::None; + } + + /** + * Tokens to spend thinking, given the ceiling for the whole response. + * + * Thinking counts against the same ceiling as the answer, so the budget is a share of it, + * clamped to leave room to reply. Callers should check {@see fitsWithin()} first: a ceiling too + * small for any budget cannot be satisfied by clamping. + * + * @param int $maxTokens The response ceiling the request will carry + * + * @return int Tokens to allot to thinking, zero when this level does not think + */ + public function budgetWithin(int $maxTokens): int + { + if (!$this->thinks()) { + return 0; + } + + $ceiling = $maxTokens - self::ANSWER_HEADROOM; + + return max(self::MINIMUM_BUDGET, min((int) floor($maxTokens * $this->share()), $ceiling)); + } + + /** + * Whether a thinking budget can fit under this ceiling at all. + * + * False means the request cannot both think and answer, which is a caller error rather than + * something to paper over by silently dropping the option. + * + * @param int $maxTokens The response ceiling the request will carry + */ + public function fitsWithin(int $maxTokens): bool + { + if (!$this->thinks()) { + return true; + } + + return $maxTokens - self::ANSWER_HEADROOM >= self::MINIMUM_BUDGET; + } + + /** + * The closest level a provider actually offers. + * + * Providers rarely implement the whole scale: Gemini 3 has two levels, OpenAI's top levels + * exist only on some models. Rather than each provider inventing its own rounding, they declare + * what they offer and ask for the nearest match. + * + * Ties round **up**. On a two-level scale a request for Medium is equally far from either, and + * quietly dropping to the floor is the more surprising outcome: the caller asked for real + * thinking and would get the least available. Erring high costs tokens, which is visible; + * erring low costs answer quality, which is not. + * + * @param non-empty-list $offered The levels this provider can honour + * + * @return self The nearest offered level + */ + public function nearestOf(array $offered): self + { + $target = $this->rank(); + $best = null; + $bestDistance = PHP_INT_MAX; + + foreach ($offered as $candidate) { + $distance = abs($candidate->rank() - $target); + + if ($distance < $bestDistance || ($distance === $bestDistance && $best !== null && $candidate->rank() > $best->rank())) { + $best = $candidate; + $bestDistance = $distance; + } + } + + return $best ?? $this; + } + + /** + * Position on the scale, low to high. + */ + private function rank(): int + { + return match ($this) { + self::None => 0, + self::Minimal => 1, + self::Low => 2, + self::Medium => 3, + self::High => 4, + self::ExtraHigh => 5, + self::Maximum => 6, + }; + } + + /** + * The proportion of the ceiling this level is willing to spend on thinking. + */ + private function share(): float + { + return match ($this) { + self::None => 0.0, + self::Minimal => 0.05, + self::Low => 0.2, + self::Medium => 0.4, + self::High => 0.6, + self::ExtraHigh => 0.8, + self::Maximum => 1.0, + }; + } +} diff --git a/src/Exception/UnknownEffortException.php b/src/Exception/UnknownEffortException.php new file mode 100644 index 0000000..94f5fe6 --- /dev/null +++ b/src/Exception/UnknownEffortException.php @@ -0,0 +1,37 @@ + + * + * 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\Exception; + +use InvalidArgumentException; +use PapiAI\Core\Effort; + +/** + * Thrown when a caller asks for a level of effort that does not exist. + * + * The message is built here rather than at each provider so the accepted vocabulary is stated in + * one place and cannot drift as levels are added. Extends `InvalidArgumentException` to sit + * alongside the other option-validation failures, which callers already catch. + */ +final class UnknownEffortException extends InvalidArgumentException +{ + public function __construct(string $value) + { + parent::__construct(sprintf( + 'Unknown effort "%s". Expected one of: %s.', + $value, + implode(', ', array_map(static fn (Effort $effort): string => '"' . $effort->value . '"', Effort::cases())), + )); + } +} diff --git a/tests/Unit/AgentTemperatureTest.php b/tests/Unit/AgentTemperatureTest.php new file mode 100644 index 0000000..ef37c6d --- /dev/null +++ b/tests/Unit/AgentTemperatureTest.php @@ -0,0 +1,95 @@ + + * + * 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\ProviderInterface; +use PapiAI\Core\Response; + +/** + * Records the options every call was made with. + */ +class TemperatureRecordingProvider implements ProviderInterface +{ + /** @var list> */ + public array $calls = []; + + public function chat(array $messages, array $options = []): Response + { + $this->calls[] = $options; + + return 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'; + } +} + +/** + * Anthropic returns a 400 for a non-default temperature on Claude 4.7 and later, and Google has + * deprecated it too. An agent that invents a temperature nobody asked for therefore breaks those + * models outright, so it is only sent when the caller actually chose one. + */ +describe('Agent temperature', function () { + beforeEach(function () { + $this->provider = new TemperatureRecordingProvider(); + }); + + it('sends no temperature when the caller never set one', function () { + (new Agent(provider: $this->provider, model: 'm'))->run('hi'); + + expect($this->provider->calls[0])->not->toHaveKey('temperature'); + }); + + it('sends the temperature the caller chose', function () { + (new Agent(provider: $this->provider, model: 'm', temperature: 0.2))->run('hi'); + + expect($this->provider->calls[0]['temperature'])->toBe(0.2); + }); + + it('sends an explicit zero, which is a real choice', function () { + (new Agent(provider: $this->provider, model: 'm', temperature: 0.0))->run('hi'); + + expect($this->provider->calls[0]['temperature'])->toBe(0.0); + }); + + it('leaves it out when streaming too', function () { + iterator_to_array((new Agent(provider: $this->provider, model: 'm'))->stream('hi')); + + expect($this->provider->calls[0])->not->toHaveKey('temperature'); + }); +}); diff --git a/tests/Unit/EffortTest.php b/tests/Unit/EffortTest.php new file mode 100644 index 0000000..aad79d1 --- /dev/null +++ b/tests/Unit/EffortTest.php @@ -0,0 +1,107 @@ + + * + * 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\Effort; + +describe('Effort', function () { + it('covers the full range providers offer between them', function () { + expect(array_map(static fn (Effort $e): string => $e->value, Effort::cases())) + ->toBe(['none', 'minimal', 'low', 'medium', 'high', 'extra-high', 'maximum']); + }); + + it('reads levels from their neutral names', function () { + expect(Effort::tryFrom('minimal'))->toBe(Effort::Minimal); + expect(Effort::tryFrom('extra-high'))->toBe(Effort::ExtraHigh); + expect(Effort::tryFrom('enormous'))->toBeNull(); + }); + + describe('whether to think at all', function () { + it('knows None means do not', function () { + expect(Effort::None->thinks())->toBeFalse(); + }); + + it('knows every other level does', function () { + foreach (Effort::cases() as $effort) { + if ($effort !== Effort::None) { + expect($effort->thinks())->toBeTrue(); + } + } + }); + }); + + describe('thinking budget', function () { + it('rises with every step up the scale', function () { + $previous = -1; + + foreach (Effort::cases() as $effort) { + $budget = $effort->budgetWithin(100_000); + expect($budget)->toBeGreaterThan($previous); + $previous = $budget; + } + }); + + it('spends nothing at all for None', function () { + expect(Effort::None->budgetWithin(20_000))->toBe(0); + }); + + it('always leaves room for the answer', function () { + foreach (Effort::cases() as $effort) { + expect($effort->budgetWithin(20_000))->toBeLessThan(20_000); + } + }); + + it('never falls below the floor providers enforce', function () { + foreach (Effort::cases() as $effort) { + if ($effort->thinks()) { + expect($effort->budgetWithin(4_096))->toBeGreaterThanOrEqual(Effort::MINIMUM_BUDGET); + } + } + }); + + it('reports when a ceiling cannot fit a thinking budget at all', function () { + expect(Effort::Low->fitsWithin(1_200))->toBeFalse(); + expect(Effort::Low->fitsWithin(4_096))->toBeTrue(); + }); + + it('always fits when the answer is not going to think', function () { + expect(Effort::None->fitsWithin(10))->toBeTrue(); + }); + }); + + describe('narrowing to what a provider actually offers', function () { + it('maps every level onto a two-level scale', function () { + // Gemini 3 offers only LOW and HIGH. + expect(Effort::None->nearestOf([Effort::Low, Effort::High]))->toBe(Effort::Low); + expect(Effort::Minimal->nearestOf([Effort::Low, Effort::High]))->toBe(Effort::Low); + expect(Effort::Medium->nearestOf([Effort::Low, Effort::High]))->toBe(Effort::High); + expect(Effort::Maximum->nearestOf([Effort::Low, Effort::High]))->toBe(Effort::High); + }); + + it('returns the level itself when the provider offers it', function () { + $all = Effort::cases(); + + foreach ($all as $effort) { + expect($effort->nearestOf($all))->toBe($effort); + } + }); + + it('clamps to the highest on offer rather than overshooting', function () { + // OpenAI's xhigh is model-dependent, so a provider may only offer up to high. + $offered = [Effort::None, Effort::Minimal, Effort::Low, Effort::Medium, Effort::High]; + + expect(Effort::ExtraHigh->nearestOf($offered))->toBe(Effort::High); + expect(Effort::Maximum->nearestOf($offered))->toBe(Effort::High); + }); + }); +});