diff --git a/.changeset/fn-7794-fallback-thinking-level.md b/.changeset/fn-7794-fallback-thinking-level.md new file mode 100644 index 0000000000..50d8f374dd --- /dev/null +++ b/.changeset/fn-7794-fallback-thinking-level.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Apply fallback models' own thinking levels when runtime swaps to them. +category: feature +dev: Adds fallbackThinkingLevel session plumbing, resolver precedence, and Grok CLI fallback remap handling. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 3cfc75ebf5..c08ea63a96 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -65,6 +65,9 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `fallbackProvider` | `string` | `undefined` | Fallback provider when the selected/default model hits transient provider failures or model-compatibility/auth-tier rejections. Dashboard chat also offers this fallback for explicit user-selected models, but the engine only swaps for retryable provider/model-selection failures. | | `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). | | `fallbackThinkingLevel` | `ThinkingLevel` | `undefined` | Optional global fallback-lane thinking override for the `fallbackProvider`/`fallbackModelId` pair. Inherits `defaultThinkingLevel` when unset. | + +Fallback thinking-level values are applied at runtime when Fusion swaps from the primary model to the configured fallback model; if unset, the active lane/default thinking level continues to apply. + | `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | `undefined` | Default reasoning effort for AI sessions. `xhigh` requests maximum reasoning effort; Claude CLI adapters map it to `high` for non-Opus models and `max` for Opus models. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. | | `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. | | `failureNotificationMode` | `"sticky-only" \| "terminal-only" \| "all"` | `"sticky-only"` | Failure notification behavior. `sticky-only` defers failed-task notifications by `failureNotificationDelayMs` and suppresses transient self-recoveries. `terminal-only` suppresses while auto-retry is still active and only dispatches when `paused === true` or `column === "in-review"` with `status === "failed"`. `all` restores legacy immediate failure notifications. | diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 64d45489f0..cb2278bbca 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -4,14 +4,19 @@ import { extractRuntimeModel, resolveExecutorSessionModel, resolveExecutorThinkingLevel, + resolveExecutorFallbackThinkingLevel, resolveHeartbeatSessionModels, resolveImplicitPlanningFallbackModel, resolveMergerSessionModel, + resolveMergerFallbackThinkingLevel, resolvePlanningSessionModel, resolvePlanningThinkingLevel, + resolvePlanningFallbackThinkingLevel, resolveTitleSummarizerThinkingLevel, + resolveTitleSummarizerFallbackThinkingLevel, resolveValidatorSessionModel, resolveValidatorThinkingLevel, + resolveValidatorFallbackThinkingLevel, } from "../agent-session-helpers.js"; const { resolveRuntimeMock } = vi.hoisted(() => ({ @@ -59,6 +64,39 @@ describe("resolve model-lane thinking levels", () => { defaultThinkingLevel: "low", })).toBe("medium"); }); + + it("resolves fallback thinking through fallback key then executor lane then defaults", () => { + expect(resolveExecutorFallbackThinkingLevel("task", { fallbackThinkingLevel: "high", executionThinkingLevel: "low" })).toBe("high"); + expect(resolveExecutorFallbackThinkingLevel(undefined, { executionThinkingLevel: "minimal", defaultThinkingLevel: "low" })).toBe("minimal"); + expect(resolveExecutorFallbackThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium"); + expect(resolveExecutorFallbackThinkingLevel(undefined, { defaultThinkingLevel: "low" })).toBe("low"); + }); + + it("resolves workflow fallback thinking before global fallback then lane defaults", () => { + expect(resolvePlanningFallbackThinkingLevel({ planningFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", planningThinkingLevel: "low" })).toBe("xhigh"); + expect(resolvePlanningFallbackThinkingLevel({ fallbackThinkingLevel: "high", planningThinkingLevel: "low" })).toBe("high"); + expect(resolvePlanningFallbackThinkingLevel({ planningThinkingLevel: "low", defaultThinkingLevel: "minimal" })).toBe("low"); + expect(resolvePlanningFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium"); + expect(resolvePlanningFallbackThinkingLevel({ defaultThinkingLevel: "minimal" })).toBe("minimal"); + + expect(resolveValidatorFallbackThinkingLevel(undefined, { validatorFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", validatorThinkingLevel: "low" })).toBe("xhigh"); + expect(resolveValidatorFallbackThinkingLevel(undefined, { fallbackThinkingLevel: "high", validatorThinkingLevel: "low" })).toBe("high"); + expect(resolveValidatorFallbackThinkingLevel(undefined, { validatorThinkingLevel: "low", defaultThinkingLevel: "minimal" })).toBe("low"); + expect(resolveValidatorFallbackThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium"); + expect(resolveValidatorFallbackThinkingLevel(undefined, { defaultThinkingLevel: "minimal" })).toBe("minimal"); + }); + + it("resolves title summarizer and merger fallback thinking through fallback and default chains", () => { + expect(resolveTitleSummarizerFallbackThinkingLevel({ titleSummarizerFallbackThinkingLevel: "xhigh", fallbackThinkingLevel: "high", titleSummarizerThinkingLevel: "low" })).toBe("xhigh"); + expect(resolveTitleSummarizerFallbackThinkingLevel({ fallbackThinkingLevel: "high", titleSummarizerThinkingLevel: "low" })).toBe("high"); + expect(resolveTitleSummarizerFallbackThinkingLevel({ titleSummarizerThinkingLevel: "low", defaultThinkingLevel: "minimal" })).toBe("low"); + expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "minimal" })).toBe("medium"); + expect(resolveTitleSummarizerFallbackThinkingLevel({ defaultThinkingLevel: "minimal" })).toBe("minimal"); + + expect(resolveMergerFallbackThinkingLevel({ fallbackThinkingLevel: "high", defaultThinkingLevel: "low" })).toBe("high"); + expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium"); + expect(resolveMergerFallbackThinkingLevel({ defaultThinkingLevel: "low" })).toBe("low"); + }); }); describe("extractRuntimeHint", () => { diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 26b4e259df..1ba4cca222 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -89,6 +89,29 @@ vi.mock("../agent-session-helpers.js", async () => { ?? (typeof settings?.executionGlobalThinkingLevel === "string" ? settings.executionGlobalThinkingLevel : undefined) ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-14:20: + * FN-7794 added fallback-swap thinking resolvers (resolveExecutorFallbackThinkingLevel / resolveValidatorFallbackThinkingLevel) that executor.ts now calls unconditionally on the main session-creation and workflow-step-review hot paths. This shared harness mocks the whole `agent-session-helpers.js` module, so leaving these unmocked throws "No export is defined on the mock" for every test that reaches those paths (51 files depend on this harness). Mirror production's fallback-key -> lane-key -> default-override -> default precedence. + */ + resolveExecutorFallbackThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => + (typeof settings?.fallbackThinkingLevel === "string" ? settings.fallbackThinkingLevel : undefined) + ?? taskThinkingLevel + ?? (typeof settings?.executionThinkingLevel === "string" ? settings.executionThinkingLevel : undefined) + ?? (typeof settings?.executionGlobalThinkingLevel === "string" ? settings.executionGlobalThinkingLevel : undefined) + ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) + ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), + resolveValidatorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => + (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) + ?? taskThinkingLevel + ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) + ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), + resolveValidatorFallbackThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | undefined) => + (typeof settings?.validatorFallbackThinkingLevel === "string" ? settings.validatorFallbackThinkingLevel : undefined) + ?? (typeof settings?.fallbackThinkingLevel === "string" ? settings.fallbackThinkingLevel : undefined) + ?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined) + ?? taskThinkingLevel + ?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined) + ?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined), resolveExecutorSessionModel: ( taskModelProvider: string | undefined, taskModelId: string | undefined, diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 8118bc6143..471f05f013 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -724,6 +724,142 @@ describe("session failure diagnostics", () => { expect(sessionWithThinking.setThinkingLevel).toHaveBeenCalledWith("xhigh"); }); + it("applies fallback thinking level after prompt-time fallback swap", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + + const primarySetThinkingLevel = vi.fn(); + const fallbackSetThinkingLevel = vi.fn(); + const primarySession = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn().mockRejectedValue(new Error("429 Too Many Requests")), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: primarySetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + const fallbackSession = { + model: { provider: "test", id: "fallback-model" }, + prompt: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: fallbackSetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + createAgentSessionMock.mockReset(); + createAgentSessionMock + .mockResolvedValueOnce({ session: primarySession } as any) + .mockResolvedValueOnce({ session: fallbackSession } as any); + + const { session } = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test fallback thinking", + defaultProvider: "test", + defaultModelId: "primary-model", + fallbackProvider: "test", + fallbackModelId: "fallback-model", + defaultThinkingLevel: "low", + fallbackThinkingLevel: "high", + }); + + await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined(); + + expect(primarySetThinkingLevel).toHaveBeenCalledWith("low"); + expect(fallbackSetThinkingLevel).toHaveBeenCalledWith("high"); + expect(fallbackSetThinkingLevel).not.toHaveBeenCalledWith("low"); + }); + + it("uses default thinking level for fallback swap when fallback thinking is unset", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + + const primarySetThinkingLevel = vi.fn(); + const fallbackSetThinkingLevel = vi.fn(); + const primarySession = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn().mockRejectedValue(new Error("429 Too Many Requests")), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: primarySetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + const fallbackSession = { + model: { provider: "test", id: "fallback-model" }, + prompt: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: fallbackSetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + createAgentSessionMock.mockReset(); + createAgentSessionMock + .mockResolvedValueOnce({ session: primarySession } as any) + .mockResolvedValueOnce({ session: fallbackSession } as any); + + const { session } = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test fallback default thinking", + defaultProvider: "test", + defaultModelId: "primary-model", + fallbackProvider: "test", + fallbackModelId: "fallback-model", + defaultThinkingLevel: "low", + }); + + await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined(); + + expect(fallbackSetThinkingLevel).toHaveBeenCalledWith("low"); + }); + + it("disables thinking when fallback session rejects thinking/reasoning compatibility", async () => { + const createAgentSessionMock = vi.mocked(createAgentSession); + + const primarySetThinkingLevel = vi.fn(); + const fallbackSetThinkingLevel = vi.fn(() => { + throw new Error("400 cannot specify both 'thinking' and 'reasoning_effort'"); + }); + const primarySession = { + model: { provider: "test", id: "primary-model" }, + prompt: vi.fn().mockRejectedValue(new Error("429 Too Many Requests")), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: primarySetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + const fallbackSession = { + model: { provider: "test", id: "fallback-model" }, + prompt: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + dispose: vi.fn(), + setThinkingLevel: fallbackSetThinkingLevel, + sessionFile: undefined, + } as unknown as AgentSession; + + createAgentSessionMock.mockReset(); + createAgentSessionMock + .mockResolvedValueOnce({ session: primarySession } as any) + .mockResolvedValueOnce({ session: fallbackSession } as any); + + const { session } = await createFnAgent({ + cwd: "/test/project", + systemPrompt: "Test fallback thinking conflict", + defaultProvider: "test", + defaultModelId: "primary-model", + fallbackProvider: "test", + fallbackModelId: "fallback-model", + defaultThinkingLevel: "low", + fallbackThinkingLevel: "high", + }); + + await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined(); + + expect(fallbackSetThinkingLevel).toHaveBeenCalledTimes(1); + expect(fallbackSetThinkingLevel).toHaveBeenCalledWith("high"); + }); + it("forwards materialized MCP servers into session creation and prompt options for supported providers", async () => { const createAgentSessionMock = vi.mocked(createAgentSession); const session = { diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 886ed0d753..fafe1b85cc 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -55,7 +55,7 @@ import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type EngineRunContext } from "./run-audit.js"; import { promptWithFallback } from "./pi.js"; import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js"; -import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels, resolveExecutorFallbackThinkingLevel } from "./agent-session-helpers.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; import { buildSessionSkillContextSync } from "./session-skill-context.js"; @@ -2814,6 +2814,7 @@ export class HeartbeatMonitor { defaultModelId: heartbeatSessionModels.defaultModelId, fallbackProvider: heartbeatSessionModels.fallbackProvider, fallbackModelId: heartbeatSessionModels.fallbackModelId, + fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings), runAuditor: audit, settings: heartbeatModelSettings, mcpServers: heartbeatMcp.servers, diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index 48de6808f4..f8612c0190 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -98,6 +98,11 @@ export interface AgentRuntimeOptions { fallbackProvider?: string; /** Optional fallback model ID */ fallbackModelId?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Runtime session options carry the fallback model's own thinking level so a fallback swap can apply it, or fall back to `defaultThinkingLevel` when unset. + */ + fallbackThinkingLevel?: string; /** Default thinking effort level (e.g. "medium", "high") */ defaultThinkingLevel?: string; /** Optional pre-configured SessionManager for persistence */ diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index fa1f767b82..0f0b0fb1ae 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -207,6 +207,54 @@ export function resolveMergerThinkingLevel(settings: Partial | undefin return firstThinkingLevel(settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); } +/** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Fallback thinking resolvers mirror fallback provider/model precedence: lane-specific fallback thinking wins where a lane can select a lane fallback model, then global fallback thinking, then the primary lane/default thinking chain for compatibility when no fallback-specific value is configured. + */ +export function resolveExecutorFallbackThinkingLevel( + taskThinkingLevel: ThinkingLevel | string | undefined, + settings: Partial | undefined, +): string | undefined { + return firstThinkingLevel( + settings?.fallbackThinkingLevel, + resolveExecutorThinkingLevel(taskThinkingLevel, settings), + ); +} + +export function resolvePlanningFallbackThinkingLevel( + settings: Partial | undefined, + taskThinkingLevel?: ThinkingLevel | string, +): string | undefined { + return firstThinkingLevel( + settings?.planningFallbackThinkingLevel, + settings?.fallbackThinkingLevel, + resolvePlanningThinkingLevel(settings, taskThinkingLevel), + ); +} + +export function resolveValidatorFallbackThinkingLevel( + taskThinkingLevel: ThinkingLevel | string | undefined, + settings: Partial | undefined, +): string | undefined { + return firstThinkingLevel( + settings?.validatorFallbackThinkingLevel, + settings?.fallbackThinkingLevel, + resolveValidatorThinkingLevel(taskThinkingLevel, settings), + ); +} + +export function resolveTitleSummarizerFallbackThinkingLevel(settings: Partial | undefined): string | undefined { + return firstThinkingLevel( + settings?.titleSummarizerFallbackThinkingLevel, + settings?.fallbackThinkingLevel, + resolveTitleSummarizerThinkingLevel(settings), + ); +} + +export function resolveMergerFallbackThinkingLevel(settings: Partial | undefined): string | undefined { + return firstThinkingLevel(settings?.fallbackThinkingLevel, resolveMergerThinkingLevel(settings)); +} + function hasCompleteRuntimeModel( model: ResolvedModelSelection, ): model is { provider: string; modelId: string } { @@ -259,8 +307,14 @@ function applyGrokCliNoKeyRuntimeOptions( ...runtimeOptions, defaultProvider: runtimeOptions.fallbackProvider, defaultModelId: stripGrokCliModelProviderPrefix(runtimeOptions.fallbackModelId), + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * When the no-visible-key Grok CLI fallback is promoted to the primary runtime, promote its fallback thinking level too; the cleared fallback pair must not leave the Grok CLI session using the superseded primary model's thinking level. + */ + defaultThinkingLevel: runtimeOptions.fallbackThinkingLevel ?? runtimeOptions.defaultThinkingLevel, fallbackProvider: undefined, fallbackModelId: undefined, + fallbackThinkingLevel: undefined, }; } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 0bad0298e8..080b1d1fae 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -88,7 +88,9 @@ import { extractRuntimeHint, resolveExecutorSessionModel, resolveExecutorThinkingLevel, + resolveExecutorFallbackThinkingLevel, resolveValidatorThinkingLevel, + resolveValidatorFallbackThinkingLevel, } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import type { SkillSelectionContext } from "./skill-resolver.js"; @@ -6719,6 +6721,12 @@ export class TaskExecutor { : detail.thinkingLevel, settings, ), + fallbackThinkingLevel: resolveValidatorFallbackThinkingLevel( + typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel) + ? (config.thinkingLevel as ThinkingLevel) + : detail.thinkingLevel, + settings, + ), taskValidatorProvider: detail.validatorModelProvider, taskValidatorModelId: detail.validatorModelId, projectValidatorProvider: settings.validatorProvider, @@ -10606,7 +10614,9 @@ export class TaskExecutor { ); const executorFallbackProvider = settings.fallbackProvider; const executorFallbackModelId = settings.fallbackModelId; - const executorThinkingLevel = resolveExecutorThinkingLevel(this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel, settings); + const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel; + const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings); + const executorFallbackThinkingLevel = resolveExecutorFallbackThinkingLevel(executorSessionThinkingSource, settings); // U1 telemetry: now that the session model/provider/node are resolved, // give the agent logger the context it needs to emit usage_events tool @@ -10705,6 +10715,7 @@ export class TaskExecutor { defaultModelId: executorModelId, fallbackProvider: executorFallbackProvider, fallbackModelId: executorFallbackModelId, + fallbackThinkingLevel: executorFallbackThinkingLevel, defaultThinkingLevel: executorThinkingLevel, runAuditor: audit, settings, @@ -11132,6 +11143,7 @@ export class TaskExecutor { defaultModelId: executorModelId, fallbackProvider: executorFallbackProvider, fallbackModelId: executorFallbackModelId, + fallbackThinkingLevel: executorFallbackThinkingLevel, defaultThinkingLevel: executorThinkingLevel, runAuditor: audit, settings, @@ -13636,6 +13648,7 @@ export class TaskExecutor { defaultModelId: settings.defaultModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveValidatorFallbackThinkingLevel(latestDetailForReview.thinkingLevel, settings), defaultThinkingLevel: resolveValidatorThinkingLevel(latestDetailForReview.thinkingLevel, settings), // Task-level validator override (from task) taskValidatorProvider: latestDetailForReview.validatorModelProvider, @@ -14997,7 +15010,8 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); type ModelTuple = { provider?: string; modelId?: string }; - const fallbackCandidates: Array = [ + type WorkflowStepFallbackLabel = "validatorFallback" | "globalFallback"; + const fallbackCandidates: Array = [ { provider: settings.validatorFallbackProvider, modelId: settings.validatorFallbackModelId, label: "validatorFallback" }, { provider: settings.fallbackProvider, modelId: settings.fallbackModelId, label: "globalFallback" }, ]; @@ -15144,8 +15158,17 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB /* * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: * WorkflowStep sessions resolve reasoning effort as node/step `thinkingLevel` first, then task override, then settings defaults/lane fallbacks. + * + * FNXC:Settings-ThinkingLevel 2026-07-10-14:20: + * The step's own `fallback` attempt already swaps to a distinct model (validator fallback OR global fallback pair) — it must honor THAT model's fallback thinking level, not silently reuse the primary lane's thinking level. Route by which candidate `fallback.label` actually matched instead of only special-casing `validatorFallback`. */ - const workflowStepThinkingLevel = resolveExecutorThinkingLevel(workflowStep.thinkingLevel ?? task.thinkingLevel, settings); + const workflowStepThinkingSource = workflowStep.thinkingLevel ?? task.thinkingLevel; + const workflowStepThinkingLevel = attemptLabel === "fallback" + ? (fallback?.label === "validatorFallback" + ? resolveValidatorFallbackThinkingLevel(workflowStepThinkingSource, settings) + : resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings)) + : resolveExecutorThinkingLevel(workflowStepThinkingSource, settings); + const workflowStepFallbackThinkingLevel = resolveExecutorFallbackThinkingLevel(workflowStepThinkingSource, settings); const { session } = await createResolvedAgentSession({ sessionPurpose: "executor", runtimeHint: workflowRuntimeHint, @@ -15157,6 +15180,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB defaultModelId: modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: workflowStepFallbackThinkingLevel, defaultThinkingLevel: workflowStepThinkingLevel, runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)), settings, @@ -18033,6 +18057,7 @@ Child agent: ${agent.id} (${name})`; defaultModelId: childExecutorModelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, settings), runAuditor: createRunAuditor(this.store, this.getRunContextFor(taskId)), settings, taskEnv, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 97b8339461..6548cf1bf9 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -835,6 +835,11 @@ export { promptWithAutoRetry, describeAgentModel, resolveExecutorThinkingLevel, + resolveExecutorFallbackThinkingLevel, + resolvePlanningFallbackThinkingLevel, + resolveValidatorFallbackThinkingLevel, + resolveTitleSummarizerFallbackThinkingLevel, + resolveMergerFallbackThinkingLevel, extractRuntimeHint, extractRuntimeModel, type ResolvedSessionOptions, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index cc66412989..5c41172710 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -109,7 +109,7 @@ import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js"; import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js"; import { describeModel, promptWithFallback } from "./pi.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; -import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel, resolveMergerThinkingLevel } from "./agent-session-helpers.js"; +import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel } from "./agent-session-helpers.js"; import { createFallbackModelObserver } from "./fallback-model-observer.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; @@ -1969,6 +1969,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: mergeRunContext?.runId ?? generateSyntheticRunId("merge", taskId), @@ -3163,6 +3164,7 @@ ${fileList} defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), @@ -3580,6 +3582,7 @@ ${fileList} defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), @@ -7084,6 +7087,7 @@ You are assisting with a paused \`git pull --rebase\`. defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), @@ -12039,6 +12043,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo defaultModelId: mergerSessionModel.modelId, fallbackProvider: settings.fallbackProvider, fallbackModelId: settings.fallbackModelId, + fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings), defaultThinkingLevel: resolveMergerThinkingLevel(settings), runAuditor: createRunAuditor(store, { runId: generateSyntheticRunId("merge", taskId), diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 1c21d975e0..629fd81eac 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1033,6 +1033,11 @@ export interface AgentOptions { fallbackProvider?: string; /** Optional fallback model ID used with `fallbackProvider`. */ fallbackModelId?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Fallback model swaps must honor the fallback model's configured thinking level while preserving the lane/default thinking level when no fallback-specific value is set. + */ + fallbackThinkingLevel?: string; /** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */ defaultThinkingLevel?: string; /** Optional pre-configured SessionManager. When provided, the agent session @@ -2525,11 +2530,18 @@ export async function createFnAgent(options: AgentOptions): Promise let thinkingCompatibilityDisabled = false; const applyThinkingLevelIfSupported = (targetSession: AgentSession, sourceModel: string): void => { - if (!options.defaultThinkingLevel || thinkingCompatibilityDisabled) { + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Fallback-swap sessions apply the fallback model's configured thinking level, or transparently keep the lane/default level when no fallback-specific value exists. The compatibility-disable guard remains shared so thinking/reasoning conflicts disable explicit thinking for both primary and fallback paths. + */ + const effectiveThinkingLevel = usingFallback + ? options.fallbackThinkingLevel ?? options.defaultThinkingLevel + : options.defaultThinkingLevel; + if (!effectiveThinkingLevel || thinkingCompatibilityDisabled) { return; } try { - (targetSession as PromptableSession).setThinkingLevel(options.defaultThinkingLevel as any); + (targetSession as PromptableSession).setThinkingLevel(effectiveThinkingLevel as any); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); if (!isThinkingReasoningConflictError(message)) { diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 5c974c13ac..3262c9efa1 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -78,6 +78,11 @@ export interface ReviewOptions { projectValidatorFallbackModelId?: string; /** Default thinking effort level for the reviewer agent session. */ defaultThinkingLevel?: string; + /** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Validator/reviewer sessions accept a fallback-specific thinking level so retryable fallback swaps preserve the selected fallback model's reasoning effort. + */ + fallbackThinkingLevel?: string; /** Task store for persisting agent log entries. When provided with `taskId`, enables full conversation logging. */ store?: TaskStore; /** Task ID for agent log persistence. Required alongside `store`. */ @@ -415,6 +420,7 @@ export async function reviewStep( defaultModelId: overrides?.forceModelId ?? validatorModelId, fallbackProvider: validatorFallbackProvider, fallbackModelId: validatorFallbackModelId, + fallbackThinkingLevel: options.fallbackThinkingLevel, defaultThinkingLevel: options.defaultThinkingLevel, runAuditor, settings: effectiveSettings,