@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }