@strav/brain 1.0.0-alpha.15 → 1.0.0-alpha.17

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.
@@ -21,24 +21,31 @@
21
21
 
22
22
  import type { Agent } from './agent.ts'
23
23
  import type { AgentResult } from './agent_result.ts'
24
+ import type { AgentStreamEvent } from './agent_stream_event.ts'
24
25
  import type { MCPServer } from './mcp_server.ts'
26
+ import type { AgentGenerateResult } from './agent_generate_result.ts'
25
27
  import type { OutputSchema } from './output_schema.ts'
26
28
  import { AgentRunner } from './agent_runner.ts'
27
29
  import { BrainError } from './brain_error.ts'
28
30
  import type { ModelTier } from './types.ts'
29
31
  import type {
32
+ AudioSource,
30
33
  ChatOptions,
31
34
  ChatResult,
35
+ EmbedOptions,
36
+ EmbedResult,
32
37
  GenerateResult,
33
38
  Message,
34
39
  StreamEvent,
40
+ TranscribeOptions,
41
+ TranscribeResult,
35
42
  } from './types.ts'
36
43
  import type { Provider, RunWithToolsOptions } from './provider.ts'
37
44
  import type { Tool } from './tool.ts'
38
45
  import { DEFAULT_TIERS } from './brain_config.ts'
39
46
 
40
47
  /** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
41
- export type AgentResolver = <A extends Agent>(cls: new (...args: never[]) => A) => A
48
+ export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
42
49
 
43
50
  export interface BrainManagerOptions {
44
51
  /** Name of the default provider — must exist in `providers`. */
@@ -168,6 +175,96 @@ export class BrainManager {
168
175
  return provider.runWithTools(messages, tools, resolved)
169
176
  }
170
177
 
