@strav/brain 1.0.0-alpha.9 → 1.0.2

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.
Files changed (73) hide show
  1. package/package.json +23 -7
  2. package/src/agent.ts +43 -5
  3. package/src/agent_generate_result.ts +32 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +218 -14
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +218 -1
  8. package/src/brain_driver.ts +247 -0
  9. package/src/brain_error.ts +86 -10
  10. package/src/brain_manager.ts +359 -11
  11. package/src/brain_provider.ts +79 -9
  12. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  13. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  14. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  15. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  16. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  17. package/src/drivers/anthropic/index.ts +1 -0
  18. package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
  19. package/src/drivers/deepseek/index.ts +1 -0
  20. package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
  21. package/src/drivers/gemini/index.ts +1 -0
  22. package/src/drivers/minimax/index.ts +1 -0
  23. package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
  24. package/src/drivers/ollama/index.ts +1 -0
  25. package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
  26. package/src/drivers/openai/index.ts +1 -0
  27. package/src/drivers/openai/openai_brain_driver.ts +796 -0
  28. package/src/drivers/openai/openai_helpers.ts +58 -0
  29. package/src/drivers/openai/openai_message_builder.ts +187 -0
  30. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  31. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  32. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  33. package/src/drivers/openai_compat/index.ts +1 -0
  34. package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
  35. package/src/drivers/openai_responses/index.ts +1 -0
  36. package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
  37. package/src/drivers/openrouter/index.ts +1 -0
  38. package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
  39. package/src/drivers/qwen/index.ts +1 -0
  40. package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
  41. package/src/index.ts +75 -11
  42. package/src/mcp/client.ts +243 -0
  43. package/src/mcp/index.ts +23 -0
  44. package/src/mcp/oauth.ts +227 -0
  45. package/src/mcp/pool.ts +106 -0
  46. package/src/mcp/resolve_mcp_tools.ts +108 -0
  47. package/src/mcp_server.ts +63 -0
  48. package/src/output_schema.ts +72 -0
  49. package/src/persistence/brain_message.ts +34 -0
  50. package/src/persistence/brain_message_repository.ts +98 -0
  51. package/src/persistence/brain_store.ts +166 -0
  52. package/src/persistence/brain_suspended_run.ts +30 -0
  53. package/src/persistence/brain_suspended_run_repository.ts +59 -0
  54. package/src/persistence/brain_thread.ts +30 -0
  55. package/src/persistence/brain_thread_repository.ts +56 -0
  56. package/src/persistence/database_brain_store.ts +190 -0
  57. package/src/persistence/index.ts +48 -0
  58. package/src/persistence/schemas/brain_message_schema.ts +61 -0
  59. package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
  60. package/src/persistence/schemas/brain_thread_schema.ts +50 -0
  61. package/src/persistence/schemas/index.ts +3 -0
  62. package/src/suspended_run.ts +153 -0
  63. package/src/thread.ts +40 -1
  64. package/src/tool.ts +7 -0
  65. package/src/tool_runner.ts +81 -0
  66. package/src/translate/index.ts +19 -0
  67. package/src/translate/translate_cache.ts +78 -0
  68. package/src/translate/translate_provider.ts +46 -0
  69. package/src/translate/translator.ts +271 -0
  70. package/src/types.ts +398 -1
  71. package/src/zod/index.ts +121 -0
  72. package/src/provider.ts +0 -74
  73. package/src/providers/anthropic_provider.ts +0 -397
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `BrainManager` — the per-app facade apps inject and call.
3
3
  *
4
- * Holds the configured `Provider` registry + the default-provider key
4
+ * Holds the configured `BrainDriver` registry + the default-provider key
5
5
  * + the tier-to-model map. Apps call `chat / stream / countTokens`
6
6
  * with framework-native types; the manager resolves which provider
7
7
  * runs the call (default unless `options.provider` overrides),
