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.
- package/.editorconfig +9 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/config/tsconfig.json +21 -0
- package/config/tsup.config.ts +11 -0
- package/config/vitest.config.ts +11 -0
- package/docs/.vitepress/config.ts +105 -0
- package/docs/api-reference/builder.md +158 -0
- package/docs/api-reference/fn.md +142 -0
- package/docs/api-reference/index.md +38 -0
- package/docs/api-reference/workflow.md +126 -0
- package/docs/guide/advanced-guides/cancellation.md +117 -0
- package/docs/guide/advanced-guides/composition.md +68 -0
- package/docs/guide/advanced-guides/custom-executor.md +180 -0
- package/docs/guide/advanced-guides/error-handling.md +135 -0
- package/docs/guide/advanced-guides/logging.md +106 -0
- package/docs/guide/advanced-guides/middleware.md +106 -0
- package/docs/guide/advanced-guides/observability.md +175 -0
- package/docs/guide/best-practices/debugging.md +182 -0
- package/docs/guide/best-practices/state-management.md +120 -0
- package/docs/guide/best-practices/sub-workflow-data.md +95 -0
- package/docs/guide/best-practices/testing.md +187 -0
- package/docs/guide/builders.md +157 -0
- package/docs/guide/functional-api.md +133 -0
- package/docs/guide/index.md +178 -0
- package/docs/guide/recipes/creating-a-loop.md +113 -0
- package/docs/guide/recipes/data-processing-pipeline.md +123 -0
- package/docs/guide/recipes/fan-out-fan-in.md +112 -0
- package/docs/guide/recipes/index.md +15 -0
- package/docs/guide/recipes/resilient-api-call.md +110 -0
- package/docs/guide/tooling/graph-validation.md +160 -0
- package/docs/guide/tooling/mermaid.md +156 -0
- package/docs/index.md +56 -0
- package/eslint.config.js +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/sandbox/1.basic/README.md +45 -0
- package/sandbox/1.basic/package.json +16 -0
- package/sandbox/1.basic/src/flow.ts +17 -0
- package/sandbox/1.basic/src/main.ts +22 -0
- package/sandbox/1.basic/src/nodes.ts +112 -0
- package/sandbox/1.basic/src/utils.ts +35 -0
- package/sandbox/1.basic/tsconfig.json +3 -0
- package/sandbox/2.research/README.md +46 -0
- package/sandbox/2.research/package.json +16 -0
- package/sandbox/2.research/src/flow.ts +14 -0
- package/sandbox/2.research/src/main.ts +31 -0
- package/sandbox/2.research/src/nodes.ts +108 -0
- package/sandbox/2.research/src/utils.ts +45 -0
- package/sandbox/2.research/src/visualize.ts +29 -0
- package/sandbox/2.research/tsconfig.json +3 -0
- package/sandbox/3.parallel/README.md +65 -0
- package/sandbox/3.parallel/package.json +16 -0
- package/sandbox/3.parallel/src/main.ts +45 -0
- package/sandbox/3.parallel/src/nodes.ts +43 -0
- package/sandbox/3.parallel/src/utils.ts +25 -0
- package/sandbox/3.parallel/tsconfig.json +3 -0
- package/sandbox/4.dag/README.md +179 -0
- package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
- package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
- package/sandbox/4.dag/data/2.job-application/200.json +103 -0
- package/sandbox/4.dag/data/2.job-application/201.json +31 -0
- package/sandbox/4.dag/data/2.job-application/202.json +31 -0
- package/sandbox/4.dag/data/2.job-application/README.md +58 -0
- package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
- package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
- package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
- package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
- package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
- package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
- package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
- package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
- package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
- package/sandbox/4.dag/package.json +19 -0
- package/sandbox/4.dag/src/main.ts +73 -0
- package/sandbox/4.dag/src/nodes.ts +134 -0
- package/sandbox/4.dag/src/registry.ts +87 -0
- package/sandbox/4.dag/src/types.ts +25 -0
- package/sandbox/4.dag/src/utils.ts +42 -0
- package/sandbox/4.dag/tsconfig.json +3 -0
- package/sandbox/5.distributed/.env.example +1 -0
- package/sandbox/5.distributed/README.md +88 -0
- package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
- package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
- package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
- package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
- package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
- package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
- package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
- package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
- package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
- package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
- package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
- package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
- package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
- package/sandbox/5.distributed/package.json +20 -0
- package/sandbox/5.distributed/src/client.ts +124 -0
- package/sandbox/5.distributed/src/executor.ts +69 -0
- package/sandbox/5.distributed/src/nodes.ts +136 -0
- package/sandbox/5.distributed/src/registry.ts +101 -0
- package/sandbox/5.distributed/src/types.ts +45 -0
- package/sandbox/5.distributed/src/utils.ts +69 -0
- package/sandbox/5.distributed/src/worker.ts +217 -0
- package/sandbox/5.distributed/tsconfig.json +3 -0
- package/sandbox/6.rag/.env.example +1 -0
- package/sandbox/6.rag/README.md +60 -0
- package/sandbox/6.rag/data/README.md +31 -0
- package/sandbox/6.rag/data/rag.json +58 -0
- package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
- package/sandbox/6.rag/package.json +18 -0
- package/sandbox/6.rag/src/main.ts +52 -0
- package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
- package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
- package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
- package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
- package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
- package/sandbox/6.rag/src/nodes/index.ts +28 -0
- package/sandbox/6.rag/src/registry.ts +23 -0
- package/sandbox/6.rag/src/types.ts +44 -0
- package/sandbox/6.rag/src/utils.ts +77 -0
- package/sandbox/6.rag/tsconfig.json +3 -0
- package/sandbox/tsconfig.json +13 -0
- package/src/builder/collection.test.ts +287 -0
- package/src/builder/collection.ts +269 -0
- package/src/builder/graph.test.ts +406 -0
- package/src/builder/graph.ts +336 -0
- package/src/builder/graph.types.ts +104 -0
- package/src/builder/index.ts +3 -0
- package/src/context.ts +111 -0
- package/src/errors.ts +34 -0
- package/src/executor.ts +29 -0
- package/src/executors/in-memory.test.ts +93 -0
- package/src/executors/in-memory.ts +140 -0
- package/src/functions.test.ts +191 -0
- package/src/functions.ts +117 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +41 -0
- package/src/types.ts +75 -0
- package/src/utils/graph.test.ts +144 -0
- package/src/utils/graph.ts +182 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/mermaid.test.ts +239 -0
- package/src/utils/mermaid.ts +133 -0
- package/src/utils/sleep.ts +20 -0
- package/src/workflow.test.ts +622 -0
- package/src/workflow.ts +561 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Use Case 4: Content Moderation
|
|
2
|
+
|
|
3
|
+
This workflow demonstrates a sophisticated "router" pattern. It performs multiple parallel analyses on a piece of content and then uses a central LLM-powered router node to decide which of several distinct moderation actions to take.
|
|
4
|
+
|
|
5
|
+
## Main Workflow ID: 400
|
|
6
|
+
|
|
7
|
+
### Description
|
|
8
|
+
|
|
9
|
+
1. **Parallel Analysis**: The workflow begins by performing three independent checks on the `userPost` in parallel:
|
|
10
|
+
- `check_for_pii`: Looks for Personally Identifiable Information.
|
|
11
|
+
- `check_for_hate_speech`: Analyzes the severity of any hate speech.
|
|
12
|
+
- `check_for_spam`: Detects if the post is commercial spam.
|
|
13
|
+
2. **Router Node**: All three analysis results are fed into the `triage_post` node. This special `llm-router` node uses an LLM to evaluate the combined inputs against a set of rules and returns a single action string (e.g., `action_ban`, `action_redact`).
|
|
14
|
+
3. **Multi-Way Branching**: The action string from the router determines which of the four possible paths is executed.
|
|
15
|
+
- `action_ban` -> Sub-Workflow 401 (Ban User)
|
|
16
|
+
- `action_redact` -> Sub-Workflow 402 (Redact Post)
|
|
17
|
+
- `action_delete_spam` -> Sub-Workflow 403 (Delete Spam)
|
|
18
|
+
- `action_approve` -> A simple node to log the approval.
|
|
19
|
+
4. **Convergence**: The result from the chosen moderation path is captured in the `final_log` node.
|
|
20
|
+
|
|
21
|
+
### Visual Graph
|
|
22
|
+
|
|
23
|
+
```mermaid
|
|
24
|
+
graph TD
|
|
25
|
+
subgraph "Content Moderation (ID: 400)"
|
|
26
|
+
A(User Post) --> B[check_for_pii]
|
|
27
|
+
A --> C[check_for_hate_speech]
|
|
28
|
+
A --> D[check_for_spam]
|
|
29
|
+
|
|
30
|
+
B --> E{triage_post}
|
|
31
|
+
C --> E
|
|
32
|
+
D --> E
|
|
33
|
+
|
|
34
|
+
E -- action_ban --> F["Sub-Workflow: Ban User (401)"]
|
|
35
|
+
E -- action_redact --> G["Sub-Workflow: Redact Post (402)"]
|
|
36
|
+
E -- action_delete_spam --> H["Sub-Workflow: Delete Spam (403)"]
|
|
37
|
+
E -- action_approve --> I[approve_post_branch]
|
|
38
|
+
|
|
39
|
+
F --> Z[final_log]
|
|
40
|
+
G --> Z
|
|
41
|
+
H --> Z
|
|
42
|
+
I --> Z
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Sub-Workflows
|
|
49
|
+
|
|
50
|
+
### Sub-Workflow ID: 401 (Ban User)
|
|
51
|
+
|
|
52
|
+
This workflow simulates banning a user by performing a database update and creating a security log entry in parallel.
|
|
53
|
+
|
|
54
|
+
```mermaid
|
|
55
|
+
graph TD
|
|
56
|
+
subgraph "Ban User Actions (ID: 401)"
|
|
57
|
+
A[api_call_ban_user] --> C[output_ban_summary]
|
|
58
|
+
B[log_ban_event] --> C
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Sub-Workflow ID: 402 (Redact Post)
|
|
63
|
+
|
|
64
|
+
This workflow redacts PII from the user's post and generates a warning message for the user, with both actions running in parallel.
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
graph TD
|
|
68
|
+
subgraph "Redact Post Actions (ID: 402)"
|
|
69
|
+
A[redact_pii] --> C[output_redaction_summary]
|
|
70
|
+
B[generate_warning] --> C
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Sub-Workflow ID: 403 (Delete Spam)
|
|
75
|
+
|
|
76
|
+
A simple, linear workflow that simulates deleting a spam post and flagging the user.
|
|
77
|
+
|
|
78
|
+
```mermaid
|
|
79
|
+
graph TD
|
|
80
|
+
subgraph "Delete Spam Action (ID: 403)"
|
|
81
|
+
A[delete_and_flag_user] --> B[output_spam_summary]
|
|
82
|
+
end
|
|
83
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dag-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
|
+
"lodash-es": "^4.17.21",
|
|
11
|
+
"openai": "^4.52.7",
|
|
12
|
+
"yaml": "^2.5.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/lodash-es": "^4.17.12",
|
|
16
|
+
"@types/node": "^24.0.13",
|
|
17
|
+
"typescript": "^5.5.4"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { ConsoleLogger, TypedContext } from 'flowcraft'
|
|
4
|
+
import { WorkflowRegistry } from './registry'
|
|
5
|
+
|
|
6
|
+
const config = {
|
|
7
|
+
'1.blog-post': {
|
|
8
|
+
mainWorkflowId: 100,
|
|
9
|
+
allWorkflowIds: [100],
|
|
10
|
+
getInitialContext: () => new TypedContext([
|
|
11
|
+
['topic', 'The rise of AI-powered workflow automation in modern software development.'],
|
|
12
|
+
]),
|
|
13
|
+
},
|
|
14
|
+
'2.job-application': {
|
|
15
|
+
mainWorkflowId: 200,
|
|
16
|
+
allWorkflowIds: [200, 201, 202],
|
|
17
|
+
getInitialContext: () => new TypedContext([
|
|
18
|
+
['applicantName', 'Jane Doe'],
|
|
19
|
+
['resume', 'Experienced developer with a background in TypeScript, Node.js, and building complex DAG workflow systems. Also proficient in React and SQL.'],
|
|
20
|
+
['coverLetter', 'To Whom It May Concern, I am writing to express my interest in the Senior Developer position. My skills and experience align perfectly with the requirements of the role.'],
|
|
21
|
+
]),
|
|
22
|
+
},
|
|
23
|
+
'3.customer-review': {
|
|
24
|
+
mainWorkflowId: 300,
|
|
25
|
+
allWorkflowIds: [300, 301, 302],
|
|
26
|
+
getInitialContext: () => new TypedContext([
|
|
27
|
+
['initial_review', 'The new dashboard is a huge improvement, but I noticed that the export-to-PDF feature is really slow and sometimes crashes the app on large datasets. It would be great if you could look into this.'],
|
|
28
|
+
]),
|
|
29
|
+
},
|
|
30
|
+
'4.content-moderation': {
|
|
31
|
+
mainWorkflowId: 400,
|
|
32
|
+
allWorkflowIds: [400, 401, 402, 403],
|
|
33
|
+
getInitialContext: () => new TypedContext([
|
|
34
|
+
['userId', 'user-456'],
|
|
35
|
+
// Try different posts to test different paths
|
|
36
|
+
// Path 1: PII detection
|
|
37
|
+
['userPost', 'Hi, I need help with my account. My email is test@example.com and my phone is 555-123-4567.'],
|
|
38
|
+
// Path 2: Spam
|
|
39
|
+
// ['userPost', '!!!BUY NOW!!! Visit my-scam-site.com for a FREE PRIZE! Limited time offer!'],
|
|
40
|
+
// Path 3: Severe hate speech
|
|
41
|
+
// ['userPost', `I don't want any dirty immigrants in my country, stealing, raping, and killing my people. They should all be eradicated!`],
|
|
42
|
+
// Path 4: Moderate hate speech (approve)
|
|
43
|
+
// ['userPost', `I don't want any illegal immigrants in my country.`],
|
|
44
|
+
// Path 5: Approved post
|
|
45
|
+
// ['userPost', 'I really enjoy using this platform. The new features are great and very helpful.'],
|
|
46
|
+
]),
|
|
47
|
+
},
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
type UseCase = keyof typeof config
|
|
51
|
+
type WorkflowId<U extends UseCase> = (typeof config)[U]['allWorkflowIds'][number]
|
|
52
|
+
|
|
53
|
+
// --- CONFIGURATION ---
|
|
54
|
+
const ACTIVE_USE_CASE: UseCase = '4.content-moderation'
|
|
55
|
+
const WORKFLOW_ID: WorkflowId<typeof ACTIVE_USE_CASE> = config[ACTIVE_USE_CASE].mainWorkflowId
|
|
56
|
+
|
|
57
|
+
async function main() {
|
|
58
|
+
console.log(`--- Running Use-Case: ${ACTIVE_USE_CASE}, Workflow ID: ${WORKFLOW_ID} ---\n`)
|
|
59
|
+
|
|
60
|
+
const useCaseDirectory = path.join(process.cwd(), 'data', ACTIVE_USE_CASE)
|
|
61
|
+
const registry = await WorkflowRegistry.create(useCaseDirectory)
|
|
62
|
+
|
|
63
|
+
const flow = await registry.getFlow(WORKFLOW_ID)
|
|
64
|
+
const context = config[ACTIVE_USE_CASE].getInitialContext()
|
|
65
|
+
|
|
66
|
+
await flow.run(context, { logger: new ConsoleLogger() })
|
|
67
|
+
|
|
68
|
+
console.log('\n--- Workflow Complete ---\n')
|
|
69
|
+
console.log('Final Output:\n')
|
|
70
|
+
console.log(context.get('final_output'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
main().catch(console.error)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { NodeArgs, NodeOptions } from 'flowcraft'
|
|
2
|
+
import type { WorkflowRegistry } from './registry'
|
|
3
|
+
import type { AgentNodeTypeMap } from './types'
|
|
4
|
+
import { DEFAULT_ACTION, Node } from 'flowcraft'
|
|
5
|
+
import { callLLM, resolveTemplate } from './utils'
|
|
6
|
+
|
|
7
|
+
interface AiNodeOptions<T extends keyof AgentNodeTypeMap> extends NodeOptions {
|
|
8
|
+
data: AgentNodeTypeMap[T] & { nodeId: string }
|
|
9
|
+
registry?: WorkflowRegistry
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A generic node that executes an LLM prompt.
|
|
14
|
+
* The prompt is a template that gets resolved with inputs from the context.
|
|
15
|
+
*/
|
|
16
|
+
export class LLMProcessNode extends Node<string, string> {
|
|
17
|
+
private data: AiNodeOptions<'llm-process'>['data']
|
|
18
|
+
|
|
19
|
+
constructor(options: AiNodeOptions<'llm-process'>) {
|
|
20
|
+
super(options)
|
|
21
|
+
this.data = options.data
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
prep(args: NodeArgs): Promise<string> {
|
|
25
|
+
const template = this.data.promptTemplate
|
|
26
|
+
const inputMappings = this.data.inputs
|
|
27
|
+
const templateData: Record<string, any> = {}
|
|
28
|
+
|
|
29
|
+
for (const [templateKey, sourcePathOrPaths] of Object.entries(inputMappings)) {
|
|
30
|
+
const sourcePaths = Array.isArray(sourcePathOrPaths) ? sourcePathOrPaths : [sourcePathOrPaths]
|
|
31
|
+
let value: any
|
|
32
|
+
|
|
33
|
+
for (const sourcePath of sourcePaths) {
|
|
34
|
+
value = args.ctx.get(sourcePath)
|
|
35
|
+
if (value !== undefined)
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// If the value is still undefined after checking all sources, use an empty string.
|
|
40
|
+
// This prevents '{{placeholder}}' from appearing in the final output.
|
|
41
|
+
if (value === undefined) {
|
|
42
|
+
args.logger.warn(`[Node: ${this.data.nodeId}] Template variable '{{${templateKey}}}' could not be resolved. Using empty string.`)
|
|
43
|
+
templateData[templateKey] = ''
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
templateData[templateKey] = value
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const resolvedPrompt = resolveTemplate(template, templateData)
|
|
51
|
+
return Promise.resolve(resolvedPrompt)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
exec(args: NodeArgs<string>): Promise<string> {
|
|
55
|
+
args.logger.info(`[Node: ${this.data.nodeId}] Executing LLM process...`)
|
|
56
|
+
return callLLM(args.prepRes)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async post(args: NodeArgs<string, string>) {
|
|
60
|
+
const keyToSet = this.data.outputKey || this.data.nodeId
|
|
61
|
+
args.ctx.set(keyToSet, args.execRes)
|
|
62
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Process complete. Result in context key '${keyToSet}'.`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An LLM-powered node that evaluates a condition and returns 'true' or 'false'.
|
|
68
|
+
*/
|
|
69
|
+
export class LLMConditionNode extends Node<string, string, 'true' | 'false'> {
|
|
70
|
+
private data: AiNodeOptions<'llm-condition'>['data']
|
|
71
|
+
|
|
72
|
+
constructor(options: AiNodeOptions<'llm-condition'>) {
|
|
73
|
+
super(options)
|
|
74
|
+
this.data = options.data
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
prep = LLMProcessNode.prototype.prep
|
|
78
|
+
|
|
79
|
+
exec(args: NodeArgs<string>): Promise<string> {
|
|
80
|
+
args.logger.info(`[Node: ${this.data.nodeId}] Evaluating condition...`)
|
|
81
|
+
return callLLM(args.prepRes)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async post(args: NodeArgs<string, string>): Promise<'true' | 'false'> {
|
|
85
|
+
const result = args.execRes.toLowerCase().includes('true') ? 'true' : 'false'
|
|
86
|
+
args.ctx.set(this.data.nodeId, result)
|
|
87
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Condition evaluated to: ${result}`)
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* An LLM-powered node that returns its raw output as an action for dynamic routing.
|
|
94
|
+
*/
|
|
95
|
+
export class LLMRouterNode extends Node<string, string, string> {
|
|
96
|
+
private data: AiNodeOptions<'llm-router'>['data']
|
|
97
|
+
|
|
98
|
+
constructor(options: AiNodeOptions<'llm-router'>) {
|
|
99
|
+
super(options)
|
|
100
|
+
this.data = options.data
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
prep = LLMProcessNode.prototype.prep
|
|
104
|
+
exec = LLMProcessNode.prototype.exec
|
|
105
|
+
|
|
106
|
+
async post(args: NodeArgs<string, string>): Promise<string> {
|
|
107
|
+
const result = args.execRes.trim()
|
|
108
|
+
args.ctx.set(this.data.nodeId, result)
|
|
109
|
+
args.logger.info(`[Node: ${this.data.nodeId}] ✓ Routing decision is: '${result}'`)
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Aggregates inputs and sets a final value in the context.
|
|
116
|
+
*/
|
|
117
|
+
export class OutputNode extends Node<string, void> {
|
|
118
|
+
private data: AiNodeOptions<'output'>['data']
|
|
119
|
+
|
|
120
|
+
constructor(options: AiNodeOptions<'output'>) {
|
|
121
|
+
super(options)
|
|
122
|
+
this.data = options.data
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
prep = LLMProcessNode.prototype.prep
|
|
126
|
+
|
|
127
|
+
async post(args: NodeArgs<string, void>): Promise<string | typeof DEFAULT_ACTION> {
|
|
128
|
+
const finalResult = args.prepRes
|
|
129
|
+
const outputKey = this.data.outputKey
|
|
130
|
+
args.ctx.set(outputKey, finalResult)
|
|
131
|
+
args.logger.info(`[Output] Workflow finished. Final value set to context key '${outputKey}'.`)
|
|
132
|
+
return this.data.returnAction || DEFAULT_ACTION
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Flow, TypedWorkflowGraph } from 'flowcraft'
|
|
2
|
+
import type { AgentNodeTypeMap } from './types'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { ConsoleLogger, createNodeRegistry, GraphBuilder } from 'flowcraft'
|
|
6
|
+
import {
|
|
7
|
+
LLMConditionNode,
|
|
8
|
+
LLMProcessNode,
|
|
9
|
+
LLMRouterNode,
|
|
10
|
+
OutputNode,
|
|
11
|
+
} from './nodes'
|
|
12
|
+
|
|
13
|
+
export const nodeRegistry = createNodeRegistry({
|
|
14
|
+
'llm-process': LLMProcessNode,
|
|
15
|
+
'llm-condition': LLMConditionNode,
|
|
16
|
+
'llm-router': LLMRouterNode,
|
|
17
|
+
'output': OutputNode,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export class WorkflowRegistry {
|
|
21
|
+
private flowCache = new Map<number, Flow>()
|
|
22
|
+
private graphDatabase = new Map<number, TypedWorkflowGraph<AgentNodeTypeMap>>()
|
|
23
|
+
private builder: GraphBuilder<AgentNodeTypeMap>
|
|
24
|
+
private isInitialized = false
|
|
25
|
+
|
|
26
|
+
private constructor() {
|
|
27
|
+
this.builder = new GraphBuilder(
|
|
28
|
+
nodeRegistry,
|
|
29
|
+
{ registry: this },
|
|
30
|
+
{ subWorkflowNodeTypes: ['sub-workflow'] },
|
|
31
|
+
new ConsoleLogger(),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public static async create(useCaseDirectory: string): Promise<WorkflowRegistry> {
|
|
36
|
+
const registry = new WorkflowRegistry()
|
|
37
|
+
await registry.initialize(useCaseDirectory)
|
|
38
|
+
return registry
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async initialize(useCaseDirectory: string): Promise<void> {
|
|
42
|
+
if (this.isInitialized)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const files = await fs.readdir(useCaseDirectory)
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
if (path.extname(file) === '.json') {
|
|
49
|
+
const workflowId = Number.parseInt(path.basename(file, '.json'), 10)
|
|
50
|
+
if (!Number.isNaN(workflowId)) {
|
|
51
|
+
const filePath = path.join(useCaseDirectory, file)
|
|
52
|
+
const fileContent = await fs.readFile(filePath, 'utf-8')
|
|
53
|
+
const graphData: TypedWorkflowGraph<AgentNodeTypeMap> = JSON.parse(fileContent)
|
|
54
|
+
this.graphDatabase.set(workflowId, graphData)
|
|
55
|
+
console.log(`[Registry] Loaded workflow ${workflowId} from ${file}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.isInitialized = true
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error(`[Registry] Failed to initialize from directory ${useCaseDirectory}`, error)
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public getGraph(workflowId: number): TypedWorkflowGraph<AgentNodeTypeMap> | undefined {
|
|
68
|
+
return this.graphDatabase.get(workflowId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getFlow(workflowId: number): Promise<Flow> {
|
|
72
|
+
if (!this.isInitialized)
|
|
73
|
+
throw new Error('Registry is not initialized. Call `await WorkflowRegistry.create()`')
|
|
74
|
+
|
|
75
|
+
if (this.flowCache.has(workflowId))
|
|
76
|
+
return this.flowCache.get(workflowId)!
|
|
77
|
+
|
|
78
|
+
console.log(`[Registry] Cache miss for workflow ${workflowId}. Building...`)
|
|
79
|
+
const graphData = this.graphDatabase.get(workflowId)
|
|
80
|
+
if (!graphData)
|
|
81
|
+
throw new Error(`Workflow with id ${workflowId} not found in the database.`)
|
|
82
|
+
|
|
83
|
+
const { flow } = this.builder.build(graphData)
|
|
84
|
+
this.flowCache.set(workflowId, flow)
|
|
85
|
+
return flow
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A generic structure for the `inputs` object in our node data.
|
|
2
|
+
// It maps a template key to a context key (or an array of fallback keys).
|
|
3
|
+
type NodeInputMap = Record<string, string | string[]>
|
|
4
|
+
|
|
5
|
+
export interface AgentNodeTypeMap {
|
|
6
|
+
'llm-process': {
|
|
7
|
+
promptTemplate: string
|
|
8
|
+
inputs: NodeInputMap
|
|
9
|
+
outputKey?: string
|
|
10
|
+
}
|
|
11
|
+
'llm-condition': {
|
|
12
|
+
promptTemplate: string
|
|
13
|
+
inputs: NodeInputMap
|
|
14
|
+
}
|
|
15
|
+
'llm-router': {
|
|
16
|
+
promptTemplate: string
|
|
17
|
+
inputs: NodeInputMap
|
|
18
|
+
}
|
|
19
|
+
'output': {
|
|
20
|
+
promptTemplate: string
|
|
21
|
+
inputs: NodeInputMap
|
|
22
|
+
outputKey: string
|
|
23
|
+
returnAction?: string
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import OpenAI from 'openai'
|
|
2
|
+
import 'dotenv/config'
|
|
3
|
+
|
|
4
|
+
const openaiClient = new OpenAI()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calls the OpenAI Chat Completions API.
|
|
8
|
+
* @param prompt The user prompt to send to the LLM.
|
|
9
|
+
* @returns The content of the LLM's response as a string.
|
|
10
|
+
*/
|
|
11
|
+
export async function callLLM(prompt: string): Promise<string> {
|
|
12
|
+
try {
|
|
13
|
+
console.log(`\n--- Sending to LLM ---\n${prompt.substring(0, 300)}...\n---------------------\n`)
|
|
14
|
+
const response = await openaiClient.chat.completions.create({
|
|
15
|
+
model: 'gpt-4o-mini',
|
|
16
|
+
messages: [{ role: 'user', content: prompt }],
|
|
17
|
+
temperature: 0.2,
|
|
18
|
+
})
|
|
19
|
+
const result = response.choices[0].message.content || ''
|
|
20
|
+
console.log(`--- Received from LLM ---\n${result}\n-----------------------\n`)
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
catch (error: any) {
|
|
24
|
+
console.error('Error calling OpenAI API:', error)
|
|
25
|
+
throw new Error(`OpenAI API call failed: ${error.message}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves a template string by replacing {{key}} with values from a data object.
|
|
31
|
+
* This is crucial for dynamically constructing prompts.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveTemplate(template: string, data: Record<string, any>): string {
|
|
34
|
+
return template.replace(/\{\{(.*?)\}\}/g, (_, key) => {
|
|
35
|
+
const value = data[key.trim()]
|
|
36
|
+
if (value === undefined || value === null) {
|
|
37
|
+
console.warn(`Template variable '{{${key.trim()}}}' not found in data.`)
|
|
38
|
+
return `{{${key.trim()}}}`
|
|
39
|
+
}
|
|
40
|
+
return String(value)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
OPENAI_API_KEY="your-api-key-here"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Distributed AI Agent with a Pluggable Executor
|
|
2
|
+
|
|
3
|
+
This example demonstrates the power of the `Executor` pattern by running the same complex, graph-based AI agent from the DAG example in a distributed environment using **BullMQ**.
|
|
4
|
+
|
|
5
|
+
It showcases a client-worker architecture where a client can initiate a workflow and **asynchronously wait for its final result**. The actual execution of each node happens as a job processed by one or more separate worker processes, which is a common pattern for building scalable, resilient, and long-running process automation systems.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Awaitable Workflows**: The client can start a workflow and wait for a definitive `completed`, `failed`, or `cancelled` status, making it easy to integrate into request-response patterns like an API server.
|
|
10
|
+
- **Pluggable `BullMQExecutor`**: A custom executor that, instead of running nodes in-memory, enqueues them as jobs in a Redis-backed BullMQ queue.
|
|
11
|
+
- **Client-Worker Separation**:
|
|
12
|
+
- The **Client** (`src/client.ts`) is a lightweight process that initiates the workflow and then polls a Redis key for the final status.
|
|
13
|
+
- The **Worker** (`src/worker.ts`) is a separate, long-running process that listens for jobs, executes the logic for a single node, and reports the final status back to the client via Redis.
|
|
14
|
+
- **State Serialization**: The `Context` is serialized to a plain object and passed between jobs, allowing state to flow through the distributed graph.
|
|
15
|
+
- **Distributed Cancellation**: A `runId` is generated for each workflow. You can gracefully abort a running workflow by pressing 'c' in the worker terminal and providing the corresponding `runId`, and the client will be notified of the cancellation.
|
|
16
|
+
- **Resilience & Scalability**: By using a message queue, workflows can survive process restarts. You can run multiple worker processes to handle a high volume of concurrent workflows.
|
|
17
|
+
- **Unchanged Business Logic**: The exact same declarative JSON graph definitions from the DAG example are used here. The change in execution environment (in-memory vs. distributed) is completely transparent to the workflow's definition.
|
|
18
|
+
|
|
19
|
+
## How to Run
|
|
20
|
+
|
|
21
|
+
1. **Start a Redis Server**: This example requires a running Redis instance for BullMQ. The easiest way is with Docker:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
docker run --name some-redis -d -p 6379:6379 redis
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
2. **Install dependencies**:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
3. **Set your OpenAI API key**:
|
|
34
|
+
Create a `.env` file in this project's root directory:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
OPENAI_API_KEY="your-api-key-here"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. **Run the Worker**: Open a terminal and start the worker process. It will connect to Redis and wait for jobs.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm run worker
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
5. **Run the Client**: Open a **second terminal** and run the client. This will kick off the workflow, log a `Run ID`, and wait for the final result.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You can change the active use-case in `src/client.ts`.
|
|
53
|
+
|
|
54
|
+
The client will log a `Run ID` like this:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
🚀 Starting Workflow and awaiting result...
|
|
58
|
+
[INFO] [Executor] Enqueuing 1 start node(s) for workflow 123
|
|
59
|
+
[INFO] [Executor] Starting Run ID: a1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Keep this `Run ID` handy.
|
|
63
|
+
|
|
64
|
+
6. **(Optional) Cancel the Workflow**:
|
|
65
|
+
- While the client is waiting, switch to the **worker terminal**.
|
|
66
|
+
- Press the `c` key.
|
|
67
|
+
- At the prompt, enter the `Run ID` from the client and press Enter.
|
|
68
|
+
- The worker will signal for cancellation, and the client in the other terminal will immediately report that the workflow was cancelled.
|
|
69
|
+
|
|
70
|
+
## How It Works
|
|
71
|
+
|
|
72
|
+
This example uses a client-worker architecture with Redis acting as a communication bus.
|
|
73
|
+
|
|
74
|
+
1. **Client (`client.ts`)**:
|
|
75
|
+
- The client's role is to **initiate** the workflow and **await its completion**.
|
|
76
|
+
- It generates a unique `runId`.
|
|
77
|
+
- It uses the `BullMQExecutor` to add the first job(s) to the BullMQ queue.
|
|
78
|
+
- It then enters a polling loop (`waitForWorkflow`), checking a specific Redis key (`workflow:status:<runId>`) for a final status.
|
|
79
|
+
|
|
80
|
+
2. **Worker Process (`worker.ts`)**:
|
|
81
|
+
- The worker is the **long-running orchestrator** of the distributed system.
|
|
82
|
+
- It processes jobs from the queue one by one.
|
|
83
|
+
- After executing a node, it checks if it was the final node in a branch (marked by a special `FINAL_ACTION`).
|
|
84
|
+
- **Status Reporting**: If the node was final, failed, or was cancelled, the worker writes a status object (e.g., `{ "status": "completed", "payload": ... }`) to the Redis status key that the client is polling.
|
|
85
|
+
- **Cancellation**: The worker also polls Redis for a cancellation signal for the current `runId` and will gracefully abort execution if the signal is found.
|
|
86
|
+
- If the workflow is not finished, the worker enqueues the next node(s) in the graph, continuing the process.
|
|
87
|
+
|
|
88
|
+
This architecture decouples the client from the execution, allowing the system to be resilient and scalable while still providing a simple, awaitable interface for the initiating process.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": [
|
|
3
|
+
{
|
|
4
|
+
"id": "generate_outline",
|
|
5
|
+
"type": "llm-process",
|
|
6
|
+
"data": {
|
|
7
|
+
"promptTemplate": "Generate a detailed, multi-point blog post outline for the topic: \"{{topic}}\".",
|
|
8
|
+
"inputs": {
|
|
9
|
+
"topic": "topic"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "draft_post",
|
|
15
|
+
"type": "llm-process",
|
|
16
|
+
"data": {
|
|
17
|
+
"promptTemplate": "Write a full-length, engaging blog post based on the following outline:\n\n{{outline}}",
|
|
18
|
+
"inputs": {
|
|
19
|
+
"outline": "generate_outline"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "suggest_titles",
|
|
25
|
+
"type": "llm-process",
|
|
26
|
+
"data": {
|
|
27
|
+
"promptTemplate": "Suggest 5 catchy, SEO-friendly titles for the following blog post. Respond with a simple numbered list.\n\nPost:\n{{draft}}",
|
|
28
|
+
"inputs": {
|
|
29
|
+
"draft": "draft_post"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "final_output",
|
|
35
|
+
"type": "output",
|
|
36
|
+
"data": {
|
|
37
|
+
"promptTemplate": "--- TITLES ---\n{{titles}}\n\n--- DRAFT ---\n{{draft}}",
|
|
38
|
+
"inputs": {
|
|
39
|
+
"titles": "suggest_titles",
|
|
40
|
+
"draft": "draft_post"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"edges": [
|
|
46
|
+
{
|
|
47
|
+
"source": "generate_outline",
|
|
48
|
+
"target": "draft_post"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"source": "draft_post",
|
|
52
|
+
"target": "suggest_titles"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"source": "suggest_titles",
|
|
56
|
+
"target": "final_output"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Use Case 1: Blog Post Generation
|
|
2
|
+
|
|
3
|
+
This workflow demonstrates a simple, linear pipeline for content creation. It takes an initial topic and sequentially processes it through a series of steps to produce a finished blog post with title suggestions.
|
|
4
|
+
|
|
5
|
+
This is a classic example of how to chain nodes together to form a multi-step, sequential process where the output of one node becomes the input for the next.
|
|
6
|
+
|
|
7
|
+
## Workflow ID: 100
|
|
8
|
+
|
|
9
|
+
### Description
|
|
10
|
+
|
|
11
|
+
1. **`generate_outline`**: Takes the initial `topic` and uses an LLM to generate a blog post outline.
|
|
12
|
+
2. **`draft_post`**: Uses the generated outline to write a full-length blog post.
|
|
13
|
+
3. **`suggest_titles`**: Analyzes the drafted post to suggest several catchy, SEO-friendly titles.
|
|
14
|
+
4. **`final_output`**: Combines the suggested titles and the final draft into a single output string for review.
|
|
15
|
+
|
|
16
|
+
### Visual Graph
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
graph TD
|
|
20
|
+
subgraph "Blog Post Generation (ID: 100)"
|
|
21
|
+
A[generate_outline] --> B[draft_post]
|
|
22
|
+
B --> C[suggest_titles]
|
|
23
|
+
C --> D[final_output]
|
|
24
|
+
end
|
|
25
|
+
```
|