feat(engine): custom workflow nodes run as their column's agent

U3: per-run IR resolution feeds the core column-agent resolver at the
runCustomNode seam; override supersedes node agent/model/persona wholesale,
defer fills bare nodes only; adoption and fallback are audited via logEntry;
raw-CLI nodes log a skip. Also fixes the customInstructions persona drift —
node-level executor:"agent" persona injection now uses the typed
soul/instructionsText fields (KTD-6).
This commit is contained in:
gsxdsm
2026-06-04 23:53:22 -07:00
parent e421774f6a
commit d88bfc4c75
2 changed files with 354 additions and 6 deletions

View File

@@ -0,0 +1,217 @@
// Column-agent custom-node resolution (plan U3, R2/R3/R4/R8, KTD-2/KTD-3/KTD-6).
//
// `runGraphCustomNode` synthesizes a `WorkflowStep` and runs it on the proven
// WorkflowStep machinery. The seam wiring (maybeExecuteWorkflowGraph) resolves
// the per-node column-agent binding and threads it in as a parameter. These
// tests call `runGraphCustomNode` directly with that binding and assert the
// synthesized step's model/persona plus the audit log entries — mirroring the
// established executor harness (executor-workflow-step-scope.test.ts): build a
// real TaskExecutor over a mock store and spy on `executeWorkflowStep` /
// `executeScriptWorkflowStep` to capture the synthesized step.
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import type { WorkflowColumnAgent } from "@fusion/core";
function makeAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-col",
name: "Column Agent",
soul: "I am the senior reviewer.",
instructionsText: "Always be thorough.",
runtimeConfig: { executorProvider: "anthropic", executorModelId: "claude-col" },
...overrides,
};
}
function makeExecutor(store: ReturnType<typeof createMockStore>, agent: unknown | null) {
const agentStore = {
getAgent: vi.fn().mockResolvedValue(agent),
};
const executor = new TaskExecutor(store as any, "/tmp/test", { agentStore } as any);
return { executor, agentStore };
}
/** Spy both session-running paths; return the captured synthesized step. */
function spyStep(executor: TaskExecutor) {
const captured: { step?: any } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
vi.spyOn(executor as any, "executeScriptWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.step = args[1];
return { success: true, output: "ok" };
});
return captured;
}
function loggedLines(store: ReturnType<typeof createMockStore>): string[] {
return store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
}
const OVERRIDE: WorkflowColumnAgent = { agentId: "agent-col", mode: "override" };
const DEFER: WorkflowColumnAgent = { agentId: "agent-col", mode: "defer" };
describe("runGraphCustomNode column-agent resolution (plan U3)", () => {
beforeEach(() => {
resetExecutorMocks();
});
it("override column: node with own cfg.agentId runs as column agent (model+persona) and logs substitution+mode", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
const node = {
id: "review",
kind: "prompt",
column: "review",
config: {
executor: "agent",
agentId: "node-own-agent",
modelProvider: "openai",
modelId: "gpt-node",
prompt: "Review the diff.",
},
};
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// Column agent fetched (not the node's own agent).
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
// Column agent's model wins over the node's own pair.
expect(captured.step.modelProvider).toBe("anthropic");
expect(captured.step.modelId).toBe("claude-col");
// Column agent's persona (soul + instructionsText) prepended to the prompt.
expect(captured.step.prompt).toContain("I am the senior reviewer.");
expect(captured.step.prompt).toContain("Always be thorough.");
expect(captured.step.prompt).toContain("Review the diff.");
// Audit log records substitution + mode.
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (override)")),
).toBe(true);
});
it("defer column: node with own cfg.agentId keeps it; bare node adopts the column agent", async () => {
// (a) own agentId present → defer yields own settings, column agent untouched.
{
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const nodeOwnAgent = makeAgent({ id: "node-own-agent", soul: "node persona", instructionsText: "", runtimeConfig: { executorProvider: "openai", executorModelId: "gpt-node" } });
const { executor, agentStore } = makeExecutor(store, nodeOwnAgent);
const captured = spyStep(executor);
const node = {
id: "review",
kind: "prompt",
column: "review",
config: { executor: "agent", agentId: "node-own-agent", prompt: "Do it." },
};
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, DEFER);
// Own agent fetched, NOT the column agent.
expect(agentStore.getAgent).toHaveBeenCalledWith("node-own-agent");
expect(agentStore.getAgent).not.toHaveBeenCalledWith("agent-col");
expect(captured.step.modelProvider).toBe("openai");
expect(captured.step.modelId).toBe("gpt-node");
expect(
loggedLines(store).some((l) => l.includes("running as column agent")),
).toBe(false);
}
// (b) bare node (no own agent/model) → defer adopts the column agent.
{
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, DEFER);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-col");
expect(captured.step.modelProvider).toBe("anthropic");
expect(captured.step.modelId).toBe("claude-col");
expect(captured.step.prompt).toContain("I am the senior reviewer.");
expect(
loggedLines(store).some((l) => l.includes("running as column agent 'agent-col' (defer)")),
).toBe(true);
}
});
it("missing column agent in registry → logged, node falls back, step still executes", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
// agentStore returns null for the column agent.
const { executor } = makeExecutor(store, null);
const captured = spyStep(executor);
const node = { id: "review", kind: "prompt", column: "review", config: { prompt: "Plain." } };
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// No column-agent model adopted (agent missing) → step has no model pair.
expect(captured.step.modelProvider).toBeUndefined();
expect(captured.step.modelId).toBeUndefined();
expect(
loggedLines(store).some((l) => l.includes("column agent 'agent-col' not found")),
).toBe(true);
});
it("node with no declared column → untouched resolution even when a binding is passed as undefined", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
const { executor, agentStore } = makeExecutor(store, makeAgent());
const captured = spyStep(executor);
// No declared column → the seam wiring resolves no binding (undefined).
const node = {
id: "review",
kind: "prompt",
config: { executor: "model", modelProvider: "openai", modelId: "gpt-node", prompt: "Plain." },
};
await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, undefined);
// Column agent never fetched; node's own model preserved.
expect(agentStore.getAgent).not.toHaveBeenCalled();
expect(captured.step.modelProvider).toBe("openai");
expect(captured.step.modelId).toBe("gpt-node");
expect(loggedLines(store).some((l) => l.includes("column agent"))).toBe(false);
});
it("CLI-executor node (raw command) in override column → mechanics unchanged, audit notes the skip", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({ id: "FN-001", worktree: "/tmp/wt", log: [] } as any);
store.isWorkflowCliCommandApproved = vi.fn().mockResolvedValue(true);
const { executor, agentStore } = makeExecutor(store, makeAgent());
// Raw CLI runs runRawCliCommand, not a session — stub it.
const rawSpy = vi.spyOn(executor as any, "runRawCliCommand").mockResolvedValue({ success: true });
const node = {
id: "lint",
kind: "script",
column: "review",
config: { executor: "cli", cliCommand: "npm run lint", cliSkipApproval: true, prompt: "" },
};
const result = await (executor as any).runGraphCustomNode(node, { id: "FN-001" }, {}, OVERRIDE);
expect(result.outcome).toBe("success");
// Raw CLI mechanics unchanged: command still ran.
expect(rawSpy).toHaveBeenCalled();
// Column agent NOT fetched/adopted for raw CLI execution.
expect(agentStore.getAgent).not.toHaveBeenCalled();
// Audit explains the skip.
expect(
loggedLines(store).some(
(l) =>
l.includes("column agent 'agent-col' (override) not applied") &&
l.includes("raw CLI execution runs no session"),
),
).toBe(true);
});
});

