@sweny-ai/core 0.1.62 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Rules / Context Cascade + max_turns Tests
3
+ *
4
+ * Covers the spec sections:
5
+ * - Workflow > Rules & Context
6
+ * - Workflow > Runtime Input (rules/context)
7
+ * - Nodes > Max Turns Semantics
8
+ * - Nodes > Rules & Context (cascade + `only` flag)
9
+ * - Nodes > Input Augmentation (assembly order)
10
+ */
11
+ export {};
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Rules / Context Cascade + max_turns Tests
3
+ *
4
+ * Covers the spec sections:
5
+ * - Workflow > Rules & Context
6
+ * - Workflow > Runtime Input (rules/context)
7
+ * - Nodes > Max Turns Semantics
8
+ * - Nodes > Rules & Context (cascade + `only` flag)
9
+ * - Nodes > Input Augmentation (assembly order)
10
+ */
11
+ import { describe, it, expect } from "vitest";
12
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import * as path from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { randomBytes } from "node:crypto";
16
+ import { execute } from "../executor.js";
17
+ import { nodeZ, workflowZ, parseWorkflow, workflowJsonSchema } from "../schema.js";
18
+ import { createSkillMap } from "../skills/index.js";
19
+ // ─── Helpers ────────────────────────────────────────────────────
20
+ function freshDir(tag) {
21
+ const dir = path.join(tmpdir(), `sweny-cascade-${tag}-${Date.now()}-${randomBytes(4).toString("hex")}`);
22
+ rmSync(dir, { recursive: true, force: true });
23
+ mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+ /** Minimal Claude that records every run() call for assertion. */
27
+ function recordingClaude(defaultData = {}) {
28
+ const runs = [];
29
+ const claude = {
30
+ async run(opts) {
31
+ runs.push({ instruction: opts.instruction, maxTurns: opts.maxTurns });
32
+ return { status: "success", data: defaultData, toolCalls: [] };
33
+ },
34
+ async evaluate(opts) {
35
+ return opts.choices[0].id;
36
+ },
37
+ };
38
+ return { claude, runs };
39
+ }
40
+ function singleNodeWorkflow(overrides = {}, workflowOverrides = {}) {
41
+ return {
42
+ id: "t",
43
+ name: "T",
44
+ description: "",
45
+ entry: "a",
46
+ edges: [],
47
+ nodes: {
48
+ a: {
49
+ name: "A",
50
+ instruction: overrides.instruction ?? "Base instruction.",
51
+ skills: overrides.skills ?? [],
52
+ ...overrides,
53
+ },
54
+ },
55
+ ...workflowOverrides,
56
+ };
57
+ }
58
+ // ─── max_turns ──────────────────────────────────────────────────
59
+ describe("spec: node.max_turns", () => {
60
+ it("passes max_turns to claude.run when set on the node", async () => {
61
+ const { claude, runs } = recordingClaude();
62
+ const w = singleNodeWorkflow({ instruction: "Do A.", max_turns: 7 });
63
+ await execute(w, {}, { skills: createSkillMap([]), claude });
64
+ expect(runs).toHaveLength(1);
65
+ expect(runs[0].maxTurns).toBe(7);
66
+ });
67
+ it("passes undefined when max_turns is absent (executor defers to Claude default)", async () => {
68
+ const { claude, runs } = recordingClaude();
69
+ const w = singleNodeWorkflow({ instruction: "Do A." });
70
+ await execute(w, {}, { skills: createSkillMap([]), claude });
71
+ expect(runs).toHaveLength(1);
72
+ expect(runs[0].maxTurns).toBeUndefined();
73
+ });
74
+ it("Zod schema accepts positive integers and rejects 0 / negatives / floats", () => {
75
+ expect(() => nodeZ.parse({ name: "X", instruction: "Go.", max_turns: 5 })).not.toThrow();
76
+ expect(() => nodeZ.parse({ name: "X", instruction: "Go." })).not.toThrow();
77
+ expect(() => nodeZ.parse({ name: "X", instruction: "Go.", max_turns: 0 })).toThrow();
78
+ expect(() => nodeZ.parse({ name: "X", instruction: "Go.", max_turns: -3 })).toThrow();
79
+ expect(() => nodeZ.parse({ name: "X", instruction: "Go.", max_turns: 1.5 })).toThrow();
80
+ });
81
+ });
82
+ // ─── Zod parsing for rules/context ──────────────────────────────
83
+ describe("spec: Zod parsing of rules/context", () => {
84
+ it("accepts top-level workflow rules/context as Source arrays", () => {
85
+ const w = parseWorkflow({
86
+ id: "wf",
87
+ name: "Wf",
88
+ entry: "a",
89
+ nodes: { a: { name: "A", instruction: "Go." } },
90
+ edges: [],
91
+ rules: ["Never break API.", { file: "./rules.md" }, { url: "https://x.test/r.md" }],
92
+ context: ["Prod is degraded."],
93
+ });
94
+ expect(w.rules).toHaveLength(3);
95
+ expect(w.context).toEqual(["Prod is degraded."]);
96
+ });
97
+ it("accepts per-node rules in additive (array) form", () => {
98
+ const node = nodeZ.parse({
99
+ name: "N",
100
+ instruction: "Go.",
101
+ rules: ["Rule 1", { inline: "Rule 2" }],
102
+ });
103
+ expect(Array.isArray(node.rules)).toBe(true);
104
+ expect(node.rules).toHaveLength(2);
105
+ });
106
+ it("accepts per-node rules in object form with `only`", () => {
107
+ const node = nodeZ.parse({
108
+ name: "N",
109
+ instruction: "Go.",
110
+ rules: { only: true, sources: ["Only this rule."] },
111
+ });
112
+ expect(node.rules).toEqual({ only: true, sources: ["Only this rule."] });
113
+ });
114
+ it("accepts object form without `only` (equivalent to additive)", () => {
115
+ const node = nodeZ.parse({
116
+ name: "N",
117
+ instruction: "Go.",
118
+ context: { sources: ["Background."] },
119
+ });
120
+ expect(node.context).toEqual({ sources: ["Background."] });
121
+ });
122
+ it("rejects object form without `sources`", () => {
123
+ expect(() => nodeZ.parse({ name: "N", instruction: "Go.", rules: { only: true } })).toThrow();
124
+ });
125
+ it("workflowZ round-trips rules/context through parse", () => {
126
+ const raw = {
127
+ id: "wf",
128
+ name: "Wf",
129
+ entry: "a",
130
+ nodes: {
131
+ a: {
132
+ name: "A",
133
+ instruction: "Go.",
134
+ rules: ["N-rule"],
135
+ context: { only: true, sources: ["N-ctx-only"] },
136
+ },
137
+ },
138
+ edges: [],
139
+ rules: ["W-rule"],
140
+ context: ["W-ctx"],
141
+ };
142
+ const w = workflowZ.parse(raw);
143
+ expect(w.rules).toEqual(["W-rule"]);
144
+ expect(w.context).toEqual(["W-ctx"]);
145
+ expect(w.nodes.a.rules).toEqual(["N-rule"]);
146
+ expect(w.nodes.a.context).toEqual({ only: true, sources: ["N-ctx-only"] });
147
+ });
148
+ });
149
+ // ─── Exported JSON Schema ───────────────────────────────────────
150
+ describe("spec: workflowJsonSchema exports rules/context", () => {
151
+ it("declares workflow-level rules/context as Source arrays", () => {
152
+ const props = workflowJsonSchema.properties;
153
+ expect(props.rules).toMatchObject({ type: "array" });
154
+ expect(props.context).toMatchObject({ type: "array" });
155
+ });
156
+ it("declares per-node rules/context using NodeSources $defs", () => {
157
+ const nodeProps = workflowJsonSchema.properties.nodes.additionalProperties.properties;
158
+ expect(nodeProps.rules).toMatchObject({ $ref: "#/$defs/NodeSources" });
159
+ expect(nodeProps.context).toMatchObject({ $ref: "#/$defs/NodeSources" });
160
+ expect(workflowJsonSchema.$defs.NodeSources).toBeDefined();
161
+ });
162
+ it("NodeSources accepts either array or object-with-sources", () => {
163
+ const ns = workflowJsonSchema.$defs.NodeSources;
164
+ expect(ns.oneOf).toHaveLength(2);
165
+ });
166
+ it("declares per-node max_turns as integer >= 1", () => {
167
+ const nodeProps = workflowJsonSchema.properties.nodes.additionalProperties.properties;
168
+ expect(nodeProps.max_turns).toMatchObject({ type: "integer", minimum: 1 });
169
+ });
170
+ });
171
+ // ─── Cascade resolution in executor ─────────────────────────────
172
+ describe("spec: rules/context cascade resolution", () => {
173
+ it("base instruction only when no rules/context anywhere", async () => {
174
+ const { claude, runs } = recordingClaude();
175
+ const w = singleNodeWorkflow({ instruction: "JUST_THE_BASE" });
176
+ await execute(w, {}, { skills: createSkillMap([]), claude });
177
+ expect(runs[0].instruction).toBe("JUST_THE_BASE");
178
+ expect(runs[0].instruction).not.toContain("## Rules");
179
+ expect(runs[0].instruction).not.toContain("## Background Context");
180
+ });
181
+ it("workflow-level rules are prepended with the canonical heading", async () => {
182
+ const { claude, runs } = recordingClaude();
183
+ const w = singleNodeWorkflow({ instruction: "Do A." }, { rules: ["Never break API."] });
184
+ await execute(w, {}, { skills: createSkillMap([]), claude });
185
+ expect(runs[0].instruction).toContain("## Rules — You MUST Follow These");
186
+ expect(runs[0].instruction).toContain("Never break API.");
187
+ expect(runs[0].instruction).toContain("Do A.");
188
+ });
189
+ it("workflow-level context is prepended with the canonical heading", async () => {
190
+ const { claude, runs } = recordingClaude();
191
+ const w = singleNodeWorkflow({ instruction: "Do A." }, { context: ["Prod is down."] });
192
+ await execute(w, {}, { skills: createSkillMap([]), claude });
193
+ expect(runs[0].instruction).toContain("## Background Context");
194
+ expect(runs[0].instruction).toContain("Prod is down.");
195
+ });
196
+ it("per-node rules (array form) are additive — both workflow and node rules appear", async () => {
197
+ const { claude, runs } = recordingClaude();
198
+ const w = {
199
+ id: "t",
200
+ name: "T",
201
+ description: "",
202
+ entry: "a",
203
+ edges: [],
204
+ rules: ["WORKFLOW_RULE"],
205
+ nodes: {
206
+ a: { name: "A", instruction: "base", skills: [], rules: ["NODE_RULE"] },
207
+ },
208
+ };
209
+ await execute(w, {}, { skills: createSkillMap([]), claude });
210
+ const instr = runs[0].instruction;
211
+ expect(instr).toContain("WORKFLOW_RULE");
212
+ expect(instr).toContain("NODE_RULE");
213
+ // Order: workflow rules appear before node rules
214
+ expect(instr.indexOf("WORKFLOW_RULE")).toBeLessThan(instr.indexOf("NODE_RULE"));
215
+ });
216
+ it("per-node rules with `only: true` blocks the cascade for rules", async () => {
217
+ const { claude, runs } = recordingClaude();
218
+ const w = {
219
+ id: "t",
220
+ name: "T",
221
+ description: "",
222
+ entry: "a",
223
+ edges: [],
224
+ rules: ["WORKFLOW_RULE"],
225
+ nodes: {
226
+ a: {
227
+ name: "A",
228
+ instruction: "base",
229
+ skills: [],
230
+ rules: { only: true, sources: ["OVERRIDE_RULE"] },
231
+ },
232
+ },
233
+ };
234
+ await execute(w, {}, { skills: createSkillMap([]), claude });
235
+ const instr = runs[0].instruction;
236
+ expect(instr).toContain("OVERRIDE_RULE");
237
+ expect(instr).not.toContain("WORKFLOW_RULE");
238
+ });
239
+ it("`only: true` on rules does NOT block context cascade", async () => {
240
+ const { claude, runs } = recordingClaude();
241
+ const w = {
242
+ id: "t",
243
+ name: "T",
244
+ description: "",
245
+ entry: "a",
246
+ edges: [],
247
+ rules: ["WORKFLOW_RULE"],
248
+ context: ["WORKFLOW_CTX"],
249
+ nodes: {
250
+ a: {
251
+ name: "A",
252
+ instruction: "base",
253
+ skills: [],
254
+ rules: { only: true, sources: ["NODE_RULE"] },
255
+ // No node-level context override → inherits normally
256
+ },
257
+ },
258
+ };
259
+ await execute(w, {}, { skills: createSkillMap([]), claude });
260
+ const instr = runs[0].instruction;
261
+ expect(instr).not.toContain("WORKFLOW_RULE");
262
+ expect(instr).toContain("NODE_RULE");
263
+ expect(instr).toContain("WORKFLOW_CTX"); // context still cascades
264
+ });
265
+ it("runtime input + workflow + node rules concatenate in order", async () => {
266
+ const { claude, runs } = recordingClaude();
267
+ const w = {
268
+ id: "t",
269
+ name: "T",
270
+ description: "",
271
+ entry: "a",
272
+ edges: [],
273
+ rules: ["WORKFLOW_RULE"],
274
+ nodes: {
275
+ a: { name: "A", instruction: "base", skills: [], rules: ["NODE_RULE"] },
276
+ },
277
+ };
278
+ await execute(w, { rules: ["INPUT_RULE"] }, { skills: createSkillMap([]), claude });
279
+ const instr = runs[0].instruction;
280
+ const inp = instr.indexOf("INPUT_RULE");
281
+ const wfl = instr.indexOf("WORKFLOW_RULE");
282
+ const nd = instr.indexOf("NODE_RULE");
283
+ expect(inp).toBeGreaterThanOrEqual(0);
284
+ expect(wfl).toBeGreaterThan(inp);
285
+ expect(nd).toBeGreaterThan(wfl);
286
+ });
287
+ it("accepts runtime input rules as a bare string (classified as inline Source)", async () => {
288
+ const { claude, runs } = recordingClaude();
289
+ const w = singleNodeWorkflow({ instruction: "base" });
290
+ await execute(w, { rules: "SINGLE_STRING_RULE" }, { skills: createSkillMap([]), claude });
291
+ expect(runs[0].instruction).toContain("SINGLE_STRING_RULE");
292
+ expect(runs[0].instruction).toContain("## Rules — You MUST Follow These");
293
+ });
294
+ it("accepts runtime input rules as a tagged object source", async () => {
295
+ const { claude, runs } = recordingClaude();
296
+ const w = singleNodeWorkflow({ instruction: "base" });
297
+ await execute(w, { rules: { inline: "TAGGED_RULE" } }, { skills: createSkillMap([]), claude });
298
+ expect(runs[0].instruction).toContain("TAGGED_RULE");
299
+ });
300
+ it("resolves file sources in workflow.rules and records them in trace.sources", async () => {
301
+ const dir = freshDir("file-rules");
302
+ writeFileSync(path.join(dir, "rules.md"), "RULES_FROM_FILE");
303
+ const { claude, runs } = recordingClaude();
304
+ const w = {
305
+ id: "t",
306
+ name: "T",
307
+ description: "",
308
+ entry: "a",
309
+ edges: [],
310
+ rules: [{ file: "./rules.md" }],
311
+ nodes: { a: { name: "A", instruction: "base", skills: [] } },
312
+ };
313
+ const result = await execute(w, {}, { skills: createSkillMap([]), claude, cwd: dir });
314
+ expect(runs[0].instruction).toContain("RULES_FROM_FILE");
315
+ expect(result.trace.sources["workflow.rules.0"]).toBeDefined();
316
+ expect(result.trace.sources["workflow.rules.0"].kind).toBe("file");
317
+ expect(result.trace.sources["workflow.rules.0"].hash).toMatch(/^[0-9a-f]{16}$/);
318
+ });
319
+ it("resolves node-level rules/context file sources and records them under correct trace keys", async () => {
320
+ const dir = freshDir("node-cascade");
321
+ writeFileSync(path.join(dir, "n-rules.md"), "N_RULES_FILE");
322
+ writeFileSync(path.join(dir, "n-ctx.md"), "N_CTX_FILE");
323
+ const { claude, runs } = recordingClaude();
324
+ const w = {
325
+ id: "t",
326
+ name: "T",
327
+ description: "",
328
+ entry: "a",
329
+ edges: [],
330
+ nodes: {
331
+ a: {
332
+ name: "A",
333
+ instruction: "base",
334
+ skills: [],
335
+ rules: [{ file: "./n-rules.md" }],
336
+ context: [{ file: "./n-ctx.md" }],
337
+ },
338
+ },
339
+ };
340
+ const result = await execute(w, {}, { skills: createSkillMap([]), claude, cwd: dir });
341
+ expect(runs[0].instruction).toContain("N_RULES_FILE");
342
+ expect(runs[0].instruction).toContain("N_CTX_FILE");
343
+ expect(result.trace.sources["nodes.a.rules.0"]).toBeDefined();
344
+ expect(result.trace.sources["nodes.a.context.0"]).toBeDefined();
345
+ expect(result.trace.sources["nodes.a.rules.0"].kind).toBe("file");
346
+ expect(result.trace.sources["nodes.a.context.0"].kind).toBe("file");
347
+ });
348
+ it("different nodes in the same workflow see different effective cascades", async () => {
349
+ const { claude, runs } = recordingClaude();
350
+ const w = {
351
+ id: "t",
352
+ name: "T",
353
+ description: "",
354
+ entry: "a",
355
+ edges: [{ from: "a", to: "b" }],
356
+ rules: ["WORKFLOW_RULE"],
357
+ nodes: {
358
+ a: {
359
+ name: "A",
360
+ instruction: "Node A base",
361
+ skills: [],
362
+ rules: { only: true, sources: ["A_ONLY"] },
363
+ },
364
+ b: {
365
+ name: "B",
366
+ instruction: "Node B base",
367
+ skills: [],
368
+ rules: ["B_ADDITIVE"],
369
+ },
370
+ },
371
+ };
372
+ await execute(w, {}, { skills: createSkillMap([]), claude });
373
+ const aInstr = runs.find((r) => r.instruction.includes("Node A base")).instruction;
374
+ const bInstr = runs.find((r) => r.instruction.includes("Node B base")).instruction;
375
+ expect(aInstr).toContain("A_ONLY");
376
+ expect(aInstr).not.toContain("WORKFLOW_RULE");
377
+ expect(bInstr).toContain("WORKFLOW_RULE");
378
+ expect(bInstr).toContain("B_ADDITIVE");
379
+ });
380
+ it("sections are separated by `---` in assembly order: rules → context → skill → base", async () => {
381
+ const { claude, runs } = recordingClaude();
382
+ const w = {
383
+ id: "t",
384
+ name: "T",
385
+ description: "",
386
+ entry: "a",
387
+ edges: [],
388
+ rules: ["R"],
389
+ context: ["C"],
390
+ nodes: {
391
+ a: { name: "A", instruction: "BASE", skills: ["rubric"] },
392
+ },
393
+ };
394
+ const rubric = {
395
+ id: "rubric",
396
+ name: "Rubric",
397
+ description: "",
398
+ category: "general",
399
+ config: {},
400
+ tools: [],
401
+ instruction: "SCORE_IT",
402
+ };
403
+ await execute(w, {}, { skills: createSkillMap([rubric]), claude });
404
+ const instr = runs[0].instruction;
405
+ const rIdx = instr.indexOf("## Rules — You MUST Follow These");
406
+ const cIdx = instr.indexOf("## Background Context");
407
+ const sIdx = instr.indexOf("## Skill: Rubric");
408
+ const bIdx = instr.indexOf("BASE");
409
+ expect(rIdx).toBeGreaterThanOrEqual(0);
410
+ expect(cIdx).toBeGreaterThan(rIdx);
411
+ expect(sIdx).toBeGreaterThan(cIdx);
412
+ expect(bIdx).toBeGreaterThan(sIdx);
413
+ });
414
+ it("empty arrays produce no rules/context sections", async () => {
415
+ const { claude, runs } = recordingClaude();
416
+ const w = {
417
+ id: "t",
418
+ name: "T",
419
+ description: "",
420
+ entry: "a",
421
+ edges: [],
422
+ rules: [],
423
+ context: [],
424
+ nodes: {
425
+ a: { name: "A", instruction: "BASE", skills: [], rules: [], context: [] },
426
+ },
427
+ };
428
+ await execute(w, {}, { skills: createSkillMap([]), claude });
429
+ expect(runs[0].instruction).toBe("BASE");
430
+ });
431
+ });
432
+ // ─── Legacy input.additionalContext compatibility ───────────────
433
+ describe("backward compat: input.additionalContext", () => {
434
+ it("still renders under the legacy heading when no rules/context are present", async () => {
435
+ const { claude, runs } = recordingClaude();
436
+ const w = singleNodeWorkflow({ instruction: "base" });
437
+ await execute(w, { additionalContext: "LEGACY_TEXT" }, { skills: createSkillMap([]), claude });
438
+ expect(runs[0].instruction).toContain("## Additional Context & Rules");
439
+ expect(runs[0].instruction).toContain("LEGACY_TEXT");
440
+ });
441
+ it("is suppressed once rules or context are present (rules/context take priority)", async () => {
442
+ const { claude, runs } = recordingClaude();
443
+ const w = singleNodeWorkflow({ instruction: "base" }, { rules: ["NEW_RULE"] });
444
+ await execute(w, { additionalContext: "LEGACY_TEXT" }, { skills: createSkillMap([]), claude });
445
+ expect(runs[0].instruction).toContain("NEW_RULE");
446
+ expect(runs[0].instruction).not.toContain("LEGACY_TEXT");
447
+ expect(runs[0].instruction).not.toContain("## Additional Context & Rules");
448
+ });
449
+ });
package/dist/executor.js CHANGED
@@ -35,12 +35,29 @@ export async function execute(workflow, input, options) {
35
35
  const trace = { steps: [], edges: [], sources: {} };
36
36
  validate(workflow, skills);
37
37
  // ── Source resolution phase ─────────────────────────────────────
38
- // Resolve all node instructions (which are Source values) into plain text
39
- // before the main execution loop. This eagerly fetches file/URL content.
38
+ // Resolve all Source values (node instructions + rules/context at every
39
+ // level) into plain text before the main execution loop. This eagerly
40
+ // fetches file/URL content so the execution path is pure-compute.
40
41
  const sourceMap = {};
42
+ // Node instructions
41
43
  for (const [nodeId, node] of Object.entries(workflow.nodes)) {
42
44
  sourceMap[`nodes.${nodeId}.instruction`] = node.instruction;
43
45
  }
46
+ // Runtime input rules/context (accepts Source | Source[])
47
+ const inputRules = extractRuntimeInputSources(input, "rules");
48
+ const inputContext = extractRuntimeInputSources(input, "context");
49
+ inputRules.forEach((s, i) => (sourceMap[`input.rules.${i}`] = s));
50
+ inputContext.forEach((s, i) => (sourceMap[`input.context.${i}`] = s));
51
+ // Workflow-level rules/context
52
+ (workflow.rules ?? []).forEach((s, i) => (sourceMap[`workflow.rules.${i}`] = s));
53
+ (workflow.context ?? []).forEach((s, i) => (sourceMap[`workflow.context.${i}`] = s));
54
+ // Node-level rules/context
55
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
56
+ const nodeRules = nodeSourcesToArray(node.rules).sources;
57
+ const nodeContext = nodeSourcesToArray(node.context).sources;
58
+ nodeRules.forEach((s, i) => (sourceMap[`nodes.${nodeId}.rules.${i}`] = s));
59
+ nodeContext.forEach((s, i) => (sourceMap[`nodes.${nodeId}.context.${i}`] = s));
60
+ }
44
61
  const resolvedSources = await resolveSources(sourceMap, {
45
62
  cwd: options.cwd ?? process.cwd(),
46
63
  env: options.env ?? process.env,
@@ -89,8 +106,10 @@ export async function execute(workflow, input, options) {
89
106
  return output;
90
107
  },
91
108
  }));
