@strav/brain 1.0.0-alpha.8 → 1.0.1

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 (75) hide show
  1. package/package.json +23 -7
  2. package/src/agent.ts +97 -0
  3. package/src/agent_generate_result.ts +32 -0
  4. package/src/agent_result.ts +39 -0
  5. package/src/agent_runner.ts +265 -0
  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 +419 -5
  11. package/src/brain_provider.ts +89 -10
  12. package/src/define_tool.ts +42 -0
  13. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  14. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  15. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  16. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  17. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  18. package/src/drivers/anthropic/index.ts +1 -0
  19. package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
  20. package/src/drivers/deepseek/index.ts +1 -0
  21. package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
  22. package/src/drivers/gemini/index.ts +1 -0
  23. package/src/drivers/minimax/index.ts +1 -0
  24. package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
  25. package/src/drivers/ollama/index.ts +1 -0
  26. package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
  27. package/src/drivers/openai/index.ts +1 -0
  28. package/src/drivers/openai/openai_brain_driver.ts +796 -0
  29. package/src/drivers/openai/openai_helpers.ts +58 -0
  30. package/src/drivers/openai/openai_message_builder.ts +187 -0
  31. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  32. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  33. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  34. package/src/drivers/openai_compat/index.ts +1 -0
  35. package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
  36. package/src/drivers/openai_responses/index.ts +1 -0
  37. package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
  38. package/src/drivers/openrouter/index.ts +1 -0
  39. package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
  40. package/src/drivers/qwen/index.ts +1 -0
  41. package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
  42. package/src/index.ts +86 -8
  43. package/src/mcp/client.ts +243 -0
  44. package/src/mcp/index.ts +23 -0
  45. package/src/mcp/oauth.ts +227 -0
  46. package/src/mcp/pool.ts +106 -0
  47. package/src/mcp/resolve_mcp_tools.ts +108 -0
  48. package/src/mcp_server.ts +63 -0
  49. package/src/output_schema.ts +72 -0
  50. package/src/persistence/brain_message.ts +34 -0
  51. package/src/persistence/brain_message_repository.ts +98 -0
  52. package/src/persistence/brain_store.ts +166 -0
  53. package/src/persistence/brain_suspended_run.ts +30 -0
  54. package/src/persistence/brain_suspended_run_repository.ts +59 -0
  55. package/src/persistence/brain_thread.ts +30 -0
  56. package/src/persistence/brain_thread_repository.ts +56 -0
  57. package/src/persistence/database_brain_store.ts +190 -0
  58. package/src/persistence/index.ts +48 -0
  59. package/src/persistence/schemas/brain_message_schema.ts +61 -0
  60. package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
  61. package/src/persistence/schemas/brain_thread_schema.ts +50 -0
  62. package/src/persistence/schemas/index.ts +3 -0
  63. package/src/suspended_run.ts +153 -0
  64. package/src/thread.ts +40 -1
  65. package/src/tool.ts +42 -0
  66. package/src/tool_execution_error.ts +26 -0
  67. package/src/tool_runner.ts +81 -0
  68. package/src/translate/index.ts +19 -0
  69. package/src/translate/translate_cache.ts +78 -0
  70. package/src/translate/translate_provider.ts +46 -0
  71. package/src/translate/translator.ts +271 -0
  72. package/src/types.ts +431 -1
  73. package/src/zod/index.ts +121 -0
  74. package/src/provider.ts +0 -48
  75. package/src/providers/anthropic_provider.ts +0 -227
