@strav/brain 0.4.30 → 1.0.0-alpha.8

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.
@@ -1,321 +0,0 @@
1
- import { parseSSE } from '../utils/sse_parser.ts'
2
- import { retryableFetch, type RetryOptions } from '../utils/retry.ts'
3
- import { ExternalServiceError } from '@strav/kernel'
4
- import { scrubProviderError } from '../utils/error_scrub.ts'
5
- import type {
6
- AIProvider,
7
- CompletionRequest,
8
- CompletionResponse,
9
- StreamChunk,
10
- ProviderConfig,
11
- Message,
12
- ToolCall,
13
- Usage,
14
- } from '../types.ts'
15
-
16
- /**
17
- * OpenAI Responses API provider (`/v1/responses`).
18
- *
19
- * Drop-in replacement for the Chat Completions provider.
20
- * Implements the same `AIProvider` interface so Thread, AgentRunner,
21
- * and all Brain helpers work unchanged.
22
- */
23
- export class OpenAIResponsesProvider implements AIProvider {
24
- readonly name: string
25
- private apiKey: string
26
- private baseUrl: string
27
- private defaultModel: string
28
- private defaultMaxTokens?: number
29
- private retryOptions: RetryOptions
30
-
31
- constructor(config: ProviderConfig, name?: string) {
32
- this.name = name ?? 'openai'
33
- this.apiKey = config.apiKey
34
- this.baseUrl = (config.baseUrl ?? 'https://api.openai.com').replace(/\/$/, '')
35
- this.defaultModel = config.model
36
- this.defaultMaxTokens = config.maxTokens
37
- this.retryOptions = {
38
- maxRetries: config.maxRetries ?? 3,
39
- baseDelay: config.retryBaseDelay ?? 1000,
40
- }
41
- }
42
-
43
- // ── Non-streaming completion ────────────────────────────────────────────
44
-
45
- async complete(request: CompletionRequest): Promise<CompletionResponse> {
46
- const body = this.buildRequestBody(request, false)
47
-
48
- const response = await retryableFetch(
49
- 'OpenAI',
50
- `${this.baseUrl}/v1/responses`,
51
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
52
- this.retryOptions
53
- )
54
-
55
- const data: any = await response.json()
56
- return this.parseResponse(data)
57
- }
58
-
59
- // ── Streaming completion ────────────────────────────────────────────────
60
-
61
- async *stream(request: CompletionRequest): AsyncIterable<StreamChunk> {
62
- const body = this.buildRequestBody(request, true)
63
-
64
- const response = await retryableFetch(
65
- 'OpenAI',
66
- `${this.baseUrl}/v1/responses`,
67
- { method: 'POST', headers: this.buildHeaders(), body: JSON.stringify(body) },
68
- this.retryOptions
69
- )
70
-
71
- if (!response.body) {
72
- throw new ExternalServiceError('OpenAI', undefined, 'No stream body returned')
73
- }
74
-
75
- // Track function call items by output_index for tool_start/tool_delta mapping
76
- const toolIndexMap = new Map<number, { callId: string; name: string }>()
77
- let toolCounter = 0
78
-
79
- for await (const sse of parseSSE(response.body)) {
80
- const eventType = sse.event ?? ''
81
- let data: any
82
-
83
- try {
84
- data = JSON.parse(sse.data)
85
- } catch {
86
- continue
87
- }
88
-
89
- // ── Text content ──────────────────────────────────────────────
90
- if (eventType === 'response.output_text.delta') {
91
- yield { type: 'text', text: data.delta ?? '' }
92
- continue
93
- }
94
-
95
- // ── Function call start ───────────────────────────────────────
96
- if (eventType === 'response.output_item.added' && data.item?.type === 'function_call') {
97
- const index = toolCounter++
98
- toolIndexMap.set(data.output_index ?? index, {
99
- callId: data.item.call_id ?? '',
100
- name: data.item.name ?? '',
101
- })
102
- yield {
103
- type: 'tool_start',
104
- toolCall: { id: data.item.call_id ?? '', name: data.item.name ?? '' },
105
- toolIndex: index,
106
- }
107
- continue
108
- }
109
-
110
- // ── Function call argument deltas ─────────────────────────────
111
- if (eventType === 'response.function_call_arguments.delta') {
112
- // Map output_index to our sequential toolIndex
113
- const outputIdx = data.output_index ?? 0
114
- let toolIdx = 0
115
- for (const [oi] of toolIndexMap) {
116
- if (oi === outputIdx) break
117
- toolIdx++
118
- }
119
- yield { type: 'tool_delta', text: data.delta ?? '', toolIndex: toolIdx }
120
- continue
121
- }
122
-
123
- // ── Function call arguments done ──────────────────────────────
124
- if (eventType === 'response.function_call_arguments.done') {
125
- const outputIdx = data.output_index ?? 0
126
- let toolIdx = 0
127
- for (const [oi] of toolIndexMap) {
128
- if (oi === outputIdx) break
129
- toolIdx++
130
- }
131
- yield { type: 'tool_end', toolIndex: toolIdx }
132
- continue
133
- }
134
-
135
- // ── Response completed ────────────────────────────────────────
136
- if (eventType === 'response.completed') {
137
- const usage = data.response?.usage
138
- if (usage) {
139
- yield {
140
- type: 'usage',
141
- usage: {
142
- inputTokens: usage.input_tokens ?? 0,
143
- outputTokens: usage.output_tokens ?? 0,
144
- totalTokens: usage.total_tokens ?? 0,
145
- },
146
- }
147
- }
148
- yield { type: 'done' }
149
- break
150
- }
151
-
152
- // ── Error ─────────────────────────────────────────────────────
153
- if (eventType === 'error') {
154
- const message = typeof data.message === 'string' ? data.message : JSON.stringify(data)
155
- throw new ExternalServiceError('OpenAI', undefined, scrubProviderError(message))
156
- }
157
- }
158
- }
159
-
160
- // ── Private helpers ─────────────────────────────────────────────────────
161
-
162
- private buildHeaders(): Record<string, string> {
163
- return {
164
- 'content-type': 'application/json',
165
- authorization: `Bearer ${this.apiKey}`,
166
- }
167
- }
168
-
169
- private buildRequestBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
170
- const body: Record<string, unknown> = {
171
- model: request.model ?? this.defaultModel,
172
- input: this.mapMessages(request.messages),
173
- }
174
-
175
- // System prompt → instructions
176
- if (request.system) {
177
- body.instructions = request.system
178
- }
179
-
180
- if (stream) body.stream = true
181
- if (request.maxTokens ?? this.defaultMaxTokens) {
182
- body.max_output_tokens = request.maxTokens ?? this.defaultMaxTokens
183
- }
184
- // Note: temperature is not supported by the Responses API for some models
185
- // if (request.temperature !== undefined) body.temperature = request.temperature
186
- if (request.stopSequences?.length) body.stop = request.stopSequences
187
-
188
- // Tools
189
- if (request.tools?.length) {
190
- body.tools = request.tools.map(t => ({
191
- type: 'function',
192
- name: t.name,
193
- description: t.description,
194
- parameters: t.parameters,
195
- }))
196
- }
197
-
198
- // Tool choice
199
- if (request.toolChoice) {
200
- if (typeof request.toolChoice === 'string') {
201
- body.tool_choice = request.toolChoice
202
- } else {
203
- body.tool_choice = {
204
- type: 'function',
205
- name: request.toolChoice.name,
206
- }
207
- }
208
- }
209
-
210
- // Structured output
211
- if (request.schema) {
212
- body.text = {
213
- format: {
214
- type: 'json_schema',
215
- name: 'response',
216
- schema: request.schema,
217
- strict: true,
218
- },
219
- }
220
- }
221
-
222
- return body
223
- }
224
-
225
- /**
226
- * Translate Brain Message[] into Responses API input items.
227
- *
228
- * User messages → { role: 'user', content }
229
- * Assistant messages → assistant message item + separate function_call items
230
- * Tool messages → { type: 'function_call_output', call_id, output }
231
- */
232
- private mapMessages(messages: Message[]): any[] {
233
- const items: any[] = []
234
-
235
- for (const msg of messages) {
236
- if (msg.role === 'user') {
237
- items.push({
238
- role: 'user',
239
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
240
- })
241
- } else if (msg.role === 'assistant') {
242
- const text = typeof msg.content === 'string' ? msg.content : ''
243
-
244
- // Add assistant message item (only if there's text content)
245
- if (text) {
246
- items.push({
247
- type: 'message',
248
- role: 'assistant',
249
- content: [{ type: 'output_text', text }],
250
- })
251
- }
252
-
253
- // Add function_call items for any tool calls
254
- if (msg.toolCalls?.length) {
255
- for (const tc of msg.toolCalls) {
256
- items.push({
257
- type: 'function_call',
258
- call_id: tc.id,
259
- name: tc.name,
260
- arguments: JSON.stringify(tc.arguments),
261
- })
262
- }
263
- }
264
- } else if (msg.role === 'tool') {
265
- items.push({
266
- type: 'function_call_output',
267
- call_id: msg.toolCallId ?? '',
268
- output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
269
- })
270
- }
271
- }
272
-
273
- return items
274
- }
275
-
276
- /**
277
- * Parse a non-streaming Responses API response into Brain CompletionResponse.
278
- */
279
- private parseResponse(data: any): CompletionResponse {
280
- const output: any[] = data.output ?? []
281
- let content = ''
282
- const toolCalls: ToolCall[] = []
283
-
284
- for (const item of output) {
285
- if (item.type === 'message' && item.role === 'assistant') {
286
- for (const part of item.content ?? []) {
287
- if (part.type === 'output_text') {
288
- content += part.text ?? ''
289
- }
290
- }
291
- } else if (item.type === 'function_call') {
292
- let args: Record<string, unknown> = {}
293
- try {
294
- args = JSON.parse(item.arguments ?? '{}')
295
- } catch {
296
- args = item.arguments ? { _raw: item.arguments } : {}
297
- }
298
- toolCalls.push({
299
- id: item.call_id ?? item.id ?? '',
300
- name: item.name ?? '',
301
- arguments: args,
302
- })
303
- }
304
- }
305
-
306
- const usage: Usage = {
307
- inputTokens: data.usage?.input_tokens ?? 0,
308
- outputTokens: data.usage?.output_tokens ?? 0,
309
- totalTokens: data.usage?.total_tokens ?? 0,
310
- }
311
-
312
- let stopReason: CompletionResponse['stopReason'] = 'end'
313
- if (toolCalls.length > 0) {
314
- stopReason = 'tool_use'
315
- } else if (data.status === 'incomplete') {
316
- stopReason = 'max_tokens'
317
- }
318
-
319
- return { id: data.id ?? '', content, toolCalls, stopReason, usage, raw: data }
320
- }
321
- }
package/src/tool.ts DELETED
@@ -1,51 +0,0 @@
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 }, context) => {
16
- * const userId = context?.userId
17
- * return await db.search(query, { userId })
18
- * },
19
- * })
20
- */
21
- export function defineTool<TArgs = any, TContext = Record<string, unknown>>(config: {
22
- name: string
23
- description: string
24
- parameters: any
25
- execute: (args: TArgs, context?: TContext) => unknown | Promise<unknown>
26
- }): ToolDefinition {
27
- return {
28
- name: config.name,
29
- description: config.description,
30
- parameters: zodToJsonSchema(config.parameters) as JsonSchema,
31
- execute: config.execute as (args: Record<string, unknown>, context?: Record<string, unknown>) => unknown | Promise<unknown>,
32
- }
33
- }
34
-
35
- /**
36
- * Group related tools into a named collection.
37
- *
38
- * A toolbox is simply a labeled array — useful for organizing
39
- * tools by domain (e.g., database tools, API tools) and
40
- * spreading them into an agent's `tools` array.
41
- *
42
- * @example
43
- * const dbTools = defineToolbox('database', [searchTool, insertTool])
44
- *
45
- * class MyAgent extends Agent {
46
- * tools = [...dbTools, weatherTool]
47
- * }
48
- */
49
- export function defineToolbox(_name: string, tools: ToolDefinition[]): ToolDefinition[] {
50
- return tools
51
- }
@@ -1,5 +0,0 @@
1
- // Re-export the shared kernel helper so the same scrubber is used across
2
- // every package that wraps upstream-provider errors. Keeping this thin
3
- // re-export avoids a breaking import-path change for callers that
4
- // already pulled `scrubProviderError` from `@strav/brain/utils/error_scrub`.
5
- export { scrubProviderError } from '@strav/kernel'
@@ -1,65 +0,0 @@
1
- /**
2
- * Heuristic detector for prompt-injection markers in untrusted strings
3
- * destined for the system prompt. The interpolation in
4
- * `agent.instructions` does naïve `replaceAll` of `{{key}}` placeholders
5
- * with string values — any user-controlled value flowing through is a
6
- * prompt-injection vector against the LLM.
7
- *
8
- * We can't fully solve this at the template layer (the proper fix is to
9
- * pass values as structured user-role messages, not interpolate them
10
- * into the system role). What we can do is detect the easy cases and
11
- * warn the developer that a value looks suspicious. Detection here is
12
- * deliberately loose — false positives are cheap, missed cases let
13
- * exploits through silently.
14
- */
15
- const INJECTION_PATTERNS: readonly RegExp[] = [
16
- /ignore\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
17
- /disregard\s+(?:[\w\s]{0,30}\s+)?(?:instructions?|prompts?|messages?)/i,
18
- /(?:^|\n)\s*system\s*[:>]/i,
19
- /(?:^|\n)\s*assistant\s*[:>]/i,
20
- /\bsystem\s*:\s*\S/i,
21
- /you\s+are\s+now\s+(?:a|an|the)/i,
22
- /act\s+as\s+(?:a|an|the)\s+(?:different|new)/i,
23
- /\[INST\]|\[\/INST\]/i,
24
- /<\|im_(?:start|end)\|>/i,
25
- /<\|system\|>|<\|user\|>|<\|assistant\|>/i,
26
- /###\s*(?:system|instruction|new\s+instruction)/i,
27
- ]
28
-
29
- /** Return true if the string contains a known prompt-injection marker. */
30
- export function looksLikePromptInjection(value: string): boolean {
31
- if (!value || typeof value !== 'string') return false
32
- return INJECTION_PATTERNS.some(re => re.test(value))
33
- }
34
-
35
- /**
36
- * Substitute `{{key}}` placeholders in a system-prompt template with
37
- * values from `context`. Emits a `console.warn` when a value matches
38
- * `looksLikePromptInjection()` so developers notice when untrusted
39
- * input is reaching the system prompt.
40
- *
41
- * The replacement still happens — the warning is informational. Callers
42
- * who need hard rejection should validate context themselves before
43
- * calling. The framework cannot decide whether a given context value is
44
- * trusted; only the application can.
45
- */
46
- export function interpolateInstructions(
47
- template: string,
48
- context: Record<string, unknown>
49
- ): string {
50
- let out = template
51
- for (const [key, rawValue] of Object.entries(context)) {
52
- const stringValue = String(rawValue)
53
- if (looksLikePromptInjection(stringValue)) {
54
- console.warn(
55
- `[brain] Possible prompt-injection in agent context.${key} — ` +
56
- `the value contains markers commonly used to override system ` +
57
- `instructions. Treat untrusted user input as user-role messages, ` +
58
- `not as interpolated system-prompt context. ` +
59
- `See packages/brain/CLAUDE.md ("Prompt-injection threat model").`
60
- )
61
- }
62
- out = out.replaceAll(`{{${key}}}`, stringValue)
63
- }
64
- return out
65
- }
@@ -1,104 +0,0 @@
1
- import { ExternalServiceError } from '@strav/kernel'
2
- import { scrubProviderError } from './error_scrub.ts'
3
-
4
- export interface RetryOptions {
5
- maxRetries?: number
6
- baseDelay?: number
7
- maxDelay?: number
8
- retryableStatuses?: number[]
9
- }
10
-
11
- const DEFAULT_RETRYABLE = [429, 500, 502, 503, 529]
12
-
13
- /**
14
- * Fetch with automatic retry and exponential backoff for transient errors.
15
- *
16
- * Retries on 429 (rate limit), 5xx, and network failures.
17
- * Parses the `retry-after` header when available; otherwise uses
18
- * exponential backoff with jitter.
19
- *
20
- * Returns the successful `Response`. On final failure, throws
21
- * `ExternalServiceError` with the last status and body.
22
- */
23
- export async function retryableFetch(
24
- service: string,
25
- url: string,
26
- init: RequestInit,
27
- options?: RetryOptions
28
- ): Promise<Response> {
29
- const maxRetries = options?.maxRetries ?? 3
30
- const baseDelay = options?.baseDelay ?? 1000
31
- const maxDelay = options?.maxDelay ?? 60_000
32
- const retryable = options?.retryableStatuses ?? DEFAULT_RETRYABLE
33
-
34
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
35
- let response: Response
36
-
37
- try {
38
- response = await fetch(url, init)
39
- } catch (err) {
40
- // Network error (DNS, connection refused, etc.). Some Bun/Node
41
- // network errors include the URL — scrub before surfacing in
42
- // case it carries credentials in query params.
43
- if (attempt === maxRetries) {
44
- throw new ExternalServiceError(
45
- service,
46
- undefined,
47
- scrubProviderError(err instanceof Error ? err.message : String(err))
48
- )
49
- }
50
- await sleep(backoffDelay(attempt, baseDelay, maxDelay))
51
- continue
52
- }
53
-
54
- if (response.ok) return response
55
-
56
- // Non-retryable status — fail immediately. Provider response bodies
57
- // can echo request headers or other context; scrub before wrapping.
58
- if (!retryable.includes(response.status)) {
59
- const text = await response.text()
60
- throw new ExternalServiceError(service, response.status, scrubProviderError(text))
61
- }
62
-
63
- // Retryable status — wait and retry (unless last attempt)
64
- if (attempt === maxRetries) {
65
- const text = await response.text()
66
- throw new ExternalServiceError(service, response.status, scrubProviderError(text))
67
- }
68
-
69
- const delay = parseRetryAfter(response) ?? backoffDelay(attempt, baseDelay, maxDelay)
70
- await sleep(delay)
71
- }
72
-
73
- // Unreachable, but satisfies TypeScript
74
- throw new ExternalServiceError(service, undefined, 'Retry loop exited unexpectedly')
75
- }
76
-
77
- /**
78
- * Parse the `retry-after` header into milliseconds.
79
- * Supports both delta-seconds ("2") and HTTP-date formats.
80
- */
81
- function parseRetryAfter(response: Response): number | null {
82
- const header = response.headers.get('retry-after')
83
- if (!header) return null
84
-
85
- const seconds = Number(header)
86
- if (!Number.isNaN(seconds)) return seconds * 1000
87
-
88
- // HTTP-date format
89
- const date = Date.parse(header)
90
- if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
91
-
92
- return null
93
- }
94
-
95
- /** Exponential backoff with jitter: base * 2^attempt + random jitter, capped at maxDelay. */
96
- function backoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
97
- const exp = baseDelay * 2 ** attempt
98
- const jitter = Math.random() * baseDelay
99
- return Math.min(exp + jitter, maxDelay)
100
- }
101
-
102
- function sleep(ms: number): Promise<void> {
103
- return new Promise(resolve => setTimeout(resolve, ms))
104
- }
@@ -1,27 +0,0 @@
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
- }
@@ -1,62 +0,0 @@
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
- }