@@ -21,38 +21,61 @@
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'
25
+ import type { MCPServer } from './mcp_server.ts'
26
+ import type { AgentGenerateResult } from './agent_generate_result.ts'
27
+ import type { OutputSchema } from './output_schema.ts'
24
28
  import { AgentRunner } from './agent_runner.ts'
25
29
  import { BrainError } from './brain_error.ts'
26
30
  import type { ModelTier } from './types.ts'
27
31
  import type {
32
+ AudioSource,
28
33
  ChatOptions,
29
34
  ChatResult,
35
+ EmbedOptions,
36
+ EmbedResult,
37
+ GenerateResult,
30
38
  Message,
31
39
  StreamEvent,
40
+ TranscribeOptions,
41
+ TranscribeResult,
32
42
  } from './types.ts'
33
- import type { Provider, RunWithToolsOptions } from './provider.ts'
43
+ import type {
44
+ BrainDriver,
45
+ RunWithToolsOptions,
46
+ RunWithToolsOptionsWithSuspend,
47
+ } from './brain_driver.ts'
48
+ import { appendResumeResults, type SuspendedRun, type SuspendedState, type ToolResultInput } from './suspended_run.ts'
34
49
  import type { Tool } from './tool.ts'
35
50
  import { DEFAULT_TIERS } from './brain_config.ts'
36
51
 
37
52
  /** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
38
- export type AgentResolver = <A extends Agent>(cls: new (...args: never[]) => A) => A
53
+ export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
39
54
 
40
55
  export interface BrainManagerOptions {
41
56
  /** Name of the default provider — must exist in `providers`. */
42
57
  default: string
43
58
  /** Provider registry keyed by name. */
44
- providers: Record<string, Provider>
59
+ providers: Record<string, BrainDriver>
45
60
  /** Tier-to-model overrides; merged on top of the framework defaults. */
46
61
  tiers?: Partial<Record<ModelTier, string>>
47
62
  /** Default for `ChatOptions.cache` when the call site doesn't pass one. */
48
63
  defaultCache?: boolean
64
+ /**
65
+ * Default MCP servers used on every `runTools` call when the per-call
66
+ * options don't specify them. Per-call `mcpServers` replaces the
67
+ * default outright (no merge) — apps that want additive behavior
68
+ * concat at the call site.
69
+ */
70
+ defaultMcpServers?: readonly MCPServer[]
49
71
  }
50
72
 
