flowneer 0.1.0

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 ADDED
@@ -0,0 +1,396 @@
1
+ # Flowneer
2
+
3
+ A tiny, zero-dependency fluent flow builder for TypeScript. Chain steps, branch on conditions, loop, batch-process, and run tasks in parallel — all through a single `FlowBuilder` class. Extend it with plugins.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add flowneer
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```typescript
14
+ import { FlowBuilder } from "flowneer";
15
+
16
+ interface State {
17
+ count: number;
18
+ }
19
+
20
+ await new FlowBuilder<State>()
21
+ .startWith(async (s) => {
22
+ s.count = 0;
23
+ })
24
+ .then(async (s) => {
25
+ s.count += 1;
26
+ })
27
+ .then(async (s) => {
28
+ console.log(s.count);
29
+ }) // 1
30
+ .run({ count: 0 });
31
+ ```
32
+
33
+ Every step receives a **shared state object** (`s`) that you mutate directly. That's the whole data model.
34
+
35
+ ## API
36
+
37
+ ### `startWith(fn, options?)`
38
+
39
+ Set the first step, resetting any prior chain.
40
+
41
+ ### `then(fn, options?)`
42
+
43
+ Append a sequential step.
44
+
45
+ ### `branch(router, branches, options?)`
46
+
47
+ Route to a named branch based on the return value of `router`.
48
+
49
+ ```typescript
50
+ interface AuthState {
51
+ role: string;
52
+ message: string;
53
+ }
54
+
55
+ await new FlowBuilder<AuthState>()
56
+ .startWith(async (s) => {
57
+ s.role = "admin";
58
+ })
59
+ .branch((s) => s.role, {
60
+ admin: async (s) => {
61
+ s.message = "Welcome, admin!";
62
+ },
63
+ guest: async (s) => {
64
+ s.message = "Limited access.";
65
+ },
66
+ })
67
+ .then(async (s) => console.log(s.message))
68
+ .run({ role: "", message: "" });
69
+ // → Welcome, admin!
70
+ ```
71
+
72
+ ### `loop(condition, body)`
73
+
74
+ Repeat a sub-flow while `condition` returns `true`.
75
+
76
+ ```typescript
77
+ interface TickState {
78
+ ticks: number;
79
+ }
80
+
81
+ await new FlowBuilder<TickState>()
82
+ .startWith(async (s) => {
83
+ s.ticks = 0;
84
+ })
85
+ .loop(
86
+ (s) => s.ticks < 3,
87
+ (b) =>
88
+ b.startWith(async (s) => {
89
+ s.ticks += 1;
90
+ }),
91
+ )
92
+ .then(async (s) => console.log("done, ticks =", s.ticks))
93
+ .run({ ticks: 0 });
94
+ // → done, ticks = 3
95
+ ```
96
+
97
+ ### `batch(items, processor)`
98
+
99
+ Run a sub-flow once per item. The current item is available as `shared.__batchItem`.
100
+
101
+ ```typescript
102
+ interface SumState {
103
+ numbers: number[];
104
+ results: number[];
105
+ __batchItem?: number;
106
+ }
107
+
108
+ await new FlowBuilder<SumState>()
109
+ .startWith(async (s) => {
110
+ s.results = [];
111
+ })
112
+ .batch(
113
+ (s) => s.numbers,
114
+ (b) =>
115
+ b.startWith(async (s) => {
116
+ s.results.push((s.__batchItem ?? 0) * 2);
117
+ }),
118
+ )
119
+ .then(async (s) => console.log(s.results))
120
+ .run({ numbers: [1, 2, 3], results: [] });
121
+ // → [2, 4, 6]
122
+ ```
123
+
124
+ ### `parallel(fns, options?)`
125
+
126
+ Run multiple functions concurrently against the same shared state.
127
+
128
+ ```typescript
129
+ interface FetchState {
130
+ posts?: any[];
131
+ users?: any[];
132
+ }
133
+
134
+ await new FlowBuilder<FetchState>()
135
+ .parallel([
136
+ async (s) => {
137
+ const res = await fetch("https://jsonplaceholder.typicode.com/posts");
138
+ s.posts = await res.json();
139
+ },
140
+ async (s) => {
141
+ const res = await fetch("https://jsonplaceholder.typicode.com/users");
142
+ s.users = await res.json();
143
+ },
144
+ ])
145
+ .then(async (s) => {
146
+ console.log(
147
+ "Fetched",
148
+ s.posts?.length,
149
+ "posts and",
150
+ s.users?.length,
151
+ "users",
152
+ );
153
+ })
154
+ .run({});
155
+ // → Fetched 100 posts and 10 users
156
+ ```
157
+
158
+ ### `run(shared, params?, options?)`
159
+
160
+ Execute the flow. Optionally pass a `params` object that every step receives as a second argument.
161
+
162
+ ```typescript
163
+ // Basic
164
+ await flow.run(shared);
165
+
166
+ // With params
167
+ await flow.run(shared, { userId: "123" });
168
+
169
+ // With AbortSignal — cancels between steps when the signal fires
170
+ const controller = new AbortController();
171
+ await flow.run(shared, undefined, { signal: controller.signal });
172
+ ```
173
+
174
+ ### Options
175
+
176
+ Any step that accepts `options` supports:
177
+
178
+ | Option | Default | Description |
179
+ | ----------- | ------- | ------------------------------------------------------ |
180
+ | `retries` | `1` | Number of attempts before throwing |
181
+ | `delaySec` | `0` | Seconds to wait between retries |
182
+ | `timeoutMs` | `0` | Milliseconds before the step is aborted (0 = no limit) |
183
+
184
+ ## Error handling
185
+
186
+ When a step throws, the error is wrapped in a `FlowError` with the step index and type:
187
+
188
+ ```typescript
189
+ import { FlowBuilder, FlowError } from "flowneer";
190
+
191
+ try {
192
+ await new FlowBuilder()
193
+ .startWith(async () => {})
194
+ .then(async () => {
195
+ throw new Error("boom");
196
+ })
197
+ .run({});
198
+ } catch (err) {
199
+ if (err instanceof FlowError) {
200
+ console.log(err.step); // "step 1"
201
+ console.log(err.cause); // Error: boom
202
+ }
203
+ }
204
+ ```
205
+
206
+ Errors inside `loop` and `batch` sub-flows are wrapped the same way:
207
+
208
+ ```
209
+ FlowError: Flow failed at loop (step 1): exploded on tick 2
210
+ FlowError: Flow failed at batch (step 0): bad item: 3
211
+ ```
212
+
213
+ ## Plugins
214
+
215
+ The core is intentionally small. Use `FlowBuilder.use(plugin)` to add chain methods.
216
+
217
+ A plugin is an object of functions that get copied onto `FlowBuilder.prototype`. Each function receives the builder as `this` and should return `this` for chaining.
218
+
219
+ ### Writing a plugin
220
+
221
+ ```typescript
222
+ import type { FlowBuilder, FlowneerPlugin, StepMeta } from "flowneer";
223
+
224
+ // 1. Augment the FlowBuilder interface for type safety
225
+ declare module "flowneer" {
226
+ interface FlowBuilder<S, P> {
227
+ withTracing(fn: (meta: StepMeta, event: string) => void): this;
228
+ }
229
+ }
230
+
231
+ // 2. Implement the plugin
232
+ export const observePlugin: FlowneerPlugin = {
233
+ withTracing(this: FlowBuilder<any, any>, fn) {
234
+ (this as any)._setHooks({
235
+ beforeStep: (meta: StepMeta) => fn(meta, "before"),
236
+ afterStep: (meta: StepMeta) => fn(meta, "after"),
237
+ onError: (meta: StepMeta) => fn(meta, "error"),
238
+ });
239
+ return this;
240
+ },
241
+ };
242
+ ```
243
+
244
+ ### Using a plugin
245
+
246
+ ```typescript
247
+ import { FlowBuilder } from "flowneer";
248
+ import { observePlugin } from "./observePlugin";
249
+
250
+ FlowBuilder.use(observePlugin); // one-time registration
251
+
252
+ const flow = new FlowBuilder<MyState>()
253
+ .withTracing((meta, event) => console.log(event, meta.type, meta.index))
254
+ .startWith(step1)
255
+ .then(step2);
256
+ ```
257
+
258
+ ### Lifecycle hooks
259
+
260
+ Plugins register hooks via `_setHooks()`. Three hook points are available:
261
+
262
+ | Hook | Called | Arguments |
263
+ | ------------ | -------------------------------------------- | ------------------------------- |
264
+ | `beforeStep` | Before each step executes | `(meta, shared, params)` |
265
+ | `afterStep` | After each step completes | `(meta, shared, params)` |
266
+ | `onError` | When a step throws (before re-throwing) | `(meta, error, shared, params)` |
267
+ | `afterFlow` | After the flow finishes (success or failure) | `(shared, params)` |
268
+
269
+ ### What plugins are for
270
+
271
+ | Concern | Example plugin | Hook it uses |
272
+ | --------------------------- | --------------- | -------------------------------------- |
273
+ | Observability / tracing | `observePlugin` | `beforeStep` + `afterStep` + `onError` |
274
+ | Persistence / checkpointing | `persistPlugin` | `afterStep` |
275
+ | Timing / metrics | custom | `beforeStep` + `afterStep` |
276
+ | Cleanup / teardown | custom | `afterFlow` |
277
+
278
+ See [examples/observePlugin.ts](examples/observePlugin.ts) and [examples/persistPlugin.ts](examples/persistPlugin.ts) for complete implementations.
279
+
280
+ ## AI agent example
281
+
282
+ Flowneer's primitives map directly to common agent patterns:
283
+
284
+ ```typescript
285
+ import { FlowBuilder } from "flowneer";
286
+
287
+ interface AgentState {
288
+ question: string;
289
+ history: Message[];
290
+ intent?: string;
291
+ answer?: string;
292
+ }
293
+
294
+ const agent = new FlowBuilder<AgentState>()
295
+ .startWith(classifyIntent)
296
+ .branch((s) => s.intent, {
297
+ weather: fetchWeather,
298
+ joke: tellJoke,
299
+ default: generalAnswer,
300
+ })
301
+ .then(formatAndRespond);
302
+
303
+ await agent.run({ question: "What's the weather in Paris?", history: [] });
304
+ ```
305
+
306
+ A ReAct-style loop:
307
+
308
+ ```typescript
309
+ const reactAgent = new FlowBuilder<AgentState>()
310
+ .startWith(think)
311
+ .loop(
312
+ (s) => !s.done,
313
+ (b) =>
314
+ b
315
+ .startWith(selectTool)
316
+ .branch(routeTool, {
317
+ search: webSearch,
318
+ code: runCode,
319
+ default: respond,
320
+ })
321
+ .then(observe),
322
+ )
323
+ .then(formatOutput);
324
+ ```
325
+
326
+ See [examples/assistantFlow.ts](examples/assistantFlow.ts) for a full interactive agent.
327
+
328
+ ### Agent-to-agent delegation
329
+
330
+ There is no special primitive for sub-agents — just call `anotherFlow.run(shared)` inside a `then`. Since `shared` is passed by reference, the sub-agent reads and writes the same state seamlessly:
331
+
332
+ ```typescript
333
+ const researchAgent = new FlowBuilder<ReportState>()
334
+ .startWith(searchWeb)
335
+ .then(summariseSources);
336
+
337
+ const writeAgent = new FlowBuilder<ReportState>()
338
+ .startWith(draftReport)
339
+ .then(formatMarkdown);
340
+
341
+ const orchestrator = new FlowBuilder<ReportState>()
342
+ .startWith(async (s) => {
343
+ s.query = "LLM benchmarks 2025";
344
+ })
345
+ .then(async (s) => researchAgent.run(s)) // delegate → sub-agent mutates s
346
+ .then(async (s) => writeAgent.run(s)) // delegate → sub-agent mutates s
347
+ .then(async (s) => console.log(s.report));
348
+ ```
349
+
350
+ Any number of flows can be composed this way. Each sub-agent is itself a `FlowBuilder`, so it can have its own retries, branches, and plugins.
351
+
352
+ ### Parallel sub-agents
353
+
354
+ Use `parallel` when sub-agents are independent and can run concurrently:
355
+
356
+ ```typescript
357
+ const sentimentAgent = new FlowBuilder<AnalysisState>()
358
+ .startWith(classifySentiment)
359
+ .then(scoreSentiment);
360
+
361
+ const summaryAgent = new FlowBuilder<AnalysisState>()
362
+ .startWith(extractKeyPoints)
363
+ .then(writeSummary);
364
+
365
+ const toxicityAgent = new FlowBuilder<AnalysisState>().startWith(checkToxicity);
366
+
367
+ const orchestrator = new FlowBuilder<AnalysisState>()
368
+ .startWith(async (s) => {
369
+ s.text = "...input text...";
370
+ })
371
+ .parallel([
372
+ (s) => sentimentAgent.run(s), // writes s.sentiment
373
+ (s) => summaryAgent.run(s), // writes s.summary
374
+ (s) => toxicityAgent.run(s), // writes s.toxicity
375
+ ])
376
+ .then(async (s) => {
377
+ console.log(s.sentiment, s.summary, s.toxicity);
378
+ });
379
+ ```
380
+
381
+ All three sub-agents share the same `shared` object and run concurrently. Avoid writing to the same key from parallel sub-agents — writes are not synchronised.
382
+
383
+ ## Project structure
384
+
385
+ ```
386
+ Flowneer.ts Core — FlowBuilder, FlowError, types (~380 lines)
387
+ index.ts Public exports
388
+ examples/
389
+ assistantFlow.ts Interactive LLM assistant with branching
390
+ observePlugin.ts Tracing plugin example
391
+ persistPlugin.ts Checkpoint plugin example
392
+ ```
393
+
394
+ ## License
395
+
396
+ MIT
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Function signature for all step logic.
3
+ * Return an action string to route, or undefined/void to continue.
4
+ */
5
+ type NodeFn<S = any, P extends Record<string, unknown> = Record<string, unknown>> = (shared: S, params: P) => Promise<string | undefined | void> | string | undefined | void;
6
+ interface NodeOptions {
7
+ retries?: number;
8
+ delaySec?: number;
9
+ timeoutMs?: number;
10
+ }
11
+ interface RunOptions {
12
+ signal?: AbortSignal;
13
+ }
14
+ interface FnStep<S, P extends Record<string, unknown>> {
15
+ type: "fn";
16
+ fn: NodeFn<S, P>;
17
+ retries: number;
18
+ delaySec: number;
19
+ timeoutMs: number;
20
+ }
21
+ interface BranchStep<S, P extends Record<string, unknown>> {
22
+ type: "branch";
23
+ router: NodeFn<S, P>;
24
+ branches: Record<string, NodeFn<S, P>>;
25
+ retries: number;
26
+ delaySec: number;
27
+ timeoutMs: number;
28
+ }
29
+ interface LoopStep<S, P extends Record<string, unknown>> {
30
+ type: "loop";
31
+ condition: (shared: S, params: P) => Promise<boolean> | boolean;
32
+ body: FlowBuilder<S, P>;
33
+ }
34
+ interface BatchStep<S, P extends Record<string, unknown>> {
35
+ type: "batch";
36
+ itemsExtractor: (shared: S, params: P) => Promise<any[]> | any[];
37
+ processor: FlowBuilder<S, P>;
38
+ }
39
+ interface ParallelStep<S, P extends Record<string, unknown>> {
40
+ type: "parallel";
41
+ fns: NodeFn<S, P>[];
42
+ retries: number;
43
+ delaySec: number;
44
+ timeoutMs: number;
45
+ }
46
+ type Step<S, P extends Record<string, unknown>> = FnStep<S, P> | BranchStep<S, P> | LoopStep<S, P> | BatchStep<S, P> | ParallelStep<S, P>;
47
+ /** Metadata exposed to hooks — intentionally minimal to avoid coupling. */
48
+ interface StepMeta {
49
+ index: number;
50
+ type: Step<any, any>["type"];
51
+ }
52
+ /** Lifecycle hooks that plugins can register. */
53
+ interface FlowHooks<S = any, P extends Record<string, unknown> = Record<string, unknown>> {
54
+ /** Fires once before the first step runs. */
55
+ beforeFlow?: (shared: S, params: P) => void | Promise<void>;
56
+ beforeStep?: (meta: StepMeta, shared: S, params: P) => void | Promise<void>;
57
+ /**
58
+ * Wraps step execution — call `next()` to invoke the step body.
59
+ * Omitting `next()` skips execution (dry-run, mock, etc.).
60
+ * Multiple `wrapStep` registrations are composed innermost-first.
61
+ */
62
+ wrapStep?: (meta: StepMeta, next: () => Promise<void>, shared: S, params: P) => Promise<void>;
63
+ afterStep?: (meta: StepMeta, shared: S, params: P) => void | Promise<void>;
64
+ onError?: (meta: StepMeta, error: unknown, shared: S, params: P) => void;
65
+ afterFlow?: (shared: S, params: P) => void | Promise<void>;
66
+ }
67
+ /**
68
+ * A plugin is an object whose keys become methods on `FlowBuilder.prototype`.
69
+ * Each method receives the builder as `this` and should return `this` for chaining.
70
+ *
71
+ * Use declaration merging to get type-safe access:
72
+ * ```ts
73
+ * declare module "flowneer" {
74
+ * interface FlowBuilder<S, P> { withTracing(fn: TraceCallback): this; }
75
+ * }
76
+ * ```
77
+ */
78
+ type FlowneerPlugin = Record<string, (this: FlowBuilder<any, any>, ...args: any[]) => any>;
79
+ /** Wraps step failures with context about which step failed. */
80
+ declare class FlowError extends Error {
81
+ readonly step: string;
82
+ readonly cause: unknown;
83
+ constructor(step: string, cause: unknown);
84
+ }
85
+ /**
86
+ * Fluent builder for composable flows.
87
+ *
88
+ * Steps execute sequentially in the order added. Call `.run(shared)` to execute.
89
+ *
90
+ * **Shared-state safety**: all steps operate on the same shared object.
91
+ * Mutate it directly; avoid spreading/replacing the entire object.
92
+ */
93
+ declare class FlowBuilder<S = any, P extends Record<string, unknown> = Record<string, unknown>> {
94
+ private steps;
95
+ private _hooksList;
96
+ /** Register a plugin — copies its methods onto `FlowBuilder.prototype`. */
97
+ static use(plugin: FlowneerPlugin): void;
98
+ /** Register lifecycle hooks (called by plugin methods, not by consumers). */
99
+ protected _setHooks(hooks: Partial<FlowHooks<S, P>>): void;
100
+ /** Set the first step, resetting any prior chain. */
101
+ startWith(fn: NodeFn<S, P>, options?: NodeOptions): this;
102
+ /** Append a sequential step. */
103
+ then(fn: NodeFn<S, P>, options?: NodeOptions): this;
104
+ /**
105
+ * Append a routing step.
106
+ * `router` returns a key; the matching branch flow executes, then the chain continues.
107
+ */
108
+ branch(router: NodeFn<S, P>, branches: Record<string, NodeFn<S, P>>, options?: NodeOptions): this;
109
+ /**
110
+ * Append a looping step.
111
+ * Repeatedly runs `body` while `condition` returns true.
112
+ */
113
+ loop(condition: (shared: S, params: P) => Promise<boolean> | boolean, body: (b: FlowBuilder<S, P>) => void): this;
114
+ /**
115
+ * Append a batch step.
116
+ * Runs `processor` once per item extracted by `items`, setting `shared.__batchItem` each time.
117
+ */
118
+ batch(items: (shared: S, params: P) => Promise<any[]> | any[], processor: (b: FlowBuilder<S, P>) => void): this;
119
+ /**
120
+ * Append a parallel step.
121
+ * Runs all `fns` concurrently against the same shared state.
122
+ */
123
+ parallel(fns: NodeFn<S, P>[], options?: NodeOptions): this;
124
+ /** Execute the flow. */
125
+ run(shared: S, params?: P, options?: RunOptions): Promise<void>;
126
+ protected _execute(shared: S, params: P, signal?: AbortSignal): Promise<void>;
127
+ private _addFn;
128
+ private _runSub;
129
+ private _retry;
130
+ private _withTimeout;
131
+ }
132
+
133
+ export { FlowBuilder, FlowError, type FlowHooks, type FlowneerPlugin, type NodeFn, type NodeOptions, type RunOptions, type StepMeta };