@strav/brain 1.0.0-alpha.9 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +23 -7
  2. package/src/agent.ts +43 -5
  3. package/src/agent_generate_result.ts +32 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +218 -14
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +218 -1
  8. package/src/brain_driver.ts +247 -0
  9. package/src/brain_error.ts +86 -10
  10. package/src/brain_manager.ts +359 -11
  11. package/src/brain_provider.ts +79 -9
  12. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  13. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  14. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  15. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  16. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  17. package/src/drivers/anthropic/index.ts +1 -0
  18. package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
  19. package/src/drivers/deepseek/index.ts +1 -0
  20. package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
  21. package/src/drivers/gemini/index.ts +1 -0
  22. package/src/drivers/minimax/index.ts +1 -0
  23. package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
  24. package/src/drivers/ollama/index.ts +1 -0
  25. package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
  26. package/src/drivers/openai/index.ts +1 -0
  27. package/src/drivers/openai/openai_brain_driver.ts +796 -0
  28. package/src/drivers/openai/openai_helpers.ts +58 -0
  29. package/src/drivers/openai/openai_message_builder.ts +187 -0
  30. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  31. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  32. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  33. package/src/drivers/openai_compat/index.ts +1 -0
  34. package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
  35. package/src/drivers/openai_responses/index.ts +1 -0
  36. package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
  37. package/src/drivers/openrouter/index.ts +1 -0
  38. package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
  39. package/src/drivers/qwen/index.ts +1 -0
  40. package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
  41. package/src/index.ts +75 -11
  42. package/src/mcp/client.ts +243 -0
  43. package/src/mcp/index.ts +23 -0
  44. package/src/mcp/oauth.ts +227 -0
  45. package/src/mcp/pool.ts +106 -0
  46. package/src/mcp/resolve_mcp_tools.ts +108 -0
  47. package/src/mcp_server.ts +63 -0
  48. package/src/output_schema.ts +72 -0
  49. package/src/persistence/brain_message.ts +34 -0
  50. package/src/persistence/brain_message_repository.ts +98 -0
  51. package/src/persistence/brain_store.ts +166 -0
  52. package/src/persistence/brain_suspended_run.ts +30 -0
  53. package/src/persistence/brain_suspended_run_repository.ts +59 -0
  54. package/src/persistence/brain_thread.ts +30 -0
  55. package/src/persistence/brain_thread_repository.ts +56 -0
  56. package/src/persistence/database_brain_store.ts +190 -0
  57. package/src/persistence/index.ts +48 -0
  58. package/src/persistence/schemas/brain_message_schema.ts +61 -0
  59. package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
  60. package/src/persistence/schemas/brain_thread_schema.ts +50 -0
  61. package/src/persistence/schemas/index.ts +3 -0
  62. package/src/suspended_run.ts +153 -0
  63. package/src/thread.ts +40 -1
  64. package/src/tool.ts +7 -0
  65. package/src/tool_runner.ts +81 -0
  66. package/src/translate/index.ts +19 -0
  67. package/src/translate/translate_cache.ts +78 -0
  68. package/src/translate/translate_provider.ts +46 -0
  69. package/src/translate/translator.ts +271 -0
  70. package/src/types.ts +398 -1
  71. package/src/zod/index.ts +121 -0
  72. package/src/provider.ts +0 -74
  73. package/src/providers/anthropic_provider.ts +0 -397
@@ -0,0 +1,641 @@
1
+ /**
2
+ * `AnthropicBrainDriver` — implementation of `Provider` backed by the
3
+ * official `@anthropic-ai/sdk`.
4
+ *
5
+ * Responsibilities:
6
+ * 1. Hold a singleton `Anthropic` client instance for the
7
+ * configured API key + base URL.
8
+ * 2. Translate the framework's `ChatOptions` / `Message` shapes
9
+ * into Anthropic's `MessageCreateParams` (system as `TextBlock[]`
10
+ * with `cache_control` when requested; messages with per-block
11
+ * cache flags translated likewise; `thinking` mapped to
12
+ * `ThinkingConfigParam`; `effort` placed under `output_config`).
13
+ * 3. Translate the response back to `ChatResult` — flatten the
14
+ * content blocks into a single `text` string, surface usage with
15
+ * cache-hit counters, and pass the raw `Message` through on `.raw`.
16
+ * 4. Stream via `client.messages.stream()` and yield the framework
17
+ * `StreamEvent` union — `text` deltas plus a terminal `stop`
18
+ * event with usage + stop reason.
19
+ *
20
+ * Errors from the SDK propagate; apps that want provider-specific
21
+ * recovery can `instanceof Anthropic.RateLimitError` etc. The brain
22
+ * facade wraps the call site in `BrainError` only for invariants the
23
+ * facade owns (e.g. "no provider configured").
24
+ */
25
+
26
+ import Anthropic from '@anthropic-ai/sdk'
27
+ import type { AgentResult } from '../../agent_result.ts'
28
+ import type { AnthropicProviderConfig } from '../../brain_config.ts'
29
+ import { DEFAULT_MODEL } from '../../brain_config.ts'
30
+ import { BrainError } from '../../brain_error.ts'
31
+ import type {
32
+ BrainDriver,
33
+ RunWithToolsOptions,
34
+ RunWithToolsOptionsWithSuspend,
35
+ } from '../../brain_driver.ts'
36
+ import type { SuspendedRun } from '../../suspended_run.ts'
37
+ import type { Tool } from '../../tool.ts'
38
+ import type {
39
+ ChatOptions,
40
+ ChatResult,
41
+ ChatUsage,
42
+ ContentBlock,
43
+ GenerateResult,
44
+ Message,
45
+ StreamEvent,
46
+ ToolResultBlock,
47
+ ToolUseBlock,
48
+ } from '../../types.ts'
49
+ import type { AgentGenerateResult } from '../../agent_generate_result.ts'
50
+ import type { AgentStreamEvent } from '../../agent_stream_event.ts'
51
+ import { parseGenerated, type OutputSchema } from '../../output_schema.ts'
52
+ import { runToolWithRecovery } from '../../tool_runner.ts'
53
+ import {
54
+ checkAborted,
55
+ collectText,
56
+ needsBetaRouting,
57
+ reqOpts,
58
+ } from './anthropic_helpers.ts'
59
+ import {
60
+ buildAnthropicMessageParams,
61
+ toMessageParam,
62
+ } from './anthropic_message_builder.ts'
63
+ import {
64
+ addAnthropicUsage,
65
+ fromAnthropicContent,
66
+ toAnthropicChatResult,
67
+ toAnthropicUsage,
68
+ } from './anthropic_response_mapper.ts'
69
+ import {
70
+ createNonStreamLoopState,
71
+ injectToolsAndMCP,
72
+ runAnthropicNonStreamIteration,
73
+ } from './anthropic_tool_loop.ts'
74
+
75
+ export class AnthropicBrainDriver implements BrainDriver {
76
+ readonly name: string
77
+ private readonly client: Anthropic
78
+ private readonly defaultModel: string
79
+ private readonly defaultMaxTokens: number
80
+ private readonly betas: readonly string[]
81
+
82
+ constructor(
83
+ name: string,
84
+ config: AnthropicProviderConfig,
85
+ options: { client?: Anthropic } = {},
86
+ ) {
87
+ this.name = name
88
+ this.defaultModel = config.defaultModel ?? DEFAULT_MODEL
89
+ this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
90
+ this.betas = config.betas ?? []
91
+ // `client` injection point — tests pass a stub; apps that want a
92
+ // pre-configured SDK instance (custom retry, fetch transport, etc.)
93
+ // build their own and hand it over here.
94
+ this.client =
95
+ options.client ??
96
+ new Anthropic({
97
+ apiKey: config.apiKey,
98
+ ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
99
+ })
100
+ }
101
+
102
+ async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
103
+ const params = this.buildParams(messages, options)
104
+ const useBeta = needsBetaRouting(params)
105
+ const response = useBeta
106
+ ? ((await this.client.beta.messages.create(
107
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
108
+ reqOpts(options),
109
+ )) as unknown as Anthropic.Message)
110
+ : await this.client.messages.create(params, reqOpts(options))
111
+ return toAnthropicChatResult(response)
112
+ }
113
+
114
+ async *stream(
115
+ messages: readonly Message[],
116
+ options: ChatOptions = {},
117
+ ): AsyncIterable<StreamEvent> {
118
+ const params = this.buildParams(messages, options)
119
+ const stream = needsBetaRouting(params)
120
+ ? this.client.beta.messages.stream(
121
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
122
+ reqOpts(options),
123
+ )
124
+ : this.client.messages.stream(params, reqOpts(options))
125
+ for await (const event of stream) {
126
+ if (
127
+ event.type === 'content_block_delta' &&
128
+ event.delta.type === 'text_delta'
129
+ ) {
130
+ yield { type: 'text', delta: event.delta.text }
131
+ }
132
+ }
133
+ const final = await stream.finalMessage()
134
+ yield {
135
+ type: 'stop',
136
+ stopReason: final.stop_reason,
137
+ usage: toAnthropicUsage(final.usage),
138
+ }
139
+ }
140
+
141
+ async countTokens(
142
+ messages: readonly Message[],
143
+ options: ChatOptions = {},
144
+ ): Promise<number> {
145
+ const base = this.buildParams(messages, options)
146
+ // count_tokens only accepts a subset of MessageCreateParams; build
147
+ // a focused payload that matches what apps actually need to budget.
148
+ const result = await this.client.messages.countTokens(
149
+ {
150
+ model: base.model,
151
+ messages: base.messages,
152
+ ...(base.system !== undefined ? { system: base.system } : {}),
153
+ ...(base.thinking !== undefined ? { thinking: base.thinking } : {}),
154
+ },
155
+ reqOpts(options),
156
+ )
157
+ return result.input_tokens
158
+ }
159
+
160
+ /**
161
+ * Agentic loop. Send → detect tool_use blocks → execute → append
162
+ * tool_result → re-send, until the model returns `end_turn` or
163
+ * the iteration ceiling is hit.
164
+ *
165
+ * Tools are passed once on every call — Anthropic doesn't carry
166
+ * tool state across requests; the model rediscovers them from the
167
+ * `tools` array each turn. Apps that care about cache hits keep
168
+ * the tool list stable across runs.
169
+ */
170
+ runWithTools(
171
+ messages: readonly Message[],
172
+ tools: readonly Tool[],
173
+ options: RunWithToolsOptionsWithSuspend,
174
+ ): Promise<AgentResult | SuspendedRun>
175
+ runWithTools(
176
+ messages: readonly Message[],
177
+ tools: readonly Tool[],
178
+ options?: RunWithToolsOptions,
179
+ ): Promise<AgentResult>
180
+ async runWithTools(
181
+ messages: readonly Message[],
182
+ tools: readonly Tool[],
183
+ options: RunWithToolsOptions = {},
184
+ ): Promise<AgentResult | SuspendedRun> {
185
+ const maxIterations = options.maxIterations ?? 10
186
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
187
+ const state = createNonStreamLoopState(messages)
188
+ const mcpServers = options.mcpServers ?? []
189
+ const buildParams = (msgs: readonly Message[]) =>
190
+ injectToolsAndMCP(this.buildParams(msgs, options), { tools, mcpServers })
191
+
192
+ while (true) {
193
+ const outcome = await runAnthropicNonStreamIteration({
194
+ state,
195
+ toolMap,
196
+ maxIterations,
197
+ client: this.client,
198
+ buildParams,
199
+ options,
200
+ suspendCheck: options.shouldSuspend,
201
+ })
202
+ if (outcome.kind === 'continue') continue
203
+ if (outcome.kind === 'suspended') {
204
+ return {
205
+ status: 'suspended',
206
+ pendingToolCalls: outcome.pendingToolCalls,
207
+ state: {
208
+ messages: state.workingMessages,
209
+ iterations: state.iterations,
210
+ usage: state.aggregated,
211
+ },
212
+ }
213
+ }
214
+ return {
215
+ text: outcome.assistantText,
216
+ messages: state.workingMessages,
217
+ iterations: state.iterations,
218
+ stopReason: outcome.kind === 'max_iterations' ? 'max_iterations' : outcome.stopReason,
219
+ usage: state.aggregated,
220
+ }
221
+ }
222
+ }
223
+
224
+ async runWithToolsAndSchema<T>(
225
+ messages: readonly Message[],
226
+ tools: readonly Tool[],
227
+ schema: OutputSchema<T>,
228
+ options: RunWithToolsOptions = {},
229
+ ): Promise<AgentGenerateResult<T>> {
230
+ const maxIterations = options.maxIterations ?? 10
231
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
232
+ const state = createNonStreamLoopState(messages)
233
+ const mcpServers = options.mcpServers ?? []
234
+ const buildParams = (msgs: readonly Message[]) => {
235
+ const params = injectToolsAndMCP(this.buildParams(msgs, options), { tools, mcpServers })
236
+ params.output_config = {
237
+ ...(params.output_config ?? {}),
238
+ format: { type: 'json_schema', schema: schema.jsonSchema },
239
+ }
240
+ return params
241
+ }
242
+
243
+ while (true) {
244
+ const outcome = await runAnthropicNonStreamIteration({
245
+ state,
246
+ toolMap,
247
+ maxIterations,
248
+ client: this.client,
249
+ buildParams,
250
+ options,
251
+ // Schema variant doesn't support suspension — same as OpenAI.
252
+ suspendCheck: undefined,
253
+ })
254
+ if (outcome.kind === 'continue') continue
255
+ if (outcome.kind === 'suspended') {
256
+ throw new BrainError(
257
+ 'AnthropicBrainDriver: runWithToolsAndSchema received a suspension outcome but does not support it.',
258
+ )
259
+ }
260
+ // For max_iterations the assistantText may be empty (last turn
261
+ // was a tool_use) — surface what we have; parseGenerated will
262
+ // likely fail and that's the correct signal.
263
+ return {
264
+ value: parseGenerated(outcome.assistantText, schema),
265
+ text: outcome.assistantText,
266
+ messages: state.workingMessages,
267
+ iterations: state.iterations,
268
+ stopReason: outcome.kind === 'max_iterations' ? 'max_iterations' : outcome.stopReason,
269
+ usage: state.aggregated,
270
+ }
271
+ }
272
+ }
273
+
274
+ async *streamWithTools(
275
+ messages: readonly Message[],
276
+ tools: readonly Tool[],
277
+ options: RunWithToolsOptions = {},
278
+ ): AsyncIterable<AgentStreamEvent> {
279
+ const maxIterations = options.maxIterations ?? 10
280
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
281
+ const workingMessages: Message[] = [...messages]
282
+ const aggregated: ChatUsage = {
283
+ inputTokens: 0,
284
+ outputTokens: 0,
285
+ cacheReadTokens: 0,
286
+ cacheCreationTokens: 0,
287
+ }
288
+ let iterations = 0
289
+
290
+ const mcpServers = options.mcpServers ?? []
291
+ const useMcpBeta = mcpServers.length > 0
292
+
293
+ while (true) {
294
+ checkAborted(options.signal)
295
+ yield { type: 'iteration_start', iteration: iterations }
296
+
297
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
298
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
299
+ }
300
+ params.tools = [
301
+ // Server tools placed first when present (from buildParams).
302
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
303
+ ...tools.map((t) => ({
304
+ name: t.name,
305
+ description: t.description,
306
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
307
+ })),
308
+ ...mcpServers
309
+ .filter((s) => s.tools?.enabled !== false)
310
+ .map((s) => ({
311
+ type: 'mcp_toolset' as const,
312
+ mcp_server_name: s.name,
313
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
314
+ })),
315
+ ] as unknown as Anthropic.MessageCreateParams['tools']
316
+
317
+ if (useMcpBeta) {
318
+ params.mcp_servers = mcpServers.map((s) => {
319
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
320
+ type: 'url',
321
+ name: s.name,
322
+ url: s.url,
323
+ }
324
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
325
+ return def
326
+ })
327
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
328
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
329
+ ? [...baseBetas]
330
+ : [...baseBetas, 'mcp-client-2025-11-20']
331
+ }
332
+
333
+ const stream = needsBetaRouting(params)
334
+ ? this.client.beta.messages.stream(
335
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
336
+ reqOpts(options),
337
+ )
338
+ : this.client.messages.stream(params, reqOpts(options))
339
+
340
+ // Track tool_use content blocks by their stream index so
341
+ // `input_json_delta` events can be paired with the correct id.
342
+ // Anthropic's streaming protocol issues a `content_block_start`
343
+ // carrying the tool's id + name, then a sequence of
344
+ // `input_json_delta`s with `partial_json` chunks, then a
345
+ // `content_block_stop`.
346
+ const toolBlockIdByIndex = new Map<number, string>()
347
+ for await (const event of stream) {
348
+ if (
349
+ event.type === 'content_block_start' &&
350
+ event.content_block.type === 'tool_use'
351
+ ) {
352
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
353
+ yield {
354
+ type: 'tool_use_start',
355
+ id: event.content_block.id,
356
+ name: event.content_block.name,
357
+ }
358
+ } else if (event.type === 'content_block_delta') {
359
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
360
+ yield { type: 'text', delta: event.delta.text }
361
+ } else if (event.delta.type === 'input_json_delta') {
362
+ const id = toolBlockIdByIndex.get(event.index)
363
+ if (id !== undefined && event.delta.partial_json.length > 0) {
364
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
365
+ }
366
+ }
367
+ }
368
+ }
369
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
370
+ addAnthropicUsage(aggregated, final.usage)
371
+ const finishReason: string | null = final.stop_reason ?? null
372
+
373
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
374
+
375
+ workingMessages.push({
376
+ role: 'assistant',
377
+ content: fromAnthropicContent(final.content),
378
+ })
379
+
380
+ if (final.stop_reason !== 'tool_use') {
381
+ yield {
382
+ type: 'stop',
383
+ stopReason: finishReason ?? 'end_turn',
384
+ iterations,
385
+ usage: aggregated,
386
+ messages: workingMessages,
387
+ }
388
+ return
389
+ }
390
+
391
+ const toolUseBlocks = final.content.filter(
392
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
393
+ )
394
+ const resultBlocks: ContentBlock[] = []
395
+ for (const block of toolUseBlocks) {
396
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
397
+ const { content, isError } = await runToolWithRecovery(
398
+ toolMap.get(block.name),
399
+ block.name,
400
+ block.id,
401
+ block.input,
402
+ options,
403
+ )
404
+ resultBlocks.push({
405
+ type: 'tool_result',
406
+ toolUseId: block.id,
407
+ content,
408
+ ...(isError ? { isError: true } : {}),
409
+ } satisfies ToolResultBlock)
410
+ yield {
411
+ type: 'tool_result',
412
+ id: block.id,
413
+ name: block.name,
414
+ content,
415
+ isError,
416
+ }
417
+ }
418
+ workingMessages.push({ role: 'user', content: resultBlocks })
419
+
420
+ iterations++
421
+ if (iterations >= maxIterations) {
422
+ yield {
423
+ type: 'stop',
424
+ stopReason: 'max_iterations',
425
+ iterations,
426
+ usage: aggregated,
427
+ messages: workingMessages,
428
+ }
429
+ return
430
+ }
431
+ }
432
+ }
433
+
434
+ async *streamWithToolsAndSchema<T>(
435
+ messages: readonly Message[],
436
+ tools: readonly Tool[],
437
+ schema: OutputSchema<T>,
438
+ options: RunWithToolsOptions = {},
439
+ ): AsyncIterable<AgentStreamEvent<T>> {
440
+ const maxIterations = options.maxIterations ?? 10
441
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
442
+ const workingMessages: Message[] = [...messages]
443
+ const aggregated: ChatUsage = {
444
+ inputTokens: 0,
445
+ outputTokens: 0,
446
+ cacheReadTokens: 0,
447
+ cacheCreationTokens: 0,
448
+ }
449
+ let iterations = 0
450
+
451
+ const mcpServers = options.mcpServers ?? []
452
+ const useMcpBeta = mcpServers.length > 0
453
+
454
+ while (true) {
455
+ checkAborted(options.signal)
456
+ yield { type: 'iteration_start', iteration: iterations }
457
+
458
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
459
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
460
+ }
461
+ params.tools = [
462
+ // Server tools placed first when present (from buildParams).
463
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
464
+ ...tools.map((t) => ({
465
+ name: t.name,
466
+ description: t.description,
467
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
468
+ })),
469
+ ...mcpServers
470
+ .filter((s) => s.tools?.enabled !== false)
471
+ .map((s) => ({
472
+ type: 'mcp_toolset' as const,
473
+ mcp_server_name: s.name,
474
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
475
+ })),
476
+ ] as unknown as Anthropic.MessageCreateParams['tools']
477
+ params.output_config = {
478
+ ...(params.output_config ?? {}),
479
+ format: { type: 'json_schema', schema: schema.jsonSchema },
480
+ }
481
+
482
+ if (useMcpBeta) {
483
+ params.mcp_servers = mcpServers.map((s) => {
484
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
485
+ type: 'url',
486
+ name: s.name,
487
+ url: s.url,
488
+ }
489
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
490
+ return def
491
+ })
492
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
493
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
494
+ ? [...baseBetas]
495
+ : [...baseBetas, 'mcp-client-2025-11-20']
496
+ }
497
+
498
+ const stream = needsBetaRouting(params)
499
+ ? this.client.beta.messages.stream(
500
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
501
+ reqOpts(options),
502
+ )
503
+ : this.client.messages.stream(params, reqOpts(options))
504
+
505
+ // Track tool_use content blocks by their stream index so
506
+ // `input_json_delta` events can be paired with the correct id.
507
+ // Anthropic's streaming protocol issues a `content_block_start`
508
+ // carrying the tool's id + name, then a sequence of
509
+ // `input_json_delta`s with `partial_json` chunks, then a
510
+ // `content_block_stop`.
511
+ const toolBlockIdByIndex = new Map<number, string>()
512
+ for await (const event of stream) {
513
+ if (
514
+ event.type === 'content_block_start' &&
515
+ event.content_block.type === 'tool_use'
516
+ ) {
517
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
518
+ yield {
519
+ type: 'tool_use_start',
520
+ id: event.content_block.id,
521
+ name: event.content_block.name,
522
+ }
523
+ } else if (event.type === 'content_block_delta') {
524
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
525
+ yield { type: 'text', delta: event.delta.text }
526
+ } else if (event.delta.type === 'input_json_delta') {
527
+ const id = toolBlockIdByIndex.get(event.index)
528
+ if (id !== undefined && event.delta.partial_json.length > 0) {
529
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
530
+ }
531
+ }
532
+ }
533
+ }
534
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
535
+ addAnthropicUsage(aggregated, final.usage)
536
+ const finishReason: string | null = final.stop_reason ?? null
537
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
538
+
539
+ workingMessages.push({
540
+ role: 'assistant',
541
+ content: fromAnthropicContent(final.content),
542
+ })
543
+
544
+ if (final.stop_reason !== 'tool_use') {
545
+ const text = collectText(final.content)
546
+ const value = parseGenerated(text, schema)
547
+ yield {
548
+ type: 'stop',
549
+ stopReason: finishReason ?? 'end_turn',
550
+ iterations,
551
+ usage: aggregated,
552
+ messages: workingMessages,
553
+ value,
554
+ text,
555
+ } as AgentStreamEvent<T>
556
+ return
557
+ }
558
+
559
+ const toolUseBlocks = final.content.filter(
560
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
561
+ )
562
+ const resultBlocks: ContentBlock[] = []
563
+ for (const block of toolUseBlocks) {
564
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
565
+ const { content, isError } = await runToolWithRecovery(
566
+ toolMap.get(block.name),
567
+ block.name,
568
+ block.id,
569
+ block.input,
570
+ options,
571
+ )
572
+ resultBlocks.push({
573
+ type: 'tool_result',
574
+ toolUseId: block.id,
575
+ content,
576
+ ...(isError ? { isError: true } : {}),
577
+ } satisfies ToolResultBlock)
578
+ yield {
579
+ type: 'tool_result',
580
+ id: block.id,
581
+ name: block.name,
582
+ content,
583
+ isError,
584
+ }
585
+ }
586
+ workingMessages.push({ role: 'user', content: resultBlocks })
587
+
588
+ iterations++
589
+ if (iterations >= maxIterations) {
590
+ const text = collectText(final.content)
591
+ const value = parseGenerated(text, schema)
592
+ yield {
593
+ type: 'stop',
594
+ stopReason: 'max_iterations',
595
+ iterations,
596
+ usage: aggregated,
597
+ messages: workingMessages,
598
+ value,
599
+ text,
600
+ } as AgentStreamEvent<T>
601
+ return
602
+ }
603
+ }
604
+ }
605
+
606
+ async generate<T>(
607
+ messages: readonly Message[],
608
+ schema: OutputSchema<T>,
609
+ options: ChatOptions = {},
610
+ ): Promise<GenerateResult<T>> {
611
+ const params = this.buildParams(messages, options) as Anthropic.MessageCreateParamsNonStreaming
612
+ params.output_config = {
613
+ ...(params.output_config ?? {}),
614
+ format: { type: 'json_schema', schema: schema.jsonSchema },
615
+ }
616
+ const response = await this.client.messages.create(params, reqOpts(options))
617
+ const text = collectText(response.content)
618
+ const value = parseGenerated(text, schema)
619
+ return {
620
+ value,
621
+ text,
622
+ model: response.model,
623
+ stopReason: response.stop_reason,
624
+ usage: toAnthropicUsage(response.usage),
625
+ raw: response,
626
+ }
627
+ }
628
+
629
+ // ─── Param translation ──────────────────────────────────────────────────
630
+
631
+ private buildParams(
632
+ messages: readonly Message[],
633
+ options: ChatOptions,
634
+ ): Anthropic.MessageCreateParamsNonStreaming {
635
+ return buildAnthropicMessageParams(messages, options, {
636
+ defaultModel: this.defaultModel,
637
+ defaultMaxTokens: this.defaultMaxTokens,
638
+ betas: this.betas,
639
+ })
640
+ }
641
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Small utilities shared by `AnthropicBrainDriver`. Kept separate
3
+ * from the message builder / response mapper because these are
4
+ * content-agnostic — beta routing, abort-signal probing, text
5
+ * collection, and beta-header merging.
6
+ */
7
+
8
+ import type Anthropic from '@anthropic-ai/sdk'
9
+
10
+ /** Build the request-options bag forwarded to the SDK. Only `signal` for now. */
11
+ export function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
12
+ return options.signal !== undefined ? { signal: options.signal } : undefined
13
+ }
14
+
15
+ /** Throw a DOMException-shaped abort error if the signal has fired. */
16
+ export function checkAborted(signal: AbortSignal | undefined): void {
17
+ if (signal?.aborted) {
18
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError')
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Whether the request needs to flow through `client.beta.messages.create`
24
+ * instead of the stable surface. Triggered by:
25
+ *
26
+ * - `edits[]` (compaction).
27
+ * - `mcp_servers[]` (server-side MCP).
28
+ *
29
+ * Tests typically stub `client.messages.create`; the beta path uses the
30
+ * stub that lives at `client.beta.messages.create`.
31
+ */
32
+ export function needsBetaRouting(params: Anthropic.MessageCreateParamsNonStreaming): boolean {
33
+ const p = params as { edits?: unknown[]; mcp_servers?: unknown[] }
34
+ return (
35
+ (p.edits !== undefined && p.edits.length > 0) ||
36
+ (p.mcp_servers !== undefined && p.mcp_servers.length > 0)
37
+ )
38
+ }
39
+
40
+ export function mergeBetas(
41
+ providerBetas: readonly string[],
42
+ callBetas: readonly string[] | undefined,
43
+ ): readonly string[] {
44
+ if (!callBetas || callBetas.length === 0) return providerBetas
45
+ const seen = new Set<string>()
46
+ const out: string[] = []
47
+ for (const b of providerBetas) {
48
+ if (seen.has(b)) continue
49
+ seen.add(b)
50
+ out.push(b)
51
+ }
52
+ for (const b of callBetas) {
53
+ if (seen.has(b)) continue
54
+ seen.add(b)
55
+ out.push(b)
56
+ }
57
+ return out
58
+ }
59
+
60
+ export function collectText(content: Anthropic.ContentBlock[]): string {
61
+ return content
62
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
63
+ .map((b) => b.text)
64
+ .join('')
65
+ }