View File

@@ -9,8 +9,8 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
import { existsSync, 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 } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask } from "@fusion/core";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent } from "@fusion/core";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent } from "@fusion/core";
import {
buildWorkflowObservationFromTask,
buildWorkflowObservation,
@@ -3320,11 +3320,27 @@ export class TaskExecutor {
// Definition load failure — leave undefined; deps/runner use fallbacks.
}
// Column-agent binding (plan U3): the IR is NOT in scope inside
// runGraphCustomNode, so resolve it here (the seam wiring) where the
// selection is known, and thread a per-node binding lookup into the custom
// node callback. Resolve the IR ONCE per run (never an uncached per-node
// fetch — mirrors the hold-release.ts irCache posture); best-effort, so a
// resolution failure simply yields no bindings (R8 graceful degradation).
let columnAgentIr: WorkflowIr | undefined;
try {
columnAgentIr = await resolveWorkflowIrForTask(this.store, task.id);
} catch {
columnAgentIr = undefined;
}
const resolveBindingForNode = (nodeId: string): WorkflowColumnAgent | undefined =>
columnAgentIr ? resolveColumnAgentBinding(columnAgentIr, nodeId) : undefined;
const runner = new WorkflowGraphTaskRunner({
store: this.store,
runId: resolvedRunId,
seams: this.createGraphSeams(settings),
runCustomNode: (node, nodeTask) => this.runGraphCustomNode(node, nodeTask, settings),
runCustomNode: (node, nodeTask) =>
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)),
onEvent: (event) => executorLog.log(`[workflow-graph] ${event.type} ${event.taskId}: ${event.detail}`),
// Wire SQLite-backed per-branch persistence in production (#1407): the
// executor writes each branch's currentNodeId/status to
@@ -4495,11 +4511,77 @@ export class TaskExecutor {
}
}
/** Run a custom (non-seam) graph node on the proven WorkflowStep machinery. */
/** Build the persona prefix for an agent from its TYPED identity fields (KTD-6).
* Reads `soul` and `instructionsText` — the fields the `Agent` type actually
* exposes (`packages/core/src/types.ts`) — and joins them. The custom-node
* `"agent"` branch historically read a non-existent `customInstructions`
* field (silently undefined); this is the single consistent source used by
* both the node-agent and column-agent paths. */
private buildAgentPersona(agent: Agent): string | undefined {
const parts = [agent.soul, agent.instructionsText]
.map((p) => (typeof p === "string" ? p.trim() : ""))
.filter((p) => p.length > 0);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
/** Fetch the column agent and surface its model + persona for adoption by a
* custom node (plan U3). Best-effort, mirroring the node-agent posture at the
* `"agent"` branch: on null/throw, log and return undefined so the caller
* falls back to the node's own/default resolution (R8). Emits a logEntry
* naming the substitution and mode so the audit trail explains who ran. */
private async adoptColumnAgentForNode(
node: WorkflowIrNode,
live: TaskDetail,
columnAgentId: string,
mode: WorkflowColumnAgent["mode"] | undefined,
): Promise<{ modelProvider?: string; modelId?: string; persona?: string } | undefined> {
try {
const agent = await this.options.agentStore?.getAgent(columnAgentId);
if (!agent) {
await this.store.logEntry(
live.id,
`Workflow node '${node.id}': column agent '${columnAgentId}' not found — falling back to node/default resolution`,
undefined,
this.getRunContextFor(live.id),
);
return undefined;
}
const rc = (agent.runtimeConfig ?? {}) as { executorProvider?: string; executorModelId?: string };
await this.store.logEntry(
live.id,
`Workflow node '${node.id}': running as column agent '${columnAgentId}' (${mode})`,
undefined,
this.getRunContextFor(live.id),
);
return {
modelProvider: rc.executorProvider,
modelId: rc.executorModelId,
persona: this.buildAgentPersona(agent),
};
} catch {
// Agent lookup is best-effort; fall back to node/default resolution (R8).
await this.store.logEntry(
live.id,
`Workflow node '${node.id}': column agent '${columnAgentId}' lookup failed — falling back to node/default resolution`,
undefined,
this.getRunContextFor(live.id),
);
return undefined;
}
}
/** Run a custom (non-seam) graph node on the proven WorkflowStep machinery.
*
* `columnBinding` (plan U3) is the agent binding governing this node's
* declared column, resolved by the seam wiring in maybeExecuteWorkflowGraph
* (the IR is not in scope here). When present, the core resolver decides
* whether the column agent supersedes (override) or defers to the node's own
* `cfg.agentId`/model pair — never a reimplemented precedence. */
private async runGraphCustomNode(
node: WorkflowIrNode,
nodeTask: TaskDetail,
settings: Settings,
columnBinding?: WorkflowColumnAgent,
): Promise<WorkflowNodeResult> {
const cfg = node.config ?? {};
const live = await this.store.getTask(nodeTask.id);
@@ -4536,6 +4618,51 @@ export class TaskExecutor {
let modelProvider = typeof cfg.modelProvider === "string" && cfg.modelProvider.trim() ? cfg.modelProvider : undefined;
let modelId = typeof cfg.modelId === "string" && cfg.modelId.trim() ? cfg.modelId : undefined;
// ── Column-agent binding (plan U3, KTD-2/KTD-3) ──────────────────────────
// When the node's declared column names an agent, the CORE resolver decides
// whether the column agent supersedes (override) or defers to the node's own
// settings — we never reimplement precedence. The node's own `cfg.agentId`
// and complete model pair feed the resolver as "own settings" (KTD-5).
const ownModelComplete = Boolean(modelProvider && modelId);
const effective = resolveEffectiveAgent({
binding: columnBinding,
ownAgentId: typeof cfg.agentId === "string" && cfg.agentId.trim() ? cfg.agentId.trim() : undefined,
ownModelProvider: ownModelComplete ? modelProvider : undefined,
ownModelId: ownModelComplete ? modelId : undefined,
});
// The effective executor identity: a column agent supersedes the node's own
// `executor: "agent"` adoption wholesale (identity + model + persona). When
// the resolver yields the column agent, we run the column-agent adoption
// path below INSTEAD of the node's own agent branch.
const columnAgentId = effective.source === "column-agent" ? effective.agentId : undefined;
const columnAgentMode = columnBinding?.mode;
if (columnAgentId) {
// CLI executor with a raw command runs no session — the column agent
// cannot contribute a model/persona to raw process execution, so it is a
// no-op here. Log the skip so the audit trail explains why the column
// agent did not apply (plan U3). Skill / model / script-via-session nodes
// DO adopt the column agent below.
if (executorKind === "cli" && rawCliCommand) {
await this.store.logEntry(
live.id,
`Workflow node '${node.id}': column agent '${columnAgentId}' (${columnAgentMode}) not applied — raw CLI execution runs no session`,
undefined,
this.getRunContextFor(live.id),
);
} else {
const adopted = await this.adoptColumnAgentForNode(node, live, columnAgentId, columnAgentMode);
if (adopted) {
modelProvider = adopted.modelProvider ?? modelProvider;
modelId = adopted.modelId ?? modelId;
if (adopted.persona) prompt = `${adopted.persona}\n\n${prompt}`;
}
// Whether or not the agent resolved, the column agent SUPERSEDES the
// node's own `executor: "agent"` adoption — skip that branch so we never
// blend the column agent's model with the node agent's persona.
}
}
// Executor kinds for prompt nodes:
// - "model" (default): run the prompt on the configured/override model.
// - "agent": run as a named agent — adopt its model and persona prompt.
@@ -4543,14 +4670,18 @@ export class TaskExecutor {
// - "cli": run a named project script with the prompt passed via env
// (FUSION_NODE_PROMPT). Named scripts only — raw commands are
// never accepted from node config.
if (executorKind === "agent" && typeof cfg.agentId === "string" && cfg.agentId.trim()) {
if (!columnAgentId && executorKind === "agent" && typeof cfg.agentId === "string" && cfg.agentId.trim()) {
try {
const agent = await this.options.agentStore?.getAgent(cfg.agentId);
if (agent) {
const rc = (agent.runtimeConfig ?? {}) as { executorProvider?: string; executorModelId?: string };
modelProvider = rc.executorProvider ?? modelProvider;
modelId = rc.executorModelId ?? modelId;
const persona = (agent as { customInstructions?: string }).customInstructions;
// KTD-6: read the TYPED persona fields (soul / instructionsText), not
// the non-existent `customInstructions` (which was silently undefined,
// so node-agent persona injection never actually fired). Same fields
// the column-agent path uses — one consistent persona source.
const persona = this.buildAgentPersona(agent);
if (persona) prompt = `${persona}\n\n${prompt}`;
} else {
await this.store.logEntry(live.id, `Workflow node '${node.id}': agent '${cfg.agentId}' not found — using default model`, undefined, this.getRunContextFor(live.id));