@sweny-ai/core 0.1.24 → 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.
- package/dist/__tests__/executor.test.js +86 -0
- package/dist/__tests__/schema.test.js +53 -1
- package/dist/executor.d.ts +5 -1
- package/dist/executor.js +31 -4
- package/dist/schema.d.ts +16 -2
- package/dist/schema.js +44 -9
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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");
|
package/dist/executor.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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 {
|