feat(x): per-turn reasoning effort — core plumbing

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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-10 09:57:53 +05:30
parent 091f3e3acd
commit 125d39ee0b
15 changed files with 484 additions and 13 deletions

View file

@ -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.

View file

@ -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 }),
},
});

View file

@ -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<boolean | undefined> {
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<Set<string>> {

View file

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

View file

@ -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<typeof ReasoningEffort>;
export interface ReasoningRequestOptions {
providerOptions: Record<string, Record<string, JsonValue>>;
// 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;
}
}

View file

@ -16,6 +16,7 @@ export interface SendMessageConfig {
agent: z.infer<typeof RequestedAgent>;
autoPermission?: boolean;
maxModelCalls?: number;
reasoningEffort?: "low" | "medium" | "high";
}
export interface ISessions {

View file

@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions {
...(config.maxModelCalls === undefined
? {}
: { maxModelCalls: config.maxModelCalls }),
...(config.reasoningEffort === undefined
? {}
: { reasoningEffort: config.reasoningEffort }),
},
});

View file

@ -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";
};
}

View file

@ -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<string, unknown>,
): Promise<InvokerOptions> {
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") }],

View file

@ -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<z.infer<typeof LlmProvider>>;
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<boolean | undefined>;
}
// Bridges models.json provider configs to live AI SDK models and normalizes
@ -57,11 +62,16 @@ export class RealModelRegistry implements IModelRegistry {
) => Promise<z.infer<typeof LlmProvider>>;
private readonly createProviderImpl: typeof createProvider;
private readonly invoke: StreamTextInvoker;
private readonly reasoningSupport: (
flavor: string,
modelId: string,
) => Promise<boolean | undefined>;
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<LlmStreamEvent, void, void> {
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<string, Record<string, JsonValue>>
: 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<string, Record<string, JsonValue>> }
: {}),
...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
...(providerOptions === undefined ? {} : { providerOptions }),
};
const parts: Array<z.infer<typeof AssistantContentPart>> = [];

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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<typeof ModelRequest> = {
...(isRef && index === 0 ? { contextRef: context } : {}),
messages: refs,
parameters: {},
parameters:
reasoningEffort === undefined ? {} : { reasoningEffort },
};
await this.append({
type: "model_call_requested",

View file

@ -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() }),

View file

@ -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(),

View file

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