@@ -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),
@@ -19,33 +19,63 @@
19
19
  * ```
20
20
  */
21
21
 
22
+ import type { Agent } from './agent.ts'
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'
28
+ import { AgentRunner } from './agent_runner.ts'
22
29
  import { BrainError } from './brain_error.ts'
23
30
  import type { ModelTier } from './types.ts'
24
31
  import type {
32
+ AudioSource,
25
33
  ChatOptions,
26
34
  ChatResult,
35
+ EmbedOptions,
36
+ EmbedResult,
37
+ GenerateResult,
27
38
  Message,
28
39
  StreamEvent,
40
+ TranscribeOptions,
41
+ TranscribeResult,
29
42
  } from './types.ts'
30
- import type { Provider } 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'
49
+ import type { Tool } from './tool.ts'
31
50
  import { DEFAULT_TIERS } from './brain_config.ts'
32
51
 
52
+ /** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
53
+ export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
54
+
33
55
  export interface BrainManagerOptions {
34
56
  /** Name of the default provider — must exist in `providers`. */
35
57
  default: string
36
58
  /** Provider registry keyed by name. */
37
- providers: Record<string, Provider>
59
+ providers: Record<string, BrainDriver>
38
60
  /** Tier-to-model overrides; merged on top of the framework defaults. */
39
61
  tiers?: Partial<Record<ModelTier, string>>
40
62
  /** Default for `ChatOptions.cache` when the call site doesn't pass one. */
41
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[]
42
71
  }
43
72
 
44
73
  export class BrainManager {
45
74
  readonly defaultProvider: string
46
- private readonly providers: Map<string, Provider>
75
+ private readonly providers: Map<string, BrainDriver>
47
76
  private readonly tiers: Record<ModelTier, string>
48
77
  private readonly defaultCache: boolean
78
+ private readonly defaultMcpServers: readonly MCPServer[]
49
79
 
50
80
  constructor(options: BrainManagerOptions) {
51
81
  if (!options.providers[options.default]) {
@@ -58,10 +88,11 @@ export class BrainManager {
58
88
  this.providers = new Map(Object.entries(options.providers))
59
89
  this.tiers = { ...DEFAULT_TIERS, ...(options.tiers ?? {}) }
60
90
  this.defaultCache = options.defaultCache ?? false
91
+ this.defaultMcpServers = options.defaultMcpServers ?? []
61
92
  }
62
93
 
63
94
  /** Resolve a provider by name. Default when no name passed. Throws when unknown. */
64
- provider(name?: string): Provider {
95
+ provider(name?: string): BrainDriver {
65
96
  const key = name ?? this.defaultProvider
66
97
  const provider = this.providers.get(key)
67
98
  if (!provider) {
@@ -72,6 +103,29 @@ export class BrainManager {
72
103
  return provider
73
104
  }
74
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
+
75
129
  /**
76
130
  * One-shot chat: send the messages, await the full reply.
77
131
  *
@@ -117,8 +171,305 @@ export class BrainManager {
117
171
  return provider.countTokens(messages, resolved)
118
172
  }
119
173
 
174
+ /**
175
+ * Run an agentic loop: send `messages` + `tools` to the model;
176
+ * execute any tool the model calls; loop until the model returns
177
+ * a terminal `stop_reason` (`'end_turn'`) or `maxIterations` is hit.
178
+ *
179
+ * Throws `BrainError` when the configured provider doesn't
180
+ * implement `runWithTools` (V1: OpenAI / Gemini / DeepSeek providers
181
+ * don't yet — only `AnthropicBrainDriver`).
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>
193
+ async runTools(
194
+ input: string | readonly Message[],
195
+ tools: readonly Tool[],
196
+ options: RunWithToolsOptions = {},
197
+ ): Promise<AgentResult | SuspendedRun> {
198
+ const provider = this.provider(options.provider)
199
+ if (!provider.runWithTools) {
200
+ throw new BrainError(
201
+ `BrainManager.runTools: provider "${provider.name}" does not implement runWithTools. Use a provider that supports tool use (V1: Anthropic).`,
202
+ { context: { provider: provider.name } },
203
+ )
204
+ }
205
+ const messages = normalizeInput(input)
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
+ }
213
+ return provider.runWithTools(messages, tools, resolved)
214
+ }
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
+
424
+ /**
425
+ * Resolve an `Agent` subclass from the container and return an
426
+ * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
427
+ * `@inject()`-decorate their Agent subclass so constructor
428
+ * injection of dependencies (Repositories, services, etc.) flows
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.
437
+ */
438
+ agent<T = never>(
439
+ AgentClass: new (...args: never[]) => Agent<T>,
440
+ instance?: Agent<T>,
441
+ ): AgentRunner<T> {
442
+ const agent = instance ?? this.resolveAgent(AgentClass)
443
+ const runner = new AgentRunner<T>(this, agent)
444
+ if (agent.outputSchema !== undefined) {
445
+ return runner.output(agent.outputSchema)
446
+ }
447
+ return runner
448
+ }
449
+
120
450
  // ─── Internal ────────────────────────────────────────────────────────────
121
451
 
452
+ private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
453
+ if (this.agentResolver) return this.agentResolver(AgentClass)
454
+ // Fallback: assume the Agent class is constructible without args.
455
+ // Apps that need DI on the agent register a resolver via
456
+ // `setAgentResolver` (BrainProvider wires this to the container).
457
+ return new (AgentClass as unknown as new () => A)()
458
+ }
459
+
460
+ /**
461
+ * Internal — `BrainProvider` calls this at boot to plug in the
462
+ * container's resolution function so `brain.agent(MyAgent)` runs
463
+ * `app.resolve(MyAgent)` under the hood. Apps that build a
464
+ * `BrainManager` by hand for tests can leave this unset and pass
465
+ * a pre-constructed agent to `brain.agent(_, instance)`.
466
+ */
467
+ setAgentResolver(resolver: AgentResolver): void {
468
+ this.agentResolver = resolver
469
+ }
470
+
471
+ private agentResolver: AgentResolver | undefined
472
+
122
473
  private applyDefaults(options: ChatOptions): ChatOptions {
123
474
  const resolved: ChatOptions = { ...options }
124
475
  if (resolved.model === undefined && resolved.tier !== undefined) {
@@ -137,3 +488,66 @@ function normalizeInput(input: string | readonly Message[]): readonly Message[]
137
488
  }
138
489
  return input
139
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,7 +72,17 @@ 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
67
- return new BrainManager(options)
75
+ if (config.mcpServers !== undefined) options.defaultMcpServers = config.mcpServers
76
+ const manager = new BrainManager(options)
77
+ // Plug in the container so `brain.agent(MyAgent)` resolves
78
+ // its constructor deps through `@inject()` like every other
79
+ // injected class. The variance widening at the boundary
80
+ // (`never[]` ↔ `any[]`) is purely a TS typing artifact — the
81
+ // container call is identical to a direct `c.resolve(MyAgent)`.
82
+ manager.setAgentResolver(<A>(cls: new (...args: never[]) => A) =>
83
+ c.resolve(cls as unknown as new (...args: unknown[]) => A),
84
+ )
85
+ return manager
68
86
  })
69
87
  }
70
88
 
@@ -74,7 +92,7 @@ export class BrainProvider extends ServiceProvider {
74
92
  }
75
93
  }
76
94
 
77
- function buildProvider(name: string, config: ProviderConfig): Provider {
95
+ function buildBrainDriver(name: string, config: ProviderConfig): BrainDriver {
78
96
  switch (config.driver) {
79
97
  case 'anthropic':
80
98
  if (!config.apiKey) {
@@ -82,10 +100,71 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
82
100
  `BrainProvider: anthropic provider "${name}" is missing apiKey. Source from env('ANTHROPIC_API_KEY').`,
83
101
  )
84
102
  }
85
- return new AnthropicProvider(name, config)
86
- 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
87
162
  throw new ConfigError(
88
- `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.`,
89
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
+ }
90
169
  }
91
170
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `defineTool({ name, description, inputSchema, execute })` — typed
3
+ * factory mirroring `defineWorkflow` / `defineMachine` / `defineDurable`.
4
+ *
5
+ * ```ts
6
+ * const getWeather = defineTool({
7
+ * name: 'get_weather',
8
+ * description: 'Get current weather for a location.',
9
+ * inputSchema: {
10
+ * type: 'object',
11
+ * properties: { city: { type: 'string' } },
12
+ * required: ['city'],
13
+ * },
14
+ * execute: async ({ city }: { city: string }, ctx) => {
15
+ * return weatherService.lookup(city, ctx.context.userId as string)
16
+ * },
17
+ * })
18
+ * ```
19
+ *
20
+ * The generic parameters are usually inferred from `execute`'s first
21
+ * arg + return type; apps that want explicit typing pass them.
22
+ */
23
+
24
+ import type { Tool, ToolContext } from './tool.ts'
25
+
26
+ export interface DefineToolSpec<TInput, TOutput> {
27
+ name: string
28
+ description: string
29
+ inputSchema: Record<string, unknown>
30
+ execute(input: TInput, ctx: ToolContext): Promise<TOutput>
31
+ }
32
+
33
+ export function defineTool<TInput = unknown, TOutput = unknown>(
34
+ spec: DefineToolSpec<TInput, TOutput>,
35
+ ): Tool<TInput, TOutput> {
36
+ return {
37
+ name: spec.name,
38
+ description: spec.description,
39
+ inputSchema: spec.inputSchema,
40
+ execute: spec.execute,
41
+ }
42
+ }