@stravigor/saina 0.4.7
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/CHANGELOG.md +7 -0
- package/README.md +82 -0
- package/package.json +25 -0
- package/src/agent.ts +73 -0
- package/src/helpers.ts +756 -0
- package/src/index.ts +38 -0
- package/src/providers/anthropic_provider.ts +278 -0
- package/src/providers/openai_provider.ts +351 -0
- package/src/saina_manager.ts +116 -0
- package/src/saina_provider.ts +16 -0
- package/src/tool.ts +50 -0
- package/src/types.ts +179 -0
- package/src/utils/schema.ts +27 -0
- package/src/utils/sse_parser.ts +62 -0
- package/src/workflow.ts +180 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { inject } from '@stravigor/core/core/inject'
|
|
2
|
+
import { ConfigurationError } from '@stravigor/core/exceptions/errors'
|
|
3
|
+
import Configuration from '@stravigor/core/config/configuration'
|
|
4
|
+
import { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
5
|
+
import { OpenAIProvider } from './providers/openai_provider.ts'
|
|
6
|
+
import type {
|
|
7
|
+
AIProvider,
|
|
8
|
+
SainaConfig,
|
|
9
|
+
ProviderConfig,
|
|
10
|
+
CompletionRequest,
|
|
11
|
+
CompletionResponse,
|
|
12
|
+
BeforeHook,
|
|
13
|
+
AfterHook,
|
|
14
|
+
} from './types.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Central AI configuration hub.
|
|
18
|
+
*
|
|
19
|
+
* Resolved once via the DI container — reads the AI config
|
|
20
|
+
* and initializes the appropriate provider drivers.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* app.singleton(SainaManager)
|
|
24
|
+
* app.resolve(SainaManager)
|
|
25
|
+
*
|
|
26
|
+
* // Plug in a custom provider
|
|
27
|
+
* SainaManager.useProvider(new OllamaProvider())
|
|
28
|
+
*/
|
|
29
|
+
@inject
|
|
30
|
+
export default class SainaManager {
|
|
31
|
+
private static _config: SainaConfig
|
|
32
|
+
private static _providers = new Map<string, AIProvider>()
|
|
33
|
+
private static _beforeHooks: BeforeHook[] = []
|
|
34
|
+
private static _afterHooks: AfterHook[] = []
|
|
35
|
+
|
|
36
|
+
constructor(config: Configuration) {
|
|
37
|
+
SainaManager._config = {
|
|
38
|
+
default: config.get('ai.default', 'anthropic') as string,
|
|
39
|
+
providers: config.get('ai.providers', {}) as Record<string, ProviderConfig>,
|
|
40
|
+
maxTokens: config.get('ai.maxTokens', 4096) as number,
|
|
41
|
+
temperature: config.get('ai.temperature', 0.7) as number,
|
|
42
|
+
maxIterations: config.get('ai.maxIterations', 10) as number,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [name, providerConfig] of Object.entries(SainaManager._config.providers)) {
|
|
46
|
+
SainaManager._providers.set(name, SainaManager.createProvider(name, providerConfig))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static createProvider(name: string, config: ProviderConfig): AIProvider {
|
|
51
|
+
const driver = config.driver ?? name
|
|
52
|
+
switch (driver) {
|
|
53
|
+
case 'anthropic':
|
|
54
|
+
return new AnthropicProvider(config)
|
|
55
|
+
case 'openai':
|
|
56
|
+
return new OpenAIProvider(config, name)
|
|
57
|
+
default:
|
|
58
|
+
throw new ConfigurationError(
|
|
59
|
+
`Unknown AI provider driver: ${driver}. Use SainaManager.useProvider() for custom providers.`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static get config(): SainaConfig {
|
|
65
|
+
if (!SainaManager._config) {
|
|
66
|
+
throw new ConfigurationError(
|
|
67
|
+
'SainaManager not configured. Resolve it through the container first.'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return SainaManager._config
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Get a provider by name, or the default provider. */
|
|
74
|
+
static provider(name?: string): AIProvider {
|
|
75
|
+
const key = name ?? SainaManager._config.default
|
|
76
|
+
const p = SainaManager._providers.get(key)
|
|
77
|
+
if (!p) throw new ConfigurationError(`AI provider "${key}" not configured.`)
|
|
78
|
+
return p
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Swap or add a provider at runtime (e.g., for testing or a custom provider). */
|
|
82
|
+
static useProvider(provider: AIProvider): void {
|
|
83
|
+
SainaManager._providers.set(provider.name, provider)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Register a hook that runs before every completion. */
|
|
87
|
+
static before(hook: BeforeHook): void {
|
|
88
|
+
SainaManager._beforeHooks.push(hook)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Register a hook that runs after every completion. */
|
|
92
|
+
static after(hook: AfterHook): void {
|
|
93
|
+
SainaManager._afterHooks.push(hook)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run a completion through the named provider, with before/after hooks.
|
|
98
|
+
* Used internally by AgentRunner and the `saina` helper.
|
|
99
|
+
*/
|
|
100
|
+
static async complete(
|
|
101
|
+
providerName: string | undefined,
|
|
102
|
+
request: CompletionRequest
|
|
103
|
+
): Promise<CompletionResponse> {
|
|
104
|
+
for (const hook of SainaManager._beforeHooks) await hook(request)
|
|
105
|
+
const response = await SainaManager.provider(providerName).complete(request)
|
|
106
|
+
for (const hook of SainaManager._afterHooks) await hook(request, response)
|
|
107
|
+
return response
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Clear all providers and hooks (for testing). */
|
|
111
|
+
static reset(): void {
|
|
112
|
+
SainaManager._providers.clear()
|
|
113
|
+
SainaManager._beforeHooks = []
|
|
114
|
+
SainaManager._afterHooks = []
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/core/core'
|
|
2
|
+
import type { Application } from '@stravigor/core/core'
|
|
3
|
+
import SainaManager from './saina_manager.ts'
|
|
4
|
+
|
|
5
|
+
export default class SainaProvider extends ServiceProvider {
|
|
6
|
+
readonly name = 'saina'
|
|
7
|
+
override readonly dependencies = ['config']
|
|
8
|
+
|
|
9
|
+
override register(app: Application): void {
|
|
10
|
+
app.singleton(SainaManager)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override boot(app: Application): void {
|
|
14
|
+
app.resolve(SainaManager)
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { zodToJsonSchema } from './utils/schema.ts'
|
|
2
|
+
import type { ToolDefinition, JsonSchema } from './types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Define a tool that an agent can invoke.
|
|
6
|
+
*
|
|
7
|
+
* Accepts either a Zod schema or a raw JSON Schema object
|
|
8
|
+
* for `parameters`. Zod schemas are automatically converted.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const searchTool = defineTool({
|
|
12
|
+
* name: 'search',
|
|
13
|
+
* description: 'Search the database',
|
|
14
|
+
* parameters: z.object({ query: z.string() }),
|
|
15
|
+
* execute: async ({ query }) => {
|
|
16
|
+
* return await db.search(query)
|
|
17
|
+
* },
|
|
18
|
+
* })
|
|
19
|
+
*/
|
|
20
|
+
export function defineTool(config: {
|
|
21
|
+
name: string
|
|
22
|
+
description: string
|
|
23
|
+
parameters: any
|
|
24
|
+
execute: (args: any) => unknown | Promise<unknown>
|
|
25
|
+
}): ToolDefinition {
|
|
26
|
+
return {
|
|
27
|
+
name: config.name,
|
|
28
|
+
description: config.description,
|
|
29
|
+
parameters: zodToJsonSchema(config.parameters) as JsonSchema,
|
|
30
|
+
execute: config.execute,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Group related tools into a named collection.
|
|
36
|
+
*
|
|
37
|
+
* A toolbox is simply a labeled array — useful for organizing
|
|
38
|
+
* tools by domain (e.g., database tools, API tools) and
|
|
39
|
+
* spreading them into an agent's `tools` array.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const dbTools = defineToolbox('database', [searchTool, insertTool])
|
|
43
|
+
*
|
|
44
|
+
* class MyAgent extends Agent {
|
|
45
|
+
* tools = [...dbTools, weatherTool]
|
|
46
|
+
* }
|
|
47
|
+
*/
|
|
48
|
+
export function defineToolbox(_name: string, tools: ToolDefinition[]): ToolDefinition[] {
|
|
49
|
+
return tools
|
|
50
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// ── JSON Schema ──────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Minimal recursive JSON Schema type. */
|
|
4
|
+
export type JsonSchema = Record<string, unknown>
|
|
5
|
+
|
|
6
|
+
// ── SSE ──────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface SSEEvent {
|
|
9
|
+
event?: string
|
|
10
|
+
data: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── Usage ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface Usage {
|
|
16
|
+
inputTokens: number
|
|
17
|
+
outputTokens: number
|
|
18
|
+
totalTokens: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Messages ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface ToolCall {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
arguments: Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ContentBlock {
|
|
30
|
+
type: 'text' | 'tool_use' | 'tool_result'
|
|
31
|
+
text?: string
|
|
32
|
+
id?: string
|
|
33
|
+
name?: string
|
|
34
|
+
input?: Record<string, unknown>
|
|
35
|
+
toolUseId?: string
|
|
36
|
+
content?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Message {
|
|
40
|
+
role: 'user' | 'assistant' | 'tool'
|
|
41
|
+
content: string | ContentBlock[]
|
|
42
|
+
toolCalls?: ToolCall[]
|
|
43
|
+
toolCallId?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Tool Definition ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface ToolDefinition {
|
|
49
|
+
name: string
|
|
50
|
+
description: string
|
|
51
|
+
parameters: JsonSchema
|
|
52
|
+
execute: (args: Record<string, unknown>) => unknown | Promise<unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Completion Request / Response ────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface CompletionRequest {
|
|
58
|
+
model: string
|
|
59
|
+
messages: Message[]
|
|
60
|
+
system?: string
|
|
61
|
+
tools?: ToolDefinition[]
|
|
62
|
+
toolChoice?: 'auto' | 'required' | { name: string }
|
|
63
|
+
maxTokens?: number
|
|
64
|
+
temperature?: number
|
|
65
|
+
schema?: JsonSchema
|
|
66
|
+
stopSequences?: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CompletionResponse {
|
|
70
|
+
id: string
|
|
71
|
+
content: string
|
|
72
|
+
toolCalls: ToolCall[]
|
|
73
|
+
stopReason: 'end' | 'tool_use' | 'max_tokens' | 'stop_sequence'
|
|
74
|
+
usage: Usage
|
|
75
|
+
raw: unknown
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Streaming ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export interface StreamChunk {
|
|
81
|
+
type: 'text' | 'tool_start' | 'tool_delta' | 'tool_end' | 'usage' | 'done'
|
|
82
|
+
text?: string
|
|
83
|
+
toolCall?: Partial<ToolCall>
|
|
84
|
+
toolIndex?: number
|
|
85
|
+
usage?: Usage
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Output Schema ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** A schema that optionally validates data via `.parse()` (e.g., Zod schema). */
|
|
91
|
+
export interface OutputSchema {
|
|
92
|
+
parse?: (data: unknown) => unknown
|
|
93
|
+
[key: string]: unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Agent ────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface ToolCallRecord {
|
|
99
|
+
name: string
|
|
100
|
+
arguments: Record<string, unknown>
|
|
101
|
+
result: unknown
|
|
102
|
+
duration: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AgentResult<T = any> {
|
|
106
|
+
data: T
|
|
107
|
+
text: string
|
|
108
|
+
toolCalls: ToolCallRecord[]
|
|
109
|
+
messages: Message[]
|
|
110
|
+
usage: Usage
|
|
111
|
+
iterations: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AgentEvent {
|
|
115
|
+
type: 'text' | 'tool_start' | 'tool_result' | 'iteration' | 'done'
|
|
116
|
+
text?: string
|
|
117
|
+
toolCall?: ToolCallRecord
|
|
118
|
+
iteration?: number
|
|
119
|
+
result?: AgentResult
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Workflow ──────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface WorkflowResult {
|
|
125
|
+
results: Record<string, AgentResult>
|
|
126
|
+
usage: Usage
|
|
127
|
+
duration: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Embedding ────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export interface EmbeddingResponse {
|
|
133
|
+
embeddings: number[][]
|
|
134
|
+
model: string
|
|
135
|
+
usage: { totalTokens: number }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Provider ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export interface AIProvider {
|
|
141
|
+
readonly name: string
|
|
142
|
+
complete(request: CompletionRequest): Promise<CompletionResponse>
|
|
143
|
+
stream(request: CompletionRequest): AsyncIterable<StreamChunk>
|
|
144
|
+
embed?(input: string | string[], model?: string): Promise<EmbeddingResponse>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export type BeforeHook = (request: CompletionRequest) => void | Promise<void>
|
|
150
|
+
export type AfterHook = (
|
|
151
|
+
request: CompletionRequest,
|
|
152
|
+
response: CompletionResponse
|
|
153
|
+
) => void | Promise<void>
|
|
154
|
+
|
|
155
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface ProviderConfig {
|
|
158
|
+
driver: string
|
|
159
|
+
apiKey: string
|
|
160
|
+
model: string
|
|
161
|
+
baseUrl?: string
|
|
162
|
+
maxTokens?: number
|
|
163
|
+
temperature?: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SainaConfig {
|
|
167
|
+
default: string
|
|
168
|
+
providers: Record<string, ProviderConfig>
|
|
169
|
+
maxTokens: number
|
|
170
|
+
temperature: number
|
|
171
|
+
maxIterations: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Serialized Thread ────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export interface SerializedThread {
|
|
177
|
+
messages: Message[]
|
|
178
|
+
system?: string
|
|
179
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { JsonSchema } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert a Zod schema to a JSON Schema object.
|
|
5
|
+
*
|
|
6
|
+
* Detection logic:
|
|
7
|
+
* - If the input has a `toJSONSchema()` method (Zod v4+), use it directly
|
|
8
|
+
* - If the input is already a plain object (raw JSON Schema), return as-is
|
|
9
|
+
* - null/undefined pass through unchanged
|
|
10
|
+
*
|
|
11
|
+
* The `$schema` meta-field is stripped from the output since
|
|
12
|
+
* AI provider APIs don't expect it.
|
|
13
|
+
*/
|
|
14
|
+
export function zodToJsonSchema(schema: any): JsonSchema {
|
|
15
|
+
if (schema == null) return schema
|
|
16
|
+
|
|
17
|
+
// Zod v4+: native toJSONSchema() method
|
|
18
|
+
if (typeof schema.toJSONSchema === 'function') {
|
|
19
|
+
const jsonSchema = schema.toJSONSchema()
|
|
20
|
+
// Strip the $schema meta-field — providers don't need it
|
|
21
|
+
const { $schema, ...rest } = jsonSchema
|
|
22
|
+
return rest as JsonSchema
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Already a plain JSON Schema object
|
|
26
|
+
return schema as JsonSchema
|
|
27
|
+
}
|
|
@@ -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
|
+
}
|
package/src/workflow.ts
ADDED
|
@@ -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 saina.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