flowneer 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -259,6 +259,59 @@ await new FlowBuilder<RefineState>()
259
259
 
260
260
  > **Tip:** Pair with [`withCycles`](#withcycles) to cap the maximum number of jumps.
261
261
 
262
+ ## using with `withCycles` plugin
263
+
264
+ `withCycles` guards against infinite anchor-jump loops. Each call registers one limit; multiple calls stack.
265
+
266
+ **Global limit** — throws after `n` total anchor jumps across the whole flow:
267
+
268
+ ```typescript
269
+ import { FlowBuilder } from "flowneer";
270
+ import { withCycles } from "flowneer/plugins/resilience";
271
+
272
+ FlowBuilder.use(withCycles);
273
+
274
+ const flow = new FlowBuilder<State>()
275
+ .withCycles(5) // max 5 total anchor jumps
276
+ .startWith(async (s) => {
277
+ s.count = 0;
278
+ })
279
+ .anchor("loop")
280
+ .then(async (s) => {
281
+ s.count += 1;
282
+ if (s.count < 3) return "#loop"; // jump back to "loop" anchor
283
+ })
284
+ .then(async (s) => console.log("done, count =", s.count));
285
+ ```
286
+
287
+ **Per-anchor limit** — pass an anchor name as the second argument to restrict visits to that specific anchor only:
288
+
289
+ ```typescript
290
+ const flow = new FlowBuilder<State>()
291
+ .withCycles(5, "refine") // max 5 visits to the "refine" anchor
292
+ .startWith(generateDraft)
293
+ .anchor("refine")
294
+ .then(async (s) => {
295
+ s.quality = await score(s.draft);
296
+ if (s.quality < 0.8) {
297
+ s.draft = await improve(s.draft);
298
+ return "#refine";
299
+ }
300
+ });
301
+ ```
302
+
303
+ **Mixed** — combine a global cap with independent per-anchor limits by chaining calls. Each limit is evaluated independently:
304
+
305
+ ```typescript
306
+ const flow = new FlowBuilder<State>()
307
+ .withCycles(100) // global: max 100 total anchor jumps
308
+ .withCycles(5, "fast") // "fast" anchor: max 5 visits
309
+ .withCycles(10, "retry") // "retry" anchor: max 10 visits
310
+ ...
311
+ ```
312
+
313
+ Unlisted anchors are unaffected by per-anchor limits. The global limit (if set) still counts every jump regardless of which anchor was targeted.
314
+
262
315
  ### `run(shared, params?, options?)`
263
316
 
264
317
  Execute the flow. Optionally pass a `params` object that every step receives as a second argument.
@@ -340,29 +393,29 @@ A plugin is an object of functions that get copied onto `FlowBuilder.prototype`.
340
393
 
341
394
  ### Available plugins
342
395
 
343
- | Category | Plugin | Method | Description |
344
- | ----------------- | ------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------- |
345
- | **Observability** | `withHistory` | `.withHistory()` | Appends a shallow state snapshot after each step to `shared.__history` |
346
- | | `withTiming` | `.withTiming()` | Records wall-clock duration (ms) of each step in `shared.__timings[index]` |
347
- | | `withVerbose` | `.withVerbose()` | Prints the full `shared` object to stdout after each step |
348
- | | `withInterrupts` | `.interruptIf(condition)` | Pauses the flow by throwing an `InterruptError` (with a deep-clone of `shared`) when condition is true |
349
- | **Persistence** | `withCheckpoint` | `.withCheckpoint(store)` | Saves `shared` to a store after each successful step |
350
- | | `withAuditLog` | `.withAuditLog(store)` | Writes an immutable deep-clone audit entry to a store after every step (success and error) |
351
- | | `withReplay` | `.withReplay(fromStep)` | Skips all steps before `fromStep`; combine with `.withCheckpoint()` to resume a failed flow |
352
- | | `withVersionedCheckpoint` | `.withVersionedCheckpoint(store)` | Saves diff-based versioned checkpoints with parent pointers after each step that changes state |
353
- | | | `.resumeFrom(version, store)` | Resolves a version id and skips all steps up to and including the saved step index |
354
- | **Resilience** | `withCircuitBreaker` | `.withCircuitBreaker(opts?)` | Opens the circuit after `maxFailures` consecutive failures and rejects all steps until `resetMs` elapses |
355
- | | `withFallback` | `.withFallback(fn)` | Catches any step error and calls `fn` instead of propagating, allowing the flow to continue |
356
- | | `withTimeout` | `.withTimeout(ms)` | Aborts any step that exceeds `ms` milliseconds with a descriptive error |
357
- | | `withCycles` | `.withCycles(maxJumps?)` | Throws if total step executions exceed `maxJumps` (default 100) — guards against infinite goto loops |
358
- | **Messaging** | `withChannels` | `.withChannels()` | Initialises a `Map`-based message-channel system on `shared.__channels` |
359
- | **LLM** | `withCostTracker` | `.withCostTracker()` | Accumulates per-step `shared.__stepCost` values into `shared.__cost` after each step |
360
- | | `withRateLimit` | `.withRateLimit({ intervalMs })` | Enforces a minimum gap of `intervalMs` ms between steps to avoid hammering rate-limited APIs |
361
- | | `withTokenBudget` | `.withTokenBudget(limit)` | Aborts the flow before any step where `shared.tokensUsed >= limit` |
362
- | **Dev** | `withDryRun` | `.withDryRun()` | Skips all step bodies while still firing hooks — useful for validating observability wiring |
363
- | | `withMocks` | `.withMocks(map)` | Replaces step bodies at specified indices with mock functions; all other steps run normally |
364
- | | `withStepLimit` | `.withStepLimit(max?)` | Throws after `max` total step executions (default 1000); counter resets on each `run()` call |
365
- | | `withAtomicUpdates` | `.parallelAtomic(fns, reducer, options?)` | Sugar over `parallel()` with a reducer — each fn runs on an isolated draft, reducer merges results |
396
+ | Category | Plugin | Method | Description |
397
+ | ----------------- | ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
398
+ | **Observability** | `withHistory` | `.withHistory()` | Appends a shallow state snapshot after each step to `shared.__history` |
399
+ | | `withTiming` | `.withTiming()` | Records wall-clock duration (ms) of each step in `shared.__timings[index]` |
400
+ | | `withVerbose` | `.withVerbose()` | Prints the full `shared` object to stdout after each step |
401
+ | | `withInterrupts` | `.interruptIf(condition)` | Pauses the flow by throwing an `InterruptError` (with a deep-clone of `shared`) when condition is true |
402
+ | **Persistence** | `withCheckpoint` | `.withCheckpoint(store)` | Saves `shared` to a store after each successful step |
403
+ | | `withAuditLog` | `.withAuditLog(store)` | Writes an immutable deep-clone audit entry to a store after every step (success and error) |
404
+ | | `withReplay` | `.withReplay(fromStep)` | Skips all steps before `fromStep`; combine with `.withCheckpoint()` to resume a failed flow |
405
+ | | `withVersionedCheckpoint` | `.withVersionedCheckpoint(store)` | Saves diff-based versioned checkpoints with parent pointers after each step that changes state |
406
+ | | | `.resumeFrom(version, store)` | Resolves a version id and skips all steps up to and including the saved step index |
407
+ | **Resilience** | `withCircuitBreaker` | `.withCircuitBreaker(opts?)` | Opens the circuit after `maxFailures` consecutive failures and rejects all steps until `resetMs` elapses |
408
+ | | `withFallback` | `.withFallback(fn)` | Catches any step error and calls `fn` instead of propagating, allowing the flow to continue |
409
+ | | `withTimeout` | `.withTimeout(ms)` | Aborts any step that exceeds `ms` milliseconds with a descriptive error |
410
+ | | `withCycles` | `.withCycles(n, anchor?)` | Throws after `n` anchor jumps globally, or after `n` visits to a named anchor — guards against infinite goto loops |
411
+ | **Messaging** | `withChannels` | `.withChannels()` | Initialises a `Map`-based message-channel system on `shared.__channels` |
412
+ | **LLM** | `withCostTracker` | `.withCostTracker()` | Accumulates per-step `shared.__stepCost` values into `shared.__cost` after each step |
413
+ | | `withRateLimit` | `.withRateLimit({ intervalMs })` | Enforces a minimum gap of `intervalMs` ms between steps to avoid hammering rate-limited APIs |
414
+ | | `withTokenBudget` | `.withTokenBudget(limit)` | Aborts the flow before any step where `shared.tokensUsed >= limit` |
415
+ | **Dev** | `withDryRun` | `.withDryRun()` | Skips all step bodies while still firing hooks — useful for validating observability wiring |
416
+ | | `withMocks` | `.withMocks(map)` | Replaces step bodies at specified indices with mock functions; all other steps run normally |
417
+ | | `withStepLimit` | `.withStepLimit(max?)` | Throws after `max` total step executions (default 1000); counter resets on each `run()` call |
418
+ | | `withAtomicUpdates` | `.parallelAtomic(fns, reducer, options?)` | Sugar over `parallel()` with a reducer — each fn runs on an isolated draft, reducer merges results |
366
419
 
367
420
  Plugins are imported from `flowneer/plugins` (or their individual subpath) and registered once with `FlowBuilder.use()`:
368
421
 
@@ -153,22 +153,39 @@ var withTimeout = {
153
153
 
154
154
  // plugins/resilience/withCycles.ts
155
155
  var withCycles = {
156
- withCycles(maxJumps = 100) {
157
- let jumps = 0;
156
+ withCycles(maxJumps, anchor) {
157
+ let count = 0;
158
+ let prevIndex = -1;
158
159
  this._setHooks({
159
160
  beforeFlow: () => {
160
- jumps = 0;
161
+ count = 0;
162
+ prevIndex = -1;
161
163
  },
162
164
  beforeStep: (meta) => {
163
- if (meta.type === "fn" || meta.type === "branch") {
165
+ if (prevIndex !== -1 && meta.index <= prevIndex) {
166
+ let jumpedAnchor;
167
+ for (let k = meta.index - 1; k >= 0; k--) {
168
+ const s = this.steps[k];
169
+ if (s?.type === "anchor") {
170
+ jumpedAnchor = s.name;
171
+ } else {
172
+ break;
173
+ }
174
+ }
175
+ count++;
176
+ if (anchor === void 0) {
177
+ if (count > maxJumps)
178
+ throw new Error(
179
+ `cycle limit exceeded: ${count} anchor jumps > maxJumps(${maxJumps})`
180
+ );
181
+ } else {
182
+ if (jumpedAnchor === anchor && count > maxJumps)
183
+ throw new Error(
184
+ `cycle limit exceeded for anchor "${anchor}": ${count} visits > limit(${maxJumps})`
185
+ );
186
+ }
164
187
  }
165
- },
166
- afterStep: (_meta, shared) => {
167
- jumps++;
168
- if (jumps > maxJumps)
169
- throw new Error(
170
- `cycle limit exceeded: ${jumps} step executions > maxJumps(${maxJumps})`
171
- );
188
+ prevIndex = meta.index;
172
189
  }
173
190
  });
174
191
  return this;
@@ -47,10 +47,13 @@ declare module "../../Flowneer" {
47
47
  interface FlowBuilder<S, P> {
48
48
  /**
49
49
  * Guard against infinite goto loops.
50
- * Tracks total label jumps per `run()` and throws if `maxJumps` is exceeded.
51
- * Default: 100.
50
+ *
51
+ * - `withCycles(n)` — throws after `n` total anchor jumps per `run()`.
52
+ * - `withCycles(n, "anchorName")` — throws after `n` visits to the named
53
+ * anchor via goto. The global limit (if also set) still applies.
54
+ * - Both forms can be combined: `.withCycles(100).withCycles(5, "fast")`
52
55
  */
53
- withCycles(maxJumps?: number): this;
56
+ withCycles(maxJumps: number, anchor?: string): this;
54
57
  }
55
58
  }
56
59
  declare const withCycles: FlowneerPlugin;
@@ -78,22 +78,39 @@ var withTimeout = {
78
78
 
79
79
  // plugins/resilience/withCycles.ts
80
80
  var withCycles = {
81
- withCycles(maxJumps = 100) {
82
- let jumps = 0;
81
+ withCycles(maxJumps, anchor) {
82
+ let count = 0;
83
+ let prevIndex = -1;
83
84
  this._setHooks({
84
85
  beforeFlow: () => {
85
- jumps = 0;
86
+ count = 0;
87
+ prevIndex = -1;
86
88
  },
87
89
  beforeStep: (meta) => {
88
- if (meta.type === "fn" || meta.type === "branch") {
90
+ if (prevIndex !== -1 && meta.index <= prevIndex) {
91
+ let jumpedAnchor;
92
+ for (let k = meta.index - 1; k >= 0; k--) {
93
+ const s = this.steps[k];
94
+ if (s?.type === "anchor") {
95
+ jumpedAnchor = s.name;
96
+ } else {
97
+ break;
98
+ }
99
+ }
100
+ count++;
101
+ if (anchor === void 0) {
102
+ if (count > maxJumps)
103
+ throw new Error(
104
+ `cycle limit exceeded: ${count} anchor jumps > maxJumps(${maxJumps})`
105
+ );
106
+ } else {
107
+ if (jumpedAnchor === anchor && count > maxJumps)
108
+ throw new Error(
109
+ `cycle limit exceeded for anchor "${anchor}": ${count} visits > limit(${maxJumps})`
110
+ );
111
+ }
89
112
  }
90
- },
91
- afterStep: (_meta, shared) => {
92
- jumps++;
93
- if (jumps > maxJumps)
94
- throw new Error(
95
- `cycle limit exceeded: ${jumps} step executions > maxJumps(${maxJumps})`
96
- );
113
+ prevIndex = meta.index;
97
114
  }
98
115
  });
99
116
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowneer",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Zero-dependency fluent flow builder for AI agents",
5
5
  "license": "MIT",
6
6
  "type": "module",