feat(core): column agent IR schema, validation, and effective-agent resolver
U1+U2 of the column-agent plan: WorkflowColumnAgent on WorkflowIrColumn (defer/override), template-subgraph column validation, v2-only-feature registration, plugin-sdk type parity, and the shared core resolver with instanceNodeId format ownership moved to core.
This commit is contained in:
258
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal file
258
packages/core/src/__tests__/column-agent-resolver.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal file
224
packages/core/src/__tests__/workflow-ir-column-agent.test.ts
Normal file
@@ -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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
186
packages/core/src/column-agent-resolver.ts
Normal file
186
packages/core/src/column-agent-resolver.ts
Normal file
@@ -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 (`<foreachId>#<stepIndex>:<templateNodeId>`) 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: `<foreachId>#<stepIndex>:<templateNodeId>`. */
|
||||
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<string, WorkflowIr["nodes"][number]> {
|
||||
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 (`<foreachId>#<i>:<templateNodeId>`) 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<WorkflowForeachConfig> | 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -105,11 +105,32 @@ export interface WorkflowIrColumnTrait {
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
|
||||
@@ -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<string>): void {
|
||||
function validateForeach(
|
||||
node: WorkflowIrNode,
|
||||
topLevelNodeIds: Set<string>,
|
||||
columnIds: Set<string>,
|
||||
): void {
|
||||
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
|
||||
if (!cfg || cfg.source !== "task-steps") {
|
||||
throw new WorkflowIrError(
|
||||
@@ -341,13 +345,20 @@ function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): 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
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user