178
+ /**
179
+ * Streaming variant of `generateWithTools`. Yields
180
+ * `AgentStreamEvent<T>`s as the loop progresses; the terminal
181
+ * `stop` event carries the parsed value + raw JSON text. Throws
182
+ * `BrainError` when the provider lacks
183
+ * `streamWithToolsAndSchema` (V1: all three providers
184
+ * implement it).
185
+ */
186
+ streamGenerateWithTools<T>(
187
+ input: string | readonly Message[],
188
+ schema: OutputSchema<T>,
189
+ tools: readonly Tool[],
190
+ options: RunWithToolsOptions = {},
191
+ ): AsyncIterable<AgentStreamEvent<T>> {
192
+ const provider = this.provider(options.provider)
193
+ if (!provider.streamWithToolsAndSchema) {
194
+ throw new BrainError(
195
+ `BrainManager.streamGenerateWithTools: provider "${provider.name}" does not implement streamWithToolsAndSchema.`,
196
+ { context: { provider: provider.name } },
197
+ )
198
+ }
199
+ const messages = normalizeInput(input)
200
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
201
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
202
+ resolved.mcpServers = this.defaultMcpServers
203
+ }
204
+ return provider.streamWithToolsAndSchema<T>(messages, tools, schema, resolved)
205
+ }
206
+
207
+ /**
208
+ * Tool-loop + structured output combined. Runs the agentic loop
209
+ * with the supplied `tools` while pinning the output to `schema`
210
+ * on every turn; returns the parsed value when the model finally
211
+ * answers without calling a tool. MCP defaults + tier resolution
212
+ * + provider routing match `runTools` / `generate`.
213
+ *
214
+ * Throws `BrainError` when the chosen provider doesn't implement
215
+ * `runWithToolsAndSchema`. V1: all three providers do.
216
+ */
217
+ async generateWithTools<T>(
218
+ input: string | readonly Message[],
219
+ schema: OutputSchema<T>,
220
+ tools: readonly Tool[],
221
+ options: RunWithToolsOptions = {},
222
+ ): Promise<AgentGenerateResult<T>> {
223
+ const provider = this.provider(options.provider)
224
+ if (!provider.runWithToolsAndSchema) {
225
+ throw new BrainError(
226
+ `BrainManager.generateWithTools: provider "${provider.name}" does not implement runWithToolsAndSchema.`,
227
+ { context: { provider: provider.name } },
228
+ )
229
+ }
230
+ const messages = normalizeInput(input)
231
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
232
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
233
+ resolved.mcpServers = this.defaultMcpServers
234
+ }
235
+ return provider.runWithToolsAndSchema<T>(messages, tools, schema, resolved)
236
+ }
237
+
238
+ /**
239
+ * Streaming variant of `runTools`. Yields `AgentStreamEvent`s
240
+ * as the agentic loop progresses — text deltas during model
241
+ * turns, `tool_use` / `tool_result` boundaries around tool
242
+ * execution, `iteration_start` / `iteration_end` per round, a
243
+ * terminal `stop` with the full trace + usage.
244
+ *
245
+ * Throws `BrainError` when the configured provider doesn't
246
+ * implement `streamWithTools`.
247
+ */
248
+ streamTools(
249
+ input: string | readonly Message[],
250
+ tools: readonly Tool[],
251
+ options: RunWithToolsOptions = {},
252
+ ): AsyncIterable<AgentStreamEvent> {
253
+ const provider = this.provider(options.provider)
254
+ if (!provider.streamWithTools) {
255
+ throw new BrainError(
256
+ `BrainManager.streamTools: provider "${provider.name}" does not implement streamWithTools.`,
257
+ { context: { provider: provider.name } },
258
+ )
259
+ }
260
+ const messages = normalizeInput(input)
261
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
262
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
263
+ resolved.mcpServers = this.defaultMcpServers
264
+ }
265
+ return provider.streamWithTools(messages, tools, resolved)
266
+ }
267
+
171
268
  /**
172
269
  * Structured output. Sends `input` to the configured (or
173
270
  * `options.provider`-overridden) provider with the JSON-Schema
@@ -194,21 +291,88 @@ export class BrainManager {
194
291
  return provider.generate<T>(messages, schema, resolved)
195
292
  }
196
293
 
294
+ /**
295
+ * Turn one or more text inputs into embedding vectors. Accepts
296
+ * either a single string (returns one vector) or an array
297
+ * (batch — returns one vector per input in the same order).
298
+ *
299
+ * Throws `BrainError` when the configured (or
300
+ * `options.provider`-overridden) provider doesn't implement
301
+ * `embed`. V1: OpenAI, Gemini, Ollama support it; Anthropic +
302
+ * DeepSeek throw with a clear "route to a different provider"
303
+ * message.
304
+ */
305
+ async embed(
306
+ input: string | readonly string[],
307
+ options: EmbedOptions = {},
308
+ ): Promise<EmbedResult> {
309
+ const provider = this.provider(options.provider)
310
+ if (!provider.embed) {
311
+ throw new BrainError(
312
+ `BrainManager.embed: provider "${provider.name}" does not implement embed. Route to a provider with an embeddings API (V1: OpenAI / Gemini / Ollama).`,
313
+ { context: { provider: provider.name } },
314
+ )
315
+ }
316
+ const texts = typeof input === 'string' ? [input] : input
317
+ return provider.embed(texts, options)
318
+ }
319
+
320
+ /**
321
+ * Transcribe one audio clip to text. Complements `AudioBlock`
322
+ * (which sends audio + a text prompt together to a multimodal
323
+ * chat model) by exposing the dedicated transcription endpoint
324
+ * where the provider has one. Apps that already have an
325
+ * `AudioBlock` can pass its `source` directly.
326
+ *
327
+ * Throws `BrainError` when the configured (or
328
+ * `options.provider`-overridden) provider doesn't implement
329
+ * `transcribe`. V1: OpenAI / Ollama (Whisper / gpt-4o-transcribe
330
+ * / local) and Gemini (chat-wrap fallback); Anthropic +
331
+ * DeepSeek throw.
332
+ */
333
+ async transcribe(
334
+ audio: AudioSource,
335
+ options: TranscribeOptions = {},
336
+ ): Promise<TranscribeResult> {
337
+ const provider = this.provider(options.provider)
338
+ if (!provider.transcribe) {
339
+ throw new BrainError(
340
+ `BrainManager.transcribe: provider "${provider.name}" does not implement transcribe. Route to a provider with audio support (V1: OpenAI / Ollama / Gemini).`,
341
+ { context: { provider: provider.name } },
342
+ )
343
+ }
344
+ return provider.transcribe(audio, options)
345
+ }
346
+
197
347
  /**
198
348
  * Resolve an `Agent` subclass from the container and return an
199
349
  * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
200
350
  * `@inject()`-decorate their Agent subclass so constructor
201
351
  * injection of dependencies (Repositories, services, etc.) flows
202
352
  * through normally.
353
+ *
354
+ * When the agent subclass extends `Agent<T>` for some `T` and
355
+ * declares `outputSchema`, the returned runner is typed as
356
+ * `AgentRunner<T>` and the schema is pre-applied — `.run()`
357
+ * returns `AgentGenerateResult<T>` without a per-call
358
+ * `.output(schema)`. Apps can still chain `.output(otherSchema)`
359
+ * to override.
203
360
  */
