diff --git a/packages/core/src/__tests__/column-agent-resolver.test.ts b/packages/core/src/__tests__/column-agent-resolver.test.ts new file mode 100644 index 0000000000..5fd19903ee --- /dev/null +++ b/packages/core/src/__tests__/column-agent-resolver.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment node +// +// column-agent plan U2 — the shared effective-agent resolver. +// +// Proves the full mode × own-settings matrix (KTD-2/KTD-5): +// - override × own-settings present → column agent; override × bare → column. +// - defer × own agentId → own; defer × complete model pair → own; +// defer × lone provider (incomplete pair, no agentId) → column agent wins. +// - no node.column / column without binding → own-settings or none. +// - foreach instance inheritance + template-node own column wins. +// - parseInstanceNodeId round-trip incl. templateNodeId containing ':'. +// - two graphs differing only in binding diverge. + +import { describe, expect, it } from "vitest"; +import { + instanceNodeId, + parseInstanceNodeId, + resolveColumnAgentBinding, + resolveEffectiveAgent, +} from "../column-agent-resolver.js"; +import type { + WorkflowColumnAgent, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrV2, +} from "../workflow-ir-types.js"; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[] = [], +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges }; +} + +const overrideBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "override" }; +const deferBinding: WorkflowColumnAgent = { agentId: "col-agent", mode: "defer" }; + +describe("resolveEffectiveAgent — precedence matrix (U2)", () => { + it("override × own settings present → column agent", () => { + expect( + resolveEffectiveAgent({ + binding: overrideBinding, + ownAgentId: "own-agent", + ownModelProvider: "anthropic", + ownModelId: "claude-x", + }), + ).toEqual({ source: "column-agent", agentId: "col-agent" }); + }); + + it("override × bare → column agent", () => { + expect(resolveEffectiveAgent({ binding: overrideBinding })).toEqual({ + source: "column-agent", + agentId: "col-agent", + }); + }); + + it("defer × own agentId only → own settings win", () => { + expect(resolveEffectiveAgent({ binding: deferBinding, ownAgentId: "own-agent" })).toEqual({ + source: "own-settings", + }); + }); + + it("defer × complete own model pair only → own settings win", () => { + expect( + resolveEffectiveAgent({ + binding: deferBinding, + ownModelProvider: "anthropic", + ownModelId: "claude-x", + }), + ).toEqual({ source: "own-settings" }); + }); + + it("defer × lone provider (incomplete pair, no agentId) → column agent wins", () => { + // An incomplete pair does NOT count as own settings (KTD-5; matches + // resolveExecutorSessionModel's both-present rule). + expect( + resolveEffectiveAgent({ binding: deferBinding, ownModelProvider: "anthropic" }), + ).toEqual({ source: "column-agent", agentId: "col-agent" }); + }); + + it("defer × bare → column agent wins", () => { + expect(resolveEffectiveAgent({ binding: deferBinding })).toEqual({ + source: "column-agent", + agentId: "col-agent", + }); + }); + + it("no binding × own settings → own-settings", () => { + expect(resolveEffectiveAgent({ binding: undefined, ownAgentId: "own-agent" })).toEqual({ + source: "own-settings", + }); + }); + + it("no binding × bare → none", () => { + expect(resolveEffectiveAgent({ binding: undefined })).toEqual({ source: "none" }); + }); +}); + +describe("resolveColumnAgentBinding — lookup (U2)", () => { + const ir = v2( + [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], agent: overrideBinding }, + ], + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "plain", kind: "prompt", column: "todo", config: { prompt: "do" } }, + { id: "nocol", kind: "prompt", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + ); + + it("resolves the bound column's agent for a node declared in it", () => { + expect(resolveColumnAgentBinding(ir, "work")).toEqual(overrideBinding); + }); + + it("returns undefined for a node in a column without a binding", () => { + expect(resolveColumnAgentBinding(ir, "plain")).toBeUndefined(); + }); + + it("returns undefined for a node with no declared column, even when other columns bind", () => { + expect(resolveColumnAgentBinding(ir, "nocol")).toBeUndefined(); + }); + + it("returns undefined for an unknown node id", () => { + expect(resolveColumnAgentBinding(ir, "ghost")).toBeUndefined(); + }); +}); + +describe("resolveColumnAgentBinding — foreach instance inheritance (U2)", () => { + function foreachIr(opts: { + foreachColumn?: string; + templateNodeColumn?: string; + reviewAgent?: WorkflowColumnAgent; + todoAgent?: WorkflowColumnAgent; + }): WorkflowIrV2 { + return v2( + [ + { id: "todo", name: "todo", traits: [], ...(opts.todoAgent ? { agent: opts.todoAgent } : {}) }, + { id: "review", name: "review", traits: [], ...(opts.reviewAgent ? { agent: opts.reviewAgent } : {}) }, + ], + [ + { id: "start", kind: "start" }, + { + id: "fe", + kind: "foreach", + ...(opts.foreachColumn ? { column: opts.foreachColumn } : {}), + config: { + source: "task-steps", + template: { + nodes: [ + { + id: "se", + kind: "prompt", + ...(opts.templateNodeColumn ? { column: opts.templateNodeColumn } : {}), + config: { seam: "step-execute" }, + }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [], + }, + }, + }, + { id: "end", kind: "end" }, + ], + ); + } + + it("instance node inherits the enclosing foreach node's column binding", () => { + const ir = foreachIr({ foreachColumn: "review", reviewAgent: overrideBinding }); + const nodeId = instanceNodeId("fe", 0, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(overrideBinding); + }); + + it("template node's own declared column wins over inheritance", () => { + const ir = foreachIr({ + foreachColumn: "review", + reviewAgent: overrideBinding, + templateNodeColumn: "todo", + todoAgent: deferBinding, + }); + const nodeId = instanceNodeId("fe", 1, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toEqual(deferBinding); + }); + + it("instance node with no foreach column and no template column → no binding", () => { + const ir = foreachIr({ reviewAgent: overrideBinding }); + const nodeId = instanceNodeId("fe", 0, "se"); + expect(resolveColumnAgentBinding(ir, nodeId)).toBeUndefined(); + }); +}); + +describe("instanceNodeId / parseInstanceNodeId round-trip (U2)", () => { + it("round-trips a simple instance id", () => { + const id = instanceNodeId("fe", 3, "se"); + expect(id).toBe("fe#3:se"); + expect(parseInstanceNodeId(id)).toEqual({ + foreachNodeId: "fe", + stepIndex: 3, + templateNodeId: "se", + }); + }); + + it("round-trips when the templateNodeId itself contains ':'", () => { + // Defensive: split on the FIRST ':' of the remainder, keep the rest. + const id = instanceNodeId("fe", 2, "ns:inner:node"); + expect(id).toBe("fe#2:ns:inner:node"); + expect(parseInstanceNodeId(id)).toEqual({ + foreachNodeId: "fe", + stepIndex: 2, + templateNodeId: "ns:inner:node", + }); + }); + + it("returns undefined for non-instance ids", () => { + expect(parseInstanceNodeId("plain")).toBeUndefined(); + expect(parseInstanceNodeId("fe#3")).toBeUndefined(); + expect(parseInstanceNodeId("fe#:se")).toBeUndefined(); + expect(parseInstanceNodeId("fe#x:se")).toBeUndefined(); + }); +}); + +describe("two graphs differing only in binding diverge (U2)", () => { + function graph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 { + return v2( + [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) }, + ], + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + ); + } + + it("the effective agent diverges when only the binding differs", () => { + const bound = graph(overrideBinding); + const unbound = graph(); + // Same node, same own settings, different graph binding → different verdict. + const own = { ownAgentId: "task-agent" } as const; + const boundResult = resolveEffectiveAgent({ + binding: resolveColumnAgentBinding(bound, "work"), + ...own, + }); + const unboundResult = resolveEffectiveAgent({ + binding: resolveColumnAgentBinding(unbound, "work"), + ...own, + }); + expect(boundResult).toEqual({ source: "column-agent", agentId: "col-agent" }); + expect(unboundResult).toEqual({ source: "own-settings" }); + expect(boundResult).not.toEqual(unboundResult); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir-column-agent.test.ts b/packages/core/src/__tests__/workflow-ir-column-agent.test.ts new file mode 100644 index 0000000000..a0c73512c5 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-column-agent.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment node +// +// column-agent plan U1 — IR schema, validation, and parity registration for the +// per-column permanent-agent binding (`WorkflowIrColumn.agent`). +// +// Proves: +// - a column `agent` binding parses + round-trips; absent field parses as today. +// - typed validation errors for empty agentId / missing mode / unknown mode. +// - v1 upgrade synthesizes columns with NO `agent` field (absent, not null). +// - a template-subgraph node with a dangling `column` is a typed error. +// - the default workflow IR round-trips byte-identically; a graph carrying a +// column agent is flagged non-default (forces v2 — KTD-1/R9). +// - a removed binding omits the `agent` key entirely on serialization. + +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, +} from "../workflow-ir.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { + WorkflowColumnAgent, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrV1, + WorkflowIrV2, +} from "../workflow-ir-types.js"; + +const baseColumns: WorkflowIrV2["columns"] = [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [] }, +]; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], + extra: Partial = {}, +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges, ...extra }; +} + +/** start → work → end, work in the second column. */ +function simpleGraph(reviewAgent?: WorkflowColumnAgent): WorkflowIrV2 { + const columns: WorkflowIrV2["columns"] = [ + { id: "todo", name: "todo", traits: [] }, + { id: "review", name: "review", traits: [], ...(reviewAgent ? { agent: reviewAgent } : {}) }, + ]; + return v2( + columns, + [ + { id: "start", kind: "start", column: "todo" }, + { id: "work", kind: "prompt", column: "review", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "review" }, + ], + [ + { from: "start", to: "work" }, + { from: "work", to: "end" }, + ], + ); +} + +describe("column-agent IR schema + validation (U1)", () => { + it("parses and round-trips a column with a defer agent binding", () => { + const ir = simpleGraph({ agentId: "agent-001", mode: "defer" }); + const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2; + const col = parsed.columns.find((c) => c.id === "review")!; + expect(col.agent).toEqual({ agentId: "agent-001", mode: "defer" }); + }); + + it("parses identically to today when no agent field is present", () => { + const ir = simpleGraph(); + const parsed = parseWorkflowIr(serializeWorkflowIr(ir)) as WorkflowIrV2; + const col = parsed.columns.find((c) => c.id === "review")!; + expect("agent" in col).toBe(false); + }); + + it("rejects an empty agentId (typed error naming the column)", () => { + const ir = simpleGraph({ agentId: "", mode: "defer" }); + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*non-empty agentId/); + }); + + it("rejects a missing mode", () => { + const ir = simpleGraph({ agentId: "agent-001" } as unknown as WorkflowColumnAgent); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/); + }); + + it("rejects an unknown mode value", () => { + const ir = simpleGraph({ agentId: "agent-001", mode: "always" as "defer" }); + expect(() => parseWorkflowIr(ir)).toThrow(/column 'review'.*mode must be/); + }); + + it("v1 upgrade synthesizes columns with no agent field (absent, not null)", () => { + const v1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "p", kind: "prompt", config: { prompt: "hi" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "p" }, + { from: "p", to: "end" }, + ], + }; + const upgraded = parseWorkflowIr(v1) as WorkflowIrV2; + for (const col of upgraded.columns) { + expect("agent" in col).toBe(false); + } + // And serialization carries no `agent` key at all. + expect(serializeWorkflowIr(upgraded)).not.toContain('"agent"'); + }); + + it("rejects a foreach template node whose column does not resolve (typed, names node)", () => { + const ir = v2( + baseColumns, + [ + { id: "start", kind: "start" }, + { + id: "ps", + kind: "parse-steps", + config: { artifact: "PROMPT.md", parser: "step-headings" }, + }, + { + id: "fe", + kind: "foreach", + config: { + source: "task-steps", + template: { + nodes: [ + // Dangling column reference on a template node. + { id: "se", kind: "prompt", column: "nope", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [ + { from: "se", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ], + }, + }, + }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "ps" }, + { from: "ps", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/node 'se' references undefined column 'nope'/); + }); + + it("accepts a foreach template node whose column resolves to a declared column", () => { + const ir = v2( + baseColumns, + [ + { id: "start", kind: "start" }, + { + id: "ps", + kind: "parse-steps", + config: { artifact: "PROMPT.md", parser: "step-headings" }, + }, + { + id: "fe", + kind: "foreach", + column: "review", + config: { + source: "task-steps", + template: { + nodes: [ + { id: "se", kind: "prompt", column: "todo", config: { seam: "step-execute" } }, + { id: "rev", kind: "step-review", config: { type: "code" } }, + { id: "exit", kind: "prompt" }, + ], + edges: [ + { from: "se", to: "rev" }, + { from: "rev", to: "exit", condition: "outcome:approve" }, + { from: "rev", to: "se", condition: "outcome:revise", kind: "rework" }, + ], + }, + }, + }, + { id: "end", kind: "end" }, + ], + [ + { from: "start", to: "ps" }, + { from: "ps", to: "fe" }, + { from: "fe", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + +describe("column-agent parity registration (U1, R9)", () => { + it("default workflow IR round-trips byte-identically", () => { + const serialized = serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR); + const reparsed = parseWorkflowIr(serialized); + expect(serializeWorkflowIr(reparsed)).toBe(serialized); + }); + + it("a graph carrying a column agent is flagged non-default (forces v2)", () => { + // A pure default-shaped graph downgrades to v1; adding an agent binding must + // keep it v2 (the v2-only-feature gate registers the field). + const bound = simpleGraph({ agentId: "agent-001", mode: "override" }); + expect(downgradeIrToV1IfPure(bound).version).toBe("v2"); + }); + + it("serialization of a column whose binding was removed omits the key entirely", () => { + const bound = simpleGraph({ agentId: "agent-001", mode: "defer" }); + const col = bound.columns.find((c) => c.id === "review")!; + delete col.agent; + const serialized = serializeWorkflowIr(bound); + expect(serialized).not.toContain('"agent"'); + const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2; + expect("agent" in reparsed.columns.find((c) => c.id === "review")!).toBe(false); + }); +}); diff --git a/packages/core/src/column-agent-resolver.ts b/packages/core/src/column-agent-resolver.ts new file mode 100644 index 0000000000..6ec7dc579b --- /dev/null +++ b/packages/core/src/column-agent-resolver.ts @@ -0,0 +1,186 @@ +/** + * Column-agent effective resolution (column-agent plan KTD-2). + * + * One shared resolver in `@fusion/core` consumed by every reader (the three engine + * resolution sites and the dashboard write-validation route) so engine and route + * can never drift — the route/engine predicate-drift learning + * (`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`). + * + * Two pure functions: + * - `resolveColumnAgentBinding(ir, nodeId)` — declared-column lookup with foreach + * template inheritance — answers "which column binding (if any) governs this + * node's work?". + * - `resolveEffectiveAgent(...)` — defer/override precedence as EXPLICIT named + * branches (never a `??` effective-value collapse), per the per-task + * auto-merge-override learning + * (`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`). + * Returns a discriminated result so callers and audit logs can state *why* an + * agent was chosen. + * + * This module must stay DI-clean: `@fusion/core` never imports from `@fusion/engine`. + */ + +import type { WorkflowColumnAgent, WorkflowForeachConfig, WorkflowIr } from "./workflow-ir-types.js"; + +// ── Foreach instance node-id ownership (column-agent plan KTD-2) ────────────── +// The instance-id FORMAT (`#:`) now has +// exactly one owner here in core. The engine re-points its import (was +// `workflow-graph-foreach.ts`). The format itself is unchanged. + +/** Materialize a deterministic foreach instance node id (step-inversion KTD-3). + * Pure, no IR mutation. Format: `#:`. */ +export function instanceNodeId( + foreachNodeId: string, + stepIndex: number, + templateNodeId: string, +): string { + return `${foreachNodeId}#${stepIndex}:${templateNodeId}`; +} + +/** Parsed components of a foreach instance node id. */ +export interface ParsedInstanceNodeId { + foreachNodeId: string; + stepIndex: number; + templateNodeId: string; +} + +/** Parse a foreach instance node id back into its components, or `undefined` when + * `nodeId` is not in instance form. Defensive against `templateNodeId` itself + * containing `:` — split on the FIRST `#`, then the FIRST `:` of the remainder, + * and keep everything after that as the template node id. The `templateNodeId` is + * not sanitized against `:`, so a greedy/last-delimiter split would corrupt it. */ +export function parseInstanceNodeId(nodeId: string): ParsedInstanceNodeId | undefined { + const hashIndex = nodeId.indexOf("#"); + if (hashIndex < 0) return undefined; + const foreachNodeId = nodeId.slice(0, hashIndex); + const remainder = nodeId.slice(hashIndex + 1); + const colonIndex = remainder.indexOf(":"); + if (colonIndex < 0) return undefined; + const stepIndexRaw = remainder.slice(0, colonIndex); + const templateNodeId = remainder.slice(colonIndex + 1); + if (foreachNodeId === "" || templateNodeId === "") return undefined; + // stepIndex must be a non-negative integer; reject anything else as non-instance. + if (!/^\d+$/.test(stepIndexRaw)) return undefined; + const stepIndex = Number(stepIndexRaw); + return { foreachNodeId, stepIndex, templateNodeId }; +} + +// ── Binding lookup ─────────────────────────────────────────────────────────── + +/** Index a graph's top-level nodes by id (handles v1 + v2 shapes). */ +function topLevelNodesById(ir: WorkflowIr): Map { + return new Map(ir.nodes.map((n) => [n.id, n])); +} + +/** Resolve the agent binding (if any) that governs the work of `nodeId`. + * + * A column WITHOUT an `agent` field yields `undefined` — that, not "column + * undeclared," is the operative guarantee, since v1→v2 upgrade synthesizes a + * column for every node (column-agent plan KTD-2). + * + * Foreach instance ids (`#:`) resolve against the + * ENCLOSING foreach node's column, but a template node that declares its OWN + * `column` wins over inheritance (R4). */ +export function resolveColumnAgentBinding( + ir: WorkflowIr, + nodeId: string, +): WorkflowColumnAgent | undefined { + // v1 graphs have no columns and therefore no bindings. (Callers normally parse + // to v2 first, but stay defensive.) + if (ir.version !== "v2") return undefined; + + const columnsById = new Map(ir.columns.map((c) => [c.id, c])); + const bindingForColumn = (columnId: string | undefined): WorkflowColumnAgent | undefined => { + if (columnId === undefined) return undefined; + return columnsById.get(columnId)?.agent; + }; + + const nodesById = topLevelNodesById(ir); + + // Direct (top-level) node. + const direct = nodesById.get(nodeId); + if (direct) { + return bindingForColumn(direct.column); + } + + // Foreach instance node: resolve against the enclosing foreach, honoring a + // template node's own declared column. + const parsed = parseInstanceNodeId(nodeId); + if (!parsed) return undefined; + + const foreachNode = nodesById.get(parsed.foreachNodeId); + if (!foreachNode || foreachNode.kind !== "foreach") return undefined; + + const cfg = foreachNode.config as Partial | undefined; + const templateNodes = cfg?.template?.nodes ?? []; + const templateNode = templateNodes.find((n) => n.id === parsed.templateNodeId); + + // Template node's own column wins; otherwise inherit the foreach node's column. + if (templateNode?.column !== undefined) { + return bindingForColumn(templateNode.column); + } + return bindingForColumn(foreachNode.column); +} + +// ── Effective-agent precedence (defer / override) ──────────────────────────── + +/** Inputs to the effective-agent decision. `ownAgentId` is the work's own agent + * identity (node `cfg.agentId` or `task.assignedAgentId`); `ownModelProvider` / + * `ownModelId` are the work's own model pair (node cfg or task model fields). */ +export interface EffectiveAgentInput { + /** The binding governing this node, from `resolveColumnAgentBinding`. */ + binding: WorkflowColumnAgent | undefined; + /** The work's own agent identity, if any. */ + ownAgentId?: string; + /** The work's own model provider, if any. */ + ownModelProvider?: string; + /** The work's own model id, if any. */ + ownModelId?: string; +} + +/** Discriminated result of effective-agent resolution: callers and audit logs can + * state *why* an agent was (or was not) chosen (column-agent plan KTD-2). */ +export type EffectiveAgentResult = + | { source: "column-agent"; agentId: string } + | { source: "own-settings" } + | { source: "none" }; + +/** Does the work carry "own settings" that suppress a `defer` column agent + * (column-agent plan KTD-5)? All-or-nothing: an own agent identity OR a COMPLETE + * modelProvider+modelId pair counts. A lone provider with no modelId and no + * agentId does NOT count — matching `resolveExecutorSessionModel`'s both-present + * rule (`packages/engine/src/agent-session-helpers.ts:147-150`). */ +function hasOwnSettings(input: EffectiveAgentInput): boolean { + const hasOwnAgent = typeof input.ownAgentId === "string" && input.ownAgentId !== ""; + const hasCompletePair = + typeof input.ownModelProvider === "string" && + input.ownModelProvider !== "" && + typeof input.ownModelId === "string" && + input.ownModelId !== ""; + return hasOwnAgent || hasCompletePair; +} + +/** Decide the effective agent for a node's work using the two EXPLICIT named rules + * (column-agent plan KTD-2/KTD-5): + * - No binding → `own-settings` if the work has any, else `none`. + * - `override` → the column agent ALWAYS (identity + model + persona). + * - `defer` → the column agent ONLY when the work has no own settings; otherwise + * own settings win. + * No `??` collapse: each branch is named so audit can explain the choice. */ +export function resolveEffectiveAgent(input: EffectiveAgentInput): EffectiveAgentResult { + const { binding } = input; + + if (!binding) { + return hasOwnSettings(input) ? { source: "own-settings" } : { source: "none" }; + } + + if (binding.mode === "override") { + return { source: "column-agent", agentId: binding.agentId }; + } + + // mode === "defer": column agent only when the work carries no own settings. + if (hasOwnSettings(input)) { + return { source: "own-settings" }; + } + return { source: "column-agent", agentId: binding.agentId }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..c14ef74fde 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -60,6 +60,7 @@ export type { WorkflowIrNodeKind, WorkflowIrColumn, WorkflowIrColumnTrait, + WorkflowColumnAgent, WorkflowHoldRelease, WorkflowJoinMode, WorkflowJoinBranchFailure, @@ -71,6 +72,17 @@ export type { WorkflowFieldOption, WorkflowFieldRender, } from "./workflow-ir-types.js"; +export { + instanceNodeId, + parseInstanceNodeId, + resolveColumnAgentBinding, + resolveEffectiveAgent, +} from "./column-agent-resolver.js"; +export type { + ParsedInstanceNodeId, + EffectiveAgentInput, + EffectiveAgentResult, +} from "./column-agent-resolver.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index b96e538fb5..b3d5765367 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -105,11 +105,32 @@ export interface WorkflowIrColumnTrait { config?: Record; } +/** Per-column permanent-agent binding (column-agent plan KTD-1). A column may name + * one agent from the registry plus a mode that decides precedence against + * node-level / task-level agent and model settings: + * - `defer`: the column agent applies only when the work carries no own settings + * (no agent identity and no complete modelProvider+modelId pair — KTD-5). + * - `override`: the column agent supersedes node/task settings wholesale. + * This is execution identity (consumed by the executor's session-building paths), + * not a board-transition trait — hence a first-class typed field, not a trait + * config blob (KTD-1). Agent *existence* is not an IR concern (no agent store at + * this layer); it is enforced at write time (route) and falls back at read time. */ +export interface WorkflowColumnAgent { + /** Registry agent id that staffs the column. Non-empty. */ + agentId: string; + /** Precedence mode against node/task settings. */ + mode: "defer" | "override"; +} + /** A workflow-defined board column. */ export interface WorkflowIrColumn { id: string; name: string; traits: WorkflowIrColumnTrait[]; + /** Optional permanent-agent binding (column-agent plan KTD-1). Additive and + * omitted entirely when unset — never serialized as `agent: null` — so legacy + * and default workflows stay byte-identical (R9). */ + agent?: WorkflowColumnAgent; } /** Release conditions for a `hold` node (KTD-2, R3). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 6a3cc5eff8..793c4e30ae 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -271,7 +271,11 @@ function reachableFrom( * - rework edges legal only when both endpoints are inside this template; * - step-review verdict routing rules (KTD-4). */ -function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set): void { +function validateForeach( + node: WorkflowIrNode, + topLevelNodeIds: Set, + columnIds: Set, +): void { const cfg = node.config as Partial | undefined; if (!cfg || cfg.source !== "task-steps") { throw new WorkflowIrError( @@ -341,13 +345,20 @@ function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set): vo ); } - // No nested foreach. + // No nested foreach. Also: a template node's declared `column` must resolve to a + // top-level column id (column-agent plan KTD-1) — otherwise a dangling reference + // is a silent no-binding no-op at runtime instead of a typed authoring error. for (const inner of templateNodes) { if (inner.kind === "foreach") { throw new WorkflowIrError( `foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`, ); } + if (inner.column !== undefined && !columnIds.has(inner.column)) { + throw new WorkflowIrError( + `Workflow node '${inner.id}' references undefined column '${inner.column}'`, + ); + } } // Edge endpoints must reference template nodes; rework edges must stay intra-template. @@ -742,6 +753,29 @@ function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(column.traits)) { throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); } + validateColumnAgent(column); + } +} + +/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1). + * Mirrors the `validateFields` early-return shape: absent → no-op; present → + * `agentId` must be a non-empty string and `mode` exactly `defer`/`override`. + * Agent existence is NOT checked here (no agent store at the IR layer). */ +function validateColumnAgent(column: WorkflowIrColumn): void { + const agent = column.agent; + if (agent === undefined) return; + if (!agent || typeof agent !== "object") { + throw new WorkflowIrError(`Workflow IR column '${column.id}' agent must be an object`); + } + if (typeof agent.agentId !== "string" || agent.agentId === "") { + throw new WorkflowIrError( + `Workflow IR column '${column.id}' agent must have a non-empty agentId`, + ); + } + if (agent.mode !== "defer" && agent.mode !== "override") { + throw new WorkflowIrError( + `Workflow IR column '${column.id}' agent mode must be 'defer' or 'override' (got '${String(agent.mode)}')`, + ); } } @@ -775,7 +809,7 @@ function validateV2(ir: WorkflowIrV2): void { const topLevelIds = new Set(ir.nodes.map((n) => n.id)); validateStepExecutePlacement(ir.nodes); for (const node of ir.nodes) { - if (node.kind === "foreach") validateForeach(node, topLevelIds); + if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); } validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateParseStepsNodes(ir); @@ -884,6 +918,9 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) { return ir; } + // A permanent-agent binding is a v2-only feature (column-agent plan, R9): a + // graph that staffs a column can never round-trip through a pre-v2 binary. + if (col.agent !== undefined) return ir; } // Every node must sit in its default seam-derived column. A node placed diff --git a/packages/engine/src/workflow-graph-foreach.ts b/packages/engine/src/workflow-graph-foreach.ts index 9c0ee826c0..67160499e3 100644 --- a/packages/engine/src/workflow-graph-foreach.ts +++ b/packages/engine/src/workflow-graph-foreach.ts @@ -1,5 +1,5 @@ import type { TaskDetail, TaskStep, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core"; -import { WorkflowIrError } from "@fusion/core"; +import { WorkflowIrError, instanceNodeId } from "@fusion/core"; import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js"; import { @@ -247,10 +247,10 @@ export interface ForeachRunResult { visitedNodeIds: string[]; } -/** Materialize a deterministic instance node id (KTD-3) — pure, no IR mutation. */ -export function instanceNodeId(foreachNodeId: string, stepIndex: number, templateNodeId: string): string { - return `${foreachNodeId}#${stepIndex}:${templateNodeId}`; -} +// `instanceNodeId` now lives in `@fusion/core` (column-agent plan KTD-2) so the +// instance-id format has exactly one owner. Re-exported here (the imported binding) +// for back-compat with any local callers; the format is unchanged. +export { instanceNodeId }; /** Resolve the foreach config, validating the bits this module relies on. */ function resolveForeachConfig(node: WorkflowIrNode): { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index d9e87b586e..a53b4267a7 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -109,6 +109,10 @@ export type { WorkflowIrNode, WorkflowIrEdge, WorkflowIrNodeKind, + // Columns + per-column permanent-agent binding (column-agent plan KTD-1, R12). + WorkflowIrColumn, + WorkflowIrColumnTrait, + WorkflowColumnAgent, // Foreach / artifacts / custom fields (step inversion). WorkflowForeachConfig, WorkflowIrArtifact,