@sweny-ai/core 0.1.25 → 0.1.26

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.
@@ -275,6 +275,92 @@ describe("executor", () => {
275
275
  // All nodes still execute (executor doesn't short-circuit on failure)
276
276
  expect(results.get("a")?.status).toBe("failed");
277
277
  });
278
+ it("executes a retry loop with max_iterations", async () => {
279
+ const retryWorkflow = {
280
+ id: "retry",
281
+ name: "Retry",
282
+ description: "A→B→A (retry once)→B→C",
283
+ entry: "extract",
284
+ nodes: {
285
+ extract: { name: "Extract", instruction: "Extract data", skills: [] },
286
+ judge: { name: "Judge", instruction: "Judge quality", skills: [] },
287
+ done: { name: "Done", instruction: "Finish", skills: [] },
288
+ },
289
+ edges: [
290
+ { from: "extract", to: "judge" },
291
+ { from: "judge", to: "extract", when: "quality is low", max_iterations: 1 },
292
+ { from: "judge", to: "done", when: "quality is high or retries exhausted" },
293
+ ],
294
+ };
295
+ // Track how many times each node runs
296
+ let extractCount = 0;
297
+ let judgeCount = 0;
298
+ const mockClaude = {
299
+ async run(opts) {
300
+ if (opts.instruction.includes("Extract")) {
301
+ extractCount++;
302
+ return { status: "success", data: { extracted: true, attempt: extractCount }, toolCalls: [] };
303
+ }
304
+ if (opts.instruction.includes("Judge")) {
305
+ judgeCount++;
306
+ // First judge: low quality (triggers retry). Second judge: still low but edge exhausted.
307
+ return { status: "success", data: { quality: "low" }, toolCalls: [] };
308
+ }
309
+ return { status: "success", data: {}, toolCalls: [] };
310
+ },
311
+ async evaluate(opts) {
312
+ // Always try to route to extract (retry), but max_iterations will block it on 2nd attempt
313
+ const retryChoice = opts.choices.find((c) => c.id === "extract");
314
+ if (retryChoice)
315
+ return "extract";
316
+ return opts.choices[0].id;
317
+ },
318
+ };
319
+ const results = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
320
+ // extract ran twice (initial + 1 retry), judge ran twice, done ran once
321
+ expect(extractCount).toBe(2);
322
+ expect(judgeCount).toBe(2);
323
+ expect(results.has("done")).toBe(true);
324
+ // The second extract result overwrites the first
325
+ expect(results.get("extract")?.data.attempt).toBe(2);
326
+ });
327
+ it("respects max_iterations=1 as single retry", async () => {
328
+ const singleRetry = {
329
+ id: "single-retry",
330
+ name: "Single Retry",
331
+ description: "A↔B with max 1 loop",
332
+ entry: "a",
333
+ nodes: {
334
+ a: { name: "A", instruction: "Step A", skills: [] },
335
+ b: { name: "B", instruction: "Step B", skills: [] },
336
+ end: { name: "End", instruction: "End", skills: [] },
337
+ },
338
+ edges: [
339
+ { from: "a", to: "b" },
340
+ { from: "b", to: "a", when: "retry", max_iterations: 1 },
341
+ { from: "b", to: "end", when: "done" },
342
+ ],
343
+ };
344
+ let aCount = 0;
345
+ const mockClaude = {
346
+ async run(opts) {
347
+ if (opts.instruction.includes("Step A"))
348
+ aCount++;
349
+ return { status: "success", data: {}, toolCalls: [] };
350
+ },
351
+ async evaluate(opts) {
352
+ // Always try to loop back
353
+ const retry = opts.choices.find((c) => c.id === "a");
354
+ if (retry)
355
+ return "a";
356
+ return opts.choices[0].id;
357
+ },
358
+ };
359
+ const results = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
360
+ // A runs twice (once initially, once via retry), then edge is exhausted → falls to "end"
361
+ expect(aCount).toBe(2);
362
+ expect(results.has("end")).toBe(true);
363
+ });
278
364
  it("validates route choice falls back on invalid Claude response", async () => {
279
365
  // Create a claude mock that returns an invalid route
280
366
  const badRouteClaude = {
@@ -60,6 +60,16 @@ describe("Zod schemas", () => {
60
60
  it("rejects empty to", () => {
61
61
  expect(() => edgeZ.parse({ from: "a", to: "" })).toThrow();
62
62
  });
63
+ it("parses an edge with max_iterations", () => {
64
+ const result = edgeZ.parse({ from: "a", to: "b", when: "needs retry", max_iterations: 3 });
65
+ expect(result.max_iterations).toBe(3);
66
+ });
67
+ it("rejects max_iterations less than 1", () => {
68
+ expect(() => edgeZ.parse({ from: "a", to: "b", max_iterations: 0 })).toThrow();
69
+ });
70
+ it("rejects non-integer max_iterations", () => {
71
+ expect(() => edgeZ.parse({ from: "a", to: "b", max_iterations: 1.5 })).toThrow();
72
+ });
63
73
  });
64
74
  describe("toolZ", () => {
65
75
  it("parses a valid tool", () => {
@@ -158,7 +168,7 @@ describe("validateWorkflow", () => {
158
168
  const errors = validateWorkflow(bad);
159
169
  expect(errors).toContainEqual(expect.objectContaining({ code: "UNKNOWN_EDGE_TARGET" }));
160
170
  });
161
- it("detects self-loops", () => {
171
+ it("detects self-loops without max_iterations", () => {
162
172
  const loopy = {
163
173
  ...validWorkflow,
164
174
  edges: [
@@ -170,6 +180,42 @@ describe("validateWorkflow", () => {
170
180
  const errors = validateWorkflow(loopy);
171
181
  expect(errors).toContainEqual(expect.objectContaining({ code: "SELF_LOOP", nodeId: "a" }));
172
182
  });
183
+ it("allows self-loops with max_iterations", () => {
184
+ const loopy = {
185
+ ...validWorkflow,
186
+ edges: [
187
+ { from: "a", to: "a", when: "needs retry", max_iterations: 2 },
188
+ { from: "a", to: "b" },
189
+ { from: "b", to: "c" },
190
+ ],
191
+ };
192
+ const errors = validateWorkflow(loopy);
193
+ expect(errors).toEqual([]);
194
+ });
195
+ it("detects unbounded multi-node cycles", () => {
196
+ const cyclic = {
197
+ ...validWorkflow,
198
+ edges: [
199
+ { from: "a", to: "b" },
200
+ { from: "b", to: "a", when: "retry" }, // cycle without max_iterations
201
+ { from: "b", to: "c" },
202
+ ],
203
+ };
204
+ const errors = validateWorkflow(cyclic);
205
+ expect(errors).toContainEqual(expect.objectContaining({ code: "UNBOUNDED_CYCLE" }));
206
+ });
207
+ it("allows multi-node cycles with max_iterations", () => {
208
+ const cyclic = {
209
+ ...validWorkflow,
210
+ edges: [
211
+ { from: "a", to: "b" },
212
+ { from: "b", to: "a", when: "retry", max_iterations: 3 },
213
+ { from: "b", to: "c" },
214
+ ],
215
+ };
216
+ const errors = validateWorkflow(cyclic);
217
+ expect(errors).toEqual([]);
218
+ });
173
219
  it("detects unreachable nodes", () => {
174
220
  const disconnected = {
175
221
  ...validWorkflow,
@@ -231,6 +277,12 @@ describe("workflowJsonSchema", () => {
231
277
  expect(workflowJsonSchema.properties.edges.items.required).toContain("from");
232
278
  expect(workflowJsonSchema.properties.edges.items.required).toContain("to");
233
279
  });
280
+ it("edge items include max_iterations", () => {
281
+ const edgeProps = workflowJsonSchema.properties.edges.items.properties;
282
+ expect(edgeProps.max_iterations).toBeDefined();
283
+ expect(edgeProps.max_iterations.type).toBe("integer");
284
+ expect(edgeProps.max_iterations.minimum).toBe(1);
285
+ });
234
286
  it("node items require name and instruction", () => {
235
287
  expect(workflowJsonSchema.properties.nodes.additionalProperties.required).toContain("name");
236
288
  expect(workflowJsonSchema.properties.nodes.additionalProperties.required).toContain("instruction");
@@ -1,11 +1,15 @@
1
1
  /**
2
- * DAG Workflow Executor
2
+ * Workflow Executor
3
3
  *
4
4
  * Walks a workflow graph node-by-node. At each node, Claude gets
5
5
  * the node's instruction + available skill tools + context from
6
6
  * prior nodes. Claude does the work, then the executor resolves
7
7
  * which edge to follow next.
8
8
  *
9
+ * Supports controlled cycles via max_iterations on edges — when
10
+ * an edge has been followed max_iterations times, it is excluded
11
+ * from routing, causing flow to fall through to alternative paths.
12
+ *
9
13
  * This replaces ~8k lines of engine + recipe step code.
10
14
  */
11
15
  import type { Workflow, Skill, Claude, Observer, NodeResult, Logger } from "./types.js";
package/dist/executor.js CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
- * DAG Workflow Executor
2
+ * Workflow Executor
3
3
  *
4
4
  * Walks a workflow graph node-by-node. At each node, Claude gets
5
5
  * the node's instruction + available skill tools + context from
6
6
  * prior nodes. Claude does the work, then the executor resolves
7
7
  * which edge to follow next.
8
8
  *
9
+ * Supports controlled cycles via max_iterations on edges — when
10
+ * an edge has been followed max_iterations times, it is excluded
11
+ * from routing, causing flow to fall through to alternative paths.
12
+ *
9
13
  * This replaces ~8k lines of engine + recipe step code.
10
14
  */
11
15
  import { consoleLogger } from "./types.js";
@@ -19,6 +23,7 @@ export async function execute(workflow, input, options) {
19
23
  const config = resolveConfig(skills, options.config);
20
24
  const logger = options.logger ?? consoleLogger;
21
25
  const results = new Map();
26
+ const edgeCounts = new Map(); // "from→to" → times followed
22
27
  validate(workflow, skills);
23
28
  safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
24
29
  let currentId = workflow.entry;
@@ -75,7 +80,7 @@ export async function execute(workflow, input, options) {
75
80
  }
76
81
  }
77
82
  // Resolve next node via edge conditions
78
- currentId = await resolveNext(workflow, currentId, results, input, claude, observer);
83
+ currentId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts);
79
84
  }
80
85
  safeObserve(observer, {
81
86
  type: "workflow:end",
@@ -160,13 +165,30 @@ function resolveConfig(skills, overrides) {
160
165
  * - 0 out-edges → terminal (return null)
161
166
  * - 1 unconditional edge → follow it
162
167
  * - Multiple or conditional → Claude evaluates
168
+ *
169
+ * Edges with max_iterations are filtered out once exhausted.
163
170
  */
164
- async function resolveNext(workflow, current, results, input, claude, observer) {
165
- const outEdges = workflow.edges.filter((e) => e.from === current);
171
+ async function resolveNext(workflow, current, results, input, claude, observer, edgeCounts) {
172
+ // Filter out edges that have exceeded their max_iterations
173
+ const outEdges = workflow.edges.filter((e) => {
174
+ if (e.from !== current)
175
+ return false;
176
+ if (e.max_iterations && edgeCounts) {
177
+ const key = `${e.from}→${e.to}`;
178
+ const count = edgeCounts.get(key) ?? 0;
179
+ if (count >= e.max_iterations)
180
+ return false;
181
+ }
182
+ return true;
183
+ });
166
184
  if (outEdges.length === 0)
167
185
  return null;
168
186
  // Single unconditional edge — just follow it
169
187
  if (outEdges.length === 1 && !outEdges[0].when) {
188
+ if (edgeCounts) {
189
+ const key = `${current}→${outEdges[0].to}`;
190
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
191
+ }
170
192
  safeObserve(observer, { type: "route", from: current, to: outEdges[0].to, reason: "only path" });
171
193
  return outEdges[0].to;
172
194
  }
@@ -194,6 +216,11 @@ async function resolveNext(workflow, current, results, input, claude, observer)
194
216
  // Validate that Claude returned a valid target
195
217
  const validTargets = new Set(outEdges.map((e) => e.to));
196
218
  const resolved = validTargets.has(chosen) ? chosen : (defaultEdge?.to ?? outEdges[0].to); // fall back to default or first edge
219
+ // Track edge usage for max_iterations
220
+ if (edgeCounts) {
221
+ const key = `${current}→${resolved}`;
222
+ edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
223
+ }
197
224
  safeObserve(observer, {
198
225
  type: "route",
199
226
  from: current,
package/dist/schema.d.ts CHANGED
@@ -122,14 +122,17 @@ export declare const edgeZ: z.ZodObject<{
122
122
  from: z.ZodString;
123
123
  to: z.ZodString;
124
124
  when: z.ZodOptional<z.ZodString>;
125
+ max_iterations: z.ZodOptional<z.ZodNumber>;
125
126
  }, "strip", z.ZodTypeAny, {
126
127
  from: string;
127
128
  to: string;
128
129
  when?: string | undefined;
130
+ max_iterations?: number | undefined;
129
131
  }, {
130
132
  from: string;
131
133
  to: string;
132
134
  when?: string | undefined;
135
+ max_iterations?: number | undefined;
133
136
  }>;
134
137
  export declare const workflowZ: z.ZodObject<{
135
138
  id: z.ZodString;
@@ -155,14 +158,17 @@ export declare const workflowZ: z.ZodObject<{
155
158
  from: z.ZodString;
156
159
  to: z.ZodString;
157
160
  when: z.ZodOptional<z.ZodString>;
161
+ max_iterations: z.ZodOptional<z.ZodNumber>;
158
162
  }, "strip", z.ZodTypeAny, {
159
163
  from: string;
160
164
  to: string;
161
165
  when?: string | undefined;
166
+ max_iterations?: number | undefined;
162
167
  }, {
163
168
  from: string;
164
169
  to: string;
165
170
  when?: string | undefined;
171
+ max_iterations?: number | undefined;
166
172
  }>, "many">;
167
173
  entry: z.ZodString;
168
174
  }, "strip", z.ZodTypeAny, {
@@ -179,6 +185,7 @@ export declare const workflowZ: z.ZodObject<{
179
185
  from: string;
180
186
  to: string;
181
187
  when?: string | undefined;
188
+ max_iterations?: number | undefined;
182
189
  }[];
183
190
  entry: string;
184
191
  }, {
@@ -194,6 +201,7 @@ export declare const workflowZ: z.ZodObject<{
194
201
  from: string;
195
202
  to: string;
196
203
  when?: string | undefined;
204
+ max_iterations?: number | undefined;
197
205
  }[];
198
206
  entry: string;
199
207
  description?: string | undefined;
@@ -213,11 +221,12 @@ export declare function parseWorkflow(raw: unknown): {
213
221
  from: string;
214
222
  to: string;
215
223
  when?: string | undefined;
224
+ max_iterations?: number | undefined;
216
225
  }[];
217
226
  entry: string;
218
227
  };
219
228
  export interface WorkflowError {
220
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "NO_OUTGOING_EDGE";
229
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "NO_OUTGOING_EDGE";
221
230
  message: string;
222
231
  nodeId?: string;
223
232
  }
@@ -241,7 +250,7 @@ export declare const workflowJsonSchema: {
241
250
  readonly $schema: "https://json-schema.org/draft/2020-12/schema";
242
251
  readonly $id: "https://sweny.ai/schemas/workflow.json";
243
252
  readonly title: "SWEny Workflow";
244
- readonly description: "A DAG workflow definition for skill-based orchestration";
253
+ readonly description: "A workflow definition for skill-based orchestration (supports controlled cycles via max_iterations)";
245
254
  readonly type: "object";
246
255
  readonly required: readonly ["id", "name", "nodes", "edges", "entry"];
247
256
  readonly additionalProperties: false;
@@ -311,6 +320,11 @@ export declare const workflowJsonSchema: {
311
320
  readonly type: "string";
312
321
  readonly description: "Natural language condition — Claude evaluates at runtime";
313
322
  };
323
+ readonly max_iterations: {
324
+ readonly type: "integer";
325
+ readonly minimum: 1;
326
+ readonly description: "Max times this edge can be followed — enables controlled retry loops";
327
+ };
314
328
  };
315
329
  };
316
330
  };
package/dist/schema.js CHANGED
@@ -43,6 +43,7 @@ export const edgeZ = z.object({
43
43
  from: z.string().min(1),
44
44
  to: z.string().min(1),
45
45
  when: z.string().optional(),
46
+ max_iterations: z.number().int().min(1).optional(),
46
47
  });
47
48
  export const workflowZ = z.object({
48
49
  id: z.string().min(1),
@@ -93,10 +94,10 @@ export function validateWorkflow(workflow, knownSkills) {
93
94
  nodeId: edge.to,
94
95
  });
95
96
  }
96
- if (edge.from === edge.to) {
97
+ if (edge.from === edge.to && !edge.max_iterations) {
97
98
  errors.push({
98
99
  code: "SELF_LOOP",
99
- message: `Edge from "${edge.from}" to itself`,
100
+ message: `Edge from "${edge.from}" to itself (add max_iterations to allow)`,
100
101
  nodeId: edge.from,
101
102
  });
102
103
  }
@@ -127,12 +128,41 @@ export function validateWorkflow(workflow, knownSkills) {
127
128
  });
128
129
  }
129
130
  }
130
- // Non-terminal nodes must have outgoing edges
131
- for (const nodeId of nodeIds) {
132
- const hasOutgoing = workflow.edges.some((e) => e.from === nodeId);
133
- if (!hasOutgoing && visited.has(nodeId)) {
134
- // Terminal node — fine, no error. This is intentional.
135
- // But if it has outgoing in the graph, it's fine too.
131
+ // Detect unbounded cycles: the graph with max_iterations edges removed must be acyclic.
132
+ // If removing bounded edges still leaves a cycle, it can loop forever.
133
+ const unboundedEdges = workflow.edges.filter((e) => !e.max_iterations && e.from !== e.to);
134
+ // DFS-based cycle detection on the unbounded subgraph
135
+ const WHITE = 0, GRAY = 1, BLACK = 2;
136
+ const color = new Map();
137
+ for (const id of nodeIds)
138
+ color.set(id, WHITE);
139
+ function dfs(node) {
140
+ color.set(node, GRAY);
141
+ for (const e of unboundedEdges) {
142
+ if (e.from !== node)
143
+ continue;
144
+ const c = color.get(e.to);
145
+ if (c === GRAY)
146
+ return e.to; // back-edge found → cycle
147
+ if (c === WHITE) {
148
+ const cycle = dfs(e.to);
149
+ if (cycle)
150
+ return cycle;
151
+ }
152
+ }
153
+ color.set(node, BLACK);
154
+ return null;
155
+ }
156
+ for (const id of nodeIds) {
157
+ if (color.get(id) === WHITE) {
158
+ const cycleNode = dfs(id);
159
+ if (cycleNode) {
160
+ errors.push({
161
+ code: "UNBOUNDED_CYCLE",
162
+ message: `Unbounded cycle detected involving node "${cycleNode}" — add max_iterations to at least one edge in the cycle`,
163
+ nodeId: cycleNode,
164
+ });
165
+ }
136
166
  }
137
167
  }
138
168
  // Skill references
@@ -160,7 +190,7 @@ export const workflowJsonSchema = {
160
190
  $schema: "https://json-schema.org/draft/2020-12/schema",
161
191
  $id: "https://sweny.ai/schemas/workflow.json",
162
192
  title: "SWEny Workflow",
163
- description: "A DAG workflow definition for skill-based orchestration",
193
+ description: "A workflow definition for skill-based orchestration (supports controlled cycles via max_iterations)",
164
194
  type: "object",
165
195
  required: ["id", "name", "nodes", "edges", "entry"],
166
196
  additionalProperties: false,
@@ -200,6 +230,11 @@ export const workflowJsonSchema = {
200
230
  from: { type: "string", minLength: 1 },
201
231
  to: { type: "string", minLength: 1 },
202
232
  when: { type: "string", description: "Natural language condition — Claude evaluates at runtime" },
233
+ max_iterations: {
234
+ type: "integer",
235
+ minimum: 1,
236
+ description: "Max times this edge can be followed — enables controlled retry loops",
237
+ },
203
238
  },
204
239
  },
205
240
  },
package/dist/types.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface Edge {
48
48
  to: string;
49
49
  /** Natural language condition — Claude evaluates at runtime */
50
50
  when?: string;
51
+ /** Max times this edge can be followed (enables retry loops). Default: unlimited. */
52
+ max_iterations?: number;
51
53
  }
52
54
  /** A complete workflow definition — pure data, fully serializable */
53
55
  export interface Workflow {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"