flowcraft 1.0.0-beta.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.
Files changed (148) hide show
  1. package/.editorconfig +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/config/tsconfig.json +21 -0
  5. package/config/tsup.config.ts +11 -0
  6. package/config/vitest.config.ts +11 -0
  7. package/docs/.vitepress/config.ts +105 -0
  8. package/docs/api-reference/builder.md +158 -0
  9. package/docs/api-reference/fn.md +142 -0
  10. package/docs/api-reference/index.md +38 -0
  11. package/docs/api-reference/workflow.md +126 -0
  12. package/docs/guide/advanced-guides/cancellation.md +117 -0
  13. package/docs/guide/advanced-guides/composition.md +68 -0
  14. package/docs/guide/advanced-guides/custom-executor.md +180 -0
  15. package/docs/guide/advanced-guides/error-handling.md +135 -0
  16. package/docs/guide/advanced-guides/logging.md +106 -0
  17. package/docs/guide/advanced-guides/middleware.md +106 -0
  18. package/docs/guide/advanced-guides/observability.md +175 -0
  19. package/docs/guide/best-practices/debugging.md +182 -0
  20. package/docs/guide/best-practices/state-management.md +120 -0
  21. package/docs/guide/best-practices/sub-workflow-data.md +95 -0
  22. package/docs/guide/best-practices/testing.md +187 -0
  23. package/docs/guide/builders.md +157 -0
  24. package/docs/guide/functional-api.md +133 -0
  25. package/docs/guide/index.md +178 -0
  26. package/docs/guide/recipes/creating-a-loop.md +113 -0
  27. package/docs/guide/recipes/data-processing-pipeline.md +123 -0
  28. package/docs/guide/recipes/fan-out-fan-in.md +112 -0
  29. package/docs/guide/recipes/index.md +15 -0
  30. package/docs/guide/recipes/resilient-api-call.md +110 -0
  31. package/docs/guide/tooling/graph-validation.md +160 -0
  32. package/docs/guide/tooling/mermaid.md +156 -0
  33. package/docs/index.md +56 -0
  34. package/eslint.config.js +16 -0
  35. package/package.json +40 -0
  36. package/pnpm-workspace.yaml +2 -0
  37. package/sandbox/1.basic/README.md +45 -0
  38. package/sandbox/1.basic/package.json +16 -0
  39. package/sandbox/1.basic/src/flow.ts +17 -0
  40. package/sandbox/1.basic/src/main.ts +22 -0
  41. package/sandbox/1.basic/src/nodes.ts +112 -0
  42. package/sandbox/1.basic/src/utils.ts +35 -0
  43. package/sandbox/1.basic/tsconfig.json +3 -0
  44. package/sandbox/2.research/README.md +46 -0
  45. package/sandbox/2.research/package.json +16 -0
  46. package/sandbox/2.research/src/flow.ts +14 -0
  47. package/sandbox/2.research/src/main.ts +31 -0
  48. package/sandbox/2.research/src/nodes.ts +108 -0
  49. package/sandbox/2.research/src/utils.ts +45 -0
  50. package/sandbox/2.research/src/visualize.ts +29 -0
  51. package/sandbox/2.research/tsconfig.json +3 -0
  52. package/sandbox/3.parallel/README.md +65 -0
  53. package/sandbox/3.parallel/package.json +16 -0
  54. package/sandbox/3.parallel/src/main.ts +45 -0
  55. package/sandbox/3.parallel/src/nodes.ts +43 -0
  56. package/sandbox/3.parallel/src/utils.ts +25 -0
  57. package/sandbox/3.parallel/tsconfig.json +3 -0
  58. package/sandbox/4.dag/README.md +179 -0
  59. package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
  60. package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
  61. package/sandbox/4.dag/data/2.job-application/200.json +103 -0
  62. package/sandbox/4.dag/data/2.job-application/201.json +31 -0
  63. package/sandbox/4.dag/data/2.job-application/202.json +31 -0
  64. package/sandbox/4.dag/data/2.job-application/README.md +58 -0
  65. package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
  66. package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
  67. package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
  68. package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
  69. package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
  70. package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
  71. package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
  72. package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
  73. package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
  74. package/sandbox/4.dag/package.json +19 -0
  75. package/sandbox/4.dag/src/main.ts +73 -0
  76. package/sandbox/4.dag/src/nodes.ts +134 -0
  77. package/sandbox/4.dag/src/registry.ts +87 -0
  78. package/sandbox/4.dag/src/types.ts +25 -0
  79. package/sandbox/4.dag/src/utils.ts +42 -0
  80. package/sandbox/4.dag/tsconfig.json +3 -0
  81. package/sandbox/5.distributed/.env.example +1 -0
  82. package/sandbox/5.distributed/README.md +88 -0
  83. package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
  84. package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
  85. package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
  86. package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
  87. package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
  88. package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
  89. package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
  90. package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
  91. package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
  92. package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
  93. package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
  94. package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
  95. package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
  96. package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
  97. package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
  98. package/sandbox/5.distributed/package.json +20 -0
  99. package/sandbox/5.distributed/src/client.ts +124 -0
  100. package/sandbox/5.distributed/src/executor.ts +69 -0
  101. package/sandbox/5.distributed/src/nodes.ts +136 -0
  102. package/sandbox/5.distributed/src/registry.ts +101 -0
  103. package/sandbox/5.distributed/src/types.ts +45 -0
  104. package/sandbox/5.distributed/src/utils.ts +69 -0
  105. package/sandbox/5.distributed/src/worker.ts +217 -0
  106. package/sandbox/5.distributed/tsconfig.json +3 -0
  107. package/sandbox/6.rag/.env.example +1 -0
  108. package/sandbox/6.rag/README.md +60 -0
  109. package/sandbox/6.rag/data/README.md +31 -0
  110. package/sandbox/6.rag/data/rag.json +58 -0
  111. package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
  112. package/sandbox/6.rag/package.json +18 -0
  113. package/sandbox/6.rag/src/main.ts +52 -0
  114. package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
  115. package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
  116. package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
  117. package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
  118. package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
  119. package/sandbox/6.rag/src/nodes/index.ts +28 -0
  120. package/sandbox/6.rag/src/registry.ts +23 -0
  121. package/sandbox/6.rag/src/types.ts +44 -0
  122. package/sandbox/6.rag/src/utils.ts +77 -0
  123. package/sandbox/6.rag/tsconfig.json +3 -0
  124. package/sandbox/tsconfig.json +13 -0
  125. package/src/builder/collection.test.ts +287 -0
  126. package/src/builder/collection.ts +269 -0
  127. package/src/builder/graph.test.ts +406 -0
  128. package/src/builder/graph.ts +336 -0
  129. package/src/builder/graph.types.ts +104 -0
  130. package/src/builder/index.ts +3 -0
  131. package/src/context.ts +111 -0
  132. package/src/errors.ts +34 -0
  133. package/src/executor.ts +29 -0
  134. package/src/executors/in-memory.test.ts +93 -0
  135. package/src/executors/in-memory.ts +140 -0
  136. package/src/functions.test.ts +191 -0
  137. package/src/functions.ts +117 -0
  138. package/src/index.ts +5 -0
  139. package/src/logger.ts +41 -0
  140. package/src/types.ts +75 -0
  141. package/src/utils/graph.test.ts +144 -0
  142. package/src/utils/graph.ts +182 -0
  143. package/src/utils/index.ts +3 -0
  144. package/src/utils/mermaid.test.ts +239 -0
  145. package/src/utils/mermaid.ts +133 -0
  146. package/src/utils/sleep.ts +20 -0
  147. package/src/workflow.test.ts +622 -0
  148. package/src/workflow.ts +561 -0
