FN-7771: add per-node thinking level for workflow model bindings

Adds a per-node thinking-level override (config.thinkingLevel) for workflow IR model bindings so individual workflow nodes can set reasoning effort independently of the global default.

- Extend workflow-ir types/schema and workflow-steps-to-ir conversion to carry config.thinkingLevel per node
- Wire thinkingLevel through executor and step-session-executor so the engine applies the per-node override during model calls
- Add a thinking-level control to WorkflowNodeEditor for authoring per-node overrides in the dashboard
- Add/extend tests covering IR round-trip, steps-to-ir conversion, executor model binding, and the WorkflowNodeEditor UI
- Document the new setting in docs/workflow-steps.md
- Add changeset for @runfusion/fusion (minor)

Files changed:
 .changeset/fn-7771-workflow-node-thinking.md       |  7 +++
 docs/workflow-steps.md                             | 10 ++-
 packages/core/src/__tests__/workflow-ir.test.ts    | 42 +++++++++++++
 .../src/__tests__/workflow-steps-to-ir.test.ts     | 15 +++++
 packages/core/src/store.ts                         |  1 +
 packages/core/src/types.ts                         |  9 +++
 packages/core/src/workflow-ir.ts                   | 21 +++++++
 packages/core/src/workflow-steps-to-ir.ts          |  5 ++
 .../app/components/WorkflowNodeEditor.tsx          | 42 ++++++++++++-
 .../__tests__/WorkflowNodeEditor.test.tsx          | 73 ++++++++++++++++++++++
 .../engine/src/__tests__/executor-test-helpers.ts  |  5 ++
 .../__tests__/executor-workflow-step-model.test.ts | 31 +++++++++
 .../src/__tests__/workflow-step-review.test.ts     | 53 ++++++++++++++++
 packages/engine/src/executor.ts                    | 61 ++++++++++++++++--
 packages/engine/src/step-session-executor.ts       | 12 +++-
 packages/engine/src/workflow-node-handlers.ts      | 29 ++++++++-
 16 files changed, 404 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7771

Fusion-Task-Lineage: 5dbe3efb-773d-47db-9412-b740eb1d7745

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 01:49:15 -07:00
parent 5f14a58d3b
commit 235ff4c65e
16 changed files with 404 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add per-node workflow thinking-level controls for custom model bindings.
category: feature
dev: Workflow IR now round-trips config.thinkingLevel and runtime precedence is node/step > task > settings.

View File

@@ -542,14 +542,20 @@ There is no longer a Settings → Workflow Steps manager or step CRUD form. To a
Plugin palette templates (above) can be dropped in as a starting point instead of authoring a node from scratch.
## Model Overrides for Prompt Steps
## Model Overrides for Workflow Nodes
<!--
FNXC:WorkflowModelBinding 2026-07-10-00:00:
FN-7771 lets workflow authors bind reasoning effort per session-running node, independently from provider/model. Node-level thinking is strongest so a custom workflow can pin a high-effort review or low-effort execution seam without changing task-wide or project/workflow lane defaults.
-->
A prompt-mode gate node can set its own model with:
- `modelProvider`
- `modelId`
- `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`)
If both are set, node execution uses that model; otherwise it falls back to default model selection. Dashboard node summaries show that unpinned prompt-node state as **Default model**.
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.
## Default-On Behavior for New Tasks

View File

