@strav/brain 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.
@@ -0,0 +1,62 @@
1
+ import type { SSEEvent } from '../types.ts'
2
+
3
+ /**
4
+ * Parse a Server-Sent Events stream into structured events.
5
+ *
6
+ * Handles:
7
+ * - Chunks split at arbitrary byte boundaries
8
+ * - Multi-line `data:` fields (concatenated with newlines)
9
+ * - Optional `event:` field
10
+ * - Empty lines / keepalive comments
11
+ */
12
+ export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncIterable<SSEEvent> {
13
+ const reader = stream.getReader()
14
+ const decoder = new TextDecoder()
15
+ let buffer = ''
16
+
17
+ try {
18
+ while (true) {
19
+ const { done, value } = await reader.read()
20
+ if (done) break
21
+
22
+ buffer += decoder.decode(value, { stream: true })
23
+
24
+ const events = buffer.split('\n\n')
25
+ // Last element is either empty (if buffer ended with \n\n) or incomplete
26
+ buffer = events.pop()!
27
+
28
+ for (const block of events) {
29
+ const parsed = parseBlock(block)
30
+ if (parsed) yield parsed
31
+ }
32
+ }
33
+
34
+ // Flush any remaining data in buffer
35
+ if (buffer.trim()) {
36
+ const parsed = parseBlock(buffer)
37
+ if (parsed) yield parsed
38
+ }
39
+ } finally {
40
+ reader.releaseLock()
41
+ }
42
+ }
43
+
44
+ function parseBlock(block: string): SSEEvent | null {
45
+ let event: string | undefined
46
+ const dataLines: string[] = []
47
+
48
+ for (const line of block.split('\n')) {
49
+ if (line.startsWith('event: ')) {
50
+ event = line.slice(7)
51
+ } else if (line.startsWith('data: ')) {
52
+ dataLines.push(line.slice(6))
53
+ } else if (line === 'data:') {
54
+ dataLines.push('')
55
+ }
56
+ // Skip comments (lines starting with ':') and other fields
57
+ }
58
+
59
+ if (dataLines.length === 0) return null
60
+
61
+ return { event, data: dataLines.join('\n') }
62
+ }
@@ -0,0 +1,180 @@
1
+ import { Workflow as BaseWorkflow } from '@stravigor/workflow'
2
+ import type { WorkflowContext as BaseContext } from '@stravigor/workflow'
3
+ import { AgentRunner } from './helpers.ts'
4
+ import type { Agent } from './agent.ts'
5
+ import type { AgentResult, WorkflowResult, Usage } from './types.ts'
6
+
7
+ // ── AI Workflow Context ─────────────────────────────────────────────────────
8
+
9
+ export interface WorkflowContext {
10
+ input: Record<string, unknown>
11
+ results: Record<string, AgentResult>
12
+ }
13
+
14
+ type StepMapInput = (ctx: WorkflowContext) => Record<string, unknown> | string
15
+
16
+ // ── Utilities ───────────────────────────────────────────────────────────────
17
+
18
+ function resolveInput(mapInput: StepMapInput | undefined, ctx: BaseContext): string {
19
+ if (!mapInput) return JSON.stringify(ctx.input)
20
+ const mapped = mapInput(ctx as unknown as WorkflowContext)
21
+ return typeof mapped === 'string' ? mapped : JSON.stringify(mapped)
22
+ }
23
+
24
+ function addUsage(total: Usage, add: Usage): void {
25
+ total.inputTokens += add.inputTokens
26
+ total.outputTokens += add.outputTokens
27
+ total.totalTokens += add.totalTokens
28
+ }
29
+
30
+ // ── Workflow Builder ────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Multi-agent workflow orchestrator built on `@stravigor/workflow`.
34
+ *
35
+ * Supports sequential steps, parallel fan-out, routing, and loops.
36
+ * Each step wraps an Agent execution through the general-purpose workflow engine.
37
+ *
38
+ * @example
39
+ * const result = await brain.workflow('content-pipeline')
40
+ * .step('research', ResearchAgent)
41
+ * .step('write', WriterAgent, (ctx) => ({
42
+ * topic: ctx.results.research.data.summary,
43
+ * }))
44
+ * .step('review', ReviewerAgent)
45
+ * .run({ topic: 'AI in healthcare' })
46
+ */
47
+ export class Workflow {
48
+ private pipeline: BaseWorkflow
49
+ private totalUsage: Usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
50
+
51
+ constructor(name: string) {
52
+ this.pipeline = new BaseWorkflow(name)
53
+ }
54
+
55
+ /**
56
+ * Add a sequential step. Runs after all previous steps complete.
57
+ * Use `mapInput` to transform context into the agent's input.
58
+ */
59
+ step(name: string, agent: new () => Agent, mapInput?: StepMapInput): this {
60
+ this.pipeline.step(name, async (ctx: BaseContext) => {
61
+ const inputText = resolveInput(mapInput, ctx)
62
+ const result = await new AgentRunner(agent).input(inputText).run()
63
+ addUsage(this.totalUsage, result.usage)
64
+ return result
65
+ })
66
+ return this
67
+ }
68
+
69
+ /**
70
+ * Run multiple agents in parallel. All agents receive the same context.
71
+ * Each agent's result is stored under its name in the workflow results.
72
+ */
73
+ parallel(
74
+ name: string,
75
+ agents: { name: string; agent: new () => Agent; mapInput?: StepMapInput }[]
76
+ ): this {
77
+ this.pipeline.parallel(
78
+ name,
79
+ agents.map(a => ({
80
+ name: a.name,
81
+ handler: async (ctx: BaseContext) => {
82
+ const inputText = resolveInput(a.mapInput, ctx)
83
+ const result = await new AgentRunner(a.agent).input(inputText).run()
84
+ addUsage(this.totalUsage, result.usage)
85
+ return result
86
+ },
87
+ }))
88
+ )
89
+ return this
90
+ }
91
+
92
+ /**
93
+ * Route to a specialized agent based on a router agent's output.
94
+ * The router agent should return structured output with a `route` field
95
+ * that matches one of the branch keys.
96
+ */
97
+ route(
98
+ name: string,
99
+ router: new () => Agent,
100
+ branches: Record<string, new () => Agent>,
101
+ mapInput?: StepMapInput
102
+ ): this {
103
+ // Router step: run the router agent, store as `${name}:router`
104
+ this.pipeline.step(`${name}:router`, async (ctx: BaseContext) => {
105
+ const inputText = resolveInput(mapInput, ctx)
106
+ const result = await new AgentRunner(router).input(inputText).run()
107
+ addUsage(this.totalUsage, result.usage)
108
+ return result
109
+ })
110
+
111
+ // Branch step: dispatch to the matching branch agent
112
+ this.pipeline.route(
113
+ name,
114
+ (ctx: BaseContext) => {
115
+ const routerResult = ctx.results[`${name}:router`] as AgentResult
116
+ return routerResult.data?.route ?? routerResult.text?.trim() ?? ''
117
+ },
118
+ Object.fromEntries(
119
+ Object.entries(branches).map(([key, BranchAgent]) => [
120
+ key,
121
+ async (ctx: BaseContext) => {
122
+ const inputText = resolveInput(mapInput, ctx)
123
+ const result = await new AgentRunner(BranchAgent).input(inputText).run()
124
+ addUsage(this.totalUsage, result.usage)
125
+ return result
126
+ },
127
+ ])
128
+ )
129
+ )
130
+ return this
131
+ }
132
+
133
+ /**
134
+ * Run an agent in a loop until a condition is met or max iterations reached.
135
+ * Use `feedback` to transform the result into the next iteration's input.
136
+ */
137
+ loop(
138
+ name: string,
139
+ agent: new () => Agent,
140
+ options: {
141
+ maxIterations: number
142
+ until?: (result: AgentResult, iteration: number) => boolean
143
+ feedback?: (result: AgentResult) => string
144
+ mapInput?: StepMapInput
145
+ }
146
+ ): this {
147
+ this.pipeline.loop(
148
+ name,
149
+ async (input: unknown, _ctx: BaseContext) => {
150
+ const result = await new AgentRunner(agent).input(String(input)).run()
151
+ addUsage(this.totalUsage, result.usage)
152
+ return result
153
+ },
154
+ {
155
+ maxIterations: options.maxIterations,
156
+ until: options.until
157
+ ? (result: unknown, iteration: number) => options.until!(result as AgentResult, iteration)
158
+ : undefined,
159
+ feedback: options.feedback
160
+ ? (result: unknown) => options.feedback!(result as AgentResult)
161
+ : undefined,
162
+ mapInput: options.mapInput
163
+ ? (ctx: BaseContext) => resolveInput(options.mapInput, ctx)
164
+ : (ctx: BaseContext) => JSON.stringify(ctx.input),
165
+ }
166
+ )
167
+ return this
168
+ }
169
+
170
+ /** Execute the workflow. */
171
+ async run(input: Record<string, unknown>): Promise<WorkflowResult> {
172
+ this.totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
173
+ const result = await this.pipeline.run(input)
174
+ return {
175
+ results: result.results as Record<string, AgentResult>,
176
+ usage: this.totalUsage,
177
+ duration: result.duration,
178
+ }
179
+ }
180
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }