@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,319 @@
1
+ import { parseSSE } from '../utils/sse_parser.ts'
2
+ import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
3
+ import { ExternalServiceError } from '@stravigor/kernel'
4
+ import type {
5
+ AIProvider,
6
+ CompletionRequest,
7
+ CompletionResponse,
8
+ StreamChunk,
9
+ ProviderConfig,
10
+ Message,
11
+ ToolCall,
12
+ Usage,
13
+ } from '../types.ts'
14
+
15
+ /**
16
+ * OpenAI Responses API provider (`/v1/responses`).
17
+ *
18
+ * Drop-in replacement for the Chat Completions provider.
19
+ * Implements the same `AIProvider` interface so Thread, AgentRunner,
20
+ * and all Brain helpers work unchanged.
21
+ */
22
+ export class OpenAIResponsesProvider implements AIProvider {
23
+ readonly name: string
24
+ private apiKey: string
25
+ private baseUrl: string
26
+ private defaultModel: string
27
+ private defaultMaxTokens?: number
28
+ private retryOptions: RetryOptions
29
+
30
+ constructor(config: ProviderConfig, name?: string) {
31
+ this.name = name ?? 'openai'
32
+ this.apiKey = config.apiKey
33
+ this.baseUrl = (config.baseUrl ?? 'https://api.openai.com').replace(/\/$/, '')
34
+ this.defaultModel = config.model
35
+ this.defaultMaxTokens = config.maxTokens
36
+ this.retryOptions = {
37
+ maxRetries: config.maxRetries ?? 3,
38
+ baseDelay: config.retryBaseDelay ?? 1000,
39
+ }
40
+ }
41
+
42
+ // ── Non-streaming completion ────────────────────────────────────────────
43
+
44
+ async complete(request: CompletionRequest): Promise<CompletionResponse> {
45
+ const body = this.buildRequestBody(request, false)
46
+
47
+ const response = await retryableFetch(
48
+ 'OpenAI',
49
+ `${this.baseUrl}/v1/responses`,
50
+ { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
51
+ this.retryOptions
52
+ )
53
+
54
+ const data: any = await response.json()
55
+ return this.parseResponse(data)
56
+ }
57
+
58
+ // ── Streaming completion ────────────────────────────────────────────────
59
+
60
+ async *stream(request: CompletionRequest): AsyncIterable<StreamChunk> {
61
+ const body = this.buildRequestBody(request, true)
62
+
63
+ const response = await retryableFetch(
64
+ 'OpenAI',
65
+ `${this.baseUrl}/v1/responses`,
66
+ { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
67
+ this.retryOptions
68
+ )
69
+
70
+ if (!response.body) {
71
+ throw new ExternalServiceError('OpenAI', undefined, 'No stream body returned')
72
+ }
73
+
74
+ // Track function call items by output_index for tool_start/tool_delta mapping
75
+ const toolIndexMap = new Map<number, { callId: string; name: string }>()
76
+ let toolCounter = 0
77
+
78
+ for await (const sse of parseSSE(response.body)) {
79
+ const eventType = sse.event ?? ''
80
+ let data: any
81
+
82
+ try {
83
+ data = JSON.parse(sse.data)
84
+ } catch {
85
+ continue
86
+ }
87
+
88
+ // ── Text content ──────────────────────────────────────────────
89
+ if (eventType === 'response.output_text.delta') {
90
+ yield { type: 'text', text: data.delta ?? '' }
91
+ continue
92
+ }
93
+
94
+ // ── Function call start ───────────────────────────────────────
95
+ if (eventType === 'response.output_item.added' && data.item?.type === 'function_call') {
96
+ const index = toolCounter++
97
+ toolIndexMap.set(data.output_index ?? index, {
98
+ callId: data.item.call_id ?? '',
99
+ name: data.item.name ?? '',
100
+ })
101
+ yield {
102
+ type: 'tool_start',
103
+ toolCall: { id: data.item.call_id ?? '', name: data.item.name ?? '' },
104
+ toolIndex: index,
105
+ }
106
+ continue
107
+ }
108
+
109
+ // ── Function call argument deltas ─────────────────────────────
110
+ if (eventType === 'response.function_call_arguments.delta') {
111
+ // Map output_index to our sequential toolIndex
112
+ const outputIdx = data.output_index ?? 0
113
+ let toolIdx = 0
114
+ for (const [oi] of toolIndexMap) {
115
+ if (oi === outputIdx) break
116
+ toolIdx++
117
+ }
118
+ yield { type: 'tool_delta', text: data.delta ?? '', toolIndex: toolIdx }
119
+ continue
120
+ }
121
+
122
+ // ── Function call arguments done ──────────────────────────────
123
+ if (eventType === 'response.function_call_arguments.done') {
124
+ const outputIdx = data.output_index ?? 0
125
+ let toolIdx = 0
126
+ for (const [oi] of toolIndexMap) {
127
+ if (oi === outputIdx) break
128
+ toolIdx++
129
+ }
130
+ yield { type: 'tool_end', toolIndex: toolIdx }
131
+ continue
132
+ }
133
+
134
+ // ── Response completed ────────────────────────────────────────
135
+ if (eventType === 'response.completed') {
136
+ const usage = data.response?.usage
137
+ if (usage) {
138
+ yield {
139
+ type: 'usage',
140
+ usage: {
141
+ inputTokens: usage.input_tokens ?? 0,
142
+ outputTokens: usage.output_tokens ?? 0,
143
+ totalTokens: usage.total_tokens ?? 0,
144
+ },
145
+ }
146
+ }
147
+ yield { type: 'done' }
148
+ break
149
+ }
150
+
151
+ // ── Error ─────────────────────────────────────────────────────
152
+ if (eventType === 'error') {
153
+ throw new ExternalServiceError('OpenAI', undefined, data.message ?? JSON.stringify(data))
154
+ }
155
+ }
156
+ }
157
+
158
+ // ── Private helpers ─────────────────────────────────────────────────────
159
+
160
+ private buildHeaders(): Record<string, string> {
161
+ return {
162
+ 'content-type': 'application/json',
163
+ authorization: `Bearer ${this.apiKey}`,
164
+ }
165
+ }
166
+
167
+ private buildRequestBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
168
+ const body: Record<string, unknown> = {
169
+ model: request.model ?? this.defaultModel,
170
+ input: this.mapMessages(request.messages),
171
+ }
172
+
173
+ // System prompt → instructions
174
+ if (request.system) {
175
+ body.instructions = request.system
176
+ }
177
+
178
+ if (stream) body.stream = true
179
+ if (request.maxTokens ?? this.defaultMaxTokens) {
180
+ body.max_output_tokens = request.maxTokens ?? this.defaultMaxTokens
181
+ }
182
+ // Note: temperature is not supported by the Responses API for some models
183
+ // if (request.temperature !== undefined) body.temperature = request.temperature
184
+ if (request.stopSequences?.length) body.stop = request.stopSequences
185
+
186
+ // Tools
187
+ if (request.tools?.length) {
188
+ body.tools = request.tools.map(t => ({
189
+ type: 'function',
190
+ name: t.name,
191
+ description: t.description,
192
+ parameters: t.parameters,
193
+ }))
194
+ }
195
+
196
+ // Tool choice
197
+ if (request.toolChoice) {
198
+ if (typeof request.toolChoice === 'string') {
199
+ body.tool_choice = request.toolChoice
200
+ } else {
201
+ body.tool_choice = {
202
+ type: 'function',
203
+ name: request.toolChoice.name,
204
+ }
205
+ }
206
+ }
207
+
208
+ // Structured output
209
+ if (request.schema) {
210
+ body.text = {
211
+ format: {
212
+ type: 'json_schema',
213
+ name: 'response',
214
+ schema: request.schema,
215
+ strict: true,
216
+ },
217
+ }
218
+ }
219
+
220
+ return body
221
+ }
222
+
223
+ /**
224
+ * Translate Brain Message[] into Responses API input items.
225
+ *
226
+ * User messages → { role: 'user', content }
227
+ * Assistant messages → assistant message item + separate function_call items
228
+ * Tool messages → { type: 'function_call_output', call_id, output }
229
+ */
230
+ private mapMessages(messages: Message[]): any[] {
231
+ const items: any[] = []
232
+
233
+ for (const msg of messages) {
234
+ if (msg.role === 'user') {
235
+ items.push({
236
+ role: 'user',
237
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
238
+ })
239
+ } else if (msg.role === 'assistant') {
240
+ const text = typeof msg.content === 'string' ? msg.content : ''
241
+
242
+ // Add assistant message item (only if there's text content)
243
+ if (text) {
244
+ items.push({
245
+ type: 'message',
246
+ role: 'assistant',
247
+ content: [{ type: 'output_text', text }],
248
+ })
249
+ }
250
+
251
+ // Add function_call items for any tool calls
252
+ if (msg.toolCalls?.length) {
253
+ for (const tc of msg.toolCalls) {
254
+ items.push({
255
+ type: 'function_call',
256
+ call_id: tc.id,
257
+ name: tc.name,
258
+ arguments: JSON.stringify(tc.arguments),
259
+ })
260
+ }
261
+ }
262
+ } else if (msg.role === 'tool') {
263
+ items.push({
264
+ type: 'function_call_output',
265
+ call_id: msg.toolCallId ?? '',
266
+ output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
267
+ })
268
+ }
269
+ }
270
+
271
+ return items
272
+ }
273
+
274
+ /**
275
+ * Parse a non-streaming Responses API response into Brain CompletionResponse.
276
+ */
277
+ private parseResponse(data: any): CompletionResponse {
278
+ const output: any[] = data.output ?? []
279
+ let content = ''
280
+ const toolCalls: ToolCall[] = []
281
+
282
+ for (const item of output) {
283
+ if (item.type === 'message' && item.role === 'assistant') {
284
+ for (const part of item.content ?? []) {
285
+ if (part.type === 'output_text') {
286
+ content += part.text ?? ''
287
+ }
288
+ }
289
+ } else if (item.type === 'function_call') {
290
+ let args: Record<string, unknown> = {}
291
+ try {
292
+ args = JSON.parse(item.arguments ?? '{}')
293
+ } catch {
294
+ args = item.arguments ? { _raw: item.arguments } : {}
295
+ }
296
+ toolCalls.push({
297
+ id: item.call_id ?? item.id ?? '',
298
+ name: item.name ?? '',
299
+ arguments: args,
300
+ })
301
+ }
302
+ }
303
+
304
+ const usage: Usage = {
305
+ inputTokens: data.usage?.input_tokens ?? 0,
306
+ outputTokens: data.usage?.output_tokens ?? 0,
307
+ totalTokens: data.usage?.total_tokens ?? 0,
308
+ }
309
+
310
+ let stopReason: CompletionResponse['stopReason'] = 'end'
311
+ if (toolCalls.length > 0) {
312
+ stopReason = 'tool_use'
313
+ } else if (data.status === 'incomplete') {
314
+ stopReason = 'max_tokens'
315
+ }
316
+
317
+ return { id: data.id ?? '', content, toolCalls, stopReason, usage, raw: data }
318
+ }
319
+ }
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,182 @@
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
+ maxRetries?: number
165
+ retryBaseDelay?: number
166
+ }
167
+
168
+ export interface BrainConfig {
169
+ default: string
170
+ providers: Record<string, ProviderConfig>
171
+ maxTokens: number
172
+ temperature: number
173
+ maxIterations: number
174
+ memory?: import('./memory/types.ts').MemoryConfig
175
+ }
176
+
177
+ // ── Serialized Thread ────────────────────────────────────────────────────────
178
+
179
+ export interface SerializedThread {
180
+ messages: Message[]
181
+ system?: string
182
+ }
@@ -0,0 +1,100 @@
1
+ import { ExternalServiceError } from '@stravigor/kernel'
2
+
3
+ export interface RetryOptions {
4
+ maxRetries?: number
5
+ baseDelay?: number
6
+ maxDelay?: number
7
+ retryableStatuses?: number[]
8
+ }
9
+
10
+ const DEFAULT_RETRYABLE = [429, 500, 502, 503, 529]
11
+
12
+ /**
13
+ * Fetch with automatic retry and exponential backoff for transient errors.
14
+ *
15
+ * Retries on 429 (rate limit), 5xx, and network failures.
16
+ * Parses the `retry-after` header when available; otherwise uses
17
+ * exponential backoff with jitter.
18
+ *
19
+ * Returns the successful `Response`. On final failure, throws
20
+ * `ExternalServiceError` with the last status and body.
21
+ */
22
+ export async function retryableFetch(
23
+ service: string,
24
+ url: string,
25
+ init: RequestInit,
26
+ options?: RetryOptions
27
+ ): Promise<Response> {
28
+ const maxRetries = options?.maxRetries ?? 3
29
+ const baseDelay = options?.baseDelay ?? 1000
30
+ const maxDelay = options?.maxDelay ?? 60_000
31
+ const retryable = options?.retryableStatuses ?? DEFAULT_RETRYABLE
32
+
33
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
34
+ let response: Response
35
+
36
+ try {
37
+ response = await fetch(url, init)
38
+ } catch (err) {
39
+ // Network error (DNS, connection refused, etc.)
40
+ if (attempt === maxRetries) {
41
+ throw new ExternalServiceError(
42
+ service,
43
+ undefined,
44
+ err instanceof Error ? err.message : String(err)
45
+ )
46
+ }
47
+ await sleep(backoffDelay(attempt, baseDelay, maxDelay))
48
+ continue
49
+ }
50
+
51
+ if (response.ok) return response
52
+
53
+ // Non-retryable status — fail immediately
54
+ if (!retryable.includes(response.status)) {
55
+ const text = await response.text()
56
+ throw new ExternalServiceError(service, response.status, text)
57
+ }
58
+
59
+ // Retryable status — wait and retry (unless last attempt)
60
+ if (attempt === maxRetries) {
61
+ const text = await response.text()
62
+ throw new ExternalServiceError(service, response.status, text)
63
+ }
64
+
65
+ const delay = parseRetryAfter(response) ?? backoffDelay(attempt, baseDelay, maxDelay)
66
+ await sleep(delay)
67
+ }
68
+
69
+ // Unreachable, but satisfies TypeScript
70
+ throw new ExternalServiceError(service, undefined, 'Retry loop exited unexpectedly')
71
+ }
72
+
73
+ /**
74
+ * Parse the `retry-after` header into milliseconds.
75
+ * Supports both delta-seconds ("2") and HTTP-date formats.
76
+ */
77
+ function parseRetryAfter(response: Response): number | null {
78
+ const header = response.headers.get('retry-after')
79
+ if (!header) return null
80
+
81
+ const seconds = Number(header)
82
+ if (!Number.isNaN(seconds)) return seconds * 1000
83
+
84
+ // HTTP-date format
85
+ const date = Date.parse(header)
86
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
87
+
88
+ return null
89
+ }
90
+
91
+ /** Exponential backoff with jitter: base * 2^attempt + random jitter, capped at maxDelay. */
92
+ function backoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
93
+ const exp = baseDelay * 2 ** attempt
94
+ const jitter = Math.random() * baseDelay
95
+ return Math.min(exp + jitter, maxDelay)
96
+ }
97
+
98
+ function sleep(ms: number): Promise<void> {
99
+ return new Promise(resolve => setTimeout(resolve, ms))
100
+ }
@@ -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
+ }