@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.
Files changed (42) hide show
  1. package/package.json +4 -2
  2. package/src/agent.ts +34 -5
  3. package/src/agent_generate_result.ts +2 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +134 -15
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +91 -1
  8. package/src/brain_manager.ts +287 -6
  9. package/src/brain_provider.ts +25 -1
  10. package/src/index.ts +37 -2
  11. package/src/mcp/client.ts +99 -13
  12. package/src/mcp/index.ts +7 -0
  13. package/src/mcp/oauth.ts +227 -0
  14. package/src/mcp/pool.ts +106 -0
  15. package/src/mcp/resolve_mcp_tools.ts +31 -9
  16. package/src/mcp_server.ts +16 -0
  17. package/src/persistence/brain_message.ts +34 -0
  18. package/src/persistence/brain_message_repository.ts +106 -0
  19. package/src/persistence/brain_store.ts +166 -0
  20. package/src/persistence/brain_suspended_run.ts +30 -0
  21. package/src/persistence/brain_suspended_run_repository.ts +68 -0
  22. package/src/persistence/brain_thread.ts +30 -0
  23. package/src/persistence/brain_thread_repository.ts +65 -0
  24. package/src/persistence/database_brain_store.ts +190 -0
  25. package/src/persistence/index.ts +48 -0
  26. package/src/persistence/schema/brain_message_schema.ts +61 -0
  27. package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
  28. package/src/persistence/schema/brain_thread_schema.ts +50 -0
  29. package/src/persistence/schema/index.ts +3 -0
  30. package/src/provider.ts +145 -1
  31. package/src/providers/anthropic_provider.ts +723 -38
  32. package/src/providers/deepseek_provider.ts +117 -0
  33. package/src/providers/gemini_provider.ts +625 -33
  34. package/src/providers/ollama_provider.ts +86 -0
  35. package/src/providers/openai_compat_provider.ts +616 -0
  36. package/src/providers/openai_provider.ts +801 -43
  37. package/src/providers/openai_responses_provider.ts +1015 -0
  38. package/src/suspended_run.ts +153 -0
  39. package/src/thread.ts +40 -1
  40. package/src/tool.ts +7 -0
  41. package/src/tool_runner.ts +81 -0
  42. package/src/types.ts +343 -0
@@ -27,25 +27,35 @@ import Anthropic from '@anthropic-ai/sdk'
27
27
  import type { AgentResult } from '../agent_result.ts'
28
28
  import type { AnthropicProviderConfig } from '../brain_config.ts'
29
29
  import { DEFAULT_MODEL } from '../brain_config.ts'
30
- import type { Provider, RunWithToolsOptions } from '../provider.ts'
30
+ import { BrainError } from '../brain_error.ts'
31
+ import type {
32
+ Provider,
33
+ RunWithToolsOptions,
34
+ RunWithToolsOptionsWithSuspend,
35
+ } from '../provider.ts'
36
+ import type { SuspendedRun } from '../suspended_run.ts'
31
37
  import type { Tool } from '../tool.ts'
32
- import { ToolExecutionError } from '../tool_execution_error.ts'
33
38
  import type {
34
39
  ChatOptions,
35
40
  ChatResult,
36
41
  ChatUsage,
42
+ CompactionBlock,
37
43
  ContentBlock,
38
44
  GenerateResult,
39
45
  MCPToolResultBlock,
40
46
  MCPToolUseBlock,
41
47
  Message,
48
+ ServerTool,
42
49
  StreamEvent,
43
50
  SystemPrompt,
44
51
  TextBlock,
45
52
  ToolResultBlock,
46
53
  ToolUseBlock,
47
54
  } from '../types.ts'
55
+ import type { AgentGenerateResult } from '../agent_generate_result.ts'
56
+ import type { AgentStreamEvent } from '../agent_stream_event.ts'
48
57
  import { parseGenerated, type OutputSchema } from '../output_schema.ts'
58
+ import { runToolWithRecovery } from '../tool_runner.ts'
49
59
 
50
60
  const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
51
61
 
@@ -78,7 +88,13 @@ export class AnthropicProvider implements Provider {
78
88
 
79
89
  async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
80
90
  const params = this.buildParams(messages, options)
81
- const response = await this.client.messages.create(params)
91
+ const useBeta = needsBetaRouting(params)
92
+ const response = useBeta
93
+ ? ((await this.client.beta.messages.create(
94
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
95
+ reqOpts(options),
96
+ )) as unknown as Anthropic.Message)
97
+ : await this.client.messages.create(params, reqOpts(options))
82
98
  return this.toChatResult(response)
83
99
  }
84
100
 