92
- // Prepend rules and context to instruction if provided
93
- const instruction = buildNodeInstruction(resolvedInstruction, input, skillInstructions);
109
+ // Prepend rules and context to instruction per cascade semantics
110
+ const effectiveRules = assembleCascaded("rules", currentId, workflow.nodes[currentId], inputRules.length, (workflow.rules ?? []).length, resolvedSources);
111
+ const effectiveContext = assembleCascaded("context", currentId, workflow.nodes[currentId], inputContext.length, (workflow.context ?? []).length, resolvedSources);
112
+ const instruction = buildNodeInstruction(resolvedInstruction, effectiveRules, effectiveContext, input, skillInstructions);
94
113
  // Run Claude on this node
95
114
  const result = await claude.run({
96
115
  instruction,
@@ -137,31 +156,33 @@ export async function execute(workflow, input, options) {
137
156
  }
138
157
  // ─── Internals ───────────────────────────────────────────────────
139
158
  /**
140
- * Build the full instruction for a node by prepending rules and context.
141
- * Rules get "You MUST follow" framing; context gets "Background" framing.
142
- * Falls back to legacy `additionalContext` if rules/context aren't set.
159
+ * Build the full instruction for a node.
160
+ *
161
+ * Assembly order (each section separated by `---`):
162
+ * 1. Rules (from runtime input + workflow + node, cascaded)
163
+ * 2. Context (from runtime input + workflow + node, cascaded)
164
+ * 3. Skill instructions (one per skill with an `instruction` field)
165
+ * 4. The node's base instruction
166
+ *
167
+ * Preserves the legacy `input.additionalContext` fallback for callers
168
+ * that predate the Source-based rules/context fields.
143
169
  */
144
- function buildNodeInstruction(baseInstruction, input, skillInstructions) {
145
- const inp = input;
170
+ function buildNodeInstruction(baseInstruction, effectiveRules, effectiveContext, input, skillInstructions) {
146
171
  const sections = [];
147
- if (inp) {
148
- const rules = typeof inp.rules === "string" && inp.rules ? inp.rules : "";
149
- const context = typeof inp.context === "string" && inp.context ? inp.context : "";
150
- if (rules) {
151
- sections.push(`## Rules — You MUST Follow These\n\n${rules}`);
152
- }
153
- if (context) {
154
- sections.push(`## Background Context\n\n${context}`);
155
- }
156
- // Legacy fallback
157
- if (sections.length === 0) {
158
- const legacy = typeof inp.additionalContext === "string" ? inp.additionalContext : "";
159
- if (legacy) {
160
- sections.push(`## Additional Context & Rules\n\n${legacy}`);
161
- }
172
+ if (effectiveRules) {
173
+ sections.push(`## Rules — You MUST Follow These\n\n${effectiveRules}`);
174
+ }
175
+ if (effectiveContext) {
176
+ sections.push(`## Background Context\n\n${effectiveContext}`);
177
+ }
178
+ // Legacy fallback for `input.additionalContext` when no rules/context cascaded
179
+ if (sections.length === 0) {
180
+ const inp = input;
181
+ const legacy = inp && typeof inp.additionalContext === "string" ? inp.additionalContext : "";
182
+ if (legacy) {
183
+ sections.push(`## Additional Context & Rules\n\n${legacy}`);
162
184
  }
163
185
  }
164
- // Skill instructions — injected between context and base instruction
165
186
  if (skillInstructions && skillInstructions.length > 0) {
166
187
  for (const { name, instruction } of skillInstructions) {
167
188
  sections.push(`## Skill: ${name}\n\n${instruction}`);
@@ -171,6 +192,64 @@ function buildNodeInstruction(baseInstruction, input, skillInstructions) {
171
192
  return baseInstruction;
172
193
  return `${sections.join("\n\n---\n\n")}\n\n---\n\n${baseInstruction}`;
173
194
  }
195
+ /**
196
+ * Normalize a NodeSources value into { sources, only }.
197
+ * - Array form → additive (only = false)
198
+ * - Object form → use its `only` and `sources`
199
+ * - undefined → empty additive
200
+ */
201
+ function nodeSourcesToArray(ns) {
202
+ if (!ns)
203
+ return { sources: [], only: false };
204
+ if (Array.isArray(ns))
205
+ return { sources: ns, only: false };
206
+ return { sources: ns.sources, only: !!ns.only };
207
+ }
208
+ /**
209
+ * Extract rules/context Sources from runtime input.
210
+ *
211
+ * Runtime input is typed `unknown` because workflows are polymorphic.
212
+ * We accept `Source`, `Source[]`, or undefined. A single string is
213
+ * classified via the usual Source prefix rules (inline by default).
214
+ * Invalid shapes are ignored — input is caller-controlled and the
215
+ * rest of the executor validates it elsewhere.
216
+ */
217
+ function extractRuntimeInputSources(input, field) {
218
+ if (!input || typeof input !== "object")
219
+ return [];
220
+ const v = input[field];
221
+ if (v == null)
222
+ return [];
223
+ if (Array.isArray(v))
224
+ return v;
225
+ if (typeof v === "string")
226
+ return [v];
227
+ if (typeof v === "object")
228
+ return [v]; // tagged form {inline}/{file}/{url}
229
+ return [];
230
+ }
231
+ /**
232
+ * Assemble the effective rules or context for a node per cascade semantics:
233
+ * runtime input + workflow-level + node-level
234
+ * unless the node sets `only: true` for that field, which discards input
235
+ * and workflow contributions and uses only the node's own sources.
236
+ *
237
+ * Returns the concatenated resolved content (empty string when nothing applies).
238
+ */
239
+ function assembleCascaded(field, nodeId, node, inputCount, workflowCount, resolved) {
240
+ const { sources: nodeSources, only } = nodeSourcesToArray(node[field]);
241
+ const parts = [];
242
+ if (!only) {
243
+ for (let i = 0; i < inputCount; i++)
244
+ parts.push(resolved[`input.${field}.${i}`]?.content ?? "");
245
+ for (let i = 0; i < workflowCount; i++)
246
+ parts.push(resolved[`workflow.${field}.${i}`]?.content ?? "");
247
+ }
248
+ for (let i = 0; i < nodeSources.length; i++) {
249
+ parts.push(resolved[`nodes.${nodeId}.${field}.${i}`]?.content ?? "");
250
+ }
251
+ return parts.filter((s) => s.length > 0).join("\n\n");
252
+ }
174
253
  /**
175
254
  * Call observer without letting exceptions crash the workflow.
176
255
  *