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:
7
.changeset/fn-7771-workflow-node-thinking.md
Normal file
7
.changeset/fn-7771-workflow-node-thinking.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: [] }],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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") */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user