@@ -87,7 +103,12 @@ export class AnthropicProvider implements Provider {
87
103
  options: ChatOptions = {},
88
104
  ): AsyncIterable<StreamEvent> {
89
105
  const params = this.buildParams(messages, options)
90
- const stream = this.client.messages.stream(params)
106
+ const stream = needsBetaRouting(params)
107
+ ? this.client.beta.messages.stream(
108
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
109
+ reqOpts(options),
110
+ )
111
+ : this.client.messages.stream(params, reqOpts(options))
91
112
  for await (const event of stream) {
92
113
  if (
93
114
  event.type === 'content_block_delta' &&
@@ -111,12 +132,15 @@ export class AnthropicProvider implements Provider {
111
132
  const base = this.buildParams(messages, options)
112
133
  // count_tokens only accepts a subset of MessageCreateParams; build
113
134
  // a focused payload that matches what apps actually need to budget.
114
- const result = await this.client.messages.countTokens({
115
- model: base.model,
116
- messages: base.messages,
117
- ...(base.system !== undefined ? { system: base.system } : {}),
118
- ...(base.thinking !== undefined ? { thinking: base.thinking } : {}),
119
- })
135
+ const result = await this.client.messages.countTokens(
136
+ {
137
+ model: base.model,
138
+ messages: base.messages,
139
+ ...(base.system !== undefined ? { system: base.system } : {}),
140
+ ...(base.thinking !== undefined ? { thinking: base.thinking } : {}),
141
+ },
142
+ reqOpts(options),
143
+ )
120
144
  return result.input_tokens
121
145
  }
122
146
 
@@ -130,11 +154,21 @@ export class AnthropicProvider implements Provider {
130
154
  * `tools` array each turn. Apps that care about cache hits keep
131
155
  * the tool list stable across runs.
132
156
  */
157
+ runWithTools(
158
+ messages: readonly Message[],
159
+ tools: readonly Tool[],
160
+ options: RunWithToolsOptionsWithSuspend,
161
+ ): Promise<AgentResult | SuspendedRun>
162
+ runWithTools(
163
+ messages: readonly Message[],
164
+ tools: readonly Tool[],
165
+ options?: RunWithToolsOptions,
166
+ ): Promise<AgentResult>
133
167
  async runWithTools(
134
168
  messages: readonly Message[],
135
169
  tools: readonly Tool[],
136
170
  options: RunWithToolsOptions = {},
137
- ): Promise<AgentResult> {
171
+ ): Promise<AgentResult | SuspendedRun> {
138
172
  const maxIterations = options.maxIterations ?? 10
139
173
  const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
140
174
  const workingMessages: Message[] = [...messages]
@@ -151,10 +185,13 @@ export class AnthropicProvider implements Provider {
151
185
  const useMcpBeta = mcpServers.length > 0
152
186
 
153
187
  while (true) {
188
+ checkAborted(options.signal)
154
189
  const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
155
190
  mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
156
191
  }
157
192
  params.tools = [
193
+ // Server tools placed first when present (from buildParams).
194
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
158
195
  ...tools.map((t) => ({
159
196
  name: t.name,
160
197
  description: t.description,
@@ -176,7 +213,6 @@ export class AnthropicProvider implements Provider {
176
213
 
177
214
  // Declare MCP servers + flip to the beta surface when in use.
178
215
  // Anthropic's MCP connector requires `mcp-client-2025-11-20`.
179
- let response: Anthropic.Message
180
216
  if (useMcpBeta) {
181
217
  params.mcp_servers = mcpServers.map((s) => {
182
218
  const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
@@ -191,12 +227,15 @@ export class AnthropicProvider implements Provider {
191
227
  ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
192
228
  ? [...baseBetas]
193
229
  : [...baseBetas, 'mcp-client-2025-11-20']
194
- response = (await this.client.beta.messages.create(
195
- params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
196
- )) as unknown as Anthropic.Message
197
- } else {
198
- response = await this.client.messages.create(params)
199
230
  }
231
+ // Route via beta when either MCP servers OR compaction are in
232
+ // play — both live on the beta surface.
233
+ const response: Anthropic.Message = needsBetaRouting(params)
234
+ ? ((await this.client.beta.messages.create(
235
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
236
+ reqOpts(options),
237
+ )) as unknown as Anthropic.Message)
238
+ : await this.client.messages.create(params, reqOpts(options))
200
239
  addUsage(aggregated, response.usage)
201
240
  lastStopReason = response.stop_reason ?? null
202
241
 
@@ -225,28 +264,40 @@ export class AnthropicProvider implements Provider {
225
264
  (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
226
265
  )
227
266
  const resultBlocks: ContentBlock[] = []
228
- for (const block of toolUseBlocks) {
229
- const tool = toolMap.get(block.name)
230
- if (!tool) {
231
- throw new ToolExecutionError(
232
- block.name,
233
- block.id,
234
- new Error(`Tool "${block.name}" is not registered.`),
235
- )
236
- }
237
- let output: unknown
238
- try {
239
- output = await tool.execute(block.input, {
240
- callId: block.id,
241
- context: options.context ?? {},
242
- })
243
- } catch (cause) {
244
- throw new ToolExecutionError(block.name, block.id, cause)
267
+ for (let i = 0; i < toolUseBlocks.length; i++) {
268
+ const block = toolUseBlocks[i]!
269
+ if (options.shouldSuspend) {
270
+ const frameworkCall: ToolUseBlock = {
271
+ type: 'tool_use',
272
+ id: block.id,
273
+ name: block.name,
274
+ input: block.input as Record<string, unknown>,
275
+ }
276
+ if (await options.shouldSuspend(frameworkCall, options.context)) {
277
+ return {
278
+ status: 'suspended',
279
+ pendingToolCalls: toolUseBlocks.slice(i).map((b) => ({
280
+ type: 'tool_use',
281
+ id: b.id,
282
+ name: b.name,
283
+ input: b.input as Record<string, unknown>,
284
+ })),
285
+ state: { messages: workingMessages, iterations, usage: aggregated },
286
+ }
287
+ }
245
288
  }
289
+ const { content, isError } = await runToolWithRecovery(
290
+ toolMap.get(block.name),
291
+ block.name,
292
+ block.id,
293
+ block.input,
294
+ options,
295
+ )
246
296
  const resultBlock: ToolResultBlock = {
247
297
  type: 'tool_result',
248
298
  toolUseId: block.id,
249
- content: typeof output === 'string' ? output : JSON.stringify(output),
299
+ content,
300
+ ...(isError ? { isError: true } : {}),
250
301
  }
251
302
  resultBlocks.push(resultBlock)
252
303
  }
@@ -265,6 +316,465 @@ export class AnthropicProvider implements Provider {
265
316
  }
266
317
  }
267
318
 
319
+ async runWithToolsAndSchema<T>(
320
+ messages: readonly Message[],
321
+ tools: readonly Tool[],
322
+ schema: OutputSchema<T>,
323
+ options: RunWithToolsOptions = {},
324
+ ): Promise<AgentGenerateResult<T>> {
325
+ const maxIterations = options.maxIterations ?? 10
326
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
327
+ const workingMessages: Message[] = [...messages]
328
+ const aggregated: ChatUsage = {
329
+ inputTokens: 0,
330
+ outputTokens: 0,
331
+ cacheReadTokens: 0,
332
+ cacheCreationTokens: 0,
333
+ }
334
+ let iterations = 0
335
+ let lastStopReason: string | null = null
336
+
337
+ const mcpServers = options.mcpServers ?? []
338
+ const useMcpBeta = mcpServers.length > 0
339
+
340
+ while (true) {
341
+ checkAborted(options.signal)
342
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
343
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
344
+ }
345
+ params.tools = [
346
+ // Server tools placed first when present (from buildParams).
347
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
348
+ ...tools.map((t) => ({
349
+ name: t.name,
350
+ description: t.description,
351
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
352
+ })),
353
+ ...mcpServers
354
+ .filter((s) => s.tools?.enabled !== false)
355
+ .map((s) => ({
356
+ type: 'mcp_toolset' as const,
357
+ mcp_server_name: s.name,
358
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
359
+ })),
360
+ ] as unknown as Anthropic.MessageCreateParams['tools']
361
+ params.output_config = {
362
+ ...(params.output_config ?? {}),
363
+ format: { type: 'json_schema', schema: schema.jsonSchema },
364
+ }
365
+
366
+ if (useMcpBeta) {
367
+ params.mcp_servers = mcpServers.map((s) => {
368
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
369
+ type: 'url',
370
+ name: s.name,
371
+ url: s.url,
372
+ }
373
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
374
+ return def
375
+ })
376
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
377
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
378
+ ? [...baseBetas]
379
+ : [...baseBetas, 'mcp-client-2025-11-20']
380
+ }
381
+ const response: Anthropic.Message = needsBetaRouting(params)
382
+ ? ((await this.client.beta.messages.create(
383
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
384
+ reqOpts(options),
385
+ )) as unknown as Anthropic.Message)
386
+ : await this.client.messages.create(params, reqOpts(options))
387
+ addUsage(aggregated, response.usage)
388
+ lastStopReason = response.stop_reason ?? null
389
+
390
+ workingMessages.push({
391
+ role: 'assistant',
392
+ content: fromAnthropicContent(response.content),
393
+ })
394
+
395
+ if (response.stop_reason !== 'tool_use') {
396
+ const text = collectText(response.content)
397
+ return {
398
+ value: parseGenerated(text, schema),
399
+ text,
400
+ messages: workingMessages,
401
+ iterations,
402
+ stopReason: lastStopReason ?? 'end_turn',
403
+ usage: aggregated,
404
+ }
405
+ }
406
+
407
+ const toolUseBlocks = response.content.filter(
408
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
409
+ )
410
+ const resultBlocks: ContentBlock[] = []
411
+ for (const block of toolUseBlocks) {
412
+ const { content, isError } = await runToolWithRecovery(
413
+ toolMap.get(block.name),
414
+ block.name,
415
+ block.id,
416
+ block.input,
417
+ options,
418
+ )
419
+ const resultBlock: ToolResultBlock = {
420
+ type: 'tool_result',
421
+ toolUseId: block.id,
422
+ content,
423
+ ...(isError ? { isError: true } : {}),
424
+ }
425
+ resultBlocks.push(resultBlock)
426
+ }
427
+ workingMessages.push({ role: 'user', content: resultBlocks })
428
+
429
+ iterations++
430
+ if (iterations >= maxIterations) {
431
+ const text = collectText(response.content)
432
+ // Last turn was a tool_use response, so text may be empty —
433
+ // surface what we have but the value will likely fail parse.
434
+ return {
435
+ value: parseGenerated(text, schema),
436
+ text,
437
+ messages: workingMessages,
438
+ iterations,
439
+ stopReason: 'max_iterations',
440
+ usage: aggregated,
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ async *streamWithTools(
447
+ messages: readonly Message[],
448
+ tools: readonly Tool[],
449
+ options: RunWithToolsOptions = {},
450
+ ): AsyncIterable<AgentStreamEvent> {
451
+ const maxIterations = options.maxIterations ?? 10
452
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
453
+ const workingMessages: Message[] = [...messages]
454
+ const aggregated: ChatUsage = {
455
+ inputTokens: 0,
456
+ outputTokens: 0,
457
+ cacheReadTokens: 0,
458
+ cacheCreationTokens: 0,
459
+ }
460
+ let iterations = 0
461
+
462
+ const mcpServers = options.mcpServers ?? []
463
+ const useMcpBeta = mcpServers.length > 0
464
+
465
+ while (true) {
466
+ checkAborted(options.signal)
467
+ yield { type: 'iteration_start', iteration: iterations }
468
+
469
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
470
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
471
+ }
472
+ params.tools = [
473
+ // Server tools placed first when present (from buildParams).
474
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
475
+ ...tools.map((t) => ({
476
+ name: t.name,
477
+ description: t.description,
478
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
479
+ })),
480
+ ...mcpServers
481
+ .filter((s) => s.tools?.enabled !== false)
482
+ .map((s) => ({
483
+ type: 'mcp_toolset' as const,
484
+ mcp_server_name: s.name,
485
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
486
+ })),
487
+ ] as unknown as Anthropic.MessageCreateParams['tools']
488
+
489
+ if (useMcpBeta) {
490
+ params.mcp_servers = mcpServers.map((s) => {
491
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
492
+ type: 'url',
493
+ name: s.name,
494
+ url: s.url,
495
+ }
496
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
497
+ return def
498
+ })
499
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
500
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
501
+ ? [...baseBetas]
502
+ : [...baseBetas, 'mcp-client-2025-11-20']
503
+ }
504
+
505
+ const stream = needsBetaRouting(params)
506
+ ? this.client.beta.messages.stream(
507
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
508
+ reqOpts(options),
509
+ )
510
+ : this.client.messages.stream(params, reqOpts(options))
511
+
512
+ // Track tool_use content blocks by their stream index so
513
+ // `input_json_delta` events can be paired with the correct id.
514
+ // Anthropic's streaming protocol issues a `content_block_start`
515
+ // carrying the tool's id + name, then a sequence of
516
+ // `input_json_delta`s with `partial_json` chunks, then a
517
+ // `content_block_stop`.
518
+ const toolBlockIdByIndex = new Map<number, string>()
519
+ for await (const event of stream) {
520
+ if (
521
+ event.type === 'content_block_start' &&
522
+ event.content_block.type === 'tool_use'
523
+ ) {
524
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
525
+ yield {
526
+ type: 'tool_use_start',
527
+ id: event.content_block.id,
528
+ name: event.content_block.name,
529
+ }
530
+ } else if (event.type === 'content_block_delta') {
531
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
532
+ yield { type: 'text', delta: event.delta.text }
533
+ } else if (event.delta.type === 'input_json_delta') {
534
+ const id = toolBlockIdByIndex.get(event.index)
535
+ if (id !== undefined && event.delta.partial_json.length > 0) {
536
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
537
+ }
538
+ }
539
+ }
540
+ }
541
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
542
+ addUsage(aggregated, final.usage)
543
+ const finishReason: string | null = final.stop_reason ?? null
544
+
545
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
546
+
547
+ workingMessages.push({
548
+ role: 'assistant',
549
+ content: fromAnthropicContent(final.content),
550
+ })
551
+
552
+ if (final.stop_reason !== 'tool_use') {
553
+ yield {
554
+ type: 'stop',
555
+ stopReason: finishReason ?? 'end_turn',
556
+ iterations,
557
+ usage: aggregated,
558
+ messages: workingMessages,
559
+ }
560
+ return
561
+ }
562
+
563
+ const toolUseBlocks = final.content.filter(
564
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
565
+ )
566
+ const resultBlocks: ContentBlock[] = []
567
+ for (const block of toolUseBlocks) {
568
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
569
+ const { content, isError } = await runToolWithRecovery(
570
+ toolMap.get(block.name),
571
+ block.name,
572
+ block.id,
573
+ block.input,
574
+ options,
575
+ )
576
+ resultBlocks.push({
577
+ type: 'tool_result',
578
+ toolUseId: block.id,
579
+ content,
580
+ ...(isError ? { isError: true } : {}),
581
+ } satisfies ToolResultBlock)
582
+ yield {
583
+ type: 'tool_result',
584
+ id: block.id,
585
+ name: block.name,
586
+ content,
587
+ isError,
588
+ }
589
+ }
590
+ workingMessages.push({ role: 'user', content: resultBlocks })
591
+
592
+ iterations++
593
+ if (iterations >= maxIterations) {
594
+ yield {
595
+ type: 'stop',
596
+ stopReason: 'max_iterations',
597
+ iterations,
598
+ usage: aggregated,
599
+ messages: workingMessages,
600
+ }
601
+ return
602
+ }
603
+ }
604
+ }
605
+
606
+ async *streamWithToolsAndSchema<T>(
607
+ messages: readonly Message[],
608
+ tools: readonly Tool[],
609
+ schema: OutputSchema<T>,
610
+ options: RunWithToolsOptions = {},
611
+ ): AsyncIterable<AgentStreamEvent<T>> {
612
+ const maxIterations = options.maxIterations ?? 10
613
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
614
+ const workingMessages: Message[] = [...messages]
615
+ const aggregated: ChatUsage = {
616
+ inputTokens: 0,
617
+ outputTokens: 0,
618
+ cacheReadTokens: 0,
619
+ cacheCreationTokens: 0,
620
+ }
621
+ let iterations = 0
622
+
623
+ const mcpServers = options.mcpServers ?? []
624
+ const useMcpBeta = mcpServers.length > 0
625
+
626
+ while (true) {
627
+ checkAborted(options.signal)
628
+ yield { type: 'iteration_start', iteration: iterations }
629
+
630
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
631
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
632
+ }
633
+ params.tools = [
634
+ // Server tools placed first when present (from buildParams).
635
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
636
+ ...tools.map((t) => ({
637
+ name: t.name,
638
+ description: t.description,
639
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
640
+ })),
641
+ ...mcpServers
642
+ .filter((s) => s.tools?.enabled !== false)
643
+ .map((s) => ({
644
+ type: 'mcp_toolset' as const,
645
+ mcp_server_name: s.name,
646
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
647
+ })),
648
+ ] as unknown as Anthropic.MessageCreateParams['tools']
649
+ params.output_config = {
650
+ ...(params.output_config ?? {}),
651
+ format: { type: 'json_schema', schema: schema.jsonSchema },
652
+ }
653
+
654
+ if (useMcpBeta) {
655
+ params.mcp_servers = mcpServers.map((s) => {
656
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
657
+ type: 'url',
658
+ name: s.name,
659
+ url: s.url,
660
+ }
661
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
662
+ return def
663
+ })
664
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
665
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
666
+ ? [...baseBetas]
667
+ : [...baseBetas, 'mcp-client-2025-11-20']
668
+ }
669
+
670
+ const stream = needsBetaRouting(params)
671
+ ? this.client.beta.messages.stream(
672
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
673
+ reqOpts(options),
674
+ )
675
+ : this.client.messages.stream(params, reqOpts(options))
676
+
677
+ // Track tool_use content blocks by their stream index so
678
+ // `input_json_delta` events can be paired with the correct id.
679
+ // Anthropic's streaming protocol issues a `content_block_start`
680
+ // carrying the tool's id + name, then a sequence of
681
+ // `input_json_delta`s with `partial_json` chunks, then a
682
+ // `content_block_stop`.
683
+ const toolBlockIdByIndex = new Map<number, string>()
684
+ for await (const event of stream) {
685
+ if (
686
+ event.type === 'content_block_start' &&
687
+ event.content_block.type === 'tool_use'
688
+ ) {
689
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
690
+ yield {
691
+ type: 'tool_use_start',
692
+ id: event.content_block.id,
693
+ name: event.content_block.name,
694
+ }
695
+ } else if (event.type === 'content_block_delta') {
696
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
697
+ yield { type: 'text', delta: event.delta.text }
698
+ } else if (event.delta.type === 'input_json_delta') {
699
+ const id = toolBlockIdByIndex.get(event.index)
700
+ if (id !== undefined && event.delta.partial_json.length > 0) {
701
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
702
+ }
703
+ }
704
+ }
705
+ }
706
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
707
+ addUsage(aggregated, final.usage)
708
+ const finishReason: string | null = final.stop_reason ?? null
709
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
710
+
711
+ workingMessages.push({
712
+ role: 'assistant',
713
+ content: fromAnthropicContent(final.content),
714
+ })
715
+
716
+ if (final.stop_reason !== 'tool_use') {
717
+ const text = collectText(final.content)
718
+ const value = parseGenerated(text, schema)
719
+ yield {
720
+ type: 'stop',
721
+ stopReason: finishReason ?? 'end_turn',
722
+ iterations,
723
+ usage: aggregated,
724
+ messages: workingMessages,
725
+ value,
726
+ text,
727
+ } as AgentStreamEvent<T>
728
+ return
729
+ }
730
+
731
+ const toolUseBlocks = final.content.filter(
732
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
733
+ )
734
+ const resultBlocks: ContentBlock[] = []
735
+ for (const block of toolUseBlocks) {
736
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
737
+ const { content, isError } = await runToolWithRecovery(
738
+ toolMap.get(block.name),
739
+ block.name,
740
+ block.id,
741
+ block.input,
742
+ options,
743
+ )
744
+ resultBlocks.push({
745
+ type: 'tool_result',
746
+ toolUseId: block.id,
747
+ content,
748
+ ...(isError ? { isError: true } : {}),
749
+ } satisfies ToolResultBlock)
750
+ yield {
751
+ type: 'tool_result',
752
+ id: block.id,
753
+ name: block.name,
754
+ content,
755
+ isError,
756
+ }
757
+ }
758
+ workingMessages.push({ role: 'user', content: resultBlocks })
759
+
760
+ iterations++
761
+ if (iterations >= maxIterations) {
762
+ const text = collectText(final.content)
763
+ const value = parseGenerated(text, schema)
764
+ yield {
765
+ type: 'stop',
766
+ stopReason: 'max_iterations',
767
+ iterations,
768
+ usage: aggregated,
769
+ messages: workingMessages,
770
+ value,
771
+ text,
772
+ } as AgentStreamEvent<T>
773
+ return
774
+ }
775
+ }
776
+ }
777
+
268
778
  async generate<T>(
269
779
  messages: readonly Message[],
270
780
  schema: OutputSchema<T>,
@@ -275,7 +785,7 @@ export class AnthropicProvider implements Provider {
275
785
  ...(params.output_config ?? {}),
276
786
  format: { type: 'json_schema', schema: schema.jsonSchema },
277
787
  }
278
- const response = await this.client.messages.create(params)
788
+ const response = await this.client.messages.create(params, reqOpts(options))
279
789
  const text = collectText(response.content)
280
790
  const value = parseGenerated(text, schema)
281
791
  return {
@@ -320,11 +830,34 @@ export class AnthropicProvider implements Provider {
320
830
  ;(params as { cache_control?: { type: 'ephemeral' } }).cache_control = EPHEMERAL_CACHE
321
831
  }
322
832
 
323
- const betas = mergeBetas(this.betas, options.betas)
833
+ // Compaction emits the beta `edits` entry + flips the
834
+ // `compact-2026-01-12` beta header so the request goes through
835
+ // the SDK's beta surface (same routing as MCP).
836
+ const baseBetas = mergeBetas(this.betas, options.betas)
837
+ const betas = options.compact !== undefined
838
+ ? mergeBetas(baseBetas, [COMPACT_BETA])
839
+ : baseBetas
840
+ if (options.compact !== undefined) {
841
+ const edit: Record<string, unknown> = { type: COMPACT_EDIT_TYPE }
842
+ if (options.compact.trigger !== undefined) {
843
+ edit.trigger = { type: 'input_tokens', value: options.compact.trigger }
844
+ }
845
+ if (options.compact.instructions !== undefined) {
846
+ edit.instructions = options.compact.instructions
847
+ }
848
+ if (options.compact.pauseAfterCompaction !== undefined) {
849
+ edit.pause_after_compaction = options.compact.pauseAfterCompaction
850
+ }
851
+ ;(params as { edits?: unknown[] }).edits = [edit]
852
+ }
324
853
  if (betas.length > 0) {
325
854
  ;(params as { betas?: readonly string[] }).betas = betas
326
855
  }
327
856
 
857
+ if (options.serverTools && options.serverTools.length > 0) {
858
+ params.tools = anthropicServerTools(options.serverTools)
859
+ }
860
+
328
861
  return params
329
862
  }
330
863
 
@@ -333,18 +866,60 @@ export class AnthropicProvider implements Provider {
333
866
  .filter((b): b is Anthropic.TextBlock => b.type === 'text')
334
867
  .map((b) => b.text)
335
868
  .join('')
336
- return {
869
+ const result: ChatResult<Anthropic.Message> = {
337
870
  text,
338
871
  model: message.model,
339
872
  stopReason: message.stop_reason,
340
873
  usage: toUsage(message.usage),
341
874
  raw: message,
342
875
  }
876
+ // Surface structured content when the turn carries blocks
877
+ // beyond plain text (compaction today; reasoning blocks in a
878
+ // future slice). Apps that persist conversations push this
879
+ // onto the message history so round-trippable blocks survive
880
+ // subsequent requests.
881
+ const blocks = fromAnthropicContent(message.content)
882
+ if (blocks.some((b) => b.type !== 'text')) {
883
+ result.content = blocks
884
+ }
885
+ return result
343
886
  }
344
887
  }
345
888
 
346
889
  // ─── Shape converters ─────────────────────────────────────────────────────
347
890
 
891
+ /** Compaction beta — required header + `edits[].type` for `compact-2026-01-12`. */
892
+ const COMPACT_BETA = 'compact-2026-01-12'
893
+ const COMPACT_EDIT_TYPE = 'compact_20260112'
894
+
895
+ /**
896
+ * Whether the request needs to flow through `client.beta.messages.create`
897
+ * instead of the stable surface. Triggered by:
898
+ *
899
+ * - `edits[]` (compaction).
900
+ * - `mcp_servers[]` (server-side MCP).
901
+ *
902
+ * Tests typically stub `client.messages.create`; the beta path uses the
903
+ * stub that lives at `client.beta.messages.create`.
904
+ */
905
+ function needsBetaRouting(params: Anthropic.MessageCreateParamsNonStreaming): boolean {
906
+ const p = params as { edits?: unknown[]; mcp_servers?: unknown[] }
907
+ return (p.edits !== undefined && p.edits.length > 0)
908
+ || (p.mcp_servers !== undefined && p.mcp_servers.length > 0)
909
+ }
910
+
911
+ /** Build the request-options bag forwarded to the SDK. Only `signal` for now. */
912
+ function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
913
+ return options.signal !== undefined ? { signal: options.signal } : undefined
914
+ }
915
+
916
+ /** Throw a DOMException-shaped abort error if the signal has fired. */
917
+ function checkAborted(signal: AbortSignal | undefined): void {
918
+ if (signal?.aborted) {
919
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError')
920
+ }
921
+ }
922
+
348
923
  function toUsage(u: Anthropic.Usage): ChatUsage {
349
924
  return {
350
925
  inputTokens: u.input_tokens,
@@ -392,6 +967,54 @@ function toMessageParam(message: Message): Anthropic.MessageParam {
392
967
  if (block.isError) param.is_error = true
393
968
  return param
394
969
  }
970
+ if (block.type === 'image') {
971
+ return {
972
+ type: 'image',
973
+ source:
974
+ block.source.type === 'base64'
975
+ ? {
976
+ type: 'base64',
977
+ media_type:
978
+ block.source.mediaType as Anthropic.Base64ImageSource['media_type'],
979
+ data: block.source.data,
980
+ }
981
+ : { type: 'url', url: block.source.url },
982
+ } satisfies Anthropic.ImageBlockParam
983
+ }
984
+ if (block.type === 'document') {
985
+ const documentParam: Anthropic.DocumentBlockParam = {
986
+ type: 'document',
987
+ source:
988
+ block.source.type === 'base64'
989
+ ? {
990
+ type: 'base64',
991
+ media_type: 'application/pdf',
992
+ data: block.source.data,
993
+ }
994
+ : { type: 'url', url: block.source.url },
995
+ }
996
+ if (block.title !== undefined) documentParam.title = block.title
997
+ return documentParam
998
+ }
999
+ if (block.type === 'audio') {
1000
+ throw new BrainError(
1001
+ "AnthropicProvider: audio blocks are not supported. Anthropic's SDK does not expose an audio block type for chat messages. Route audio workloads to Gemini, or transcribe upstream and pass the text.",
1002
+ { context: { provider: 'anthropic' } },
1003
+ )
1004
+ }
1005
+ if (block.type === 'compaction') {
1006
+ // Round-trip the compaction block verbatim — the server uses
1007
+ // the opaque `encrypted_content` to stitch prior compactions
1008
+ // together; mutating either field would invalidate the
1009
+ // history. Untyped on the stable SDK surface; cast through
1010
+ // the beta type shape.
1011
+ const param: Record<string, unknown> = { type: 'compaction' }
1012
+ if (block.content !== null) param.content = block.content
1013
+ if (block.encryptedContent !== null) {
1014
+ param.encrypted_content = block.encryptedContent
1015
+ }
1016
+ return param as unknown as Anthropic.ContentBlockParam
1017
+ }
395
1018
  const text: Anthropic.TextBlockParam = { type: 'text', text: block.text }
396
1019
  if (block.cache) text.cache_control = EPHEMERAL_CACHE
397
1020
  return text
@@ -416,6 +1039,61 @@ function toSystemParam(
416
1039
  return [param]
417
1040
  }
418
1041
 
1042
+ /**
1043
+ * Translate framework `ServerTool[]` into Anthropic's typed
1044
+ * server-tool entries. Uses the latest SDK-known versions; the
1045
+ * Anthropic backend is backward-compatible to older clients
1046
+ * pinning earlier dates, but we standardize on current. Web fetch
1047
+ * is Anthropic-only; `url_context` is rejected (Gemini-only).
1048
+ */
1049
+ function anthropicServerTools(serverTools: readonly ServerTool[]): Anthropic.ToolUnion[] {
1050
+ const out: Anthropic.ToolUnion[] = []
1051
+ for (const t of serverTools) {
1052
+ if (t.type === 'web_search') {
1053
+ const tool: Anthropic.WebSearchTool20260209 = {
1054
+ type: 'web_search_20260209',
1055
+ name: 'web_search',
1056
+ }
1057
+ if (t.maxUses !== undefined) {
1058
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
1059
+ }
1060
+ if (t.allowedDomains !== undefined) {
1061
+ tool.allowed_domains = [...t.allowedDomains]
1062
+ }
1063
+ if (t.blockedDomains !== undefined) {
1064
+ tool.blocked_domains = [...t.blockedDomains]
1065
+ }
1066
+ out.push(tool)
1067
+ } else if (t.type === 'code_execution') {
1068
+ out.push({
1069
+ type: 'code_execution_20260120',
1070
+ name: 'code_execution',
1071
+ } satisfies Anthropic.CodeExecutionTool20260120)
1072
+ } else if (t.type === 'web_fetch') {
1073
+ const tool: Anthropic.WebFetchTool20260309 = {
1074
+ type: 'web_fetch_20260309',
1075
+ name: 'web_fetch',
1076
+ }
1077
+ if (t.maxUses !== undefined) {
1078
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
1079
+ }
1080
+ if (t.allowedDomains !== undefined) {
1081
+ tool.allowed_domains = [...t.allowedDomains]
1082
+ }
1083
+ if (t.blockedDomains !== undefined) {
1084
+ tool.blocked_domains = [...t.blockedDomains]
1085
+ }
1086
+ out.push(tool)
1087
+ } else if (t.type === 'url_context') {
1088
+ throw new BrainError(
1089
+ 'AnthropicProvider: server tool `url_context` is Gemini-only. Use `web_fetch` for Anthropic or route the call to Gemini.',
1090
+ { context: { provider: 'anthropic' } },
1091
+ )
1092
+ }
1093
+ }
1094
+ return out
1095
+ }
1096
+
419
1097
  function mergeBetas(
420
1098
  providerBetas: readonly string[],
421
1099
  callBetas: readonly string[] | undefined,
@@ -503,6 +1181,13 @@ function fromAnthropicContent(
503
1181
  }
504
1182
  if (r.is_error) result.isError = true
505
1183
  out.push(result)
1184
+ } else if (block.type === 'compaction') {
1185
+ const c = block as { content?: string | null; encrypted_content?: string | null }
1186
+ out.push({
1187
+ type: 'compaction',
1188
+ content: c.content ?? null,
1189
+ encryptedContent: c.encrypted_content ?? null,
1190
+ } satisfies CompactionBlock)
506
1191
  }
507
1192
  }
508
1193
  return out