diff --git a/.changeset/fn-7772-workflow-lane-thinking.md b/.changeset/fn-7772-workflow-lane-thinking.md new file mode 100644 index 0000000000..2751846996 --- /dev/null +++ b/.changeset/fn-7772-workflow-lane-thinking.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-workflow model lane thinking-level controls for planning, execution, and review. +category: feature +dev: Adds execution/planning/validator workflow thinking settings and phase precedence threading. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 35e7fdbde9..9ef20ddb73 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -259,8 +259,7 @@ govern that execution belong to the workflow. **Where to set them.** The common model lanes for a project's default workflow are available directly in **Settings → Project Models → Default workflow model lanes**: Plan/Triage, Executor, Reviewer, and the Planning/Reviewer fallback lanes declared -by the default workflow. Those dropdown controls use the shared model picker and -are persisted by the Settings modal's primary **Save** action, which writes +by the default workflow. Primary Plan/Triage, Executor, and Reviewer rows also show an inline Thinking Level control when the workflow declares `planningThinkingLevel`, `executionThinkingLevel`, or `validatorThinkingLevel`; unset means inherit. Those dropdown controls use the shared model picker and are persisted by the Settings modal's primary **Save** action, which writes workflow setting values for the active project's default workflow; they do not restore the old project settings keys. The global **Fallback Model** remains in Settings → General Models, and workflow-specific fallbacks are also editable from @@ -285,7 +284,8 @@ Actions. It has two tabs: editable for any workflow, including built-ins. Common provider/model lane pairs (Plan/Triage, Executor, Reviewer, and fallbacks declared by the workflow) use the same model dropdown picker as Project Models so clearing or selecting a model - updates both keys together. Advanced/custom non-model settings still use typed + updates both keys together. Declared primary-lane thinking companions render inline + and clear with the lane reset instead of as separate enum fields. Advanced/custom non-model settings still use typed controls. Built-in Plan Review/spec and Code Review revision caps also live here: leave `planReviewMaxRevisions` or `codeReviewMaxRevisions` empty for unbounded automatic revisions, enter a non-negative integer to cap attempts, or enter `0` @@ -332,7 +332,7 @@ These groups moved out of project settings and into workflow settings (built-in | **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | | **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` | | **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h) | -| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) | +| **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. | ### Workflow-native triage policy settings @@ -971,7 +971,7 @@ Short-lived token bounds are enforced server-side: Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models. -Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Empty thinking values inherit the global `defaultThinkingLevel`; explicit lane values are cleared by the lane reset action. Runtime thinking precedence is task `thinkingLevel` > lane thinking override > `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). +Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 93b8dd6d5d..92f17bb330 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -546,7 +546,7 @@ Plugin palette templates (above) can be dropped in as a starting point instead o A prompt-mode gate node can set its own model with: @@ -555,7 +555,7 @@ A prompt-mode gate node can set its own model with: - `modelId` - `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`) -If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow/project lane thinking override → global `defaultThinkingLevel`**. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned. +If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned. ## Default-On Behavior for New Tasks diff --git a/packages/core/src/__tests__/workflow-settings.test.ts b/packages/core/src/__tests__/workflow-settings.test.ts index 75f2a161b0..60b64dd4c9 100644 --- a/packages/core/src/__tests__/workflow-settings.test.ts +++ b/packages/core/src/__tests__/workflow-settings.test.ts @@ -8,6 +8,7 @@ import { } from "../workflow-settings.js"; import type { WorkflowSettingDefinition, WorkflowIrV2 } from "../workflow-ir-types.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; +import { THINKING_LEVELS } from "../types.js"; import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js"; const BUILTIN_CODING = "builtin:coding"; @@ -309,6 +310,46 @@ describe("TaskStore.updateWorkflowSettingValues", () => { }); }); +describe("workflow model-lane thinking settings", () => { + const harness = createSharedTaskStoreTestHarness(); + + beforeAll(harness.beforeAll); + afterAll(harness.afterAll); + beforeEach(harness.beforeEach); + afterEach(harness.afterEach); + + it("round-trips primary lane thinking levels and clears them with null-as-delete", async () => { + const store = harness.store(); + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { + executionThinkingLevel: "low", + planningThinkingLevel: "high", + validatorThinkingLevel: "minimal", + }); + + expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).toMatchObject({ + executionThinkingLevel: "low", + planningThinkingLevel: "high", + validatorThinkingLevel: "minimal", + }); + + await store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { executionThinkingLevel: null }); + expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).not.toHaveProperty("executionThinkingLevel"); + }); + + it("declares thinking companions as THINKING_LEVELS enum settings and rejects invalid values", async () => { + const ids = ["executionThinkingLevel", "planningThinkingLevel", "validatorThinkingLevel"]; + for (const id of ids) { + const decl = BUILTIN_WORKFLOW_SETTINGS.find((setting) => setting.id === id); + expect(decl?.type).toBe("enum"); + expect(decl?.options?.map((option) => option.value)).toEqual([...THINKING_LEVELS]); + } + + const store = harness.store(); + await expect(store.updateWorkflowSettingValues(BUILTIN_CODING, PROJECT, { executionThinkingLevel: "turbo" })).rejects.toBeInstanceOf(WorkflowSettingRejectionError); + expect(store.getWorkflowSettingValues(BUILTIN_CODING, PROJECT)).not.toHaveProperty("executionThinkingLevel"); + }); +}); + describe("TaskStore.getModelLaneDrift", () => { const harness = createSharedTaskStoreTestHarness(); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 6b0a982cce..504d14cac5 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -1,4 +1,4 @@ -import type { Settings } from "./types.js"; +import { THINKING_LEVELS, type Settings } from "./types.js"; import type { WorkflowSettingDefinition } from "./workflow-ir-types.js"; /** @@ -203,6 +203,10 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ // ── Per-phase model lanes ────────────────────────────────────────────── // Legacy defaults are all `undefined`; `default` is omitted so resolution // falls through to the global lane / project default (KTD-7). + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Workflow-declared primary model lanes may pin a thinking effort per (workflow, project). Empty values inherit through the lane/global/default chain, enum options are validated against THINKING_LEVELS, and fallback/title-summarizer lanes intentionally reuse their primary lane thinking level instead of adding separate companion settings. + */ { id: "executionProvider", name: "Execution provider", @@ -215,6 +219,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Model id for the execution phase. Empty falls through to the global lane.", }, + { + id: "executionThinkingLevel", + name: "Execution thinking level", + type: "enum", + options: THINKING_LEVELS.map((level) => ({ value: level, label: level })), + description: "Thinking effort for the execution phase. Empty inherits from the task or default thinking level.", + }, { id: "planningProvider", name: "Planning provider", @@ -227,6 +238,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Model id for the planning phase. Empty falls through to the global lane.", }, + { + id: "planningThinkingLevel", + name: "Planning thinking level", + type: "enum", + options: THINKING_LEVELS.map((level) => ({ value: level, label: level })), + description: "Thinking effort for the planning phase. Empty inherits from the task or default thinking level.", + }, { id: "planningFallbackProvider", name: "Planning fallback provider", @@ -251,6 +269,13 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ type: "string", description: "Model id for the validation phase. Empty falls through to the global lane.", }, + { + id: "validatorThinkingLevel", + name: "Validator thinking level", + type: "enum", + options: THINKING_LEVELS.map((level) => ({ value: level, label: level })), + description: "Thinking effort for the validation/review phase. Empty inherits from the task or default thinking level.", + }, { id: "validatorFallbackProvider", name: "Validator fallback provider", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 410b03312b..995236d600 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1357,8 +1357,10 @@ export { applyTestModeOverrides, isTestModeActive, resolveExecutionSettingsModel, + resolvePhaseThinkingLevel, resolvePlanningSettingsModel, resolveProjectDefaultModel, + resolveSettingsLaneThinkingLevel, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, @@ -1369,7 +1371,7 @@ export { routeTaskPlanningModel, routeTaskValidatorModel, } from "./model-resolution.js"; -export type { ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js"; +export type { ModelThinkingPhase, ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js"; export { routeModel, routeModelAndEmit, diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index 6159ea1752..3eca759913 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -1,4 +1,4 @@ -import type { Settings } from "./types.js"; +import type { Settings, ThinkingLevel } from "./types.js"; import type { ModelGovernancePredicate, RouterDecision, @@ -12,6 +12,8 @@ export interface ResolvedModelSelection { modelId?: string; } +export type ModelThinkingPhase = "execution" | "planning" | "validation"; + export const TEST_MODE_RESOLVED: ResolvedModelSelection = { provider: "mock", modelId: "scripted" }; export function isTestModeActive(settings?: Partial): boolean { @@ -55,6 +57,47 @@ function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection { return {}; } +function firstThinkingLevel(...levels: Array): string | undefined { + for (const level of levels) { + if (typeof level === "string" && level.trim().length > 0) { + return level.trim(); + } + } + return undefined; +} + +/** + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Workflow model-lane thinking companions are workflow-declared settings whose unset state means inherit. Resolve them centrally so executor, reviewer, triage, step sessions, and merger-adjacent validation agree on precedence: node/step override > task thinking > workflow lane > global lane > project default thinking override > global default thinking level. + */ +export function resolveSettingsLaneThinkingLevel( + phase: ModelThinkingPhase, + settings?: Partial, +): ThinkingLevel | undefined { + if (phase === "execution") return settings?.executionThinkingLevel; + if (phase === "planning") return settings?.planningThinkingLevel; + return settings?.validatorThinkingLevel; +} + +export function resolvePhaseThinkingLevel( + phase: ModelThinkingPhase, + settings: Partial | undefined, + nodeOrTaskThinkingLevel?: ThinkingLevel | string, +): string | undefined { + const globalLane = phase === "execution" + ? settings?.executionGlobalThinkingLevel + : phase === "planning" + ? settings?.planningGlobalThinkingLevel + : settings?.validatorGlobalThinkingLevel; + return firstThinkingLevel( + nodeOrTaskThinkingLevel, + resolveSettingsLaneThinkingLevel(phase, settings), + globalLane, + settings?.defaultThinkingLevelOverride, + settings?.defaultThinkingLevel, + ); +} + export function resolveProjectDefaultModel(settings?: Partial): ResolvedModelSelection { return applyTestModeOverrides( pickFirstModelPair( diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index b8b0e95174..ea9cb0e73e 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -42,12 +42,15 @@ type MovedProjectSettingsKey = | "reflectionEnabled" | "executionProvider" | "executionModelId" + | "executionThinkingLevel" | "planningProvider" | "planningModelId" + | "planningThinkingLevel" | "planningFallbackProvider" | "planningFallbackModelId" | "validatorProvider" | "validatorModelId" + | "validatorThinkingLevel" | "validatorFallbackProvider" | "validatorFallbackModelId"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2a97ce60a3..4877cd2bdf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4105,6 +4105,10 @@ export interface ProjectSettings { /** Project-level AI model ID for task execution. * Must be set together with `executionProvider`. */ executionModelId?: string; + /** Workflow-declared execution-lane thinking override. Inherits through task/default thinking when unset. */ + executionThinkingLevel?: ThinkingLevel; + /** Workflow-declared planning-lane thinking override. Inherits through task/default thinking when unset. */ + planningThinkingLevel?: ThinkingLevel; /** AI model provider for validator/reviewer agent. * Must be set together with `validatorModelId`. When both are undefined, * falls back to `defaultProvider`/`defaultModelId`. */ @@ -4120,6 +4124,8 @@ export interface ProjectSettings { /** Fallback model ID for validator/reviewer. When unset, falls back to the * global fallback model. Must be set together with `validatorFallbackProvider`. */ validatorFallbackModelId?: string; + /** Workflow-declared validator-lane thinking override. Inherits through task/default thinking when unset. */ + validatorThinkingLevel?: ThinkingLevel; /** Reusable model configuration presets for task creation. */ modelPresets?: ModelPreset[]; /** When true, task creation UIs automatically recommend/apply a preset based on task size. */ diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx index a2172d7d51..687e74bdbe 100644 --- a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx @@ -504,6 +504,7 @@ export interface WorkflowModelLanePair { id: string; providerId: string; modelId: string; + thinkingId?: string; label: string; help: string; } @@ -511,12 +512,16 @@ export interface WorkflowModelLanePair { /* FNXC:WorkflowSettings 2026-06-17-09:13: Title summarization is owned by project/global Settings → Project Models, not workflow Values. Keep this workflow-editor catalog limited to workflow-executed model lanes so custom declarations cannot create misleading title-summarizer dropdowns whose values are orphaned for built-in workflows. + +FNXC:Settings-ThinkingLevel 2026-07-10-00:00: +Workflow Values renders thinking controls inline with declared primary model lanes only. The companion setting ids are excluded from generic enum rendering so operators get one inherit/override/reset affordance and undeclared lanes leave no empty control shell. */ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ { id: "planning", providerId: "planningProvider", modelId: "planningModelId", + thinkingId: "planningThinkingLevel", label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.", }, @@ -524,6 +529,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ id: "execution", providerId: "executionProvider", modelId: "executionModelId", + thinkingId: "executionThinkingLevel", label: "Executor Model", help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.", }, @@ -531,6 +537,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ id: "validator", providerId: "validatorProvider", modelId: "validatorModelId", + thinkingId: "validatorThinkingLevel", label: "Reviewer Model", help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.", }, @@ -593,12 +600,13 @@ function ValuesTab({ WORKFLOW_MODEL_LANE_CATALOG.filter((pair) => { const provider = settingsById.get(pair.providerId); const model = settingsById.get(pair.modelId); - return provider?.type === "string" && model?.type === "string"; + const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined; + return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string"); }), [settingsById], ); const modelPairSettingIds = useMemo( - () => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId])), + () => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId, ...(pair.thinkingId ? [pair.thinkingId] : [])])), [modelLanePairs], ); @@ -716,6 +724,16 @@ function ValuesTab({ }); }; + const setModelPairThinkingValue = (pair: WorkflowModelLanePair, value: string) => { + if (!pair.thinkingId) return; + setValue(pair.thinkingId, value || null); + }; + + const clearModelPairValue = (pair: WorkflowModelLanePair) => { + setModelPairValue(pair, ""); + setModelPairThinkingValue(pair, ""); + }; + const dirty = Object.keys(pending).length > 0; const save = useCallback(async () => { @@ -773,8 +791,11 @@ function ValuesTab({ const providerValue = valueOfSettingId(pair.providerId); const modelValue = valueOfSettingId(pair.modelId); const value = typeof providerValue === "string" && typeof modelValue === "string" ? `${providerValue}/${modelValue}` : ""; - const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message; - const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId); + const thinkingValue = pair.thinkingId && typeof valueOfSettingId(pair.thinkingId) === "string" + ? (valueOfSettingId(pair.thinkingId) as string) + : ""; + const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message ?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined); + const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId) || Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId)); const dropdownDisabled = modelsLoading || availableModels.length === 0; const emptyHelp = !modelsLoading && availableModels.length === 0 @@ -791,7 +812,7 @@ function ValuesTab({ scope="project" disabled={modelsLoading} clearable={customized} - onClear={() => setModelPairValue(pair, "")} + onClear={() => clearModelPairValue(pair)} > setModelPairThinkingValue(pair, level) : undefined} + defaultThinkingLevel="off" /> {customized && ( diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index d450f9c3da..bac4c603ac 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -354,14 +354,17 @@ describe("SettingsModal", () => { } }); - const declaredWorkflowModelSettings = (ids: string[]) => ids.map((id) => ({ id, name: id, type: "string" as const })); + const declaredWorkflowModelSettings = (ids: string[]) => ids.map((id) => ({ id, name: id, type: id.endsWith("ThinkingLevel") ? "enum" as const : "string" as const, options: id.endsWith("ThinkingLevel") ? [{ value: "high", label: "High" }] : undefined })); const primaryWorkflowModelSettingIds = [ "planningProvider", "planningModelId", + "planningThinkingLevel", "executionProvider", "executionModelId", + "executionThinkingLevel", "validatorProvider", "validatorModelId", + "validatorThinkingLevel", ]; const fallbackWorkflowModelSettingIds = [ "planningFallbackProvider", @@ -573,7 +576,7 @@ describe("SettingsModal", () => { await waitFor(() => { expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith( "workflow-custom", - { executionProvider: null, executionModelId: null }, + { executionProvider: null, executionModelId: null, executionThinkingLevel: null }, "proj-1", ); }); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx index f0e6d206fa..31c9f712ee 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -193,6 +193,7 @@ describe("WorkflowSettingsPanel — Values tab", () => { initial={[ { id: "planningProvider", name: "Planning provider", type: "string" }, { id: "planningModelId", name: "Planning model", type: "string" }, + { id: "planningThinkingLevel", name: "Planning thinking level", type: "enum", options: [{ value: "high", label: "High" }] }, { id: "validatorProvider", name: "Validator provider", type: "string" }, { id: "requirePlanApproval", name: "Require plan approval", type: "boolean" }, { id: "planReviewMaxRevisions", name: "Plan Review revision cap", type: "number" }, @@ -583,10 +584,13 @@ describe("WorkflowSettingsPanel — Values tab", () => { const modelDecls: WorkflowSettingDefinition[] = [ { id: "planningProvider", name: "Planning provider", type: "string" }, { id: "planningModelId", name: "Planning model", type: "string" }, + { id: "planningThinkingLevel", name: "Planning thinking level", type: "enum", options: [{ value: "high", label: "High" }] }, { id: "executionProvider", name: "Execution provider", type: "string" }, { id: "executionModelId", name: "Execution model", type: "string" }, + { id: "executionThinkingLevel", name: "Execution thinking level", type: "enum", options: [{ value: "low", label: "Low" }] }, { id: "validatorProvider", name: "Validator provider", type: "string" }, { id: "validatorModelId", name: "Validator model", type: "string" }, + { id: "validatorThinkingLevel", name: "Validator thinking level", type: "enum", options: [{ value: "minimal", label: "Minimal" }] }, { id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" }, { id: "planningFallbackModelId", name: "Planning fallback model", type: "string" }, { id: "customModelProvider", name: "Custom model provider", type: "string" }, @@ -662,6 +666,33 @@ describe("WorkflowSettingsPanel — Values tab", () => { ); }); + it("edits workflow lane thinking inline and reset clears the companion setting", async () => { + mockFetchValues.mockResolvedValue( + payload({ + stored: { planningThinkingLevel: "high" }, + effective: { planningThinkingLevel: "high" }, + }), + ); + render(); + await waitFor(() => expect(mockFetchModels).toHaveBeenCalled()); + + await openPlanningDropdown(); + fireEvent.change(screen.getByTestId("custom-model-dropdown-thinking"), { target: { value: "" } }); + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); + expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { planningThinkingLevel: null }, "proj-1"); + }); + + it("does not render a thinking control when a lane lacks its companion declaration", async () => { + const withoutThinking = modelDecls.filter((setting) => setting.id !== "planningThinkingLevel"); + render(); + await waitFor(() => expect(mockFetchModels).toHaveBeenCalled()); + + expect(screen.queryByLabelText("Plan/Triage Model")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Planning Fallback Model")).toBeInTheDocument(); + }); + it("clearing a workflow model dropdown writes paired null values", async () => { mockFetchValues.mockResolvedValue( payload({ diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 967d675371..fffbb0ae08 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -13,6 +13,7 @@ type WorkflowModelPair = { id: "planning" | "execution" | "validator" | "planning-fallback" | "validator-fallback" | "title-summarizer-fallback"; providerId: string; modelId: string; + thinkingId?: string; label: string; help: string; }; @@ -20,12 +21,16 @@ const DEFAULT_WORKFLOW_ID = "builtin:coding"; /* FNXC:SettingsModels 2026-06-16-19:58: Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes only fallback pairs declared by the active default workflow so saves never PATCH undeclared keys. + +FNXC:Settings-ThinkingLevel 2026-07-10-00:00: +Primary workflow model lanes may expose an inline thinking selector only when the active workflow declares the matching companion setting. Reset must clear both the model pair and the thinking companion; fallback/title-summarizer rows intentionally render no orphan thinking shell. */ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ { id: "planning", providerId: "planningProvider", modelId: "planningModelId", + thinkingId: "planningThinkingLevel", label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the workflow default.", }, @@ -33,6 +38,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ id: "execution", providerId: "executionProvider", modelId: "executionModelId", + thinkingId: "executionThinkingLevel", label: "Executor Model", help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.", }, @@ -40,6 +46,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ id: "validator", providerId: "validatorProvider", modelId: "validatorModelId", + thinkingId: "validatorThinkingLevel", label: "Reviewer Model", help: "Provider and model used for workflow review or validation lanes. Leave unset to inherit from the workflow default.", }, @@ -70,7 +77,8 @@ function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): Wor return WORKFLOW_MODEL_PAIRS.filter((pair) => { const provider = settingsById.get(pair.providerId); const model = settingsById.get(pair.modelId); - return provider?.type === "string" && model?.type === "string"; + const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined; + return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string"); }); } function modelPairValue(values: Record, pair: WorkflowModelPair): string { @@ -213,9 +221,22 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje }; }); }, []); + const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => { + if (!pair.thinkingId) + return; + setWorkflowRejections((current) => { + if (!pair.thinkingId || !current[pair.thinkingId]) + return current; + const next = { ...current }; + delete next[pair.thinkingId]; + return next; + }); + setWorkflowPending((current) => ({ ...current, [pair.thinkingId as string]: value || null })); + }, []); const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => { setWorkflowPairValue(pair, ""); - }, [setWorkflowPairValue]); + setWorkflowThinkingValue(pair, ""); + }, [setWorkflowPairValue, setWorkflowThinkingValue]); const saveWorkflowLanes = useCallback(async () => { if (!projectId || !workflowDirty) return; @@ -329,11 +350,19 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje {!projectId ? (
{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}
) : workflowLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}
) : (<> {workflowModelPairs.map((pair) => { const value = modelPairValue(effectiveWorkflowValues, pair); - const customized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId) + const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined; + const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : ""; + const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId) ? workflowPending[pair.providerId] !== null : Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId) || Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.modelId))); - const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message; + const thinkingCustomized = pair.thinkingId + ? (Object.prototype.hasOwnProperty.call(workflowPending, pair.thinkingId) + ? workflowPending[pair.thinkingId] !== null + : Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId))) + : false; + const customized = modelCustomized || thinkingCustomized; + const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined); return (
@@ -343,7 +372,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
- setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/> + setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
{customized && ()}
diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index c53b3f63de..64d45489f0 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -28,23 +28,26 @@ vi.mock("../runtime-resolution.js", async () => { describe("resolve model-lane thinking levels", () => { - it("applies task > execution lane > project default lane > global default precedence", () => { + it("applies node/task > workflow execution lane > global lane > project default lane > global default precedence", () => { const settings = { defaultThinkingLevel: "low", defaultThinkingLevelOverride: "medium", executionGlobalThinkingLevel: "high", + executionThinkingLevel: "minimal", } as const; expect(resolveExecutorThinkingLevel("xhigh", settings)).toBe("xhigh"); - expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("high"); + expect(resolveExecutorThinkingLevel(undefined, settings)).toBe("minimal"); + expect(resolveExecutorThinkingLevel(undefined, { executionGlobalThinkingLevel: "high", defaultThinkingLevel: "low" })).toBe("high"); expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevelOverride: "medium", defaultThinkingLevel: "low" })).toBe("medium"); expect(resolveExecutorThinkingLevel(undefined, { defaultThinkingLevel: "low" })).toBe("low"); }); it("resolves planning, reviewer, and summarization lane overrides before the global default", () => { - expect(resolvePlanningThinkingLevel({ planningGlobalThinkingLevel: "minimal", defaultThinkingLevel: "high" })).toBe("minimal"); - expect(resolveValidatorThinkingLevel(undefined, { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("medium"); - expect(resolveValidatorThinkingLevel("xhigh", { validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("xhigh"); + expect(resolvePlanningThinkingLevel({ planningThinkingLevel: "low", planningGlobalThinkingLevel: "minimal", defaultThinkingLevel: "high" })).toBe("low"); + expect(resolvePlanningThinkingLevel({ planningThinkingLevel: "low", defaultThinkingLevel: "high" }, "xhigh")).toBe("xhigh"); + expect(resolveValidatorThinkingLevel(undefined, { validatorThinkingLevel: "minimal", validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("minimal"); + expect(resolveValidatorThinkingLevel("xhigh", { validatorThinkingLevel: "minimal", validatorGlobalThinkingLevel: "medium", defaultThinkingLevel: "low" })).toBe("xhigh"); expect(resolveTitleSummarizerThinkingLevel({ titleSummarizerThinkingLevel: "high", titleSummarizerGlobalThinkingLevel: "medium", diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 7b75605367..26b4e259df 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -85,6 +85,7 @@ vi.mock("../agent-session-helpers.js", async () => { }, resolveExecutorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record | 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), diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 071d1ccf7e..5259946e53 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -16,6 +16,7 @@ import { isGrokApiKeyFusionVisible, isTestModeActive, resolveExecutionSettingsModel, + resolvePhaseThinkingLevel, resolveProjectDefaultModel, resolveTaskExecutionModel, resolveTaskPlanningModel, @@ -161,29 +162,27 @@ function firstThinkingLevel(...levels: Array | undefined, ): string | undefined { - return firstThinkingLevel( - taskThinkingLevel, - settings?.executionGlobalThinkingLevel, - settings?.defaultThinkingLevelOverride, - settings?.defaultThinkingLevel, - ); + return resolvePhaseThinkingLevel("execution", settings, taskThinkingLevel); } -export function resolvePlanningThinkingLevel(settings: Partial | undefined): string | undefined { - return firstThinkingLevel(settings?.planningGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); +export function resolvePlanningThinkingLevel( + settings: Partial | undefined, + taskThinkingLevel?: ThinkingLevel | string, +): string | undefined { + return resolvePhaseThinkingLevel("planning", settings, taskThinkingLevel); } export function resolveValidatorThinkingLevel( taskThinkingLevel: ThinkingLevel | string | undefined, settings: Partial | undefined, ): string | undefined { - return firstThinkingLevel(taskThinkingLevel, settings?.validatorGlobalThinkingLevel, settings?.defaultThinkingLevelOverride, settings?.defaultThinkingLevel); + return resolvePhaseThinkingLevel("validation", settings, taskThinkingLevel); } export function resolveTitleSummarizerThinkingLevel(settings: Partial | undefined): string | undefined { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 8343b86ca8..7c5ca77b27 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6688,9 +6688,9 @@ export class TaskExecutor { fallbackModelId: settings.fallbackModelId, /* * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * Step-review model sessions honor per-node `config.thinkingLevel` before task and settings defaults. + * Step-review model sessions honor per-node `config.thinkingLevel` before task, validator workflow lane, global lane, and default thinking settings. */ - defaultThinkingLevel: resolveExecutorThinkingLevel( + defaultThinkingLevel: resolveValidatorThinkingLevel( typeof config.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(config.thinkingLevel) ? (config.thinkingLevel as ThinkingLevel) : detail.thinkingLevel, diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 609ae5e21f..412675bc85 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -1274,7 +1274,7 @@ export class StepSessionExecutor { /* * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: - * Reusable primary step sessions resolve reasoning effort as workflow node/step override first, then parsed step metadata, then task and settings defaults. + * Reusable primary step sessions resolve reasoning effort as workflow node/step override first, then parsed step metadata, task thinking, workflow execution lane, global lane, and default thinking settings. */ const stepThinkingLevel = this.options.workflowStepThinkingLevel ?? (taskDetail.steps[stepIndex] as { thinkingLevel?: string } | undefined)?.thinkingLevel; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index b976415ad8..f1a81b2467 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -1161,7 +1161,11 @@ export class TriageProcessor { fallbackModelId: hasExplicitPlanningFallback ? settings.planningFallbackModelId : (hasExplicitGlobalFallback ? settings.fallbackModelId : implicitPlanningFallback.modelId), - defaultThinkingLevel: resolvePlanningThinkingLevel(settings), + /* + * FNXC:Settings-ThinkingLevel 2026-07-10-00:00: + * Planning sessions carry task thinking first, then the workflow-declared planning lane, global planning lane, and default thinking settings into pi.ts' existing thinking fallback path. + */ + defaultThinkingLevel: resolvePlanningThinkingLevel(settings, task.thinkingLevel), runAuditor, settings, // FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material. @@ -1179,7 +1183,7 @@ export class TriageProcessor { }), }); - const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings)); + const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings, task.thinkingLevel)); planLog.log(`${task.id}: using model ${modelDesc}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.appendAgentLog(