@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.
- package/CHANGELOG.md +32 -0
- package/README.md +82 -0
- package/package.json +28 -0
- package/src/agent.ts +73 -0
- package/src/brain_manager.ts +136 -0
- package/src/brain_provider.ts +16 -0
- package/src/helpers.ts +903 -0
- package/src/index.ts +42 -0
- package/src/memory/context_budget.ts +120 -0
- package/src/memory/index.ts +17 -0
- package/src/memory/memory_manager.ts +168 -0
- package/src/memory/semantic_memory.ts +89 -0
- package/src/memory/strategies/sliding_window.ts +20 -0
- package/src/memory/strategies/summarize.ts +157 -0
- package/src/memory/thread_store.ts +56 -0
- package/src/memory/token_counter.ts +101 -0
- package/src/memory/types.ts +68 -0
- package/src/providers/anthropic_provider.ts +276 -0
- package/src/providers/openai_provider.ts +509 -0
- package/src/providers/openai_responses_provider.ts +319 -0
- package/src/tool.ts +50 -0
- package/src/types.ts +182 -0
- package/src/utils/retry.ts +100 -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 +5 -0
|
@@ -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
|
+
}
|