mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
091f3e3acd
commit
125d39ee0b
15 changed files with 484 additions and 13 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
76
apps/x/packages/core/src/models/reasoning.test.ts
Normal file
76
apps/x/packages/core/src/models/reasoning.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
101
apps/x/packages/core/src/models/reasoning.ts
Normal file
101
apps/x/packages/core/src/models/reasoning.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export interface SendMessageConfig {
|
|||
agent: z.infer<typeof RequestedAgent>;
|
||||
autoPermission?: boolean;
|
||||
maxModelCalls?: number;
|
||||
reasoningEffort?: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export interface ISessions {
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions {
|
|||
...(config.maxModelCalls === undefined
|
||||
? {}
|
||||
: { maxModelCalls: config.maxModelCalls }),
|
||||
...(config.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: config.reasoningEffort }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }],
|
||||
|
|
|
|||
|
|
@ -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>> = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue