@strav/brain 1.0.0-alpha.16 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.16",
3
+ "version": "1.0.0-alpha.17",
4
4
  "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -24,7 +24,7 @@
24
24
  "@anthropic-ai/sdk": "^0.100.0",
25
25
  "@google/genai": "^2.7.0",
26
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
- "@strav/kernel": "1.0.0-alpha.16",
27
+ "@strav/kernel": "1.0.0-alpha.17",
28
28
  "openai": "^6.0.0"
29
29
  },
30
30
  "peerDependencies": {
package/src/agent.ts CHANGED
@@ -21,17 +21,30 @@
21
21
  * .run()
22
22
  * ```
23
23
  *
24
- * V1 makes the configuration declarative-only apps that need
25
- * runtime knobs (per-request model overrides, dynamic tool sets)
26
- * use `BrainManager.runTools(...)` directly. Adding per-instance
27
- * overrides on the Agent class is a future ergonomic slice.
24
+ * Structured output (typed result without `.output(schema)` at the call site):
25
+ *
26
+ * ```ts
27
+ * class CityAgent extends Agent<CityAnswer> {
28
+ * override readonly instructions = 'You only emit verified city data.'
29
+ * override readonly outputSchema = citySchema // OutputSchema<CityAnswer>
30
+ * }
31
+ *
32
+ * const { value } = await brain.agent(CityAgent).input('Capital of France?').run()
33
+ * // ^? CityAnswer — runner is typed from the class generic
34
+ * ```
35
+ *
36
+ * The generic threads `T` through `BrainManager.agent(Class)` →
37
+ * `AgentRunner<T>` → `AgentGenerateResult<T>`. Subclasses that
38
+ * don't declare an output type stay `Agent<never>` and `run()`
39
+ * returns `AgentResult` exactly as before.
28
40
  */
29
41
 
30
42
  import type { MCPServer } from './mcp_server.ts'
43
+ import type { OutputSchema } from './output_schema.ts'
31
44
  import type { ModelTier } from './types.ts'
32
45
  import type { Tool } from './tool.ts'
33
46
 
34
- export abstract class Agent {
47
+ export abstract class Agent<T = never> {
35
48
  /** System prompt — the persona / instructions Claude sees on every turn. */
36
49
  abstract readonly instructions: string
37
50
 
@@ -65,4 +78,20 @@ export abstract class Agent {
65
78
 
66
79
  /** Hard cap on per-call response tokens. Default `4096`. */
67
80
  readonly maxTokens: number = 4096
81
+
82
+ /**
83
+ * Structured-output schema. Set on subclasses that extend
84
+ * `Agent<SomeType>` to declare the agent always returns that
85
+ * shape; `BrainManager.agent(Class)` then types the runner
86
+ * automatically so `.run()` returns `AgentGenerateResult<T>`
87
+ * without a per-call `.output(schema)`.
88
+ *
89
+ * Leave unset on `Agent<never>` subclasses (no structured
90
+ * output). The runner falls back to the standard tool-loop path
91
+ * and returns `AgentResult`.
92
+ *
93
+ * Apps that want a per-call override still chain
94
+ * `.output(otherSchema)` — that wins over the class-side value.
95
+ */
96
+ readonly outputSchema?: OutputSchema<T>
68
97
  }
@@ -22,6 +22,7 @@
22
22
  import type { Agent } from './agent.ts'
23
23
  import type { AgentGenerateResult } from './agent_generate_result.ts'
24
24
  import type { AgentResult } from './agent_result.ts'
25
+ import type { AgentStreamEvent } from './agent_stream_event.ts'
25
26
  import type { BrainManager } from './brain_manager.ts'
26
27
  import { BrainError } from './brain_error.ts'
27
28
  import type { OutputSchema } from './output_schema.ts'
@@ -48,7 +49,7 @@ export class AgentRunner<T = never> {
48
49
 
49
50
  constructor(
50
51
  private readonly brain: BrainManager,
51
- private readonly agent: Agent,
52
+ private readonly agent: Agent<unknown>,
52
53
  ) {}
53
54
 
54
55
  /** Set the user input. Required before `run()`. */
@@ -88,6 +89,44 @@ export class AgentRunner<T = never> {
88
89
  return this as unknown as AgentRunner<U>
89
90
  }
90
91
 
92
+ /**
93
+ * Streaming variant of `run()`. Returns an
94
+ * `AsyncIterable<AgentStreamEvent<T>>` — yields text deltas,
95
+ * tool-use/result boundaries, and a terminal `stop` event with
96
+ * the full trace.
97
+ *
98
+ * Default (no `.output(schema)` set): the terminal `stop` has the
99
+ * plain shape and `T` defaults to `never`.
100
+ *
101
+ * With `.output(schema)`: the terminal `stop` event carries the
102
+ * parsed `value: T` + raw `text` alongside the loop bookkeeping,
103
+ * and the runner delegates to
104
+ * `BrainManager.streamGenerateWithTools`.
105
+ */
106
+ stream(): AsyncIterable<AgentStreamEvent<T>> {
107
+ if (this.prompt === undefined) {
108
+ throw new BrainError('AgentRunner.stream: input() must be called before stream().')
109
+ }
110
+ const messages: Message[] = [{ role: 'user', content: this.prompt }]
111
+ const options: RunWithToolsOptions = {
112
+ ...this.buildChatOptions(),
113
+ maxIterations: this.agent.maxIterations,
114
+ context: this.contextBag,
115
+ }
116
+ if (this.agent.mcpServers.length > 0) options.mcpServers = this.agent.mcpServers
117
+ if (this.schema !== undefined) {
118
+ return this.brain.streamGenerateWithTools<T>(
119
+ messages,
120
+ this.schema,
121
+ this.agent.tools,
122
+ options,
123
+ )
124
+ }
125
+ return this.brain.streamTools(messages, this.agent.tools, options) as AsyncIterable<
126
+ AgentStreamEvent<T>
127
+ >
128
+ }
129
+
91
130
  async run(): Promise<AgentRunResult<T>> {
92
131
  if (this.prompt === undefined) {
93
132
  throw new BrainError('AgentRunner.run: input() must be called before run().')
@@ -95,17 +134,21 @@ export class AgentRunner<T = never> {
95
134
  const messages: Message[] = [{ role: 'user', content: this.prompt }]
96
135
 
97
136
  if (this.schema !== undefined) {
98
- if (this.agent.tools.length > 0 || this.agent.mcpServers.length > 0) {
99
- throw new BrainError(
100
- 'AgentRunner.output() does not yet support tool use. The agent declares tools or mcpServers — drop them on the agent, or run runTools(...) and generate(...) as two separate calls. Combined tool + schema lands in a later slice.',
101
- {
102
- context: {
103
- agent: this.agent.constructor.name,
104
- tools: this.agent.tools.length,
105
- mcpServers: this.agent.mcpServers.length,
106
- },
107
- },
137
+ const hasTools = this.agent.tools.length > 0 || this.agent.mcpServers.length > 0
138
+ if (hasTools) {
139
+ const toolOptions: RunWithToolsOptions = {
140
+ ...this.buildChatOptions(),
141
+ maxIterations: this.agent.maxIterations,
142
+ context: this.contextBag,
143
+ }
144
+ if (this.agent.mcpServers.length > 0) toolOptions.mcpServers = this.agent.mcpServers
145
+ const result = await this.brain.generateWithTools<T>(
146
+ messages,
147
+ this.schema,
148
+ this.agent.tools,
149
+ toolOptions,
108
150
  )
151
+ return result as AgentRunResult<T>
109
152
  }
110
153
  const generateOptions = this.buildChatOptions()
111
154
  const result = await this.brain.generate<T>(messages, this.schema, generateOptions)
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `AgentStreamEvent<T>` — the union yielded by streaming agentic
3
+ * runs (`Provider.streamWithTools` / `BrainManager.streamTools` /
4
+ * `AgentRunner.stream`).
5
+ *
6
+ * The generic `T` carries the structured-output type when the run
7
+ * was schema-constrained:
8
+ * - With the default `T = never`, the terminal `stop` event is the
9
+ * plain shape: `{ stopReason, iterations, usage, messages }`.
10
+ * - With `T` set (via `BrainManager.streamGenerateWithTools` or
11
+ * `AgentRunner.stream()` after `.output(schema)`), the `stop`
12
+ * event additionally carries `{ value: T; text: string }` — the
13
+ * parsed JSON shaped to the schema and the raw text the model
14
+ * produced.
15
+ *
16
+ * Event vocabulary:
17
+ *
18
+ * - `iteration_start` — fired before each model call. `iteration`
19
+ * starts at `0` and increments per round.
20
+ * - `text` — a text delta from the assistant turn currently in
21
+ * flight. Same shape as `StreamEvent.text` so apps can reuse
22
+ * UI code.
23
+ * - `tool_use_start` — a tool call has begun streaming. Fires
24
+ * as soon as the model emits the call's `id` + `name`,
25
+ * before the arguments finish streaming. Apps that render
26
+ * "(calling X with …)" indicators show the tool name here.
27
+ * Anthropic + OpenAI emit this from streaming chunks; Gemini
28
+ * doesn't stream tool arguments (parts arrive complete) and
29
+ * skips both `tool_use_start` and `tool_use_delta`.
30
+ * - `tool_use_delta` — a chunk of the tool-call's argument JSON.
31
+ * Apps that render the model composing the call (e.g. typing
32
+ * `search(q='current state of bun.sql ...`) accumulate
33
+ * `argsDelta` per `id` and re-render. The argument JSON is
34
+ * partial / possibly malformed mid-stream — only the final
35
+ * `tool_use` event carries the parsed input.
36
+ * - `tool_use` — the assistant turn finished and the framework
37
+ * parsed a tool call. Emitted with the parsed `input` before
38
+ * the framework runs the tool. Source-of-truth for tool calls;
39
+ * cross-provider consumers can rely on this even when the
40
+ * start/delta events aren't fired.
41
+ * - `tool_result` — the framework executed the tool and is about
42
+ * to feed the result back to the model. `isError` reflects
43
+ * whether the tool reported a failure (V1: only false today —
44
+ * thrown executions abort the loop with `ToolExecutionError`;
45
+ * graceful tool-error recovery is a later slice).
46
+ * - `iteration_end` — the assistant turn fully drained, including
47
+ * its `stopReason`. Apps that render per-iteration boundaries
48
+ * use this.
49
+ * - `stop` — terminal event. Carries the full `messages` trace,
50
+ * iteration count, aggregated usage, and the framework
51
+ * stop reason (`'end_turn'`, `'max_iterations'`, etc.). When the
52
+ * run was schema-constrained, also carries `value` (parsed JSON)
53
+ * and `text` (the raw JSON the model emitted).
54
+ *
55
+ * What's NOT in V1:
56
+ * - `error` events. Failures throw out of the iterator (the
57
+ * consumer's `for await` rejects). Apps that want resilient
58
+ * loops catch around the consumer.
59
+ */
60
+
61
+ import type { ChatUsage, Message } from './types.ts'
62
+
63
+ interface BaseStopEvent {
64
+ type: 'stop'
65
+ stopReason: string
66
+ iterations: number
67
+ usage: ChatUsage
68
+ messages: Message[]
69
+ }
70
+
71
+ interface ValueStopEvent<T> extends BaseStopEvent {
72
+ /** Parsed JSON shaped to the supplied `OutputSchema<T>`. */
73
+ value: T
74
+ /** Raw JSON text the model emitted on its terminal turn. */
75
+ text: string
76
+ }
77
+
78
+ /**
79
+ * The `stop` variant narrows when the stream was schema-constrained
80
+ * — the `[T] extends [never]` form is the standard "is this still
81
+ * the default never?" check (a bare `T extends never` would
82
+ * distribute over union types and break).
83
+ */
84
+ type StopEvent<T> = [T] extends [never] ? BaseStopEvent : ValueStopEvent<T>
85
+
86
+ export type AgentStreamEvent<T = never> =
87
+ | { type: 'iteration_start'; iteration: number }
88
+ | { type: 'text'; delta: string }
89
+ | { type: 'tool_use_start'; id: string; name: string }
90
+ | { type: 'tool_use_delta'; id: string; argsDelta: string }
91
+ | { type: 'tool_use'; id: string; name: string; input: unknown }
92
+ | {
93
+ type: 'tool_result'
94
+ id: string
95
+ name: string
96
+ content: string
97
+ isError: boolean
98
+ }
99
+ | { type: 'iteration_end'; iteration: number; stopReason: string | null }
100
+ | StopEvent<T>
@@ -34,6 +34,31 @@ export interface AnthropicProviderConfig {
34
34
  betas?: readonly string[]
35
35
  }
36
36
 
37
+ /**
38
+ * OpenAI Responses API driver config — backed by the `openai`
39
+ * SDK's `client.responses.create` endpoint. Use when you need
40
+ * OpenAI's server-side tools (web search, code interpreter) or
41
+ * the Responses API's reasoning surfaces. The chat completions
42
+ * provider (`driver: 'openai'`) covers everything else.
43
+ */
44
+ export interface OpenAIResponsesProviderConfig {
45
+ driver: 'openai-responses'
46
+ /** API key. Required. Most apps source from `env('OPENAI_API_KEY')`. */
47
+ apiKey: string
48
+ /** Optional override of the SDK's base URL. */
49
+ baseUrl?: string
50
+ /** Optional organization id. */
51
+ organization?: string
52
+ /** Default model. Defaults to `gpt-5`. */
53
+ defaultModel?: string
54
+ /** Default `max_output_tokens`. Defaults to 4096. */
55
+ defaultMaxTokens?: number
56
+ /** Default embedding model (inherited from chat completions endpoint). Defaults to `text-embedding-3-small`. */
57
+ defaultEmbedModel?: string
58
+ /** Default audio-transcription model. Defaults to `whisper-1`. */
59
+ defaultTranscribeModel?: string
60
+ }
61
+
37
62
  /** OpenAI-specific driver config. */
38
63
  export interface OpenAIProviderConfig {
39
64
  driver: 'openai'
@@ -47,6 +72,10 @@ export interface OpenAIProviderConfig {
47
72
  defaultModel?: string
48
73
  /** Default `max_tokens` for `chat()` calls that don't specify one. */
49
74
  defaultMaxTokens?: number
75
+ /** Default embedding model for `brain.embed(...)`. Defaults to `text-embedding-3-small`. */
76
+ defaultEmbedModel?: string
77
+ /** Default audio-transcription model for `brain.transcribe(...)`. Defaults to `whisper-1`. */
78
+ defaultTranscribeModel?: string
50
79
  }
51
80
 
52
81
  /** Google (Gemini) driver config — backed by `@google/genai`. */
@@ -62,12 +91,73 @@ export interface GeminiProviderConfig {
62
91
  defaultMaxTokens?: number
63
92
  /** Optional API version pin (`v1` / `v1beta`). */
64
93
  apiVersion?: string
94
+ /** Default embedding model for `brain.embed(...)`. Defaults to `text-embedding-004`. */
95
+ defaultEmbedModel?: string
96
+ }
97
+
98
+ /** DeepSeek driver config — backed by the `openai` SDK pointed at DeepSeek's OpenAI-compatible endpoint. */
99
+ export interface DeepSeekProviderConfig {
100
+ driver: 'deepseek'
101
+ /** API key. Required. Most apps source from `env('DEEPSEEK_API_KEY')`. */
102
+ apiKey: string
103
+ /** Optional override of the SDK's base URL. Defaults to `https://api.deepseek.com/v1`. */
104
+ baseUrl?: string
105
+ /** Default model when neither `options.model` nor `options.tier` is passed. Defaults to `deepseek-chat`. */
106
+ defaultModel?: string
107
+ /** Default `max_tokens` for `chat()` calls that don't specify one. */
108
+ defaultMaxTokens?: number
109
+ }
110
+
111
+ /**
112
+ * Ollama driver config — backed by the `openai` SDK pointed at a
113
+ * local Ollama server's OpenAI-compatible `/v1` endpoint. The same
114
+ * shape works against any OpenAI-compatible local server (LM Studio,
115
+ * llama.cpp's server, vLLM, …) by overriding `baseUrl`.
116
+ */
117
+ export interface OllamaProviderConfig {
118
+ driver: 'ollama'
119
+ /**
120
+ * Required — model must be already pulled on the Ollama server
121
+ * (`ollama pull <model>`). No universal default exists because
122
+ * apps install whichever models they need. Common picks for
123
+ * tool-calling: `llama3.2`, `llama3.1`, `qwen2.5`, `mistral`.
124
+ */
125
+ defaultModel: string
126
+ /** Optional override of the SDK's base URL. Defaults to `http://localhost:11434/v1`. */
127
+ baseUrl?: string
128
+ /**
129
+ * Optional API key. Ollama doesn't require one — the SDK demands
130
+ * a non-empty string, so a placeholder is fine and the default
131
+ * (`'ollama'`) works. Override only when running behind a proxy
132
+ * that adds its own auth layer.
133
+ */
134
+ apiKey?: string
135
+ /** Default `max_tokens` for `chat()` calls that don't specify one. */
136
+ defaultMaxTokens?: number
137
+ /**
138
+ * Default embedding model for `brain.embed(...)`. No universal
139
+ * default — apps pull an embedding-tuned model (e.g.
140
+ * `nomic-embed-text`, `mxbai-embed-large`) and reference it here.
141
+ * Calls that omit `options.model` without this set throw.
142
+ */
143
+ defaultEmbedModel?: string
144
+ /**
145
+ * Default audio-transcription model for `brain.transcribe(...)`.
146
+ * No universal default — Ollama versions vary on whether they
147
+ * expose a Whisper-style endpoint, and apps pull whatever
148
+ * model their build supports (e.g. `whisper`). Calls that omit
149
+ * `options.model` without this set throw.
150
+ */
151
+ defaultTranscribeModel?: string
65
152
  }
66
153
 
67
154
  export type ProviderConfig =
68
155
  | AnthropicProviderConfig
69
156
  | OpenAIProviderConfig
70
- | GeminiProviderConfig // | DeepSeekProviderConfig (later slice)
157
+ | OpenAIResponsesProviderConfig
158
+ | GeminiProviderConfig
159
+ | DeepSeekProviderConfig
160
+ | OllamaProviderConfig
71
161
 
72
162
  /** Cache-shape defaults applied when `ChatOptions.cache` is omitted. */
73
163
  export interface BrainCacheConfig {
@@ -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