@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.
- package/package.json +23 -7
- package/src/agent.ts +97 -0
- package/src/agent_generate_result.ts +32 -0
- package/src/agent_result.ts +39 -0
- package/src/agent_runner.ts +265 -0
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +218 -1
- package/src/brain_driver.ts +247 -0
- package/src/brain_error.ts +86 -10
- package/src/brain_manager.ts +419 -5
- package/src/brain_provider.ts +89 -10
- package/src/define_tool.ts +42 -0
- package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
- package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
- package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
- package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
- package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
- package/src/drivers/anthropic/index.ts +1 -0
- package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
- package/src/drivers/deepseek/index.ts +1 -0
- package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
- package/src/drivers/gemini/index.ts +1 -0
- package/src/drivers/minimax/index.ts +1 -0
- package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
- package/src/drivers/ollama/index.ts +1 -0
- package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
- package/src/drivers/openai/index.ts +1 -0
- package/src/drivers/openai/openai_brain_driver.ts +796 -0
- package/src/drivers/openai/openai_helpers.ts +58 -0
- package/src/drivers/openai/openai_message_builder.ts +187 -0
- package/src/drivers/openai/openai_response_mapper.ts +70 -0
- package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
- package/src/drivers/openai/openai_tool_loop.ts +191 -0
- package/src/drivers/openai_compat/index.ts +1 -0
- package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
- package/src/drivers/openai_responses/index.ts +1 -0
- package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
- package/src/drivers/openrouter/index.ts +1 -0
- package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
- package/src/drivers/qwen/index.ts +1 -0
- package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
- package/src/index.ts +86 -8
- package/src/mcp/client.ts +243 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +108 -0
- package/src/mcp_server.ts +63 -0
- package/src/output_schema.ts +72 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +98 -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 +59 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +56 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schemas/brain_message_schema.ts +61 -0
- package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schemas/brain_thread_schema.ts +50 -0
- package/src/persistence/schemas/index.ts +3 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +42 -0
- package/src/tool_execution_error.ts +26 -0
- package/src/tool_runner.ts +81 -0
- package/src/translate/index.ts +19 -0
- package/src/translate/translate_cache.ts +78 -0
- package/src/translate/translate_provider.ts +46 -0
- package/src/translate/translator.ts +271 -0
- package/src/types.ts +431 -1
- package/src/zod/index.ts +121 -0
- package/src/provider.ts +0 -48
- package/src/providers/anthropic_provider.ts +0 -227
package/src/brain_manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `BrainManager` — the per-app facade apps inject and call.
|
|
3
3
|
*
|
|
4
|
-
* Holds the configured `
|
|
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 {
|
|
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,
|
|
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,
|
|
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):
|
|
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
|
+
}
|
package/src/brain_provider.ts
CHANGED
|
@@ -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 {
|
|
31
|
-
import
|
|
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,
|
|
64
|
+
const providers: Record<string, BrainDriver> = {}
|
|
57
65
|
for (const [name, entry] of Object.entries(config.providers)) {
|
|
58
|
-
providers[name] =
|
|
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
|
-
|
|
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
|
|
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
|
|
86
|
-
|
|
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
|
+
}
|