204
- agent<A extends Agent>(AgentClass: new (...args: never[]) => A, instance?: A): AgentRunner {
361
+ agent<T = never>(
362
+ AgentClass: new (...args: never[]) => Agent<T>,
363
+ instance?: Agent<T>,
364
+ ): AgentRunner<T> {
205
365
  const agent = instance ?? this.resolveAgent(AgentClass)
206
- return new AgentRunner(this, agent)
366
+ const runner = new AgentRunner<T>(this, agent)
367
+ if (agent.outputSchema !== undefined) {
368
+ return runner.output(agent.outputSchema)
369
+ }
370
+ return runner
207
371
  }
208
372
 
209
373
  // ─── Internal ────────────────────────────────────────────────────────────
210
374
 
211
- private resolveAgent<A extends Agent>(AgentClass: new (...args: never[]) => A): A {
375
+ private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
212
376
  if (this.agentResolver) return this.agentResolver(AgentClass)
213
377
  // Fallback: assume the Agent class is constructible without args.
214
378
  // Apps that need DI on the agent register a resolver via
@@ -28,8 +28,11 @@ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from
28
28
  import { BrainManager } from './brain_manager.ts'
29
29
  import type { BrainConfigShape, ProviderConfig } from './brain_config.ts'
30
30
  import { AnthropicProvider } from './providers/anthropic_provider.ts'
31
+ import { DeepSeekProvider } from './providers/deepseek_provider.ts'
31
32
  import { GeminiProvider } from './providers/gemini_provider.ts'
33
+ import { OllamaProvider } from './providers/ollama_provider.ts'
32
34
  import { OpenAIProvider } from './providers/openai_provider.ts'
35
+ import { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
33
36
  import type { Provider } from './provider.ts'
34
37
 
35
38
  export class BrainProvider extends ServiceProvider {
@@ -102,6 +105,13 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
102
105
  )
103
106
  }
104
107
  return new OpenAIProvider(name, config)
108
+ case 'openai-responses':
109
+ if (!config.apiKey) {
110
+ throw new ConfigError(
111
+ `BrainProvider: openai-responses provider "${name}" is missing apiKey. Source from env('OPENAI_API_KEY').`,
112
+ )
113
+ }
114
+ return new OpenAIResponsesProvider(name, config)
105
115
  case 'google':
106
116
  if (!config.apiKey) {
107
117
  throw new ConfigError(
@@ -109,10 +119,24 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
109
119
  )
110
120
  }
111
121
  return new GeminiProvider(name, config)
122
+ case 'deepseek':
123
+ if (!config.apiKey) {
124
+ throw new ConfigError(
125
+ `BrainProvider: deepseek provider "${name}" is missing apiKey. Source from env('DEEPSEEK_API_KEY').`,
126
+ )
127
+ }
128
+ return new DeepSeekProvider(name, config)
129
+ case 'ollama':
130
+ if (!config.defaultModel) {
131
+ throw new ConfigError(
132
+ `BrainProvider: ollama provider "${name}" is missing defaultModel. Ollama models are user-installed — pick one you've pulled (e.g. 'llama3.2').`,
133
+ )
134
+ }
135
+ return new OllamaProvider(name, config)
112
136
  default: {
113
137
  const exhaustiveCheck: never = config
114
138
  throw new ConfigError(
115
- `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, google.`,
139
+ `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, openai-responses, google, deepseek, ollama.`,
116
140
  )
117
141
  // (unreachable — kept for the exhaustive check to fire when a new driver lands)
118
142
  // biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
package/src/index.ts CHANGED
@@ -8,16 +8,21 @@
8
8
  // tools, structured outputs, other providers.
9
9
 
10
10
  export { Agent } from './agent.ts'
11
+ export type { AgentGenerateResult } from './agent_generate_result.ts'
11
12
  export type { AgentResult } from './agent_result.ts'
12
- export { AgentRunner } from './agent_runner.ts'
13
+ export { AgentRunner, type AgentRunResult } from './agent_runner.ts'
14
+ export type { AgentStreamEvent } from './agent_stream_event.ts'
13
15
  export {
14
16
  type AnthropicProviderConfig,
15
17
  type BrainCacheConfig,
16
18
  type BrainConfigShape,
19
+ type DeepSeekProviderConfig,
17
20
  DEFAULT_MODEL,
18
21
  DEFAULT_TIERS,
19
22
  type GeminiProviderConfig,
23
+ type OllamaProviderConfig,
20
24
  type OpenAIProviderConfig,
25
+ type OpenAIResponsesProviderConfig,
21
26
  type ProviderConfig,
22
27
  } from './brain_config.ts'
23
28
  export { BrainError } from './brain_error.ts'
@@ -31,8 +36,12 @@ export { defineTool, type DefineToolSpec } from './define_tool.ts'
31
36
  export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
32
37
  export type { OutputSchema } from './output_schema.ts'
33
38
  export { AnthropicProvider } from './providers/anthropic_provider.ts'
39
+ export { DeepSeekProvider } from './providers/deepseek_provider.ts'
34
40
  export { GeminiProvider } from './providers/gemini_provider.ts'
41
+ export { OllamaProvider } from './providers/ollama_provider.ts'
42
+ export { OpenAICompatProvider } from './providers/openai_compat_provider.ts'
35
43
  export { OpenAIProvider } from './providers/openai_provider.ts'
44
+ export { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
36
45
  export type { Provider, RunWithToolsOptions } from './provider.ts'
37
46
  export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
38
47
  export type { Tool, ToolContext } from './tool.ts'
@@ -42,14 +51,23 @@ export type {
42
51
  ChatResult,
43
52
  ChatUsage,
44
53
  ContentBlock,
54
+ AudioBlock,
55
+ AudioSource,
56
+ DocumentBlock,
57
+ EmbedOptions,
58
+ EmbedResult,
45
59
  GenerateResult,
60
+ ImageBlock,
46
61
  MCPToolResultBlock,
47
62
  MCPToolUseBlock,
48
63
  Message,
49
64
  ModelTier,
65
+ ServerTool,
50
66
  StreamEvent,
51
67
  SystemPrompt,
52
68
  TextBlock,
53
69
  ToolResultBlock,
54
70
  ToolUseBlock,
71
+ TranscribeOptions,
72
+ TranscribeResult,
55
73
  } from './types.ts'
package/src/mcp/client.ts CHANGED
@@ -16,10 +16,16 @@
16
16
  * await client.close()
17
17
  *
18
18
  * Authentication:
19
- * `MCPServer.authorizationToken` is forwarded as
20
- * `Authorization: Bearer <token>`. OAuth-flow servers need
21
- * out-of-band token exchange same constraint as the server-side
22
- * path. Full OAuth handshake is a later slice.
19
+ * - `MCPServer.authorizationToken` static bearer; fine for
20
+ * self-hosted servers where the app controls the token.
21
+ * - `MCPServer.oauth` drives the authorization-code-with-PKCE
22
+ * flow against the server's OAuth endpoints. On
23
+ * `connect()` against an un-authorized server,
24
+ * `MCPAuthRequiredError` is thrown carrying the URL the user
25
+ * should be redirected to. After the user authorizes and is
26
+ * redirected back, the app's callback handler calls
27
+ * `completeAuthorization(code)` to finish the exchange.
28
+ * The two are mutually exclusive — passing both throws.
23
29
  *
24
30
  * Transport:
25
31
  * V1 only does Streamable HTTP — the current MCP transport. Legacy
@@ -31,8 +37,10 @@
31
37
 
32
38
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
33
39
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
40
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
34
41
  import { BrainError } from '../brain_error.ts'
35
42
  import type { MCPServer } from '../mcp_server.ts'
43
+ import { MCPAuthRequiredError, StoreBackedOAuthProvider } from './oauth.ts'
36
44
 
37
45
  /** Result of a single MCP tool invocation, as returned by `tools/call`. */
38
46
  export interface MCPCallToolResult {
@@ -58,8 +66,16 @@ export class MCPClient {
58
66
  readonly server: MCPServer
59
67
  private readonly _client: Client
60
68
  private _connected = false
69
+ private _transport: StreamableHTTPClientTransport | undefined
70
+ private _authProvider: StoreBackedOAuthProvider | undefined
61
71
 
62
72
  constructor(server: MCPServer, options: MCPClientOptions = {}) {
73
+ if (server.authorizationToken !== undefined && server.oauth !== undefined) {
74
+ throw new BrainError(
75
+ `MCPClient(${server.name}): \`authorizationToken\` and \`oauth\` are mutually exclusive — set one.`,
76
+ { context: { server: server.name } },
77
+ )
78
+ }
63
79
  this.server = server
64
80
  this._client =
65
81
  options.client ??
@@ -72,10 +88,17 @@ export class MCPClient {
72
88
  async connect(): Promise<void> {
73
89
  if (this._connected) return
74
90
  const transport = this._buildTransport()
91
+ this._transport = transport
75
92
  try {
76
93
  await this._client.connect(transport)
77
94
  this._connected = true
78
95
  } catch (cause) {
96
+ if (cause instanceof UnauthorizedError && this._authProvider?.capturedAuthorizationUrl) {
97
+ throw new MCPAuthRequiredError(
98
+ this.server.name,
99
+ this._authProvider.capturedAuthorizationUrl.toString(),
100
+ )
101
+ }
79
102
  throw new BrainError(
80
103
  `MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
81
104
  { context: { server: this.server.name, url: this.server.url }, cause },
@@ -83,11 +106,44 @@ export class MCPClient {
83
106
  }
84
107
  }
85
108
 
86
- async listTools(): Promise<MCPToolDescriptor[]> {
109
+ /**
110
+ * Finish the OAuth authorization-code flow after the user
111
+ * authorized and was redirected back to the app's callback URL.
112
+ * Exchanges `code` for tokens, persists them via the configured
113
+ * `MCPOAuthStore`, then connects.
114
+ *
115
+ * Throws `BrainError` when called on a server without OAuth
116
+ * configured.
117
+ */
118
+ async completeAuthorization(code: string): Promise<void> {
119
+ if (this.server.oauth === undefined) {
120
+ throw new BrainError(
121
+ `MCPClient(${this.server.name}): completeAuthorization() called on a server without \`oauth\` configured.`,
122
+ { context: { server: this.server.name } },
123
+ )
124
+ }
125
+ if (this._transport === undefined) {
126
+ // A previous `connect()` attempt builds the transport + captures
127
+ // the auth URL on the provider. Apps that lost the transport
128
+ // reference (typical for stateless callback handlers) can
129
+ // construct a fresh transport here — the store carries every
130
+ // bit of state the exchange needs.
131
+ this._transport = this._buildTransport()
132
+ }
133
+ await this._transport.finishAuth(code)
134
+ // Now that tokens are saved, re-attempt the connection.
135
+ this._connected = false
136
+ await this.connect()
137
+ }
138
+
139
+ async listTools(opts: { signal?: AbortSignal } = {}): Promise<MCPToolDescriptor[]> {
87
140
  await this.connect()
88
141
  let response: Awaited<ReturnType<Client['listTools']>>
89
142
  try {
90
- response = await this._client.listTools()
143
+ response = await this._client.listTools(
144
+ undefined,
145
+ opts.signal !== undefined ? { signal: opts.signal } : undefined,
146
+ )
91
147
  } catch (cause) {
92
148
  throw new BrainError(
93
149
  `MCPClient(${this.server.name}): tools/list failed.`,
@@ -101,14 +157,22 @@ export class MCPClient {
101
157
  }))
102
158
  }
103
159
 
104
- async callTool(name: string, input: unknown): Promise<MCPCallToolResult> {
160
+ async callTool(
161
+ name: string,
162
+ input: unknown,
163
+ opts: { signal?: AbortSignal } = {},
164
+ ): Promise<MCPCallToolResult> {
105
165
  await this.connect()
106
166
  let response: Awaited<ReturnType<Client['callTool']>>
107
167
  try {
108
- response = await this._client.callTool({
109
- name,
110
- arguments: (input ?? {}) as Record<string, unknown>,
111
- })
168
+ response = await this._client.callTool(
169
+ {
170
+ name,
171
+ arguments: (input ?? {}) as Record<string, unknown>,
172
+ },
173
+ undefined,
174
+ opts.signal !== undefined ? { signal: opts.signal } : undefined,
175
+ )
112
176
  } catch (cause) {
113
177
  throw new BrainError(
114
178
  `MCPClient(${this.server.name}): tools/call ${name} failed.`,
@@ -135,9 +199,14 @@ export class MCPClient {
135
199
  if (this.server.authorizationToken !== undefined) {
136
200
  headers.Authorization = `Bearer ${this.server.authorizationToken}`
137
201
  }
138
- return new StreamableHTTPClientTransport(new URL(this.server.url), {
202
+ const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
139
203
  requestInit: { headers },
140
- })
204
+ }
205
+ if (this.server.oauth !== undefined) {
206
+ this._authProvider = new StoreBackedOAuthProvider(this.server.oauth)
207
+ transportOpts.authProvider = this._authProvider
208
+ }
209
+ return new StreamableHTTPClientTransport(new URL(this.server.url), transportOpts)
141
210
  }
142
211
  }
143
212
 
package/src/mcp/index.ts CHANGED
@@ -9,6 +9,12 @@ export {
9
9
  type MCPClientOptions,
10
10
  type MCPToolDescriptor,
11
11
  } from './client.ts'
12
+ export {
13
+ MCPAuthRequiredError,
14
+ type MCPOAuthConfig,
15
+ type MCPOAuthStore,
16
+ MemoryOAuthStore,
17
+ } from './oauth.ts'
12
18
  export {
13
19
  resolveMcpTools,
14
20
  type ResolveMcpToolsOptions,