FN-7794: apply fallback model's own thinking level when swapping in at runtime

Adds fallbackThinkingLevel plumbing so, when Fusion swaps from a primary model to a configured fallback model (executor, validator/reviewer, merger, planning, title-summarizer, heartbeat, and workflow-step lanes), the fallback's own configured thinking level is applied instead of silently reusing the primary lane's level.

- Add fallbackThinkingLevel option to AgentRuntimeOptions (agent-runtime.ts), AgentOptions (pi.ts), and ReviewOptions (reviewer.ts)
- Add per-lane resolvers: resolveExecutorFallbackThinkingLevel, resolvePlanningFallbackThinkingLevel, resolveValidatorFallbackThinkingLevel, resolveTitleSummarizerFallbackThinkingLevel, resolveMergerFallbackThinkingLevel (agent-session-helpers.ts), each following fallback-provider precedence and falling back to the primary lane/default thinking level when unset
- Export new resolvers from packages/engine/src/index.ts
- Apply the resolved fallback thinking level in createFnAgent's applyThinkingLevelIfSupported once a session has swapped to the fallback model (pi.ts)
- Wire fallbackThinkingLevel through executor session creation (workflow-step, task validator, child-agent, and main executor session paths), merger session creation, and heartbeat session creation
- Promote the fallback thinking level alongside the fallback model/provider when the no-visible-key Grok CLI fallback is promoted to primary, so the cleared fallback pair doesn't leave the session on the superseded primary's thinking level
- Route workflow-step fallback thinking level by which fallback candidate (validatorFallback vs globalFallback) actually matched
- Document fallbackThinkingLevel runtime-swap behavior in docs/settings-reference.md
- Add minor changeset for @runfusion/fusion
- Add regression tests covering fallback thinking-level resolution and application (agent-session-helpers.test.ts, pi.test.ts) and a shared test helper (executor-test-helpers.ts)

Files changed:
 .changeset/fn-7794-fallback-thinking-level.md      |   7 ++
 docs/settings-reference.md                         |   3 +
 .../src/__tests__/agent-session-helpers.test.ts    |  38 ++++++
 .../engine/src/__tests__/executor-test-helpers.ts  |  23 ++++
 packages/engine/src/__tests__/pi.test.ts           | 136 +++++++++++++++++++++
 packages/engine/src/agent-heartbeat.ts             |   3 +-
 packages/engine/src/agent-runtime.ts               |   5 +
 packages/engine/src/agent-session-helpers.ts       |  54 ++++++++
 packages/engine/src/executor.ts                    |  31 ++++-
 packages/engine/src/index.ts                       |   5 +
 packages/engine/src/merger.ts                      |   7 +-
 packages/engine/src/pi.ts                          |  16 ++-
 packages/engine/src/reviewer.ts                    |   6 +
 13 files changed, 327 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7794

Fusion-Task-Lineage: c94d621a-ccbd-42b2-9fe6-cb619418ad90

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 11:53:07 -07:00
parent de67b57517
commit fc4acd4d66
13 changed files with 327 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -207,6 +207,54 @@ export function resolveMergerThinkingLevel(settings: Partial<Settings> | 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<Settings> | undefined,
): string | undefined {
return firstThinkingLevel(
settings?.fallbackThinkingLevel,
resolveExecutorThinkingLevel(taskThinkingLevel, settings),
);
}
export function resolvePlanningFallbackThinkingLevel(
settings: Partial<Settings> | undefined,
taskThinkingLevel?: ThinkingLevel | string,
): string | undefined {
return firstThinkingLevel(
settings?.planningFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolvePlanningThinkingLevel(settings, taskThinkingLevel),
);
}
export function resolveValidatorFallbackThinkingLevel(
taskThinkingLevel: ThinkingLevel | string | undefined,
settings: Partial<Settings> | undefined,
): string | undefined {
return firstThinkingLevel(
settings?.validatorFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveValidatorThinkingLevel(taskThinkingLevel, settings),
);
}
export function resolveTitleSummarizerFallbackThinkingLevel(settings: Partial<Settings> | undefined): string | undefined {
return firstThinkingLevel(
settings?.titleSummarizerFallbackThinkingLevel,
settings?.fallbackThinkingLevel,
resolveTitleSummarizerThinkingLevel(settings),
);
}
export function resolveMergerFallbackThinkingLevel(settings: Partial<Settings> | 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,
};
}

View File

@@ -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<ModelTuple & { label: string }> = [
type WorkflowStepFallbackLabel = "validatorFallback" | "globalFallback";
const fallbackCandidates: Array<ModelTuple & { label: WorkflowStepFallbackLabel }> = [
{ 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,

View File

@@ -835,6 +835,11 @@ export {
promptWithAutoRetry,
describeAgentModel,
resolveExecutorThinkingLevel,
resolveExecutorFallbackThinkingLevel,
resolvePlanningFallbackThinkingLevel,
resolveValidatorFallbackThinkingLevel,
resolveTitleSummarizerFallbackThinkingLevel,
resolveMergerFallbackThinkingLevel,
extractRuntimeHint,
extractRuntimeModel,
type ResolvedSessionOptions,

View File

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

View File

@@ -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<AgentResult>
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)) {

View File

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