@@ -155,6 +155,48 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/);
});
it("validates optional node thinkingLevel values", () => {
const valid = v2(
[{ id: "only", name: "Only", traits: [] }],
[
{ id: "start", kind: "start", column: "only" },
{ id: "a", kind: "prompt", column: "only", config: { thinkingLevel: "high" } },
{ id: "review", kind: "step-review", column: "only", config: { type: "code", thinkingLevel: "low" } },
{ id: "end", kind: "end", column: "only" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "review" },
{ from: "review", to: "end", condition: "outcome:approve" },
{ from: "review", to: "end", condition: "outcome:revise" },
],
);
expect(() => parseWorkflowIr(valid)).not.toThrow();
const absent = v2(
[{ id: "only", name: "Only", traits: [] }],
[
{ id: "start", kind: "start", column: "only" },
{ id: "a", kind: "prompt", column: "only" },
{ id: "end", kind: "end", column: "only" },
],
[{ from: "start", to: "a" }, { from: "a", to: "end" }],
);
expect(() => parseWorkflowIr(absent)).not.toThrow();
const invalid = v2(
[{ id: "only", name: "Only", traits: [] }],
[
{ id: "start", kind: "start", column: "only" },
{ id: "a", kind: "prompt", column: "only", config: { thinkingLevel: "ultra" } },
{ id: "end", kind: "end", column: "only" },
],
[{ from: "start", to: "a" }, { from: "a", to: "end" }],
);
expect(() => parseWorkflowIr(invalid)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(invalid)).toThrow(/Workflow node 'a' thinkingLevel must be one of/);
});
it("rejects duplicate top-level node ids before Map de-duplication can mask them", () => {
const ir = v2(
[{ id: "only", name: "Only", traits: [] }],

View File

@@ -31,6 +31,7 @@ function step(overrides: Partial<WorkflowStep>): WorkflowStep {
defaultOn: overrides.defaultOn,
modelProvider: overrides.modelProvider,
modelId: overrides.modelId,
thinkingLevel: overrides.thinkingLevel,
migratedFragmentId: overrides.migratedFragmentId,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -71,6 +72,20 @@ describe("stepsToWorkflowIr — produces valid IR structure", () => {
expect(ir.nodes.find((n) => n.id === "step-1")?.config?.prompt).toBe("Implement the change");
});
it("lowers thinkingLevel independently from the model pair", () => {
const thinkingOnly = step({ id: "WS-thinking", name: "Think", mode: "prompt", gateMode: "advisory", prompt: "x", thinkingLevel: "high" });
const withoutThinking = step({ id: "WS-default", name: "Default", mode: "prompt", gateMode: "advisory", prompt: "y" });
const fragment = stepToFragmentIr(thinkingOnly);
expect(fragment.nodes.find((n) => n.id === "step-1")?.config).toMatchObject({ thinkingLevel: "high" });
expect(fragment.nodes.find((n) => n.id === "step-1")?.config).not.toHaveProperty("modelProvider");
expect(fragment.nodes.find((n) => n.id === "step-1")?.config).not.toHaveProperty("modelId");
const ir = stepsToWorkflowIr([thinkingOnly, withoutThinking], "Thinking");
expect(ir.nodes.find((n) => n.id === "step-1")?.config).toMatchObject({ thinkingLevel: "high" });
expect(ir.nodes.find((n) => n.id === "step-2")?.config).not.toHaveProperty("thinkingLevel");
});
it("empty step list yields a minimal valid IR (start + seams + end)", () => {
const ir = stepsToWorkflowIr([], "Empty");
expect(() => parseWorkflowIr(ir)).not.toThrow();

View File

@@ -15432,6 +15432,7 @@ ${stepsSection}`;
defaultOn: entry.template.defaultOn,
modelProvider: entry.template.modelProvider,
modelId: entry.template.modelId,
thinkingLevel: entry.template.thinkingLevel,
createdAt: now,
updatedAt: now,
};

View File

@@ -711,6 +711,11 @@ export interface WorkflowStep {
* Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. Only used when mode is "prompt". */
modelId?: string;
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Workflow IR nodes may pin reasoning effort independently from the model pair so authors can inherit the model while overriding thinking level. Runtime precedence is node/step `thinkingLevel` > task `thinkingLevel` > settings `defaultThinkingLevel`.
*/
thinkingLevel?: ThinkingLevel;
/** (workflow-editor-consolidation U1/U2, KTD-1/KTD-3) when this legacy step has
* been migrated into a fragment WorkflowDefinition, the fragment's id is stamped
* here so the lazy step migration is idempotent (already-stamped rows are
@@ -852,6 +857,8 @@ export interface WorkflowStepInput {
modelProvider?: string;
/** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */
modelId?: string;
/** Optional per-node reasoning-effort override; inherits from task/settings when omitted. */
thinkingLevel?: ThinkingLevel;
/** (workflow-editor-consolidation U2, KTD-3) fragment id stamped when this step
* was migrated into a fragment WorkflowDefinition. Set by the migration only. */
migratedFragmentId?: string;
@@ -1017,6 +1024,8 @@ export interface WorkflowStepTemplate {
modelProvider?: string;
/** AI model ID override for prompt-mode templates. */
modelId?: string;
/** Optional per-node reasoning-effort override for prompt-mode templates. */
thinkingLevel?: ThinkingLevel;
/** Grouping category (e.g., "Quality", "Security") */
category: string;
/** Optional icon identifier for UI (e.g., "file-text", "shield") */

View File

@@ -17,6 +17,7 @@ import type {
} from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
import { THINKING_LEVELS } from "./types.js";
export class WorkflowIrError extends Error {
constructor(message: string) {
@@ -70,6 +71,8 @@ const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"toggle",
]);
const THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */
export const WORKFLOW_SETTING_TYPES: ReadonlySet<WorkflowSettingType> = new Set([
"string",
@@ -845,6 +848,23 @@ function validateStepReviewRouting(
}
}
function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void {
for (const node of nodes) {
const value = node.config?.thinkingLevel;
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Per-node thinking overrides are workflow model-binding config, so IR validation rejects unknown reasoning levels before editor-authored or imported graphs reach execution.
*/
if (value !== undefined && (typeof value !== "string" || !THINKING_LEVEL_SET.has(value))) {
throw new WorkflowIrError(
`Workflow node '${node.id}' thinkingLevel must be one of ${THINKING_LEVELS.join(", ")} when present`,
);
}
const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes;
if (Array.isArray(templateNodes)) validateThinkingLevelConfig(templateNodes as WorkflowIrNode[]);
}
}
/** Compute the set of node ids that lie strictly inside some split..join branch
* region. Walks each split's branches forward to the join. Lightweight; used
* for the step-review advisory-only rule. */
@@ -1469,6 +1489,7 @@ function validateV2(ir: WorkflowIrV2): void {
// configs first, then structural rules.
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
validateThinkingLevelConfig(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);

View File

@@ -69,6 +69,11 @@ function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode {
config.modelProvider = step.modelProvider;
config.modelId = step.modelId;
}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* A workflow step can pin reasoning effort while inheriting its model, so lower `thinkingLevel` independently from the model-provider/model-id pair.
*/
if (step.thinkingLevel) config.thinkingLevel = step.thinkingLevel;
return { id, kind: "prompt", config };
}

View File

@@ -37,6 +37,7 @@ import {
fetchPluginWorkflowStepTemplates,
fetchWorkflowPromptOverrides,
updateWorkflowPromptOverrides,
fetchSettings,
type ModelInfo,
type WorkflowPromptOverridesPayload,
} from "../api";
@@ -322,6 +323,11 @@ function stepTemplateToNode(tpl: WorkflowStepTemplate): {
config.modelProvider = tpl.modelProvider;
config.modelId = tpl.modelId;
}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Template-seeded prompt nodes preserve reasoning effort independently from model selection so authors can inherit a model while pinning per-node thinking.
*/
if (tpl.thinkingLevel) config.thinkingLevel = tpl.thinkingLevel;
return { kind: "prompt", label: tpl.name, config };
}
@@ -1734,7 +1740,17 @@ function InnerEditor({
config:
typeof patch.config === "function"
? patch.config((n.data.config ?? {}) as Record<string, unknown>)
: { ...(n.data.config ?? {}), ...patch.config },
: (() => {
const nextConfig = { ...(n.data.config ?? {}), ...patch.config };
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Inspector controls use `undefined` as the clear signal for optional config keys such as `thinkingLevel`; remove those keys so saved IR has absence, not an undefined shell.
*/
for (const key of Object.keys(nextConfig)) {
if (nextConfig[key] === undefined) delete nextConfig[key];
}
return nextConfig;
})(),
}
: patch),
},
@@ -2249,6 +2265,7 @@ function InnerEditor({
// summaries; the inspector selects reuse the same state. Failures are
// non-fatal — summaries fall back to raw ids — so the prefetch is toastless.
const [models, setModels] = useState<ModelInfo[]>([]);
const [projectDefaultThinkingLevel, setProjectDefaultThinkingLevel] = useState("off");
const [agents, setAgents] = useState<Agent[]>([]);
// The agent fetches are project-scoped, but this cache survives project
// switches — both load paths short-circuit on agents.length > 0, which would
@@ -2282,6 +2299,13 @@ function InnerEditor({
if (!cancelled && Array.isArray(res)) setSkills(res);
})
.catch(() => {});
Promise.resolve(fetchSettings(projectId))
.then((res) => {
if (!cancelled) setProjectDefaultThinkingLevel(res?.defaultThinkingLevel ?? "off");
})
.catch(() => {
if (!cancelled) setProjectDefaultThinkingLevel("off");
});
return () => {
cancelled = true;
};
@@ -3837,6 +3861,14 @@ function InnerEditor({
const { provider, modelId } = parseModelDropdownValue(value);
updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } });
}}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Prompt model nodes expose the shared inline thinking selector only for the model executor, persisting `config.thinkingLevel` with Default clearing the key.
*/
showThinkingLevel
thinkingLevel={String(selectedNode.data.config?.thinkingLevel ?? "")}
onThinkingLevelChange={(value) => updateSelectedData({ config: { thinkingLevel: value || undefined } })}
defaultThinkingLevel={projectDefaultThinkingLevel ?? "off"}
/>
</label>
)}
@@ -4568,6 +4600,14 @@ function InnerEditor({
},
});
}}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-review nodes share the model dropdown thinking selector so review sessions can pin reasoning effort with the same node > task > settings precedence as executor steps.
*/
showThinkingLevel
thinkingLevel={String(selectedNode.data.config?.thinkingLevel ?? "")}
onThinkingLevelChange={(value) => updateSelectedData({ config: { thinkingLevel: value || undefined } })}
defaultThinkingLevel={projectDefaultThinkingLevel ?? "off"}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">

View File

@@ -356,6 +356,40 @@ function scriptDef(): WorkflowDefinition {
};
}
function thinkingModelDef(): WorkflowDefinition {
return {
id: "WF-THINKING",
kind: "workflow",
name: "Thinking workflow",
description: "",
ir: {
version: "v2",
name: "Thinking workflow",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "model", kind: "prompt", column: "triage", config: { name: "Model node", executor: "model", prompt: "run" } },
{ id: "review", kind: "step-review", column: "triage", config: { type: "code", thinkingLevel: "low" } },
{ id: "script", kind: "script", column: "triage", config: { scriptName: "lint" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "model" },
{ from: "model", to: "review" },
{ from: "review", to: "script", condition: "outcome:approve" },
{ from: "review", to: "model", condition: "outcome:revise", kind: "rework" },
{ from: "script", to: "end" },
],
},
layout: { start: { x: 0, y: 20 }, model: { x: 120, y: 60 }, review: { x: 240, y: 120 }, script: { x: 360, y: 180 }, end: { x: 480, y: 240 } },
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
function plainConnectDef(): WorkflowDefinition {
return {
id: "WF-PLAIN-CONNECT",
@@ -1186,6 +1220,45 @@ describe("WorkflowNodeEditor", () => {
await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument());
});
it("wires thinking controls only on workflow model pickers and persists clear semantics", async () => {
const source = thinkingModelDef();
vi.mocked(fetchWorkflows).mockResolvedValue([source]);
vi.mocked(fetchModels).mockResolvedValue({ models: [{ provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet" }] });
vi.mocked(fetchSettings).mockResolvedValue({ defaultThinkingLevel: "medium" } as Settings);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...source, ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-model")).getAllByRole("button")[0]);
let inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Default (medium)");
fireEvent.click(within(inspector).getByRole("button", { name: "Model" }));
await screen.findByTestId("custom-model-dropdown-thinking");
fireEvent.change(screen.getAllByTestId("custom-model-dropdown-thinking").at(-1)!, { target: { value: "high" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
let saved = vi.mocked(updateWorkflow).mock.calls.at(-1)?.[1] as { ir?: WorkflowDefinition["ir"] };
expect(saved.ir?.nodes.find((node) => node.id === "model")?.config?.thinkingLevel).toBe("high");
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-review")).getAllByRole("button")[0]);
inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Low");
fireEvent.click(within(inspector).getByRole("button", { name: "Review model (optional)" }));
await screen.findByTestId("custom-model-dropdown-thinking");
fireEvent.change(screen.getAllByTestId("custom-model-dropdown-thinking").at(-1)!, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateWorkflow).toHaveBeenCalledTimes(2));
saved = vi.mocked(updateWorkflow).mock.calls.at(-1)?.[1] as { ir?: WorkflowDefinition["ir"] };
expect(saved.ir?.nodes.find((node) => node.id === "review")?.config).not.toHaveProperty("thinkingLevel");
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-script")).getAllByRole("button")[0]);
inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).queryByTestId("custom-model-dropdown-thinking-badge")).not.toBeInTheDocument();
});
it("opens selected edge details as a dismissible full-screen mobile stage", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);

View File

@@ -83,6 +83,11 @@ vi.mock("../agent-session-helpers.js", async () => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorThinkingLevel: (taskThinkingLevel: string | undefined, settings: Record<string, unknown> | undefined) =>
taskThinkingLevel
?? (typeof settings?.executionGlobalThinkingLevel === "string" ? settings.executionGlobalThinkingLevel : undefined)
?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined)
?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined),
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,

View File

@@ -11,6 +11,7 @@ import {
type CapturedSession = {
defaultProvider?: string;
defaultModelId?: string;
defaultThinkingLevel?: string;
};
function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: CapturedSession } {
@@ -19,6 +20,7 @@ function captureSession(output = '{"verdict":"APPROVE","notes":""}'): { last?: C
holder.last = {
defaultProvider: opts.defaultProvider,
defaultModelId: opts.defaultModelId,
defaultThinkingLevel: opts.defaultThinkingLevel,
};
const listeners: Array<(event: any) => void> = [];
@@ -232,6 +234,35 @@ describe("executor workflow-step model resolution", () => {
});
});
it("resolves workflow-step thinking level before task and settings defaults", async () => {
await expect(
runStepWithSettings(
{
defaultThinkingLevel: "low",
},
{
task: { thinkingLevel: "medium" },
step: { thinkingLevel: "high" },
},
),
).resolves.toMatchObject({ defaultThinkingLevel: "high" });
await expect(
runStepWithSettings(
{
defaultThinkingLevel: "low",
},
{
task: { thinkingLevel: "medium" },
},
),
).resolves.toMatchObject({ defaultThinkingLevel: "medium" });
await expect(
runStepWithSettings({ defaultThinkingLevel: "low" }),
).resolves.toMatchObject({ defaultThinkingLevel: "low" });
});
it("logs workflow-step model rows with thinking effort before override annotations", async () => {
const primary = await runStepWithSettings(
{

View File

@@ -98,6 +98,59 @@ describe("WorkflowGraphExecutor step-review (U5)", () => {
expect(doneMarks).toEqual([{ index: 0, status: "done" }]);
});
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Regression for a node's own `config.thinkingLevel` being dropped by resolveStepReviewConfig
* before it reached `seams.stepReview` — the FN-7771 per-node override would silently never
* apply to review sessions even though the dashboard persisted it on the node.
*/
it("threads the review node's own config.thinkingLevel into the stepReview seam config", async () => {
const seenThinkingLevels: Array<string | undefined> = [];
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
stepReview: async (_t, _ctx, cfg) => {
seenThinkingLevels.push(cfg.thinkingLevel);
return { verdict: "APPROVE" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
reviewForeachIr({ config: {} }),
);
void result;
// Baseline: no thinkingLevel on the node -> undefined reaches the seam.
expect(seenThinkingLevels).toEqual([undefined]);
// Now with a pinned node-level thinkingLevel on the review node itself.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "step-review" as const, config: { type: "code", thinkingLevel: "high" } },
] as WorkflowIrNode[],
edges: [{ from: "exec", to: "review", condition: "success" }],
};
const ir: WorkflowIr = {
version: "v2",
name: "review-thinking-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
],
};
seenThinkingLevels.length = 0;
await executor.run(taskWithSteps(1), settingsOn(), ir);
expect(seenThinkingLevels).toEqual(["high"]);
});
it("REVISE routes a rework edge without triggering a reset", async () => {
const resets: string[] = [];
let reviewCalls = 0;

View File

@@ -6,12 +6,14 @@ import { setImmediate as setImmediateCb } from "node:timers";
// Internal git plumbing intentionally bypasses sandbox backends.
const execAsync = promisify(exec);
const WORKFLOW_THINKING_LEVEL_SET: ReadonlySet<string> = new Set(THINKING_LEVELS);
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, lstatSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
@@ -36,6 +38,7 @@ import { observeWorkflowParity, WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from ".
import {
FOREACH_ACTIVE_CONTEXT_KEY,
SEAM_GOVERNING_NODE_CONTEXT_KEY,
SEAM_THINKING_LEVEL_CONTEXT_KEY,
SPLIT_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type WorkflowLegacySeams,
@@ -4811,6 +4814,12 @@ export class TaskExecutor {
* session build, and cleared by the seam afterward. Keyed by task id. */
private graphSeamGoverningNodeId = new Map<string, string>();
/**
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Execute and step-execute seam nodes can pin reasoning effort for the implementation session; keep it per graph run so session creation applies node/step > task > settings precedence.
*/
private graphSeamThinkingLevel = new Map<string, ThinkingLevel>();
/** Tasks currently being orchestrated by the graph runner. Process-wide for
* the same reason as executingTaskLock (FN-4811): duplicate execute()
* invocations can arrive from different TaskExecutor instances in one
@@ -5140,6 +5149,7 @@ export class TaskExecutor {
this.graphColumnAgentResolver.delete(task.id);
this.graphUnattendedRuns.delete(task.id);
this.graphSeamGoverningNodeId.delete(task.id);
this.graphSeamThinkingLevel.delete(task.id);
this.graphExecuteSelfRequeued.delete(task.id);
// Per-instance keys: clear every instance slot owned by this task.
const ctxPrefix = `${task.id}:`;
@@ -5828,6 +5838,7 @@ export class TaskExecutor {
stepIndex: number,
instanceId?: string,
governingNodeId?: string,
thinkingLevel?: ThinkingLevel,
): Promise<{ success: boolean; error?: string }> {
const active = this.foreachActiveForTask(task.id, instanceId);
/*
@@ -5859,6 +5870,9 @@ export class TaskExecutor {
if (typeof governingNodeId === "string") {
this.graphSeamGoverningNodeId.set(task.id, governingNodeId);
}
if (thinkingLevel) {
this.graphSeamThinkingLevel.set(task.id, thinkingLevel);
}
phase = this.runImplementationPhase(task);
this.graphStepRunOnce.set(task.id, phase);
void phase
@@ -5868,6 +5882,9 @@ export class TaskExecutor {
if (typeof governingNodeId === "string" && this.graphSeamGoverningNodeId.get(task.id) === governingNodeId) {
this.graphSeamGoverningNodeId.delete(task.id);
}
if (thinkingLevel && this.graphSeamThinkingLevel.get(task.id) === thinkingLevel) {
this.graphSeamThinkingLevel.delete(task.id);
}
});
}
try {
@@ -6436,11 +6453,16 @@ export class TaskExecutor {
if (typeof governingNodeId === "string") {
this.graphSeamGoverningNodeId.set(seamTask.id, governingNodeId);
}
const seamThinkingLevel = context?.[SEAM_THINKING_LEVEL_CONTEXT_KEY];
if (typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel)) {
this.graphSeamThinkingLevel.set(seamTask.id, seamThinkingLevel as ThinkingLevel);
}
let result: { taskDone: boolean; modifiedFiles: string[] };
try {
result = await this.runImplementationPhase(seamTask);
} finally {
this.graphSeamGoverningNodeId.delete(seamTask.id);
this.graphSeamThinkingLevel.delete(seamTask.id);
}
if (result.taskDone) {
return { outcome: "success", value: "implemented" };
@@ -6563,6 +6585,7 @@ export class TaskExecutor {
// instance's; per-invocation set/delete here would race under parallel
// foreach (overwrite mid-build, or clear while the shared pass is live).
const stepGoverningNodeId = context[SEAM_GOVERNING_NODE_CONTEXT_KEY];
const seamThinkingLevel = context[SEAM_THINKING_LEVEL_CONTEXT_KEY];
const result: Awaited<ReturnType<typeof runTaskStep>> = await runTaskStep(
{
store: this.store,
@@ -6578,6 +6601,9 @@ export class TaskExecutor {
stepIndex,
active.instanceId,
typeof stepGoverningNodeId === "string" ? stepGoverningNodeId : undefined,
typeof seamThinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(seamThinkingLevel)
? (seamThinkingLevel as ThinkingLevel)
: undefined,
),
},
{ id: seamTask.id, steps: live.steps },
@@ -6660,7 +6686,16 @@ export class TaskExecutor {
defaultModelId: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: resolveExecutorThinkingLevel(detail.thinkingLevel, settings),
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-review model sessions honor per-node `config.thinkingLevel` before task and settings defaults.
*/
defaultThinkingLevel: resolveExecutorThinkingLevel(
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,
@@ -7579,6 +7614,13 @@ export class TaskExecutor {
const stepSkillName = executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim()
? cfg.skillName.trim()
: undefined;
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Graph model nodes can pin reasoning effort independently from modelProvider/modelId; carry only validated THINKING_LEVELS into the synthesized WorkflowStep.
*/
const stepThinkingLevel = typeof cfg.thinkingLevel === "string" && WORKFLOW_THINKING_LEVEL_SET.has(cfg.thinkingLevel)
? cfg.thinkingLevel as ThinkingLevel
: undefined;
const step: WorkflowStep = {
id: `graph:${node.id}`,
name: typeof cfg.name === "string" && cfg.name.trim() ? cfg.name : node.id,
@@ -7595,6 +7637,7 @@ export class TaskExecutor {
...(stepSkillName ? { skillName: stepSkillName } : {}),
...(cfg.requiresBrowser === true ? { requiresBrowser: true } : {}),
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
...(stepThinkingLevel ? { thinkingLevel: stepThinkingLevel } : {}),
};
if (cfg.summaryTarget === "task") {
(step as WorkflowStep & { summaryTarget?: "task" }).summaryTarget = "task";
@@ -9834,6 +9877,7 @@ export class TaskExecutor {
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, stepIdentityAgent, settings.defaultAgentPermissionPolicy),
// FNXC:McpConfig 2026-06-25-23:03: Per-step workflow sessions are an executor lane, so they inherit the task's resolved MCP set from the effective step identity agent and never re-read or log plaintext secret values.
mcpServers: await this.resolveMcpServers(stepIdentityAgent?.id),
workflowStepThinkingLevel: this.graphSeamThinkingLevel.get(task.id),
// Pass skill selection context from the main executor session
skillSelection: skillContext.skillSelectionContext,
// Pass agentStore and messageStore for delegation and messaging tools
@@ -10543,7 +10587,7 @@ export class TaskExecutor {
);
const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId;
const executorThinkingLevel = resolveExecutorThinkingLevel(detail.thinkingLevel, settings);
const executorThinkingLevel = resolveExecutorThinkingLevel(this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel, 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
@@ -15069,6 +15113,11 @@ 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.
*/
const workflowStepThinkingLevel = resolveExecutorThinkingLevel(workflowStep.thinkingLevel ?? task.thinkingLevel, settings);
const { session } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: workflowRuntimeHint,
@@ -15080,7 +15129,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
defaultModelId: modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: resolveExecutorThinkingLevel(task.thinkingLevel, settings),
defaultThinkingLevel: workflowStepThinkingLevel,
runAuditor: createRunAuditor(this.store, this.getRunContextFor(task.id)),
settings,
taskEnv: stepEnv,
@@ -15098,7 +15147,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
const workflowModelDetails = formatModelMarkerDetails(
describeModel(session),
resolveExecutorThinkingLevel(task.thinkingLevel, settings),
workflowStepThinkingLevel,
[
useOverride && attemptLabel === "primary" ? "workflow step override" : "",
attemptLabel === "fallback" ? "fallback after timeout" : "",

View File

@@ -135,6 +135,8 @@ export interface StepSessionExecutorOptions {
permanentAgentGating?: PermanentAgentGatingContext;
/** Optional resolved MCP servers to forward into workflow step sessions. */
mcpServers?: ResolvedMcpServerDefinition[];
/** Optional workflow node/step reasoning-effort override for all sessions in this graph-pinned step pass. */
workflowStepThinkingLevel?: string;
/** Task-scoped environment injected into non-git subprocesses. */
taskEnv?: NodeJS.ProcessEnv;
/**
@@ -1270,6 +1272,14 @@ export class StepSessionExecutor {
let session: AgentSession | null = null;
const localTelemetry = { agentLogger, trackingKey };
/*
* 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.
*/
const stepThinkingLevel = this.options.workflowStepThinkingLevel
?? (taskDetail.steps[stepIndex] as { thinkingLevel?: string } | undefined)?.thinkingLevel;
const effectiveThinkingLevel = resolveExecutorThinkingLevel(stepThinkingLevel ?? taskDetail.thinkingLevel, settings);
try {
// Get plugin tools from plugin runner if available
const pluginTools = this.options.pluginRunner?.getPluginTools() ?? [];
@@ -1345,7 +1355,7 @@ Follow instructions precisely and avoid unrelated changes.`,
defaultModelId: executorModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: resolveExecutorThinkingLevel(taskDetail.thinkingLevel, settings),
defaultThinkingLevel: effectiveThinkingLevel,
runAuditor: createRunAuditor(this.store, {
runId: generateSyntheticRunId("workflow-step", taskDetail.id),
// Column-agent attribution (U4): the effective column agent is the

View File

@@ -53,6 +53,8 @@ export {
// node still declaring `config.seam: "workflow-step"` is no longer a recognized
// seam: `resolveSeamName` throws a WorkflowIrError for it (fails loud, never a
// silent no-op).
export const SEAM_THINKING_LEVEL_CONTEXT_KEY = "workflow:seamThinkingLevel";
export type WorkflowSeamName =
| "planning"
| "execute"
@@ -112,6 +114,8 @@ export interface WorkflowLegacySeams {
export interface StepReviewConfig {
type: "plan" | "code";
model?: string;
/** Optional per-node reasoning-effort override for the review session. */
thinkingLevel?: string;
/** Single-writer rule (KTD-4): true when the node is inside a split branch, so
* the review is advisory-only — no projection write, no authoritative verdict. */
advisory?: boolean;
@@ -296,6 +300,15 @@ export function createPromptLikeHandler(
active.stepIndex,
node.id,
);
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-execute nodes carry per-node reasoning effort through workflow context so the implementation session can resolve node/step > task > settings precedence.
*/
if (typeof node.config?.thinkingLevel === "string") {
context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY] = node.config.thinkingLevel;
} else {
delete context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY];
}
return seams.stepExecute(context.task, context.context);
}
if (seam) {
@@ -303,6 +316,11 @@ export function createPromptLikeHandler(
// IS the seam node, so its declared column drives the binding. (Other seams
// — planning/review/merge/schedule — stamp it too; only execute reads it.)
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
if (typeof node.config?.thinkingLevel === "string") {
context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY] = node.config.thinkingLevel;
} else {
delete context.context[SEAM_THINKING_LEVEL_CONTEXT_KEY];
}
return seams[seam]!(context.task, context.context);
}
if (!runCustomNode) {
@@ -429,10 +447,17 @@ const STEP_REVIEW_UNAVAILABLE_RETRY_CAP = 2;
/** Resolve a step-review node's config (KTD-4). Defaults `type` to `code` (the
* enforcing review level — matches the legacy code-review authority). */
function resolveStepReviewConfig(node: WorkflowIrNode, advisory: boolean): StepReviewConfig {
const raw = (node.config ?? {}) as { type?: unknown; model?: unknown };
const raw = (node.config ?? {}) as { type?: unknown; model?: unknown; thinkingLevel?: unknown };
const type = raw.type === "plan" ? "plan" : "code";
const model = typeof raw.model === "string" ? raw.model : undefined;
return { type, model, advisory };
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* step-review nodes persist their own `config.thinkingLevel` (WorkflowNodeEditor); without
* reading it here the executor.ts seam's node-level-override precedence was dead code — the
* config object it receives never carried the node's pinned reasoning effort.
*/
const thinkingLevel = typeof raw.thinkingLevel === "string" ? raw.thinkingLevel : undefined;
return { type, model, thinkingLevel, advisory };
}
/**