51
73
  export class BrainManager {
52
74
  readonly defaultProvider: string
53
- private readonly providers: Map<string, Provider>
75
+ private readonly providers: Map<string, BrainDriver>
54
76
  private readonly tiers: Record<ModelTier, string>
55
77
  private readonly defaultCache: boolean
78
+ private readonly defaultMcpServers: readonly MCPServer[]
56
79
 
57
80
  constructor(options: BrainManagerOptions) {
58
81
  if (!options.providers[options.default]) {
@@ -65,10 +88,11 @@ export class BrainManager {
65
88
  this.providers = new Map(Object.entries(options.providers))
66
89
  this.tiers = { ...DEFAULT_TIERS, ...(options.tiers ?? {}) }
67
90
  this.defaultCache = options.defaultCache ?? false
91
+ this.defaultMcpServers = options.defaultMcpServers ?? []
68
92
  }
69
93
 
70
94
  /** Resolve a provider by name. Default when no name passed. Throws when unknown. */
71
- provider(name?: string): Provider {
95
+ provider(name?: string): BrainDriver {
72
96
  const key = name ?? this.defaultProvider
73
97
  const provider = this.providers.get(key)
74
98
  if (!provider) {
@@ -79,6 +103,29 @@ export class BrainManager {
79
103
  return provider
80
104
  }
81
105
 
106
+ /**
107
+ * Register an additional provider after construction. Apps that
108
+ * wire a custom adapter (a fine-tuned model server, a fork of
109
+ * Anthropic with extra knobs, an internal LLM) use this to add it
110
+ * to the registry without going through `config.brain.providers`.
111
+ *
112
+ * Overwrites any existing provider under the same name.
113
+ *
114
+ * ```ts
115
+ * brain.extend('internal', new InternalLlmProvider({ baseUrl }))
116
+ * const reply = await brain.chat(messages, { provider: 'internal' })
117
+ * ```
118
+ *
119
+ * Mirrors `RagManager.extend(name, factory)` / `PaymentManager.extend(name, factory)`
120
+ * — the OCP escape hatch every multi-driver Strav manager exposes.
121
+ */
122
+ extend(name: string, provider: BrainDriver): void {
123
+ if (!name) {
124
+ throw new BrainError('BrainManager.extend: provider name must be a non-empty string.')
125
+ }
126
+ this.providers.set(name, provider)
127
+ }
128
+
82
129
  /**
83
130
  * One-shot chat: send the messages, await the full reply.
84
131
  *
@@ -131,13 +178,23 @@ export class BrainManager {
131
178
  *
132
179
  * Throws `BrainError` when the configured provider doesn't
133
180
  * implement `runWithTools` (V1: OpenAI / Gemini / DeepSeek providers
134
- * don't yet — only `AnthropicProvider`).
181
+ * don't yet — only `AnthropicBrainDriver`).
135
182
  */
183
+ runTools(
184
+ input: string | readonly Message[],
185
+ tools: readonly Tool[],
186
+ options: RunWithToolsOptionsWithSuspend,
187
+ ): Promise<AgentResult | SuspendedRun>
188
+ runTools(
189
+ input: string | readonly Message[],
190
+ tools: readonly Tool[],
191
+ options?: RunWithToolsOptions,
192
+ ): Promise<AgentResult>
136
193
  async runTools(
137
194
  input: string | readonly Message[],
138
195
  tools: readonly Tool[],
139
196
  options: RunWithToolsOptions = {},
140
- ): Promise<AgentResult> {
197
+ ): Promise<AgentResult | SuspendedRun> {
141
198
  const provider = this.provider(options.provider)
142
199
  if (!provider.runWithTools) {
143
200
  throw new BrainError(
@@ -147,24 +204,252 @@ export class BrainManager {
147
204
  }
148
205
  const messages = normalizeInput(input)
149
206
  const resolved = this.applyDefaults(options) as RunWithToolsOptions
207
+ // MCP defaults — per-call override (when present) replaces the
208
+ // configured list outright; apps that want concat behavior
209
+ // construct the merged array themselves and pass it in.
210
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
211
+ resolved.mcpServers = this.defaultMcpServers
212
+ }
150
213
  return provider.runWithTools(messages, tools, resolved)
151
214
  }
152
215
 
216
+ /**
217
+ * Resume a previously-suspended tool-use loop. Takes the
218
+ * `SuspendedRun.state` snapshot plus the results the integrator
219
+ * gathered for each `pendingToolCalls` entry; appends a `tool_result`
220
+ * block per entry; re-enters `runTools` so the model can continue
221
+ * (potentially suspending again on the next tool).
222
+ *
223
+ * Mid-batch invariant: every pending call MUST get a result —
224
+ * otherwise the provider rejects the next request because the
225
+ * assistant turn's `tool_use` blocks are no longer balanced.
226
+ * `resumeTools` throws `BrainError` when results are missing.
227
+ *
228
+ * The `previousResponseId` carried on the snapshot (when the
229
+ * provider supports stateful conversations) is threaded back via
230
+ * `options.previousResponseId` automatically — per-call
231
+ * `options.previousResponseId` wins if supplied explicitly.
232
+ */
233
+ async resumeTools(
234
+ state: SuspendedState,
235
+ results: readonly ToolResultInput[],
236
+ tools: readonly Tool[],
237
+ options: RunWithToolsOptions = {},
238
+ ): Promise<AgentResult | SuspendedRun> {
239
+ const resumed = appendResumeResults(state, results)
240
+ const merged: RunWithToolsOptions = { ...options }
241
+ if (merged.previousResponseId === undefined && state.responseId !== undefined) {
242
+ merged.previousResponseId = state.responseId
243
+ }
244
+ const out = await this.runTools(
245
+ resumed,
246
+ tools,
247
+ merged as RunWithToolsOptionsWithSuspend,
248
+ )
249
+ return mergeResumeCounters(out, state)
250
+ }
251
+
252
+ /**
253
+ * Streaming variant of `generateWithTools`. Yields
254
+ * `AgentStreamEvent<T>`s as the loop progresses; the terminal
255
+ * `stop` event carries the parsed value + raw JSON text. Throws
256
+ * `BrainError` when the provider lacks
257
+ * `streamWithToolsAndSchema` (V1: all three providers
258
+ * implement it).
259
+ */
260
+ streamGenerateWithTools<T>(
261
+ input: string | readonly Message[],
262
+ schema: OutputSchema<T>,
263
+ tools: readonly Tool[],
264
+ options: RunWithToolsOptions = {},
265
+ ): AsyncIterable<AgentStreamEvent<T>> {
266
+ rejectShouldSuspend(options, 'streamGenerateWithTools')
267
+ const provider = this.provider(options.provider)
268
+ if (!provider.streamWithToolsAndSchema) {
269
+ throw new BrainError(
270
+ `BrainManager.streamGenerateWithTools: provider "${provider.name}" does not implement streamWithToolsAndSchema.`,
271
+ { context: { provider: provider.name } },
272
+ )
273
+ }
274
+ const messages = normalizeInput(input)
275
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
276
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
277
+ resolved.mcpServers = this.defaultMcpServers
278
+ }
279
+ return provider.streamWithToolsAndSchema<T>(messages, tools, schema, resolved)
280
+ }
281
+
282
+ /**
283
+ * Tool-loop + structured output combined. Runs the agentic loop
284
+ * with the supplied `tools` while pinning the output to `schema`
285
+ * on every turn; returns the parsed value when the model finally
286
+ * answers without calling a tool. MCP defaults + tier resolution
287
+ * + provider routing match `runTools` / `generate`.
288
+ *
289
+ * Throws `BrainError` when the chosen provider doesn't implement
290
+ * `runWithToolsAndSchema`. V1: all three providers do.
291
+ */
292
+ async generateWithTools<T>(
293
+ input: string | readonly Message[],
294
+ schema: OutputSchema<T>,
295
+ tools: readonly Tool[],
296
+ options: RunWithToolsOptions = {},
297
+ ): Promise<AgentGenerateResult<T>> {
298
+ rejectShouldSuspend(options, 'generateWithTools')
299
+ const provider = this.provider(options.provider)
300
+ if (!provider.runWithToolsAndSchema) {
301
+ throw new BrainError(
302
+ `BrainManager.generateWithTools: provider "${provider.name}" does not implement runWithToolsAndSchema.`,
303
+ { context: { provider: provider.name } },
304
+ )
305
+ }
306
+ const messages = normalizeInput(input)
307
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
308
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
309
+ resolved.mcpServers = this.defaultMcpServers
310
+ }
311
+ return provider.runWithToolsAndSchema<T>(messages, tools, schema, resolved)
312
+ }
313
+
314
+ /**
315
+ * Streaming variant of `runTools`. Yields `AgentStreamEvent`s
316
+ * as the agentic loop progresses — text deltas during model
317
+ * turns, `tool_use` / `tool_result` boundaries around tool
318
+ * execution, `iteration_start` / `iteration_end` per round, a
319
+ * terminal `stop` with the full trace + usage.
320
+ *
321
+ * Throws `BrainError` when the configured provider doesn't
322
+ * implement `streamWithTools`.
323
+ */
324
+ streamTools(
325
+ input: string | readonly Message[],
326
+ tools: readonly Tool[],
327
+ options: RunWithToolsOptions = {},
328
+ ): AsyncIterable<AgentStreamEvent> {
329
+ rejectShouldSuspend(options, 'streamTools')
330
+ const provider = this.provider(options.provider)
331
+ if (!provider.streamWithTools) {
332
+ throw new BrainError(
333
+ `BrainManager.streamTools: provider "${provider.name}" does not implement streamWithTools.`,
334
+ { context: { provider: provider.name } },
335
+ )
336
+ }
337
+ const messages = normalizeInput(input)
338
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
339
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
340
+ resolved.mcpServers = this.defaultMcpServers
341
+ }
342
+ return provider.streamWithTools(messages, tools, resolved)
343
+ }
344
+
345
+ /**
346
+ * Structured output. Sends `input` to the configured (or
347
+ * `options.provider`-overridden) provider with the JSON-Schema
348
+ * constraint described by `schema`; returns the parsed object.
349
+ *
350
+ * Throws `BrainError` when the chosen provider doesn't implement
351
+ * `generate`. All three V1 providers (Anthropic, OpenAI, Gemini)
352
+ * do.
353
+ */
354
+ async generate<T>(
355
+ input: string | readonly Message[],
356
+ schema: OutputSchema<T>,
357
+ options: ChatOptions = {},
358
+ ): Promise<GenerateResult<T>> {
359
+ const provider = this.provider(options.provider)
360
+ if (!provider.generate) {
361
+ throw new BrainError(
362
+ `BrainManager.generate: provider "${provider.name}" does not implement generate.`,
363
+ { context: { provider: provider.name } },
364
+ )
365
+ }
366
+ const messages = normalizeInput(input)
367
+ const resolved = this.applyDefaults(options)
368
+ return provider.generate<T>(messages, schema, resolved)
369
+ }
370
+
371
+ /**
372
+ * Turn one or more text inputs into embedding vectors. Accepts
373
+ * either a single string (returns one vector) or an array
374
+ * (batch — returns one vector per input in the same order).
375
+ *
376
+ * Throws `BrainError` when the configured (or
377
+ * `options.provider`-overridden) provider doesn't implement
378
+ * `embed`. V1: OpenAI, Gemini, Ollama support it; Anthropic +
379
+ * DeepSeek throw with a clear "route to a different provider"
380
+ * message.
381
+ */
382
+ async embed(
383
+ input: string | readonly string[],
384
+ options: EmbedOptions = {},
385
+ ): Promise<EmbedResult> {
386
+ const provider = this.provider(options.provider)
387
+ if (!provider.embed) {
388
+ throw new BrainError(
389
+ `BrainManager.embed: provider "${provider.name}" does not implement embed. Route to a provider with an embeddings API (V1: OpenAI / Gemini / Ollama).`,
390
+ { context: { provider: provider.name } },
391
+ )
392
+ }
393
+ const texts = typeof input === 'string' ? [input] : input
394
+ return provider.embed(texts, options)
395
+ }
396
+
397
+ /**
398
+ * Transcribe one audio clip to text. Complements `AudioBlock`
399
+ * (which sends audio + a text prompt together to a multimodal
400
+ * chat model) by exposing the dedicated transcription endpoint
401
+ * where the provider has one. Apps that already have an
402
+ * `AudioBlock` can pass its `source` directly.
403
+ *
404
+ * Throws `BrainError` when the configured (or
405
+ * `options.provider`-overridden) provider doesn't implement
406
+ * `transcribe`. V1: OpenAI / Ollama (Whisper / gpt-4o-transcribe
407
+ * / local) and Gemini (chat-wrap fallback); Anthropic +
408
+ * DeepSeek throw.
409
+ */
410
+ async transcribe(
411
+ audio: AudioSource,
412
+ options: TranscribeOptions = {},
413
+ ): Promise<TranscribeResult> {
414
+ const provider = this.provider(options.provider)
415
+ if (!provider.transcribe) {
416
+ throw new BrainError(
417
+ `BrainManager.transcribe: provider "${provider.name}" does not implement transcribe. Route to a provider with audio support (V1: OpenAI / Ollama / Gemini).`,
418
+ { context: { provider: provider.name } },
419
+ )
420
+ }
421
+ return provider.transcribe(audio, options)
422
+ }
423
+
153
424
  /**
154
425
  * Resolve an `Agent` subclass from the container and return an
155
426
  * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
156
427
  * `@inject()`-decorate their Agent subclass so constructor
157
428
  * injection of dependencies (Repositories, services, etc.) flows
158
429
  * through normally.
430
+ *
431
+ * When the agent subclass extends `Agent<T>` for some `T` and
432
+ * declares `outputSchema`, the returned runner is typed as
433
+ * `AgentRunner<T>` and the schema is pre-applied — `.run()`
434
+ * returns `AgentGenerateResult<T>` without a per-call
435
+ * `.output(schema)`. Apps can still chain `.output(otherSchema)`
436
+ * to override.
159
437
  */
160
- agent<A extends Agent>(AgentClass: new (...args: never[]) => A, instance?: A): AgentRunner {
438
+ agent<T = never>(
439
+ AgentClass: new (...args: never[]) => Agent<T>,
440
+ instance?: Agent<T>,
441
+ ): AgentRunner<T> {
161
442
  const agent = instance ?? this.resolveAgent(AgentClass)
162
- return new AgentRunner(this, agent)
443
+ const runner = new AgentRunner<T>(this, agent)
444
+ if (agent.outputSchema !== undefined) {
445
+ return runner.output(agent.outputSchema)
446
+ }
447
+ return runner
163
448
  }
164
449
 
165
450
  // ─── Internal ────────────────────────────────────────────────────────────
166
451
 
167
- private resolveAgent<A extends Agent>(AgentClass: new (...args: never[]) => A): A {
452
+ private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
168
453
  if (this.agentResolver) return this.agentResolver(AgentClass)
169
454
  // Fallback: assume the Agent class is constructible without args.
170
455
  // Apps that need DI on the agent register a resolver via
@@ -203,3 +488,66 @@ function normalizeInput(input: string | readonly Message[]): readonly Message[]
203
488
  }
204
489
  return input
205
490
  }
491
+
492
+ /**
493
+ * V1 scope guard. `shouldSuspend` is wired only into the non-
494
+ * streaming `runWithTools` loop; the streaming and schema variants
495
+ * don't yet model pause / resume, so silently ignoring would be
496
+ * worse than throwing. Apps that need both should run tools first
497
+ * (suspending as needed), then call `generate` for the structured
498
+ * summary in a separate step.
499
+ */
500
+ /**
501
+ * Carry forward the pre-suspension iteration count + token usage so
502
+ * `result.iterations` / `result.usage` reflect the full run, not
503
+ * just the post-resume portion. When the resumed call suspends
504
+ * again, the new state's iterations + usage also get the carry-
505
+ * forward so apps see a running total across an arbitrary number
506
+ * of suspension cycles.
507
+ */
508
+ function mergeResumeCounters(
509
+ out: AgentResult | SuspendedRun,
510
+ state: SuspendedState,
511
+ ): AgentResult | SuspendedRun {
512
+ // +1 accounts for the suspended round itself — at suspension time
513
+ // the loop hadn't yet incremented `iterations` (we paused mid-
514
+ // batch, before tool execution). Supplying results to resume
515
+ // effectively completes that round.
516
+ const carryIter = state.iterations + 1
517
+ if ('status' in out) {
518
+ return {
519
+ ...out,
520
+ state: {
521
+ ...out.state,
522
+ iterations: out.state.iterations + carryIter,
523
+ usage: addUsage(out.state.usage, state.usage),
524
+ },
525
+ }
526
+ }
527
+ return {
528
+ ...out,
529
+ iterations: out.iterations + carryIter,
530
+ usage: addUsage(out.usage, state.usage),
531
+ }
532
+ }
533
+
534
+ function addUsage(
535
+ a: SuspendedState['usage'],
536
+ b: SuspendedState['usage'],
537
+ ): SuspendedState['usage'] {
538
+ return {
539
+ inputTokens: a.inputTokens + b.inputTokens,
540
+ outputTokens: a.outputTokens + b.outputTokens,
541
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
542
+ cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
543
+ }
544
+ }
545
+
546
+ function rejectShouldSuspend(options: RunWithToolsOptions, entry: string): void {
547
+ if (options.shouldSuspend !== undefined) {
548
+ throw new BrainError(
549
+ `BrainManager.${entry}: \`shouldSuspend\` is only supported on \`runTools\` (the non-streaming + no-schema entrypoint) in V1. Run tools first with suspension, then call \`generate\` for the structured summary as a separate step.`,
550
+ { context: { entry } },
551
+ )
552
+ }
553
+ }
@@ -25,10 +25,18 @@
25
25
  */
26
26
 
27
27
  import { type Application, ConfigError, ConfigRepository, ServiceProvider } from '@strav/kernel'
28
- import { BrainManager } from './brain_manager.ts'
29
28
  import type { BrainConfigShape, ProviderConfig } from './brain_config.ts'
30
- import { AnthropicProvider } from './providers/anthropic_provider.ts'
31
- import type { Provider } from './provider.ts'
29
+ import type { BrainDriver } from './brain_driver.ts'
30
+ import { BrainManager } from './brain_manager.ts'
31
+ import { AnthropicBrainDriver } from './drivers/anthropic/anthropic_brain_driver.ts'
32
+ import { DeepSeekBrainDriver } from './drivers/deepseek/deepseek_brain_driver.ts'
33
+ import { GeminiBrainDriver } from './drivers/gemini/gemini_brain_driver.ts'
34
+ import { MiniMaxBrainDriver } from './drivers/minimax/minimax_brain_driver.ts'
35
+ import { OllamaBrainDriver } from './drivers/ollama/ollama_brain_driver.ts'
36
+ import { OpenAIBrainDriver } from './drivers/openai/openai_brain_driver.ts'
37
+ import { OpenAIResponsesBrainDriver } from './drivers/openai_responses/openai_responses_brain_driver.ts'
38
+ import { OpenRouterBrainDriver } from './drivers/openrouter/openrouter_brain_driver.ts'
39
+ import { QwenBrainDriver } from './drivers/qwen/qwen_brain_driver.ts'
32
40
 
33
41
  export class BrainProvider extends ServiceProvider {
34
42
  override readonly name = 'brain'
@@ -53,9 +61,9 @@ export class BrainProvider extends ServiceProvider {
53
61
  )
54
62
  }
55
63
 
56
- const providers: Record<string, Provider> = {}
64
+ const providers: Record<string, BrainDriver> = {}
57
65
  for (const [name, entry] of Object.entries(config.providers)) {
58
- providers[name] = buildProvider(name, entry)
66
+ providers[name] = buildBrainDriver(name, entry)
59
67
  }
60
68
 
61
69
  const options: ConstructorParameters<typeof BrainManager>[0] = {
@@ -64,6 +72,7 @@ export class BrainProvider extends ServiceProvider {
64
72
  }
65
73
  if (config.tiers !== undefined) options.tiers = config.tiers
66
74
  if (config.cache?.auto !== undefined) options.defaultCache = config.cache.auto
75
+ if (config.mcpServers !== undefined) options.defaultMcpServers = config.mcpServers
67
76
  const manager = new BrainManager(options)
68
77
  // Plug in the container so `brain.agent(MyAgent)` resolves
69
78
  // its constructor deps through `@inject()` like every other
@@ -83,7 +92,7 @@ export class BrainProvider extends ServiceProvider {
83
92
  }
84
93
  }
85
94
 
86
- function buildProvider(name: string, config: ProviderConfig): Provider {
95
+ function buildBrainDriver(name: string, config: ProviderConfig): BrainDriver {
87
96
  switch (config.driver) {
88
97
  case 'anthropic':
89
98
  if (!config.apiKey) {
@@ -91,10 +100,71 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
91
100
  `BrainProvider: anthropic provider "${name}" is missing apiKey. Source from env('ANTHROPIC_API_KEY').`,
92
101
  )
93
102
  }
94
- return new AnthropicProvider(name, config)
95
- default:
103
+ return new AnthropicBrainDriver(name, config)
104
+ case 'openai':
105
+ if (!config.apiKey) {
106
+ throw new ConfigError(
107
+ `BrainProvider: openai provider "${name}" is missing apiKey. Source from env('OPENAI_API_KEY').`,
108
+ )
109
+ }
110
+ return new OpenAIBrainDriver(name, config)
111
+ case 'openai-responses':
112
+ if (!config.apiKey) {
113
+ throw new ConfigError(
114
+ `BrainProvider: openai-responses provider "${name}" is missing apiKey. Source from env('OPENAI_API_KEY').`,
115
+ )
116
+ }
117
+ return new OpenAIResponsesBrainDriver(name, config)
118
+ case 'google':
119
+ if (!config.apiKey) {
120
+ throw new ConfigError(
121
+ `BrainProvider: google provider "${name}" is missing apiKey. Source from env('GOOGLE_API_KEY').`,
122
+ )
123
+ }
124
+ return new GeminiBrainDriver(name, config)
125
+ case 'deepseek':
126
+ if (!config.apiKey) {
127
+ throw new ConfigError(
128
+ `BrainProvider: deepseek provider "${name}" is missing apiKey. Source from env('DEEPSEEK_API_KEY').`,
129
+ )
130
+ }
131
+ return new DeepSeekBrainDriver(name, config)
132
+ case 'ollama':
133
+ if (!config.defaultModel) {
134
+ throw new ConfigError(
135
+ `BrainProvider: ollama provider "${name}" is missing defaultModel. Ollama models are user-installed — pick one you've pulled (e.g. 'llama3.2').`,
136
+ )
137
+ }
138
+ return new OllamaBrainDriver(name, config)
139
+ case 'qwen':
140
+ if (!config.apiKey) {
141
+ throw new ConfigError(
142
+ `BrainProvider: qwen provider "${name}" is missing apiKey. Source from env('DASHSCOPE_API_KEY') or env('QWEN_API_KEY').`,
143
+ )
144
+ }
145
+ return new QwenBrainDriver(name, config)
146
+ case 'minimax':
147
+ if (!config.apiKey) {
148
+ throw new ConfigError(
149
+ `BrainProvider: minimax provider "${name}" is missing apiKey. Source from env('MINIMAX_API_KEY').`,
150
+ )
151
+ }
152
+ return new MiniMaxBrainDriver(name, config)
153
+ case 'openrouter':
154
+ if (!config.apiKey) {
155
+ throw new ConfigError(
156
+ `BrainProvider: openrouter provider "${name}" is missing apiKey. Source from env('OPENROUTER_API_KEY').`,
157
+ )
158
+ }
159
+ return new OpenRouterBrainDriver(name, config)
160
+ default: {
161
+ const exhaustiveCheck: never = config
96
162
  throw new ConfigError(
97
- `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic.`,
163
+ `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, openai-responses, google, deepseek, ollama, qwen, minimax, openrouter.`,
98
164
  )
165
+ // (unreachable — kept for the exhaustive check to fire when a new driver lands)
166
+ // biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
167
+ return exhaustiveCheck
168
+ }
99
169
  }
100
170
  }