@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.18
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 +4 -2
- package/src/agent.ts +34 -5
- package/src/agent_generate_result.ts +2 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +134 -15
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +91 -1
- package/src/brain_manager.ts +287 -6
- package/src/brain_provider.ts +25 -1
- package/src/index.ts +37 -2
- package/src/mcp/client.ts +99 -13
- package/src/mcp/index.ts +7 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +31 -9
- package/src/mcp_server.ts +16 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +106 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +68 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +65 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schema/brain_message_schema.ts +61 -0
- package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schema/brain_thread_schema.ts +50 -0
- package/src/persistence/schema/index.ts +3 -0
- package/src/provider.ts +145 -1
- package/src/providers/anthropic_provider.ts +723 -38
- package/src/providers/deepseek_provider.ts +117 -0
- package/src/providers/gemini_provider.ts +625 -33
- package/src/providers/ollama_provider.ts +86 -0
- package/src/providers/openai_compat_provider.ts +616 -0
- package/src/providers/openai_provider.ts +801 -43
- package/src/providers/openai_responses_provider.ts +1015 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/types.ts +343 -0
package/src/brain_manager.ts
CHANGED
|
@@ -21,24 +21,36 @@
|
|
|
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
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
Provider,
|
|
45
|
+
RunWithToolsOptions,
|
|
46
|
+
RunWithToolsOptionsWithSuspend,
|
|
47
|
+
} from './provider.ts'
|
|
48
|
+
import { appendResumeResults, type SuspendedRun, type SuspendedState, type ToolResultInput } from './suspended_run.ts'
|
|
37
49
|
import type { Tool } from './tool.ts'
|
|
38
50
|
import { DEFAULT_TIERS } from './brain_config.ts'
|
|
39
51
|
|
|
40
52
|
/** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
|
|
41
|
-
export type AgentResolver = <A extends Agent
|
|
53
|
+
export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
|
|
42
54
|
|
|
43
55
|
export interface BrainManagerOptions {
|
|
44
56
|
/** Name of the default provider — must exist in `providers`. */
|
|
@@ -145,11 +157,21 @@ export class BrainManager {
|
|
|
145
157
|
* implement `runWithTools` (V1: OpenAI / Gemini / DeepSeek providers
|
|
146
158
|
* don't yet — only `AnthropicProvider`).
|
|
147
159
|
*/
|
|
160
|
+
runTools(
|
|
161
|
+
input: string | readonly Message[],
|
|
162
|
+
tools: readonly Tool[],
|
|
163
|
+
options: RunWithToolsOptionsWithSuspend,
|
|
164
|
+
): Promise<AgentResult | SuspendedRun>
|
|
165
|
+
runTools(
|
|
166
|
+
input: string | readonly Message[],
|
|
167
|
+
tools: readonly Tool[],
|
|
168
|
+
options?: RunWithToolsOptions,
|
|
169
|
+
): Promise<AgentResult>
|
|
148
170
|
async runTools(
|
|
149
171
|
input: string | readonly Message[],
|
|
150
172
|
tools: readonly Tool[],
|
|
151
173
|
options: RunWithToolsOptions = {},
|
|
152
|
-
): Promise<AgentResult> {
|
|
174
|
+
): Promise<AgentResult | SuspendedRun> {
|
|
153
175
|
const provider = this.provider(options.provider)
|
|
154
176
|
if (!provider.runWithTools) {
|
|
155
177
|
throw new BrainError(
|
|
@@ -168,6 +190,135 @@ export class BrainManager {
|
|
|
168
190
|
return provider.runWithTools(messages, tools, resolved)
|
|
169
191
|
}
|
|
170
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Resume a previously-suspended tool-use loop. Takes the
|
|
195
|
+
* `SuspendedRun.state` snapshot plus the results the integrator
|
|
196
|
+
* gathered for each `pendingToolCalls` entry; appends a `tool_result`
|
|
197
|
+
* block per entry; re-enters `runTools` so the model can continue
|
|
198
|
+
* (potentially suspending again on the next tool).
|
|
199
|
+
*
|
|
200
|
+
* Mid-batch invariant: every pending call MUST get a result —
|
|
201
|
+
* otherwise the provider rejects the next request because the
|
|
202
|
+
* assistant turn's `tool_use` blocks are no longer balanced.
|
|
203
|
+
* `resumeTools` throws `BrainError` when results are missing.
|
|
204
|
+
*
|
|
205
|
+
* The `previousResponseId` carried on the snapshot (when the
|
|
206
|
+
* provider supports stateful conversations) is threaded back via
|
|
207
|
+
* `options.previousResponseId` automatically — per-call
|
|
208
|
+
* `options.previousResponseId` wins if supplied explicitly.
|
|
209
|
+
*/
|
|
210
|
+
async resumeTools(
|
|
211
|
+
state: SuspendedState,
|
|
212
|
+
results: readonly ToolResultInput[],
|
|
213
|
+
tools: readonly Tool[],
|
|
214
|
+
options: RunWithToolsOptions = {},
|
|
215
|
+
): Promise<AgentResult | SuspendedRun> {
|
|
216
|
+
const resumed = appendResumeResults(state, results)
|
|
217
|
+
const merged: RunWithToolsOptions = { ...options }
|
|
218
|
+
if (merged.previousResponseId === undefined && state.responseId !== undefined) {
|
|
219
|
+
merged.previousResponseId = state.responseId
|
|
220
|
+
}
|
|
221
|
+
const out = await this.runTools(
|
|
222
|
+
resumed,
|
|
223
|
+
tools,
|
|
224
|
+
merged as RunWithToolsOptionsWithSuspend,
|
|
225
|
+
)
|
|
226
|
+
return mergeResumeCounters(out, state)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Streaming variant of `generateWithTools`. Yields
|
|
231
|
+
* `AgentStreamEvent<T>`s as the loop progresses; the terminal
|
|
232
|
+
* `stop` event carries the parsed value + raw JSON text. Throws
|
|
233
|
+
* `BrainError` when the provider lacks
|
|
234
|
+
* `streamWithToolsAndSchema` (V1: all three providers
|
|
235
|
+
* implement it).
|
|
236
|
+
*/
|
|
237
|
+
streamGenerateWithTools<T>(
|
|
238
|
+
input: string | readonly Message[],
|
|
239
|
+
schema: OutputSchema<T>,
|
|
240
|
+
tools: readonly Tool[],
|
|
241
|
+
options: RunWithToolsOptions = {},
|
|
242
|
+
): AsyncIterable<AgentStreamEvent<T>> {
|
|
243
|
+
rejectShouldSuspend(options, 'streamGenerateWithTools')
|
|
244
|
+
const provider = this.provider(options.provider)
|
|
245
|
+
if (!provider.streamWithToolsAndSchema) {
|
|
246
|
+
throw new BrainError(
|
|
247
|
+
`BrainManager.streamGenerateWithTools: provider "${provider.name}" does not implement streamWithToolsAndSchema.`,
|
|
248
|
+
{ context: { provider: provider.name } },
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
const messages = normalizeInput(input)
|
|
252
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
253
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
254
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
255
|
+
}
|
|
256
|
+
return provider.streamWithToolsAndSchema<T>(messages, tools, schema, resolved)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Tool-loop + structured output combined. Runs the agentic loop
|
|
261
|
+
* with the supplied `tools` while pinning the output to `schema`
|
|
262
|
+
* on every turn; returns the parsed value when the model finally
|
|
263
|
+
* answers without calling a tool. MCP defaults + tier resolution
|
|
264
|
+
* + provider routing match `runTools` / `generate`.
|
|
265
|
+
*
|
|
266
|
+
* Throws `BrainError` when the chosen provider doesn't implement
|
|
267
|
+
* `runWithToolsAndSchema`. V1: all three providers do.
|
|
268
|
+
*/
|
|
269
|
+
async generateWithTools<T>(
|
|
270
|
+
input: string | readonly Message[],
|
|
271
|
+
schema: OutputSchema<T>,
|
|
272
|
+
tools: readonly Tool[],
|
|
273
|
+
options: RunWithToolsOptions = {},
|
|
274
|
+
): Promise<AgentGenerateResult<T>> {
|
|
275
|
+
rejectShouldSuspend(options, 'generateWithTools')
|
|
276
|
+
const provider = this.provider(options.provider)
|
|
277
|
+
if (!provider.runWithToolsAndSchema) {
|
|
278
|
+
throw new BrainError(
|
|
279
|
+
`BrainManager.generateWithTools: provider "${provider.name}" does not implement runWithToolsAndSchema.`,
|
|
280
|
+
{ context: { provider: provider.name } },
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
const messages = normalizeInput(input)
|
|
284
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
285
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
286
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
287
|
+
}
|
|
288
|
+
return provider.runWithToolsAndSchema<T>(messages, tools, schema, resolved)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Streaming variant of `runTools`. Yields `AgentStreamEvent`s
|
|
293
|
+
* as the agentic loop progresses — text deltas during model
|
|
294
|
+
* turns, `tool_use` / `tool_result` boundaries around tool
|
|
295
|
+
* execution, `iteration_start` / `iteration_end` per round, a
|
|
296
|
+
* terminal `stop` with the full trace + usage.
|
|
297
|
+
*
|
|
298
|
+
* Throws `BrainError` when the configured provider doesn't
|
|
299
|
+
* implement `streamWithTools`.
|
|
300
|
+
*/
|
|
301
|
+
streamTools(
|
|
302
|
+
input: string | readonly Message[],
|
|
303
|
+
tools: readonly Tool[],
|
|
304
|
+
options: RunWithToolsOptions = {},
|
|
305
|
+
): AsyncIterable<AgentStreamEvent> {
|
|
306
|
+
rejectShouldSuspend(options, 'streamTools')
|
|
307
|
+
const provider = this.provider(options.provider)
|
|
308
|
+
if (!provider.streamWithTools) {
|
|
309
|
+
throw new BrainError(
|
|
310
|
+
`BrainManager.streamTools: provider "${provider.name}" does not implement streamWithTools.`,
|
|
311
|
+
{ context: { provider: provider.name } },
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
const messages = normalizeInput(input)
|
|
315
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
316
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
317
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
318
|
+
}
|
|
319
|
+
return provider.streamWithTools(messages, tools, resolved)
|
|
320
|
+
}
|
|
321
|
+
|
|
171
322
|
/**
|
|
172
323
|
* Structured output. Sends `input` to the configured (or
|
|
173
324
|
* `options.provider`-overridden) provider with the JSON-Schema
|
|
@@ -194,21 +345,88 @@ export class BrainManager {
|
|
|
194
345
|
return provider.generate<T>(messages, schema, resolved)
|
|
195
346
|
}
|
|
196
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Turn one or more text inputs into embedding vectors. Accepts
|
|
350
|
+
* either a single string (returns one vector) or an array
|
|
351
|
+
* (batch — returns one vector per input in the same order).
|
|
352
|
+
*
|
|
353
|
+
* Throws `BrainError` when the configured (or
|
|
354
|
+
* `options.provider`-overridden) provider doesn't implement
|
|
355
|
+
* `embed`. V1: OpenAI, Gemini, Ollama support it; Anthropic +
|
|
356
|
+
* DeepSeek throw with a clear "route to a different provider"
|
|
357
|
+
* message.
|
|
358
|
+
*/
|
|
359
|
+
async embed(
|
|
360
|
+
input: string | readonly string[],
|
|
361
|
+
options: EmbedOptions = {},
|
|
362
|
+
): Promise<EmbedResult> {
|
|
363
|
+
const provider = this.provider(options.provider)
|
|
364
|
+
if (!provider.embed) {
|
|
365
|
+
throw new BrainError(
|
|
366
|
+
`BrainManager.embed: provider "${provider.name}" does not implement embed. Route to a provider with an embeddings API (V1: OpenAI / Gemini / Ollama).`,
|
|
367
|
+
{ context: { provider: provider.name } },
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
const texts = typeof input === 'string' ? [input] : input
|
|
371
|
+
return provider.embed(texts, options)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Transcribe one audio clip to text. Complements `AudioBlock`
|
|
376
|
+
* (which sends audio + a text prompt together to a multimodal
|
|
377
|
+
* chat model) by exposing the dedicated transcription endpoint
|
|
378
|
+
* where the provider has one. Apps that already have an
|
|
379
|
+
* `AudioBlock` can pass its `source` directly.
|
|
380
|
+
*
|
|
381
|
+
* Throws `BrainError` when the configured (or
|
|
382
|
+
* `options.provider`-overridden) provider doesn't implement
|
|
383
|
+
* `transcribe`. V1: OpenAI / Ollama (Whisper / gpt-4o-transcribe
|
|
384
|
+
* / local) and Gemini (chat-wrap fallback); Anthropic +
|
|
385
|
+
* DeepSeek throw.
|
|
386
|
+
*/
|
|
387
|
+
async transcribe(
|
|
388
|
+
audio: AudioSource,
|
|
389
|
+
options: TranscribeOptions = {},
|
|
390
|
+
): Promise<TranscribeResult> {
|
|
391
|
+
const provider = this.provider(options.provider)
|
|
392
|
+
if (!provider.transcribe) {
|
|
393
|
+
throw new BrainError(
|
|
394
|
+
`BrainManager.transcribe: provider "${provider.name}" does not implement transcribe. Route to a provider with audio support (V1: OpenAI / Ollama / Gemini).`,
|
|
395
|
+
{ context: { provider: provider.name } },
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
return provider.transcribe(audio, options)
|
|
399
|
+
}
|
|
400
|
+
|
|
197
401
|
/**
|
|
198
402
|
* Resolve an `Agent` subclass from the container and return an
|
|
199
403
|
* `AgentRunner` ready to receive `input(...)` and `run()`. Apps
|
|
200
404
|
* `@inject()`-decorate their Agent subclass so constructor
|
|
201
405
|
* injection of dependencies (Repositories, services, etc.) flows
|
|
202
406
|
* through normally.
|
|
407
|
+
*
|
|
408
|
+
* When the agent subclass extends `Agent<T>` for some `T` and
|
|
409
|
+
* declares `outputSchema`, the returned runner is typed as
|
|
410
|
+
* `AgentRunner<T>` and the schema is pre-applied — `.run()`
|
|
411
|
+
* returns `AgentGenerateResult<T>` without a per-call
|
|
412
|
+
* `.output(schema)`. Apps can still chain `.output(otherSchema)`
|
|
413
|
+
* to override.
|
|
203
414
|
*/
|
|
204
|
-
agent<
|
|
415
|
+
agent<T = never>(
|
|
416
|
+
AgentClass: new (...args: never[]) => Agent<T>,
|
|
417
|
+
instance?: Agent<T>,
|
|
418
|
+
): AgentRunner<T> {
|
|
205
419
|
const agent = instance ?? this.resolveAgent(AgentClass)
|
|
206
|
-
|
|
420
|
+
const runner = new AgentRunner<T>(this, agent)
|
|
421
|
+
if (agent.outputSchema !== undefined) {
|
|
422
|
+
return runner.output(agent.outputSchema)
|
|
423
|
+
}
|
|
424
|
+
return runner
|
|
207
425
|
}
|
|
208
426
|
|
|
209
427
|
// ─── Internal ────────────────────────────────────────────────────────────
|
|
210
428
|
|
|
211
|
-
private resolveAgent<A extends Agent
|
|
429
|
+
private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
|
|
212
430
|
if (this.agentResolver) return this.agentResolver(AgentClass)
|
|
213
431
|
// Fallback: assume the Agent class is constructible without args.
|
|
214
432
|
// Apps that need DI on the agent register a resolver via
|
|
@@ -247,3 +465,66 @@ function normalizeInput(input: string | readonly Message[]): readonly Message[]
|
|
|
247
465
|
}
|
|
248
466
|
return input
|
|
249
467
|
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* V1 scope guard. `shouldSuspend` is wired only into the non-
|
|
471
|
+
* streaming `runWithTools` loop; the streaming and schema variants
|
|
472
|
+
* don't yet model pause / resume, so silently ignoring would be
|
|
473
|
+
* worse than throwing. Apps that need both should run tools first
|
|
474
|
+
* (suspending as needed), then call `generate` for the structured
|
|
475
|
+
* summary in a separate step.
|
|
476
|
+
*/
|
|
477
|
+
/**
|
|
478
|
+
* Carry forward the pre-suspension iteration count + token usage so
|
|
479
|
+
* `result.iterations` / `result.usage` reflect the full run, not
|
|
480
|
+
* just the post-resume portion. When the resumed call suspends
|
|
481
|
+
* again, the new state's iterations + usage also get the carry-
|
|
482
|
+
* forward so apps see a running total across an arbitrary number
|
|
483
|
+
* of suspension cycles.
|
|
484
|
+
*/
|
|
485
|
+
function mergeResumeCounters(
|
|
486
|
+
out: AgentResult | SuspendedRun,
|
|
487
|
+
state: SuspendedState,
|
|
488
|
+
): AgentResult | SuspendedRun {
|
|
489
|
+
// +1 accounts for the suspended round itself — at suspension time
|
|
490
|
+
// the loop hadn't yet incremented `iterations` (we paused mid-
|
|
491
|
+
// batch, before tool execution). Supplying results to resume
|
|
492
|
+
// effectively completes that round.
|
|
493
|
+
const carryIter = state.iterations + 1
|
|
494
|
+
if ('status' in out) {
|
|
495
|
+
return {
|
|
496
|
+
...out,
|
|
497
|
+
state: {
|
|
498
|
+
...out.state,
|
|
499
|
+
iterations: out.state.iterations + carryIter,
|
|
500
|
+
usage: addUsage(out.state.usage, state.usage),
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
...out,
|
|
506
|
+
iterations: out.iterations + carryIter,
|
|
507
|
+
usage: addUsage(out.usage, state.usage),
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function addUsage(
|
|
512
|
+
a: SuspendedState['usage'],
|
|
513
|
+
b: SuspendedState['usage'],
|
|
514
|
+
): SuspendedState['usage'] {
|
|
515
|
+
return {
|
|
516
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
517
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
518
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
519
|
+
cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function rejectShouldSuspend(options: RunWithToolsOptions, entry: string): void {
|
|
524
|
+
if (options.shouldSuspend !== undefined) {
|
|
525
|
+
throw new BrainError(
|
|
526
|
+
`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.`,
|
|
527
|
+
{ context: { entry } },
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
}
|
package/src/brain_provider.ts
CHANGED
|
@@ -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
|
@@ -10,15 +10,23 @@
|
|
|
10
10
|
export { Agent } from './agent.ts'
|
|
11
11
|
export type { AgentGenerateResult } from './agent_generate_result.ts'
|
|
12
12
|
export type { AgentResult } from './agent_result.ts'
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
AgentRunner,
|
|
15
|
+
type AgentRunMaybeSuspended,
|
|
16
|
+
type AgentRunResult,
|
|
17
|
+
} from './agent_runner.ts'
|
|
18
|
+
export type { AgentStreamEvent } from './agent_stream_event.ts'
|
|
14
19
|
export {
|
|
15
20
|
type AnthropicProviderConfig,
|
|
16
21
|
type BrainCacheConfig,
|
|
17
22
|
type BrainConfigShape,
|
|
23
|
+
type DeepSeekProviderConfig,
|
|
18
24
|
DEFAULT_MODEL,
|
|
19
25
|
DEFAULT_TIERS,
|
|
20
26
|
type GeminiProviderConfig,
|
|
27
|
+
type OllamaProviderConfig,
|
|
21
28
|
type OpenAIProviderConfig,
|
|
29
|
+
type OpenAIResponsesProviderConfig,
|
|
22
30
|
type ProviderConfig,
|
|
23
31
|
} from './brain_config.ts'
|
|
24
32
|
export { BrainError } from './brain_error.ts'
|
|
@@ -29,12 +37,28 @@ export {
|
|
|
29
37
|
} from './brain_manager.ts'
|
|
30
38
|
export { BrainProvider } from './brain_provider.ts'
|
|
31
39
|
export { defineTool, type DefineToolSpec } from './define_tool.ts'
|
|
40
|
+
export { MCPClientPool, type MCPClientFactory } from './mcp/pool.ts'
|
|
32
41
|
export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
|
|
33
42
|
export type { OutputSchema } from './output_schema.ts'
|
|
34
43
|
export { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
44
|
+
export { DeepSeekProvider } from './providers/deepseek_provider.ts'
|
|
35
45
|
export { GeminiProvider } from './providers/gemini_provider.ts'
|
|
46
|
+
export { OllamaProvider } from './providers/ollama_provider.ts'
|
|
47
|
+
export { OpenAICompatProvider } from './providers/openai_compat_provider.ts'
|
|
36
48
|
export { OpenAIProvider } from './providers/openai_provider.ts'
|
|
37
|
-
export
|
|
49
|
+
export { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
|
|
50
|
+
export type {
|
|
51
|
+
Provider,
|
|
52
|
+
RunWithToolsOptions,
|
|
53
|
+
RunWithToolsOptionsWithSuspend,
|
|
54
|
+
} from './provider.ts'
|
|
55
|
+
export {
|
|
56
|
+
appendResumeResults,
|
|
57
|
+
isSuspended,
|
|
58
|
+
type SuspendedRun,
|
|
59
|
+
type SuspendedState,
|
|
60
|
+
type ToolResultInput,
|
|
61
|
+
} from './suspended_run.ts'
|
|
38
62
|
export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
|
|
39
63
|
export type { Tool, ToolContext } from './tool.ts'
|
|
40
64
|
export { ToolExecutionError } from './tool_execution_error.ts'
|
|
@@ -42,15 +66,26 @@ export type {
|
|
|
42
66
|
ChatOptions,
|
|
43
67
|
ChatResult,
|
|
44
68
|
ChatUsage,
|
|
69
|
+
CompactConfig,
|
|
70
|
+
CompactionBlock,
|
|
45
71
|
ContentBlock,
|
|
72
|
+
AudioBlock,
|
|
73
|
+
AudioSource,
|
|
74
|
+
DocumentBlock,
|
|
75
|
+
EmbedOptions,
|
|
76
|
+
EmbedResult,
|
|
46
77
|
GenerateResult,
|
|
78
|
+
ImageBlock,
|
|
47
79
|
MCPToolResultBlock,
|
|
48
80
|
MCPToolUseBlock,
|
|
49
81
|
Message,
|
|
50
82
|
ModelTier,
|
|
83
|
+
ServerTool,
|
|
51
84
|
StreamEvent,
|
|
52
85
|
SystemPrompt,
|
|
53
86
|
TextBlock,
|
|
54
87
|
ToolResultBlock,
|
|
55
88
|
ToolUseBlock,
|
|
89
|
+
TranscribeOptions,
|
|
90
|
+
TranscribeResult,
|
|
56
91
|
} 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`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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,25 @@ export class MCPClient {
|
|
|
58
66
|
readonly server: MCPServer
|
|
59
67
|
private readonly _client: Client
|
|
60
68
|
private _connected = false
|
|
69
|
+
/**
|
|
70
|
+
* In-flight connect promise — set on the first concurrent
|
|
71
|
+
* `connect()` and cleared on settle. Subsequent callers that
|
|
72
|
+
* race against the first one await the same promise instead of
|
|
73
|
+
* each kicking off their own transport handshake. Necessary for
|
|
74
|
+
* pooled clients: a fresh `borrow()` followed by parallel
|
|
75
|
+
* `listTools()` + `callTool()` calls both hit the same connect.
|
|
76
|
+
*/
|
|
77
|
+
private _connecting: Promise<void> | undefined
|
|
78
|
+
private _transport: StreamableHTTPClientTransport | undefined
|
|
79
|
+
private _authProvider: StoreBackedOAuthProvider | undefined
|
|
61
80
|
|
|
62
81
|
constructor(server: MCPServer, options: MCPClientOptions = {}) {
|
|
82
|
+
if (server.authorizationToken !== undefined && server.oauth !== undefined) {
|
|
83
|
+
throw new BrainError(
|
|
84
|
+
`MCPClient(${server.name}): \`authorizationToken\` and \`oauth\` are mutually exclusive — set one.`,
|
|
85
|
+
{ context: { server: server.name } },
|
|
86
|
+
)
|
|
87
|
+
}
|
|
63
88
|
this.server = server
|
|
64
89
|
this._client =
|
|
65
90
|
options.client ??
|
|
@@ -71,11 +96,26 @@ export class MCPClient {
|
|
|
71
96
|
|
|
72
97
|
async connect(): Promise<void> {
|
|
73
98
|
if (this._connected) return
|
|
99
|
+
if (this._connecting) return this._connecting
|
|
100
|
+
this._connecting = this._doConnect().finally(() => {
|
|
101
|
+
this._connecting = undefined
|
|
102
|
+
})
|
|
103
|
+
return this._connecting
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async _doConnect(): Promise<void> {
|
|
74
107
|
const transport = this._buildTransport()
|
|
108
|
+
this._transport = transport
|
|
75
109
|
try {
|
|
76
110
|
await this._client.connect(transport)
|
|
77
111
|
this._connected = true
|
|
78
112
|
} catch (cause) {
|
|
113
|
+
if (cause instanceof UnauthorizedError && this._authProvider?.capturedAuthorizationUrl) {
|
|
114
|
+
throw new MCPAuthRequiredError(
|
|
115
|
+
this.server.name,
|
|
116
|
+
this._authProvider.capturedAuthorizationUrl.toString(),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
79
119
|
throw new BrainError(
|
|
80
120
|
`MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
|
|
81
121
|
{ context: { server: this.server.name, url: this.server.url }, cause },
|
|
@@ -83,11 +123,44 @@ export class MCPClient {
|
|
|
83
123
|
}
|
|
84
124
|
}
|
|
85
125
|
|
|
86
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Finish the OAuth authorization-code flow after the user
|
|
128
|
+
* authorized and was redirected back to the app's callback URL.
|
|
129
|
+
* Exchanges `code` for tokens, persists them via the configured
|
|
130
|
+
* `MCPOAuthStore`, then connects.
|
|
131
|
+
*
|
|
132
|
+
* Throws `BrainError` when called on a server without OAuth
|
|
133
|
+
* configured.
|
|
134
|
+
*/
|
|
135
|
+
async completeAuthorization(code: string): Promise<void> {
|
|
136
|
+
if (this.server.oauth === undefined) {
|
|
137
|
+
throw new BrainError(
|
|
138
|
+
`MCPClient(${this.server.name}): completeAuthorization() called on a server without \`oauth\` configured.`,
|
|
139
|
+
{ context: { server: this.server.name } },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
if (this._transport === undefined) {
|
|
143
|
+
// A previous `connect()` attempt builds the transport + captures
|
|
144
|
+
// the auth URL on the provider. Apps that lost the transport
|
|
145
|
+
// reference (typical for stateless callback handlers) can
|
|
146
|
+
// construct a fresh transport here — the store carries every
|
|
147
|
+
// bit of state the exchange needs.
|
|
148
|
+
this._transport = this._buildTransport()
|
|
149
|
+
}
|
|
150
|
+
await this._transport.finishAuth(code)
|
|
151
|
+
// Now that tokens are saved, re-attempt the connection.
|
|
152
|
+
this._connected = false
|
|
153
|
+
await this.connect()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async listTools(opts: { signal?: AbortSignal } = {}): Promise<MCPToolDescriptor[]> {
|
|
87
157
|
await this.connect()
|
|
88
158
|
let response: Awaited<ReturnType<Client['listTools']>>
|
|
89
159
|
try {
|
|
90
|
-
response = await this._client.listTools(
|
|
160
|
+
response = await this._client.listTools(
|
|
161
|
+
undefined,
|
|
162
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
163
|
+
)
|
|
91
164
|
} catch (cause) {
|
|
92
165
|
throw new BrainError(
|
|
93
166
|
`MCPClient(${this.server.name}): tools/list failed.`,
|
|
@@ -101,14 +174,22 @@ export class MCPClient {
|
|
|
101
174
|
}))
|
|
102
175
|
}
|
|
103
176
|
|
|
104
|
-
async callTool(
|
|
177
|
+
async callTool(
|
|
178
|
+
name: string,
|
|
179
|
+
input: unknown,
|
|
180
|
+
opts: { signal?: AbortSignal } = {},
|
|
181
|
+
): Promise<MCPCallToolResult> {
|
|
105
182
|
await this.connect()
|
|
106
183
|
let response: Awaited<ReturnType<Client['callTool']>>
|
|
107
184
|
try {
|
|
108
|
-
response = await this._client.callTool(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
185
|
+
response = await this._client.callTool(
|
|
186
|
+
{
|
|
187
|
+
name,
|
|
188
|
+
arguments: (input ?? {}) as Record<string, unknown>,
|
|
189
|
+
},
|
|
190
|
+
undefined,
|
|
191
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
192
|
+
)
|
|
112
193
|
} catch (cause) {
|
|
113
194
|
throw new BrainError(
|
|
114
195
|
`MCPClient(${this.server.name}): tools/call ${name} failed.`,
|
|
@@ -135,9 +216,14 @@ export class MCPClient {
|
|
|
135
216
|
if (this.server.authorizationToken !== undefined) {
|
|
136
217
|
headers.Authorization = `Bearer ${this.server.authorizationToken}`
|
|
137
218
|
}
|
|
138
|
-
|
|
219
|
+
const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
|
|
139
220
|
requestInit: { headers },
|
|
140
|
-
}
|
|
221
|
+
}
|
|
222
|
+
if (this.server.oauth !== undefined) {
|
|
223
|
+
this._authProvider = new StoreBackedOAuthProvider(this.server.oauth)
|
|
224
|
+
transportOpts.authProvider = this._authProvider
|
|
225
|
+
}
|
|
226
|
+
return new StreamableHTTPClientTransport(new URL(this.server.url), transportOpts)
|
|
141
227
|
}
|
|
142
228
|
}
|
|
143
229
|
|
package/src/mcp/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ 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'
|
|
18
|
+
export { MCPClientPool, type MCPClientFactory } from './pool.ts'
|
|
12
19
|
export {
|
|
13
20
|
resolveMcpTools,
|
|
14
21
|
type ResolveMcpToolsOptions,
|