From 1f795f422d860243a5336bbf5336b791fda937f9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Fri, 31 Jul 2026 15:25:48 +0100 Subject: [PATCH] Add a neutral reasoning-effort option, and stop inventing a temperature Adds an Effort scale (none through maximum) as a chat option. Every reasoning provider spells this knob differently, so core owns the vocabulary and the level-to-budget arithmetic while each provider narrows it to what it actually offers. Also stops Agent sending temperature unless the caller chose one. It was hardcoded to 0.7 on every call, which Anthropic rejects with a 400 on Claude 4.7 and later and Google has deprecated. Inventing a sampling parameter nobody asked for was the bug underneath. Callers who relied on the implicit 0.7 will now get the model's own default. --- src/Agent.php | 18 ++- src/AgentBuilder.php | 6 +- src/Contracts/ProviderInterface.php | 7 + src/Effort.php | 175 +++++++++++++++++++++++ src/Exception/UnknownEffortException.php | 37 +++++ tests/Unit/AgentTemperatureTest.php | 95 ++++++++++++ tests/Unit/EffortTest.php | 107 ++++++++++++++ 7 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 src/Effort.php create mode 100644 src/Exception/UnknownEffortException.php create mode 100644 tests/Unit/AgentTemperatureTest.php create mode 100644 tests/Unit/EffortTest.php 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); + }); + }); +});