@@ -0,0 +1,45 @@
1
+ import { contextKey } from 'flowcraft'
2
+
3
+ // A generic structure for the `inputs` object in our node data.
4
+ // It maps a template key to a context key (or an array of fallback keys).
5
+ type NodeInputMap = Record<string, string | string[]>
6
+
7
+ export interface AgentNodeTypeMap {
8
+ 'llm-process': {
9
+ promptTemplate: string
10
+ inputs: NodeInputMap
11
+ }
12
+ 'llm-condition': {
13
+ promptTemplate: string
14
+ inputs: NodeInputMap
15
+ }
16
+ 'llm-router': {
17
+ promptTemplate: string
18
+ inputs: NodeInputMap
19
+ }
20
+ 'output': {
21
+ promptTemplate: string
22
+ inputs: NodeInputMap
23
+ outputKey?: string // defaults to 'final_output'
24
+ returnAction?: string
25
+ }
26
+ }
27
+
28
+ // A unique ID for an entire workflow execution.
29
+ export const RUN_ID = contextKey<string>('run_id')
30
+
31
+ export interface NodeJobPayload {
32
+ runId: string
33
+ workflowId: number
34
+ nodeId: string
35
+ context: Record<string, any>
36
+ params: Record<string, any>
37
+ }
38
+
39
+ export const FINAL_ACTION = Symbol('final_action')
40
+
41
+ export interface WorkflowStatus {
42
+ status: 'completed' | 'failed' | 'cancelled'
43
+ payload?: any
44
+ reason?: string
45
+ }
@@ -0,0 +1,69 @@
1
+ import type Redis from 'ioredis'
2
+ import type { WorkflowStatus } from './types'
3
+ import OpenAI from 'openai'
4
+ import 'dotenv/config'
5
+
6
+ const openaiClient = new OpenAI()
7
+
8
+ /**
9
+ * Calls the OpenAI Chat Completions API.
10
+ * @param prompt The user prompt to send to the LLM.
11
+ * @returns The content of the LLM's response as a string.
12
+ */
13
+ export async function callLLM(prompt: string): Promise<string> {
14
+ try {
15
+ console.log(`\n--- Sending to LLM ---\n${prompt.substring(0, 300)}...\n---------------------\n`)
16
+ const response = await openaiClient.chat.completions.create({
17
+ model: 'gpt-4o-mini',
18
+ messages: [{ role: 'user', content: prompt }],
19
+ temperature: 0.2,
20
+ })
21
+ const result = response.choices[0].message.content || ''
22
+ console.log(`--- Received from LLM ---\n${result}\n-----------------------\n`)
23
+ return result
24
+ }
25
+ catch (error: any) {
26
+ console.error('Error calling OpenAI API:', error)
27
+ throw new Error(`OpenAI API call failed: ${error.message}`)
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Resolves a template string by replacing {{key}} with values from a data object.
33
+ * This is crucial for dynamically constructing prompts.
34
+ */
35
+ export function resolveTemplate(template: string, data: Record<string, any>): string {
36
+ return template.replace(/\{\{(.*?)\}\}/g, (_, key) => {
37
+ const value = data[key.trim()]
38
+ if (value === undefined || value === null) {
39
+ console.warn(`Template variable '{{${key.trim()}}}' not found in data.`)
40
+ return `{{${key.trim()}}}`
41
+ }
42
+ return String(value)
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Polls Redis for the final status of a workflow run.
48
+ * @param redis The IORedis client instance.
49
+ * @param runId The unique ID of the workflow run to wait for.
50
+ * @param timeoutMs The maximum time to wait in milliseconds.
51
+ * @returns A promise that resolves with the final WorkflowStatus.
52
+ */
53
+ export async function waitForWorkflow(redis: Redis, runId: string, timeoutMs: number): Promise<WorkflowStatus> {
54
+ const statusKey = `workflow:status:${runId}`
55
+ const startTime = Date.now()
56
+
57
+ while (Date.now() - startTime < timeoutMs) {
58
+ const statusJson = await redis.get(statusKey)
59
+ if (statusJson) {
60
+ await redis.del(statusKey) // Clean up the key
61
+ return JSON.parse(statusJson) as WorkflowStatus
62
+ }
63
+ // Wait a bit before polling again
64
+ await new Promise(resolve => setTimeout(resolve, 500))
65
+ }
66
+
67
+ // If the loop finishes, it's a timeout.
68
+ return { status: 'failed', reason: `Timeout: Workflow did not complete within ${timeoutMs}ms.` }
69
+ }
@@ -0,0 +1,217 @@
1
+ import type { NodeJobPayload } from './types'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import readline from 'node:readline'
5
+ import { Queue, Worker } from 'bullmq'
6
+ import { AbortError, ConsoleLogger, Flow, TypedContext } from 'flowcraft'
7
+ import IORedis from 'ioredis'
8
+ import { WorkflowRegistry } from './registry'
9
+ import { FINAL_ACTION, RUN_ID } from './types'
10
+ import 'dotenv/config'
11
+
12
+ const QUEUE_NAME = 'distributed-flowcraft-queue'
13
+ const CANCELLATION_KEY_PREFIX = 'workflow:cancel:'
14
+
15
+ function getCancellationKey(runId: string) {
16
+ return `${CANCELLATION_KEY_PREFIX}${runId}`
17
+ }
18
+
19
+ async function setupCancellationListener(redis: IORedis, logger: ConsoleLogger) {
20
+ readline.emitKeypressEvents(process.stdin)
21
+ if (process.stdin.isTTY)
22
+ process.stdin.setRawMode(true)
23
+
24
+ logger.info('... Press \'c\' to cancel a running workflow ...')
25
+
26
+ process.stdin.on('keypress', (_str, key) => {
27
+ if (key.ctrl && key.name === 'c') {
28
+ process.exit()
29
+ }
30
+
31
+ if (key.name === 'c') {
32
+ const rl = readline.createInterface({
33
+ input: process.stdin,
34
+ output: process.stdout,
35
+ })
36
+
37
+ readline.clearLine(process.stdout, 0)
38
+ readline.cursorTo(process.stdout, 0)
39
+
40
+ rl.question('Enter Run ID to cancel: ', async (runId) => {
41
+ if (runId) {
42
+ logger.warn(`Signaling cancellation for Run ID: ${runId}`)
43
+ await redis.set(getCancellationKey(runId), 'true', 'EX', 3600)
44
+ }
45
+ rl.close()
46
+ })
47
+ }
48
+ })
49
+ }
50
+
51
+ async function main() {
52
+ const logger = new ConsoleLogger()
53
+ logger.info('--- Distributed Workflow Worker ---')
54
+
55
+ const redisConnection = new IORedis({ maxRetriesPerRequest: null })
56
+ const queue = new Queue(QUEUE_NAME, { connection: redisConnection })
57
+
58
+ // Define all use-case directories the worker should be aware of.
59
+ const useCaseDirectories = [
60
+ '1.blog-post',
61
+ '2.job-application',
62
+ '3.customer-review',
63
+ '4.content-moderation',
64
+ ].map(dir => path.join(process.cwd(), 'data', dir))
65
+
66
+ // Create and initialize the registry from all directories in one clean call.
67
+ const masterRegistry = await WorkflowRegistry.create(useCaseDirectories)
68
+
69
+ setupCancellationListener(redisConnection, logger)
70
+ logger.info(`Worker listening on queue: "${QUEUE_NAME}"`)
71
+
72
+ const worker = new Worker<NodeJobPayload>(QUEUE_NAME, async (job) => {
73
+ const { runId, workflowId, nodeId, params } = job.data
74
+ const statusKey = `workflow:status:${runId}`
75
+ const contextKey = `workflow:context:${runId}`
76
+
77
+ logger.info(`[Worker] Processing job: ${job.name} (Workflow: ${workflowId}, Run: ${runId})`)
78
+
79
+ const controller = new AbortController()
80
+ const pollInterval = setInterval(async () => {
81
+ if (await redisConnection.get(getCancellationKey(runId)) === 'true') {
82
+ logger.warn(`[Worker] Abort signal received for Run ID ${runId}. Aborting...`)
83
+ controller.abort()
84
+ clearInterval(pollInterval)
85
+ }
86
+ }, 500)
87
+
88
+ try {
89
+ if (controller.signal.aborted)
90
+ throw new AbortError(`Job for Run ID ${runId} was cancelled before starting.`)
91
+
92
+ const node = await masterRegistry.getNode(workflowId, nodeId)
93
+ if (!node)
94
+ throw new Error(`Node '${nodeId}' in workflow '${workflowId}' not found.`)
95
+
96
+ // Load the most up-to-date context from the Redis hash.
97
+ const contextData = await redisConnection.hgetall(contextKey)
98
+ const context = new TypedContext()
99
+
100
+ if (Object.keys(contextData).length === 0 && Object.keys(job.data.context).length > 0) {
101
+ // This is the first node for this run. Persist the initial context from the job payload.
102
+ const initialContextObject = job.data.context
103
+ for (const [key, value] of Object.entries(initialContextObject))
104
+ context.set(key, value)
105
+
106
+ const serializedInitialContext = Object.entries(initialContextObject).flatMap(([key, value]) => [key, JSON.stringify(value)])
107
+ if (serializedInitialContext.length > 0)
108
+ await redisConnection.hset(contextKey, ...serializedInitialContext)
109
+ }
110
+ else {
111
+ // For subsequent nodes, hydrate the context from the Redis hash.
112
+ for (const [key, value] of Object.entries(contextData)) {
113
+ try {
114
+ context.set(key, JSON.parse(value))
115
+ }
116
+ catch {
117
+ context.set(key, value) // Fallback for non-JSON strings
118
+ }
119
+ }
120
+ }
121
+
122
+ context.set(RUN_ID, runId)
123
+
124
+ const action = await node._run({
125
+ ctx: context,
126
+ params,
127
+ signal: controller.signal,
128
+ logger,
129
+ })
130
+
131
+ // Persist the entire updated context back to Redis for the next job.
132
+ const updatedContextObject = Object.fromEntries(context.entries())
133
+ const serializedUpdatedContext = Object.entries(updatedContextObject).flatMap(([key, value]) => {
134
+ if (typeof key === 'symbol')
135
+ return [] // Symbols cannot be keys in Redis hashes
136
+ return [key, JSON.stringify(value)]
137
+ })
138
+
139
+ if (serializedUpdatedContext.length > 0)
140
+ await redisConnection.hset(contextKey, ...serializedUpdatedContext)
141
+
142
+ if (action === FINAL_ACTION) {
143
+ logger.info(`[Worker] Final node executed for Run ID ${runId}. Reporting 'completed' status...`)
144
+ const finalPayload = context.get('__final_payload')
145
+ const statusPayload = { status: 'completed', payload: finalPayload ?? null }
146
+ await redisConnection.set(statusKey, JSON.stringify(statusPayload), 'EX', 3600)
147
+ await redisConnection.del(contextKey) // Clean up context hash
148
+ return
149
+ }
150
+
151
+ if (controller.signal.aborted)
152
+ throw new AbortError('Job cancelled after execution, before enqueueing next step.')
153
+
154
+ const successor = node.successors.get(action)
155
+ if (!successor) {
156
+ logger.info(`[Worker] Branch complete for run ${runId}. Node '${nodeId}' has no successor for action '${String(action)}'.`)
157
+ return
158
+ }
159
+
160
+ const nodesToEnqueue = (successor instanceof Flow) ? (successor as any).nodesToRun : [successor]
161
+
162
+ for (const nextNode of nodesToEnqueue) {
163
+ const nextNodeId = nextNode.id!
164
+ const predecessorCount = await masterRegistry.getPredecessorCount(workflowId, nextNodeId)
165
+
166
+ if (predecessorCount <= 1) {
167
+ logger.info(`[Worker] Enqueuing successor: ${nextNodeId} for run ${runId}.`)
168
+ await queue.add(nextNodeId, { runId, workflowId, nodeId: nextNodeId, context: {}, params })
169
+ }
170
+ else {
171
+ const joinKey = `workflow:join:${runId}:${nextNodeId}`
172
+ const completedCount = await redisConnection.incr(joinKey)
173
+ await redisConnection.expire(joinKey, 3600)
174
+
175
+ logger.info(`[Worker] Predecessor ${nodeId} completed for fan-in node ${nextNodeId}. (${completedCount}/${predecessorCount})`)
176
+
177
+ if (completedCount >= predecessorCount) {
178
+ logger.info(`[Worker] All ${predecessorCount} predecessors for ${nextNodeId} have completed. Enqueuing join node.`)
179
+ await queue.add(nextNodeId, { runId, workflowId, nodeId: nextNodeId, context: {}, params })
180
+ await redisConnection.del(joinKey)
181
+ }
182
+ }
183
+ }
184
+ }
185
+ catch (error) {
186
+ if (error instanceof AbortError) {
187
+ logger.warn(`[Worker] Job for Run ID ${runId} was aborted. Reporting 'cancelled' status.`)
188
+ const statusPayload = { status: 'cancelled', reason: error.message }
189
+ if (await redisConnection.setnx(statusKey, JSON.stringify(statusPayload))) {
190
+ await redisConnection.expire(statusKey, 3600)
191
+ await redisConnection.del(contextKey)
192
+ }
193
+ }
194
+ else {
195
+ logger.error(`[Worker] Job for Run ID ${runId} failed. Reporting 'failed' status.`, { error })
196
+ const statusPayload = { status: 'failed', reason: (error as Error).message }
197
+ if (await redisConnection.setnx(statusKey, JSON.stringify(statusPayload))) {
198
+ await redisConnection.expire(statusKey, 3600)
199
+ await redisConnection.del(contextKey)
200
+ }
201
+ throw error
202
+ }
203
+ }
204
+ finally {
205
+ clearInterval(pollInterval)
206
+ }
207
+ }, {
208
+ connection: redisConnection,
209
+ concurrency: 5,
210
+ })
211
+
212
+ worker.on('failed', (job, err) => {
213
+ logger.error(`Job ${job?.id} failed with error: ${err.message}`, { job, err })
214
+ })
215
+ }
216
+
217
+ main().catch(console.error)
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../tsconfig.json"
3
+ }
@@ -0,0 +1 @@
1
+ OPENAI_API_KEY="your-api-key-here"
@@ -0,0 +1,60 @@
1
+ # Advanced RAG Agent with Document Analysis
2
+
3
+ This example demonstrates a sophisticated Retrieval-Augmented Generation (RAG) agent built with Flowcraft. The workflow ingests and analyzes a document, uses embeddings to find relevant information, and generates a precise answer to a user's question.
4
+
5
+ This project serves two main purposes:
6
+
7
+ 1. To provide a practical, real-world example of a complex, multi-step AI workflow.
8
+ 2. To illustrate the importance of robust state serialization (`superjson`) when passing complex data structures (like `Map`, `Date`, and custom class instances) through a workflow's `Context`.
9
+
10
+ ## Features
11
+
12
+ - **RAG Pipeline**: Implements a full RAG pipeline: document loading, chunking, embedding generation, vector search, and final answer synthesis.
13
+ - **Complex Data Structures**: The workflow creates and manages `Map` objects, `Date` objects, and custom `DocumentChunk` and `SearchResult` class instances.
14
+ - **Robust Serialization**: At the end of the workflow, it demonstrates how `superjson` can correctly serialize the entire final context, preserving all complex data types that would be lost with `JSON.stringify`.
15
+ - **Declarative & Modular**: The entire workflow is defined in a single `rag.json` file, and the logic is broken down into reusable, single-responsibility nodes.
16
+
17
+ ## How to Run
18
+
19
+ 1. **Install dependencies**:
20
+
21
+ ```bash
22
+ npm install
23
+ ```
24
+
25
+ 2. **Set your OpenAI API key**:
26
+ Create a `.env` file in this project's root directory:
27
+
28
+ ```
29
+ OPENAI_API_KEY="your-api-key-here"
30
+ ```
31
+
32
+ 3. **Run the application**:
33
+
34
+ ```bash
35
+ npm start
36
+ ```
37
+
38
+ The application will process the `documents/sample-flowcraft.txt` file and answer a hard-coded question. You can change the question in `src/main.ts`.
39
+
40
+ ## How It Works
41
+
42
+ The workflow is defined in `data/rag.json` and executed by the `InMemoryExecutor`.
43
+
44
+ ```mermaid
45
+ graph TD
46
+ subgraph "Advanced RAG Agent"
47
+ A[Load & Chunk Document] --> B[Generate Embeddings in Parallel]
48
+ B --> C[Store in Vector DB]
49
+ C --> D[Vector Search for Question]
50
+ D --> E[Generate Final Answer]
51
+ end
52
+ ```
53
+
54
+ 1. **`LoadAndChunkNode`**: Reads the source document and splits it into smaller text chunks, creating `DocumentChunk` class instances which include an `ingestedAt: Date`.
55
+ 2. **`GenerateEmbeddingsNode`**: A `ParallelBatchFlow` that concurrently generates a vector embedding for each document chunk.
56
+ 3. **`StoreInVectorDBNode`**: Simulates storing the chunks and their embeddings in a vector database (represented as a `Map` in the context).
57
+ 4. **`VectorSearchNode`**: Takes a user's question, generates an embedding for it, and performs a cosine similarity search to find the most relevant chunks from the "database".
58
+ 5. **`LLMProcessNode`**: Takes the original question and the retrieved chunks (the "context") and passes them to an LLM to generate a final, synthesized answer.
59
+
60
+ At the conclusion, `main.ts` prints the final answer and then logs the entire `Context` object, serialized with `superjson`, to show that all the rich data types were preserved throughout the workflow's execution.
@@ -0,0 +1,31 @@
1
+ # Advanced RAG Agent Workflow
2
+
3
+ This workflow demonstrates a complete Retrieval-Augmented Generation (RAG) pipeline. It ingests a document, processes it into a searchable format, retrieves relevant context based on a question, and synthesizes a final answer.
4
+
5
+ This is a powerful example of a linear, data-processing workflow where each step enriches the `Context` for the subsequent step, culminating in a sophisticated AI-powered response.
6
+
7
+ ## Workflow ID: (Implicit, from file name `rag.json`)
8
+
9
+ ### Description
10
+
11
+ 1. **`load_and_chunk`**: Reads a source document from a file path. It splits the content into smaller, manageable text chunks, creating `DocumentChunk` objects that include metadata like an ID and ingestion timestamp.
12
+
13
+ 2. **`generate_embeddings`**: Takes the document chunks and, using a `ParallelBatchFlow`, calls an embedding API for each chunk concurrently. This efficiently transforms the text into numerical vector representations.
14
+
15
+ 3. **`store_in_db`**: Simulates the process of upserting the chunks and their corresponding embedding vectors into a vector database. The combined data is stored in the `Context` for the next step.
16
+
17
+ 4. **`vector_search`**: Performs the "retrieval" step. It generates an embedding for the user's question, calculates the similarity against all document chunks in the "database", and returns the top `k` most relevant chunks.
18
+
19
+ 5. **`generate_final_answer`**: The final "generation" step. It constructs a prompt containing the user's original question and the retrieved text chunks as context, then asks an LLM to synthesize a comprehensive answer based only on the provided information.
20
+
21
+ ### Visual Graph
22
+
23
+ ```mermaid
24
+ graph TD
25
+ subgraph "Advanced RAG Agent"
26
+ A[load_and_chunk] --> B[generate_embeddings]
27
+ B --> C[store_in_db]
28
+ C --> D[vector_search]
29
+ D --> E[generate_final_answer]
30
+ end
31
+ ```
@@ -0,0 +1,58 @@
1
+ {
2
+ "nodes": [
3
+ {
4
+ "id": "load_and_chunk",
5
+ "type": "load-and-chunk",
6
+ "data": {
7
+ "filePath": "./documents/sample-flowcraft.txt"
8
+ }
9
+ },
10
+ {
11
+ "id": "generate_embeddings",
12
+ "type": "generate-embeddings",
13
+ "data": {}
14
+ },
15
+ {
16
+ "id": "store_in_db",
17
+ "type": "store-in-db",
18
+ "data": {}
19
+ },
20
+ {
21
+ "id": "vector_search",
22
+ "type": "vector-search",
23
+ "data": {
24
+ "question": "How does Flowcraft handle conditional branching?",
25
+ "topK": 2
26
+ }
27
+ },
28
+ {
29
+ "id": "generate_final_answer",
30
+ "type": "llm-process",
31
+ "data": {
32
+ "promptTemplate": "Based on the following context, please provide a clear and concise answer to the user's question.\n\n**CONTEXT**\n\n{{context}}\n\n**QUESTION**\n\n{{question}}\n\n**ANSWER**",
33
+ "inputs": {
34
+ "context": "search_results",
35
+ "question": "question"
36
+ }
37
+ }
38
+ }
39
+ ],
40
+ "edges": [
41
+ {
42
+ "source": "load_and_chunk",
43
+ "target": "generate_embeddings"
44
+ },
45
+ {
46
+ "source": "generate_embeddings",
47
+ "target": "store_in_db"
48
+ },
49
+ {
50
+ "source": "store_in_db",
51
+ "target": "vector_search"
52
+ },
53
+ {
54
+ "source": "vector_search",
55
+ "target": "generate_final_answer"
56
+ }
57
+ ]
58
+ }
@@ -0,0 +1,11 @@
1
+ Flowcraft is a lightweight, zero-dependency TypeScript framework for building complex, multi-step processes. It empowers you to model everything from simple sequential tasks to dynamic, graph-driven AI agents with a clear and composable API.
2
+
3
+ The Node is the most fundamental building block. It represents a single, atomic unit of work in your process. Every Node has a well-defined, three-phase lifecycle that separates data preparation (prep), core logic (exec), and result processing (post). This separation makes nodes highly testable and reusable.
4
+
5
+ A Flow is a special type of Node that acts as an orchestrator. It doesn't have its own business logic; instead, its purpose is to manage the execution of a graph of other nodes. The Executor is the engine that runs the Flow, traversing the graph and executing each node.
6
+
7
+ An action is a string returned by a node's post() method. The Executor uses this string to determine which path to take next in the workflow graph. If a node returns the default action, the flow proceeds linearly. However, if a node returns a custom string like 'user_is_valid' or 'error_occurred', the Executor will look for a successor connected to that specific action. This is the primary mechanism for implementing conditional branching and creating dynamic, responsive workflows.
8
+
9
+ The Context is the shared memory of a running workflow. It is a type-safe, Map-like object that is passed to every single node, allowing different steps in the process to communicate and share state with each other. For example, an early node might fetch user data and place it in the context, while a later node reads that data to make a decision.
10
+
11
+ Flowcraft also supports advanced features like middleware for cross-cutting concerns, automatic retries with fallback logic for resilience, and robust cancellation support via standard AbortControllers. This makes it suitable for building production-grade applications.
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "rag-workflow",
3
+ "type": "module",
4
+ "scripts": {
5
+ "start": "npx tsx src/main.ts"
6
+ },
7
+ "dependencies": {
8
+ "dotenv": "^16.4.5",
9
+ "flowcraft": "workspace:*",
10
+ "openai": "^4.52.7",
11
+ "superjson": "^2.2.1"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "^24.0.13",
15
+ "tsx": "^4.16.2",
16
+ "typescript": "^5.5.4"
17
+ }
18
+ }
@@ -0,0 +1,52 @@
1
+ import type { TypedWorkflowGraph } from 'flowcraft'
2
+ import type { RagNodeTypeMap } from './types'
3
+ import { promises as fs } from 'node:fs'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { ConsoleLogger, TypedContext } from 'flowcraft'
7
+ import SuperJSON from 'superjson'
8
+ import { DOCUMENT_PATH, FINAL_ANSWER, keyRegistry, QUESTION } from './nodes'
9
+ import { ragGraphBuilder } from './registry'
10
+
11
+ async function main() {
12
+ console.log('--- RAG Agent Workflow ---')
13
+
14
+ // 1. Load the declarative workflow graph from the JSON file.
15
+ const graphPath = path.join(process.cwd(), 'data', 'rag.json')
16
+ const graphContent = await fs.readFile(graphPath, 'utf-8')
17
+ const graph: TypedWorkflowGraph<RagNodeTypeMap> = JSON.parse(graphContent)
18
+
19
+ // 2. Build the executable flow from the graph definition.
20
+ const { flow } = ragGraphBuilder.build(graph)
21
+
22
+ // 3. Set up the initial context for the workflow run.
23
+ const documentPath = path.join(process.cwd(), 'documents', 'sample-flowcraft.txt')
24
+ const context = new TypedContext()
25
+ context.set(DOCUMENT_PATH, documentPath)
26
+ context.set(QUESTION, 'How does Flowcraft handle conditional branching?')
27
+
28
+ // 4. Run the workflow.
29
+ await flow.run(context, { logger: new ConsoleLogger() })
30
+
31
+ console.log('\n--- Workflow Complete ---\n')
32
+
33
+ // 5. Inspect the final state of the context.
34
+ const finalAnswer = context.get(FINAL_ANSWER)
35
+ console.log('Final Answer:\n', finalAnswer)
36
+
37
+ // 6. Demonstrate robust serialization of the final context.
38
+ console.log('\n\n--- Final Context State (Serialized with SuperJSON) ---')
39
+
40
+ // maps the symbol to its string description for superjson
41
+ keyRegistry.forEach((symbolValue, stringKey) => SuperJSON.registerSymbol(symbolValue, stringKey))
42
+ const finalContextMap = new Map(context.entries())
43
+ const outputFilePath = path.join(process.cwd(), 'tmp', 'final-context.json')
44
+ const serializedObject = SuperJSON.serialize(finalContextMap)
45
+
46
+ // Save the full, untruncated data to a file for detailed inspection.
47
+ await fs.mkdir(path.dirname(outputFilePath), { recursive: true })
48
+ await fs.writeFile(outputFilePath, JSON.stringify(serializedObject, null, 2), 'utf-8')
49
+ console.log(`Full context saved to: ${outputFilePath}\n`)
50
+ }
51
+
52
+ main().catch(console.error)
@@ -0,0 +1,54 @@
1
+ import type { AbstractNode, NodeArgs } from 'flowcraft'
2
+ import { Node, ParallelBatchFlow } from 'flowcraft'
3
+ import { getEmbedding } from '../utils'
4
+ import { CHUNKS, EMBEDDINGS } from './index'
5
+
6
+ // The "worker" node that processes a single item from the batch.
7
+ class GetSingleEmbeddingNode extends Node<{ chunkId: string, text: string }, { chunkId: string, vector: number[] }> {
8
+ async exec({ params }: NodeArgs) {
9
+ const vector = await getEmbedding(params.text)
10
+ return { chunkId: params.chunkId, vector }
11
+ }
12
+
13
+ async post({ execRes }: NodeArgs) {
14
+ return execRes
15
+ }
16
+ }
17
+
18
+ // This is the main orchestrator node for this step.
19
+ export class GenerateEmbeddingsNode extends ParallelBatchFlow {
20
+ protected nodeToRun: AbstractNode = new GetSingleEmbeddingNode()
21
+
22
+ // The `prep` phase gathers the items to be processed in parallel.
23
+ async prep({ ctx }: NodeArgs) {
24
+ const chunks = ctx.get(CHUNKS)
25
+ if (!chunks)
26
+ return []
27
+
28
+ // Return an array of parameter objects for the batch processor.
29
+ return Array.from(chunks.values()).map(chunk => ({
30
+ chunkId: chunk.id,
31
+ text: chunk.text,
32
+ }))
33
+ }
34
+
35
+ // The `post` phase runs after all parallel jobs are complete to aggregate the results.
36
+ async post({ ctx, execRes, logger }: NodeArgs) {
37
+ const embeddings = new Map<string, number[]>()
38
+ const batchResults = execRes as PromiseSettledResult<{ chunkId: string, vector: number[] }>[] | undefined
39
+
40
+ if (batchResults) {
41
+ for (const result of batchResults) {
42
+ if (result.status === 'fulfilled' && result.value) {
43
+ embeddings.set(result.value.chunkId, result.value.vector)
44
+ }
45
+ else if (result.status === 'rejected') {
46
+ logger?.error('[GenerateEmbeddingsNode] A batch embedding generation failed.', { error: result.reason })
47
+ }
48
+ }
49
+ }
50
+
51
+ ctx.set(EMBEDDINGS, embeddings)
52
+ logger?.info(`[GenerateEmbeddingsNode] Generated ${embeddings.size} embeddings.`)
53
+ }
54
+ }
@@ -0,0 +1,48 @@
1
+ import type { NodeArgs } from 'flowcraft'
2
+ import type { RagNodeOptions, SearchResult } from '../types'
3
+ import { Node } from 'flowcraft'
4
+ import { callLLM, resolveTemplate } from '../utils'
5
+ import { FINAL_ANSWER, keyRegistry, SEARCH_RESULTS } from './index'
6
+
7
+ export class LLMProcessNode extends Node<string, string> {
8
+ private data: RagNodeOptions<'llm-process'>['data']
9
+
10
+ constructor(options: RagNodeOptions<'llm-process'>) {
11
+ super(options)
12
+ this.data = options.data
13
+ }
14
+
15
+ prep(args: NodeArgs): Promise<string> {
16
+ const template = this.data.promptTemplate
17
+ const templateData: Record<string, any> = {}
18
+
19
+ for (const [templateKey, contextKeyString] of Object.entries(this.data.inputs)) {
20
+ const keySymbol = keyRegistry.get(contextKeyString)
21
+ if (keySymbol) {
22
+ let value = args.ctx.get(keySymbol as any)
23
+
24
+ if (keySymbol === SEARCH_RESULTS) {
25
+ const searchResults = value as SearchResult[] | undefined
26
+ value = searchResults
27
+ ?.map(result => result.chunk.text)
28
+ .join('\n\n---\n\n') ?? ''
29
+ }
30
+
31
+ templateData[templateKey] = value
32
+ }
33
+ else {
34
+ args.logger.warn(`[LLMProcessNode] Unknown context key '${contextKeyString}' in graph definition.`)
35
+ }
36
+ }
37
+
38
+ return Promise.resolve(resolveTemplate(template, templateData))
39
+ }
40
+
41
+ exec(args: NodeArgs<string>): Promise<string> {
42
+ return callLLM(args.prepRes)
43
+ }
44
+
45
+ async post(args: NodeArgs<string, string>) {
46
+ args.ctx.set(FINAL_ANSWER, args.execRes)
47
+ }
48
+ }