From 125d39ee0bfc1e988109e4a793f5367083e14980 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:57:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(x):=20per-turn=20reasoning=20effort=20?= =?UTF-8?q?=E2=80=94=20core=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a canonical reasoning-effort ladder (low|medium|high, absent = auto/provider default) that travels: send config → turn_created.config → every model call's persisted parameters (§8.3) → provider-specific options at invoke time. Behavior-neutral: nothing sends an effort yet, and auto produces byte-identical requests to today. - shared: ReasoningEffort enum (single source, reused by models.json provider config), TurnCreated.config.reasoningEffort (additive optional on a non-strict object — old builds strip it, no schemaVersion bump), sessions:sendMessage IPC schema. - turns: CreateTurnInput/SendMessageConfig/HeadlessAgentOptions carry the value; runModelStep stamps it on each call's parameters so every step durably records what it ran with. - bridge: maps canonical effort to provider options, transport-only like prompt caching — OpenAI reasoningEffort, Anthropic thinking budgets (raising maxOutputTokens to the budget floor, never lowering an explicit value), Gemini thinkingLevel/thinkingBudget by generation, OpenRouter/rowboat reasoning.effort. Capability-gated via a cache-only models.dev lookup (never blocks a turn on the network); unknown support fails closed on strict flavors, while OpenRouter-shaped flavors map permissively since OpenRouter drops the field for non-reasoning models. Explicit persisted providerOptions win over the mapping. Ollama keeps its existing provider-level think rewrite; openai-compatible endpoints get nothing (no safe universal parameter). Co-Authored-By: Claude Fable 5 --- .../packages/core/docs/turn-runtime-design.md | 7 ++ apps/x/packages/core/src/agents/headless.ts | 6 + apps/x/packages/core/src/models/models-dev.ts | 43 +++++++ .../core/src/models/reasoning.test.ts | 76 +++++++++++++ apps/x/packages/core/src/models/reasoning.ts | 101 +++++++++++++++++ apps/x/packages/core/src/sessions/api.ts | 1 + apps/x/packages/core/src/sessions/sessions.ts | 3 + apps/x/packages/core/src/turns/api.ts | 3 + .../turns/bridges/real-model-registry.test.ts | 105 ++++++++++++++++++ .../src/turns/bridges/real-model-registry.ts | 57 +++++++++- .../x/packages/core/src/turns/runtime.test.ts | 55 +++++++++ apps/x/packages/core/src/turns/runtime.ts | 11 +- apps/x/packages/shared/src/ipc.ts | 3 +- apps/x/packages/shared/src/models.ts | 21 +++- apps/x/packages/shared/src/turns.ts | 5 + 15 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 apps/x/packages/core/src/models/reasoning.test.ts create mode 100644 apps/x/packages/core/src/models/reasoning.ts diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index c3ef6be9..d487aa90 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -597,6 +597,13 @@ bytes and the two views cannot diverge. Requests exclude credentials, auth headers, functions, model objects, and transport objects. +`parameters` holds only canonical, provider-agnostic generation knobs. The +model bridge whitelists what it forwards (`temperature`, `topP`, +`maxOutputTokens`, `providerOptions`) and maps `reasoningEffort` +(`"low" | "medium" | "high"`, stamped from `turn_created.config` onto every +call) into provider-specific options at invoke time — transport-only, like +prompt caching, so a persisted turn replays correctly on a different model. + The name `requested` is intentional. The event proves durable intent, not that the provider definitely received the request. diff --git a/apps/x/packages/core/src/agents/headless.ts b/apps/x/packages/core/src/agents/headless.ts index 0372d977..755076c3 100644 --- a/apps/x/packages/core/src/agents/headless.ts +++ b/apps/x/packages/core/src/agents/headless.ts @@ -36,6 +36,9 @@ export interface HeadlessAgentOptions { model?: string; provider?: string; maxModelCalls?: number; + // Canonical reasoning effort for this run; omitted = auto (provider + // default). Background callers that want cheap turns can pin "low". + reasoningEffort?: "low" | "medium" | "high"; signal?: AbortSignal; // Old waitForRunCompletion({ throwOnError: true }) semantics: `done` // rejects with HeadlessRunError unless the turn completes. @@ -157,6 +160,9 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner { ...(options.maxModelCalls === undefined ? {} : { maxModelCalls: options.maxModelCalls }), + ...(options.reasoningEffort === undefined + ? {} + : { reasoningEffort: options.reasoningEffort }), }, }); diff --git a/apps/x/packages/core/src/models/models-dev.ts b/apps/x/packages/core/src/models/models-dev.ts index 50e91411..5be56c9f 100644 --- a/apps/x/packages/core/src/models/models-dev.ts +++ b/apps/x/packages/core/src/models/models-dev.ts @@ -66,6 +66,7 @@ const ModelsDevModel = z.object({ name: z.string().optional(), release_date: z.string().optional(), tool_call: z.boolean().optional(), + reasoning: z.boolean().optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), }).passthrough(); @@ -225,6 +226,48 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma return { providers, lastUpdated: fetchedAt }; } +/** + * Whether a model supports reasoning/extended thinking, per the models.dev + * catalog. Reads ONLY the on-disk cache (stale is fine) — this sits on the + * turn-start path and must never block on the network. Returns undefined + * when the model or provider is unknown or no cache exists; callers treat + * unknown as "don't send reasoning parameters" (fail closed). + * + * Accepts gateway/OpenRouter-style "vendor/model" ids by splitting on the + * first slash and matching the vendor against the catalog's providers. + */ +export async function isReasoningModel( + flavor: string, + modelId: string, +): Promise { + let vendor = flavor; + let id = modelId; + if ((flavor === "rowboat" || flavor === "openrouter" || flavor === "aigateway") && modelId.includes("/")) { + const slash = modelId.indexOf("/"); + vendor = modelId.slice(0, slash); + id = modelId.slice(slash + 1); + } + if (vendor !== "openai" && vendor !== "anthropic" && vendor !== "google") { + return undefined; + } + try { + const cached = await readCache(); + if (!cached) return undefined; + const parsed = ModelsDevResponse.safeParse(cached.data); + if (!parsed.success) return undefined; + const provider = pickProvider(parsed.data, vendor); + if (!provider) return undefined; + for (const [key, model] of Object.entries(provider.models)) { + if ((model.id ?? key) === id) { + return model.reasoning === true; + } + } + return undefined; + } catch { + return undefined; + } +} + export async function getChatModelIds( flavor: "openai" | "anthropic" | "google", ): Promise> { diff --git a/apps/x/packages/core/src/models/reasoning.test.ts b/apps/x/packages/core/src/models/reasoning.test.ts new file mode 100644 index 00000000..ad6af975 --- /dev/null +++ b/apps/x/packages/core/src/models/reasoning.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { mapReasoningEffort, parseReasoningEffort } from "./reasoning.js"; + +describe("parseReasoningEffort", () => { + it("accepts only the canonical ladder", () => { + expect(parseReasoningEffort("low")).toBe("low"); + expect(parseReasoningEffort("medium")).toBe("medium"); + expect(parseReasoningEffort("high")).toBe("high"); + expect(parseReasoningEffort("xhigh")).toBeUndefined(); + expect(parseReasoningEffort(3)).toBeUndefined(); + expect(parseReasoningEffort(undefined)).toBeUndefined(); + }); +}); + +describe("mapReasoningEffort", () => { + it("maps OpenAI effort directly, gated on known reasoning support", () => { + expect(mapReasoningEffort("openai", "o4-mini", "high", true)).toEqual({ + providerOptions: { openai: { reasoningEffort: "high" } }, + }); + // Unknown or absent capability fails closed: gpt-4.1 400s on the param. + expect(mapReasoningEffort("openai", "gpt-4.1", "high", undefined)).toBeUndefined(); + expect(mapReasoningEffort("openai", "gpt-4.1", "high", false)).toBeUndefined(); + }); + + it("maps Anthropic to thinking budgets with an output-token floor", () => { + expect(mapReasoningEffort("anthropic", "claude-x", "low", true)).toEqual({ + providerOptions: { anthropic: { thinking: { type: "disabled" } } }, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "medium", true)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 8192 } }, + }, + minOutputTokens: 12288, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "high", true)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 16384 } }, + }, + minOutputTokens: 20480, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "high", undefined)).toBeUndefined(); + }); + + it("maps Gemini 3 to thinking levels, clamping Pro's missing medium", () => { + expect(mapReasoningEffort("google", "gemini-3.5-flash", "medium", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingLevel: "medium" } } }, + }); + expect(mapReasoningEffort("google", "gemini-3.5-pro", "medium", true)).toBeUndefined(); + expect(mapReasoningEffort("google", "gemini-3.5-pro", "high", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingLevel: "high" } } }, + }); + }); + + it("maps Gemini 2.5 to token budgets and skips unknown generations", () => { + expect(mapReasoningEffort("google", "gemini-2.5-flash", "low", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingBudget: 2048 } } }, + }); + expect(mapReasoningEffort("google", "gemini-1.5-pro", "high", true)).toBeUndefined(); + }); + + it("maps OpenRouter-shaped flavors permissively (OpenRouter drops it for non-reasoning models)", () => { + expect(mapReasoningEffort("rowboat", "google/gemini-3.5-flash", "high", undefined)).toEqual({ + providerOptions: { openrouter: { reasoning: { effort: "high" } } }, + }); + expect(mapReasoningEffort("openrouter", "openai/o4-mini", "low", true)).toEqual({ + providerOptions: { openrouter: { reasoning: { effort: "low" } } }, + }); + expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined(); + }); + + it("sends nothing for flavors without a safe parameter", () => { + expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined(); + expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined(); + expect(mapReasoningEffort("aigateway", "openai/o4-mini", "high", true)).toBeUndefined(); + }); +}); diff --git a/apps/x/packages/core/src/models/reasoning.ts b/apps/x/packages/core/src/models/reasoning.ts new file mode 100644 index 00000000..c85e09c3 --- /dev/null +++ b/apps/x/packages/core/src/models/reasoning.ts @@ -0,0 +1,101 @@ +import type { z } from "zod"; +import type { ReasoningEffort } from "@x/shared/dist/models.js"; +import type { JsonValue } from "@x/shared/dist/turns.js"; + +export type ReasoningEffortLevel = z.infer; + +export interface ReasoningRequestOptions { + providerOptions: Record>; + // Anthropic requires max_tokens to exceed the thinking budget; when the + // mapping enables a budget it also supplies this floor. The bridge sends + // max(caller value, floor) so an explicit caller value is never lowered. + minOutputTokens?: number; +} + +// Token budgets for providers that express effort as a thinking budget. +// Values follow the levels other assistants converged on (~8k balanced, +// ~16k thorough); "low" means thinking off where the provider allows it. +const ANTHROPIC_BUDGET = { medium: 8192, high: 16384 } as const; +const GEMINI_25_BUDGET = { low: 2048, medium: 8192, high: 16384 } as const; +const ANTHROPIC_OUTPUT_HEADROOM = 4096; + +export function parseReasoningEffort(value: unknown): ReasoningEffortLevel | undefined { + return value === "low" || value === "medium" || value === "high" + ? value + : undefined; +} + +/** + * Map the canonical reasoning effort to provider-specific request options. + * Transport-only, like prompt caching: persisted turn events carry only the + * canonical value; this translation happens at invoke time. + * + * Returns undefined when nothing should be sent — unsupported flavor, model + * not known to reason, or a level the model family cannot express. Unknown + * capability fails closed for strict flavors (OpenAI/Anthropic/Google reject + * reasoning parameters on non-reasoning models); OpenRouter-shaped flavors + * (openrouter, rowboat) are forgiving — OpenRouter drops the field for + * models that cannot reason — so they map unless the model is known-false. + * + * Ollama is deliberately absent: its `think` parameter is applied by the + * provider-level fetch rewrite (models/local.ts), and openai-compatible + * endpoints have no safe universal parameter. + */ +export function mapReasoningEffort( + flavor: string, + modelId: string, + effort: ReasoningEffortLevel, + supportsReasoning: boolean | undefined, +): ReasoningRequestOptions | undefined { + switch (flavor) { + case "openai": { + if (supportsReasoning !== true) return undefined; + return { providerOptions: { openai: { reasoningEffort: effort } } }; + } + case "anthropic": { + if (supportsReasoning !== true) return undefined; + if (effort === "low") { + return { providerOptions: { anthropic: { thinking: { type: "disabled" } } } }; + } + const budgetTokens = ANTHROPIC_BUDGET[effort]; + return { + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens } }, + }, + minOutputTokens: budgetTokens + ANTHROPIC_OUTPUT_HEADROOM, + }; + } + case "google": { + if (supportsReasoning !== true) return undefined; + const id = modelId.toLowerCase(); + if (id.includes("gemini-3")) { + // Gemini 3 Pro exposes only low/high thinking levels; its + // default is already deep, so "medium" sends nothing. + if (id.includes("pro") && effort === "medium") return undefined; + return { + providerOptions: { + google: { thinkingConfig: { thinkingLevel: effort } }, + }, + }; + } + if (id.includes("gemini-2.5")) { + return { + providerOptions: { + google: { + thinkingConfig: { thinkingBudget: GEMINI_25_BUDGET[effort] }, + }, + }, + }; + } + // Unknown Gemini generation: don't guess a dialect. + return undefined; + } + case "openrouter": + case "rowboat": { + if (supportsReasoning === false) return undefined; + return { providerOptions: { openrouter: { reasoning: { effort } } } }; + } + default: + return undefined; + } +} diff --git a/apps/x/packages/core/src/sessions/api.ts b/apps/x/packages/core/src/sessions/api.ts index 7f787b49..6353ab63 100644 --- a/apps/x/packages/core/src/sessions/api.ts +++ b/apps/x/packages/core/src/sessions/api.ts @@ -16,6 +16,7 @@ export interface SendMessageConfig { agent: z.infer; autoPermission?: boolean; maxModelCalls?: number; + reasoningEffort?: "low" | "medium" | "high"; } export interface ISessions { diff --git a/apps/x/packages/core/src/sessions/sessions.ts b/apps/x/packages/core/src/sessions/sessions.ts index d42640b1..1bf63df0 100644 --- a/apps/x/packages/core/src/sessions/sessions.ts +++ b/apps/x/packages/core/src/sessions/sessions.ts @@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions { ...(config.maxModelCalls === undefined ? {} : { maxModelCalls: config.maxModelCalls }), + ...(config.reasoningEffort === undefined + ? {} + : { reasoningEffort: config.reasoningEffort }), }, }); diff --git a/apps/x/packages/core/src/turns/api.ts b/apps/x/packages/core/src/turns/api.ts index 55a60e2c..37b35d5a 100644 --- a/apps/x/packages/core/src/turns/api.ts +++ b/apps/x/packages/core/src/turns/api.ts @@ -20,6 +20,9 @@ export interface CreateTurnInput { autoPermission?: boolean; humanAvailable: boolean; maxModelCalls?: number; + // Canonical per-turn reasoning effort; omitted = auto (provider + // default, byte-identical requests to today). + reasoningEffort?: "low" | "medium" | "high"; }; } diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts index 6fcb489e..0f3ef353 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts @@ -298,6 +298,111 @@ describe("RealModelRegistry", () => { }); }); + describe("reasoning effort mapping", () => { + function makeFlavorRegistry( + flavor: string, + supportsReasoning: boolean | undefined, + capture: InvokerOptions[], + ) { + const fakeModel = { modelId: "m" } as unknown as LanguageModel; + return new RealModelRegistry({ + resolveProvider: async () => ({ flavor }) as never, + createProviderImpl: (() => ({ + languageModel: () => fakeModel, + })) as never, + reasoningSupport: async () => supportsReasoning, + invoke: (options) => { + capture.push(options); + return { + fullStream: (async function* () { + yield { type: "finish-step", finishReason: "stop", usage: {} }; + })(), + }; + }, + }); + } + + async function invokeWith( + flavor: string, + model: string, + supportsReasoning: boolean | undefined, + parameters: Record, + ): Promise { + const capture: InvokerOptions[] = []; + const registry = makeFlavorRegistry(flavor, supportsReasoning, capture); + const resolved = await registry.resolve({ provider: flavor, model }); + for await (const event of resolved.stream( + request({ parameters: parameters as never }), + )) { + void event; + } + return capture[0]; + } + + it("maps a persisted canonical effort to Anthropic thinking options", async () => { + const options = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "medium", + }); + expect(options.providerOptions).toEqual({ + anthropic: { thinking: { type: "enabled", budgetTokens: 8192 } }, + }); + expect(options.maxOutputTokens).toBe(12288); + }); + + it("fails closed when reasoning support is unknown on strict flavors", async () => { + const options = await invokeWith("openai", "gpt-test", undefined, { + reasoningEffort: "high", + }); + expect(options.providerOptions).toBeUndefined(); + expect(options.maxOutputTokens).toBeUndefined(); + }); + + it("maps gateway (rowboat) effort through the OpenRouter shape without known support", async () => { + const options = await invokeWith("rowboat", "google/gemini-3.5-flash", undefined, { + reasoningEffort: "high", + }); + expect(options.providerOptions).toEqual({ + openrouter: { reasoning: { effort: "high" } }, + }); + }); + + it("lets explicit persisted providerOptions win over the mapping", async () => { + const options = await invokeWith("openai", "o4-mini", true, { + reasoningEffort: "high", + providerOptions: { openai: { reasoningEffort: "low" } }, + }); + expect(options.providerOptions).toEqual({ + openai: { reasoningEffort: "low" }, + }); + }); + + it("raises an explicit maxOutputTokens to the thinking floor but never lowers it", async () => { + const raised = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "high", + maxOutputTokens: 4096, + }); + expect(raised.maxOutputTokens).toBe(20480); + + const kept = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "high", + maxOutputTokens: 32000, + }); + expect(kept.maxOutputTokens).toBe(32000); + }); + + it("sends nothing for unknown effort values or unmapped flavors", async () => { + const bogus = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "xhigh", + }); + expect(bogus.providerOptions).toBeUndefined(); + + const local = await invokeWith("openai-compatible", "my-vllm", true, { + reasoningEffort: "high", + }); + expect(local.providerOptions).toBeUndefined(); + }); + }); + it("throws on provider error parts (a model failure, not a completion)", async () => { const registry = makeRegistry( [{ type: "error", error: new Error("rate limited") }], diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts index 2d5c15d8..c7fa5348 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts @@ -14,8 +14,10 @@ import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns import { convertFromMessages } from "../../agents/runtime.js"; import { resolveProviderConfig } from "../../models/defaults.js"; import { createProvider } from "../../models/models.js"; +import { isReasoningModel } from "../../models/models-dev.js"; import { applyPromptCaching } from "../../models/prompt-caching.js"; import { applyLocalModelSettings } from "../../models/local.js"; +import { mapReasoningEffort, parseReasoningEffort } from "../../models/reasoning.js"; import type { IModelRegistry, LlmStreamEvent, @@ -46,6 +48,9 @@ export interface RealModelRegistryDeps { resolveProvider?: (name: string) => Promise>; createProviderImpl?: typeof createProvider; invoke?: StreamTextInvoker; + // Capability probe for "does this model reason?" (models.dev cache by + // default). undefined = unknown; the effort mapping fails closed on it. + reasoningSupport?: (flavor: string, modelId: string) => Promise; } // Bridges models.json provider configs to live AI SDK models and normalizes @@ -57,11 +62,16 @@ export class RealModelRegistry implements IModelRegistry { ) => Promise>; private readonly createProviderImpl: typeof createProvider; private readonly invoke: StreamTextInvoker; + private readonly reasoningSupport: ( + flavor: string, + modelId: string, + ) => Promise; constructor(deps: RealModelRegistryDeps = {}) { this.resolveProvider = deps.resolveProvider ?? resolveProviderConfig; this.createProviderImpl = deps.createProviderImpl ?? createProvider; this.invoke = deps.invoke ?? defaultInvoker; + this.reasoningSupport = deps.reasoningSupport ?? isReasoningModel; } async resolve( @@ -74,6 +84,12 @@ export class RealModelRegistry implements IModelRegistry { provider.languageModel(descriptor.model), providerConfig, ); + // Cache-only capability lookup (never blocks a turn on the network); + // unknown support makes the effort mapping fail closed. + const supportsReasoning = await this.reasoningSupport( + providerConfig.flavor, + descriptor.model, + ).catch(() => undefined); return { descriptor, // The structural -> wire conversion the app uses today: weaves @@ -83,7 +99,13 @@ export class RealModelRegistry implements IModelRegistry { encodeMessages: (messages) => convertFromMessages(messages) as unknown as JsonValue[], stream: (request) => - this.run(model, request, providerConfig.flavor, descriptor.model), + this.run( + model, + request, + providerConfig.flavor, + descriptor.model, + supportsReasoning, + ), }; } @@ -92,6 +114,7 @@ export class RealModelRegistry implements IModelRegistry { request: ModelStreamRequest, flavor: string, modelId: string, + supportsReasoning?: boolean, ): AsyncGenerator { const tools: ToolSet = {}; for (const descriptor of request.tools) { @@ -111,13 +134,37 @@ export class RealModelRegistry implements IModelRegistry { // Persisted per-call parameters (turn-runtime-design.md §8.3): only // the whitelisted generation knobs are forwarded to the provider. const params = request.parameters ?? {}; + + // Canonical reasoningEffort maps to provider-specific options here, + // transport-only (like prompt caching) — persisted events carry only + // the canonical value. Explicit persisted providerOptions win over + // the mapping; an explicit maxOutputTokens is only ever raised to + // the thinking-budget floor, never lowered. + const effort = parseReasoningEffort(params.reasoningEffort); + const reasoning = effort === undefined + ? undefined + : mapReasoningEffort(flavor, modelId, effort, supportsReasoning); + + const callerProviderOptions = + params.providerOptions && typeof params.providerOptions === "object" && !Array.isArray(params.providerOptions) + ? params.providerOptions as Record> + : undefined; + const providerOptions = reasoning === undefined + ? callerProviderOptions + : mergeProviderOptions(reasoning.providerOptions, callerProviderOptions); + + const callerMaxOutputTokens = + typeof params.maxOutputTokens === "number" ? params.maxOutputTokens : undefined; + const maxOutputTokens = + reasoning?.minOutputTokens === undefined + ? callerMaxOutputTokens + : Math.max(callerMaxOutputTokens ?? 0, reasoning.minOutputTokens); + const generationParams = { ...(typeof params.temperature === "number" ? { temperature: params.temperature } : {}), ...(typeof params.topP === "number" ? { topP: params.topP } : {}), - ...(typeof params.maxOutputTokens === "number" ? { maxOutputTokens: params.maxOutputTokens } : {}), - ...(params.providerOptions && typeof params.providerOptions === "object" && !Array.isArray(params.providerOptions) - ? { providerOptions: params.providerOptions as Record> } - : {}), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(providerOptions === undefined ? {} : { providerOptions }), }; const parts: Array> = []; diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 5b1c8d41..9bc8b072 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -510,6 +510,61 @@ describe("plain model response (26.1)", () => { }); }); +// --------------------------------------------------------------------------- +// Per-turn reasoning effort (turn_created.config → per-call parameters) +// --------------------------------------------------------------------------- + +describe("per-turn reasoning effort", () => { + it("stamps the turn's reasoningEffort into every model call's persisted parameters", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantCalls(toolCallPart("A", "echo", { i: 1 })))), + respond(completedResp(assistantText("done"))), + ], + }); + const turnId = await newTurn(runtime, { + config: { humanAvailable: true, reasoningEffort: "high" }, + }); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + + const log = await persisted(repo, turnId); + const requests = log.filter((e) => e.type === "model_call_requested"); + expect(requests).toHaveLength(2); + for (const event of requests) { + expect( + event.type === "model_call_requested" + ? event.request.parameters + : undefined, + ).toEqual({ reasoningEffort: "high" }); + } + // The live stream received the persisted parameters verbatim. + expect(models.requests[0].parameters).toEqual({ reasoningEffort: "high" }); + expect(models.requests[1].parameters).toEqual({ reasoningEffort: "high" }); + // And the effort rides the durable turn_created config. + const created = log[0]; + expect( + created.type === "turn_created" ? created.config.reasoningEffort : undefined, + ).toBe("high"); + }); + + it("leaves parameters empty when no effort is set (auto)", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [respond(completedResp(assistantText("done")))], + }); + const turnId = await newTurn(runtime); + await advanceAndSettle(runtime, turnId); + const log = await persisted(repo, turnId); + const request = log.find((e) => e.type === "model_call_requested"); + expect( + request?.type === "model_call_requested" + ? request.request.parameters + : undefined, + ).toEqual({}); + expect(models.requests[0].parameters).toEqual({}); + }); +}); + // --------------------------------------------------------------------------- // §26.2 Mixed sync and async tools // --------------------------------------------------------------------------- diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index 521a8f19..08d9120e 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -164,6 +164,9 @@ export class TurnRuntime implements ITurnRuntime { autoPermission: input.config.autoPermission ?? false, humanAvailable: input.config.humanAvailable, maxModelCalls: input.config.maxModelCalls ?? DEFAULT_MAX_MODEL_CALLS, + ...(input.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: input.config.reasoningEffort }), }, }); await this.turnRepo.create(event); @@ -1110,10 +1113,16 @@ class TurnAdvance { ] : []; // re-issue after an interrupted call adds nothing new } + // The turn's reasoning effort is stamped on every call's persisted + // parameters (turn-runtime-design.md §8.3): each step durably records + // what it ran with, and the model bridge maps the canonical value to + // provider-specific options at invoke time. + const reasoningEffort = this.definition.config.reasoningEffort; const request: z.infer = { ...(isRef && index === 0 ? { contextRef: context } : {}), messages: refs, - parameters: {}, + parameters: + reasoningEffort === undefined ? {} : { reasoningEffort }, }; await this.append({ type: "model_call_requested", diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c00220bc..77b573f9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef } from './models.js'; +import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -488,6 +488,7 @@ const ipcSchemas = { agent: RequestedAgent, autoPermission: z.boolean().optional(), maxModelCalls: z.number().int().positive().optional(), + reasoningEffort: ReasoningEffort.optional(), }), }), res: z.object({ turnId: z.string() }), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 7d51a089..5ed03104 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +// Canonical reasoning-effort ladder, used everywhere effort appears: the +// per-provider default in models.json, the per-turn override on turn +// creation, and the persisted per-call parameters. Absence means "auto" — +// send nothing and let the provider default apply. Provider-specific +// syntax (OpenAI reasoningEffort, Anthropic thinking budgets, Gemini +// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time. +export const ReasoningEffort = z.enum(["low", "medium", "high"]); + export const LlmProvider = z.object({ flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), apiKey: z.string().optional(), @@ -9,11 +17,12 @@ export const LlmProvider = z.object({ // to a ~4k window that silently truncates Rowboat's prompts; when unset, // local providers get a larger default (see core/models/local.ts). contextLength: z.number().int().positive().optional(), - // Reasoning effort for local thinking models (Ollama `think` parameter). - // gpt-oss supports the levels directly; other thinking models map - // low → thinking off, high → thinking on. Defaults to "low" for Ollama — - // background agents and chat both want snappy responses on local hardware. - reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + // Default reasoning effort for this provider. For Ollama this drives the + // `think` parameter (gpt-oss takes the levels directly; other thinking + // models map low → off, high → on; defaults to "low" — background agents + // and chat both want snappy responses on local hardware). For cloud + // providers it seeds the per-turn effort when the user hasn't chosen one. + reasoningEffort: ReasoningEffort.optional(), }); // A provider-qualified model reference. `provider` is a provider name as @@ -47,7 +56,7 @@ export const LlmModelConfig = z.object({ baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), contextLength: z.number().int().positive().optional(), - reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + reasoningEffort: ReasoningEffort.optional(), model: z.string().optional(), models: z.array(z.string()).optional(), knowledgeGraphModel: z.string().optional(), diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 192e77ff..9a6c14cc 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -5,6 +5,7 @@ import { ToolMessage, UserMessage, } from "./message.js"; +import { ReasoningEffort } from "./models.js"; // Durable turn contract for the turn runtime (see // packages/core/docs/turn-runtime-design.md). This module is the @@ -176,6 +177,10 @@ export const TurnCreated = z.object({ autoPermission: z.boolean(), humanAvailable: z.boolean(), maxModelCalls: z.number().int().positive(), + // Canonical per-turn reasoning effort; absent = auto (provider + // default). Stamped into every model call's parameters and mapped + // to provider-specific options at invoke time. + reasoningEffort: ReasoningEffort.optional(), }), });