@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,9 +27,9 @@ 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 { BrainError } from '../brain_error.ts'
30
31
  import type { Provider, RunWithToolsOptions } from '../provider.ts'
31
32
  import type { Tool } from '../tool.ts'
32
- import { ToolExecutionError } from '../tool_execution_error.ts'
33
33
  import type {
34
34
  ChatOptions,
35
35
  ChatResult,
@@ -39,13 +39,17 @@ import type {
39
39
  MCPToolResultBlock,
40
40
  MCPToolUseBlock,
41
41
  Message,
42
+ ServerTool,
42
43
  StreamEvent,
43
44
  SystemPrompt,
44
45
  TextBlock,
45
46
  ToolResultBlock,
46
47
  ToolUseBlock,
47
48
  } from '../types.ts'
49
+ import type { AgentGenerateResult } from '../agent_generate_result.ts'
50
+ import type { AgentStreamEvent } from '../agent_stream_event.ts'
48
51
  import { parseGenerated, type OutputSchema } from '../output_schema.ts'
52
+ import { runToolWithRecovery } from '../tool_runner.ts'
49
53
 
50
54
  const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
51
55
 
@@ -78,7 +82,7 @@ export class AnthropicProvider implements Provider {
78
82
 
79
83
  async chat(messages: readonly Message[], options: ChatOptions = {}): Promise<ChatResult> {
80
84
  const params = this.buildParams(messages, options)
81
- const response = await this.client.messages.create(params)
85
+ const response = await this.client.messages.create(params, reqOpts(options))
82
86
  return this.toChatResult(response)
83
87
  }
84
88
 
@@ -87,7 +91,7 @@ export class AnthropicProvider implements Provider {
87
91
  options: ChatOptions = {},
88
92
  ): AsyncIterable<StreamEvent> {
89
93
  const params = this.buildParams(messages, options)
90
- const stream = this.client.messages.stream(params)
94
+ const stream = this.client.messages.stream(params, reqOpts(options))
91
95
  for await (const event of stream) {
92
96
  if (
93
97
  event.type === 'content_block_delta' &&
@@ -111,12 +115,15 @@ export class AnthropicProvider implements Provider {
111
115
  const base = this.buildParams(messages, options)
112
116
  // count_tokens only accepts a subset of MessageCreateParams; build
113
117
  // 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
- })
118
+ const result = await this.client.messages.countTokens(
119
+ {
120
+ model: base.model,
121
+ messages: base.messages,
122
+ ...(base.system !== undefined ? { system: base.system } : {}),
123
+ ...(base.thinking !== undefined ? { thinking: base.thinking } : {}),
124
+ },
125
+ reqOpts(options),
126
+ )
120
127
  return result.input_tokens
121
128
  }
122
129
 
@@ -151,10 +158,13 @@ export class AnthropicProvider implements Provider {
151
158
  const useMcpBeta = mcpServers.length > 0
152
159
 
153
160
  while (true) {
161
+ checkAborted(options.signal)
154
162
  const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
155
163
  mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
156
164
  }
157
165
  params.tools = [
166
+ // Server tools placed first when present (from buildParams).
167
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
158
168
  ...tools.map((t) => ({
159
169
  name: t.name,
160
170
  description: t.description,
@@ -193,9 +203,10 @@ export class AnthropicProvider implements Provider {
193
203
  : [...baseBetas, 'mcp-client-2025-11-20']
194
204
  response = (await this.client.beta.messages.create(
195
205
  params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
206
+ reqOpts(options),
196
207
  )) as unknown as Anthropic.Message
197
208
  } else {
198
- response = await this.client.messages.create(params)
209
+ response = await this.client.messages.create(params, reqOpts(options))
199
210
  }
200
211
  addUsage(aggregated, response.usage)
201
212
  lastStopReason = response.stop_reason ?? null
@@ -226,27 +237,142 @@ export class AnthropicProvider implements Provider {
226
237
  )
227
238
  const resultBlocks: ContentBlock[] = []
228
239
  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
- )
240
+ const { content, isError } = await runToolWithRecovery(
241
+ toolMap.get(block.name),
242
+ block.name,
243
+ block.id,
244
+ block.input,
245
+ options,
246
+ )
247
+ const resultBlock: ToolResultBlock = {
248
+ type: 'tool_result',
249
+ toolUseId: block.id,
250
+ content,
251
+ ...(isError ? { isError: true } : {}),
252
+ }
253
+ resultBlocks.push(resultBlock)
254
+ }
255
+ workingMessages.push({ role: 'user', content: resultBlocks })
256
+
257
+ iterations++
258
+ if (iterations >= maxIterations) {
259
+ return {
260
+ text: collectText(response.content),
261
+ messages: workingMessages,
262
+ iterations,
263
+ stopReason: 'max_iterations',
264
+ usage: aggregated,
236
265
  }
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)
266
+ }
267
+ }
268
+ }
269
+
270
+ async runWithToolsAndSchema<T>(
271
+ messages: readonly Message[],
272
+ tools: readonly Tool[],
273
+ schema: OutputSchema<T>,
274
+ options: RunWithToolsOptions = {},
275
+ ): Promise<AgentGenerateResult<T>> {
276
+ const maxIterations = options.maxIterations ?? 10
277
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
278
+ const workingMessages: Message[] = [...messages]
279
+ const aggregated: ChatUsage = {
280
+ inputTokens: 0,
281
+ outputTokens: 0,
282
+ cacheReadTokens: 0,
283
+ cacheCreationTokens: 0,
284
+ }
285
+ let iterations = 0
286
+ let lastStopReason: string | null = null
287
+
288
+ const mcpServers = options.mcpServers ?? []
289
+ const useMcpBeta = mcpServers.length > 0
290
+
291
+ while (true) {
292
+ checkAborted(options.signal)
293
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
294
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
295
+ }
296
+ params.tools = [
297
+ // Server tools placed first when present (from buildParams).
298
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
299
+ ...tools.map((t) => ({
300
+ name: t.name,
301
+ description: t.description,
302
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
303
+ })),
304
+ ...mcpServers
305
+ .filter((s) => s.tools?.enabled !== false)
306
+ .map((s) => ({
307
+ type: 'mcp_toolset' as const,
308
+ mcp_server_name: s.name,
309
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
310
+ })),
311
+ ] as unknown as Anthropic.MessageCreateParams['tools']
312
+ params.output_config = {
313
+ ...(params.output_config ?? {}),
314
+ format: { type: 'json_schema', schema: schema.jsonSchema },
315
+ }
316
+
317
+ let response: Anthropic.Message
318
+ if (useMcpBeta) {
319
+ params.mcp_servers = mcpServers.map((s) => {
320
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
321
+ type: 'url',
322
+ name: s.name,
323
+ url: s.url,
324
+ }
325
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
326
+ return def
327
+ })
328
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
329
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
330
+ ? [...baseBetas]
331
+ : [...baseBetas, 'mcp-client-2025-11-20']
332
+ response = (await this.client.beta.messages.create(
333
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
334
+ reqOpts(options),
335
+ )) as unknown as Anthropic.Message
336
+ } else {
337
+ response = await this.client.messages.create(params, reqOpts(options))
338
+ }
339
+ addUsage(aggregated, response.usage)
340
+ lastStopReason = response.stop_reason ?? null
341
+
342
+ workingMessages.push({
343
+ role: 'assistant',
344
+ content: fromAnthropicContent(response.content),
345
+ })
346
+
347
+ if (response.stop_reason !== 'tool_use') {
348
+ const text = collectText(response.content)
349
+ return {
350
+ value: parseGenerated(text, schema),
351
+ text,
352
+ messages: workingMessages,
353
+ iterations,
354
+ stopReason: lastStopReason ?? 'end_turn',
355
+ usage: aggregated,
245
356
  }
357
+ }
358
+
359
+ const toolUseBlocks = response.content.filter(
360
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
361
+ )
362
+ const resultBlocks: ContentBlock[] = []
363
+ for (const block of toolUseBlocks) {
364
+ const { content, isError } = await runToolWithRecovery(
365
+ toolMap.get(block.name),
366
+ block.name,
367
+ block.id,
368
+ block.input,
369
+ options,
370
+ )
246
371
  const resultBlock: ToolResultBlock = {
247
372
  type: 'tool_result',
248
373
  toolUseId: block.id,
249
- content: typeof output === 'string' ? output : JSON.stringify(output),
374
+ content,
375
+ ...(isError ? { isError: true } : {}),
250
376
  }
251
377
  resultBlocks.push(resultBlock)
252
378
  }
@@ -254,8 +380,12 @@ export class AnthropicProvider implements Provider {
254
380
 
255
381
  iterations++
256
382
  if (iterations >= maxIterations) {
383
+ const text = collectText(response.content)
384
+ // Last turn was a tool_use response, so text may be empty —
385
+ // surface what we have but the value will likely fail parse.
257
386
  return {
258
- text: collectText(response.content),
387
+ value: parseGenerated(text, schema),
388
+ text,
259
389
  messages: workingMessages,
260
390
  iterations,
261
391
  stopReason: 'max_iterations',
@@ -265,6 +395,338 @@ export class AnthropicProvider implements Provider {
265
395
  }
266
396
  }
267
397
 
398
+ async *streamWithTools(
399
+ messages: readonly Message[],
400
+ tools: readonly Tool[],
401
+ options: RunWithToolsOptions = {},
402
+ ): AsyncIterable<AgentStreamEvent> {
403
+ const maxIterations = options.maxIterations ?? 10
404
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
405
+ const workingMessages: Message[] = [...messages]
406
+ const aggregated: ChatUsage = {
407
+ inputTokens: 0,
408
+ outputTokens: 0,
409
+ cacheReadTokens: 0,
410
+ cacheCreationTokens: 0,
411
+ }
412
+ let iterations = 0
413
+
414
+ const mcpServers = options.mcpServers ?? []
415
+ const useMcpBeta = mcpServers.length > 0
416
+
417
+ while (true) {
418
+ checkAborted(options.signal)
419
+ yield { type: 'iteration_start', iteration: iterations }
420
+
421
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
422
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
423
+ }
424
+ params.tools = [
425
+ // Server tools placed first when present (from buildParams).
426
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
427
+ ...tools.map((t) => ({
428
+ name: t.name,
429
+ description: t.description,
430
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
431
+ })),
432
+ ...mcpServers
433
+ .filter((s) => s.tools?.enabled !== false)
434
+ .map((s) => ({
435
+ type: 'mcp_toolset' as const,
436
+ mcp_server_name: s.name,
437
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
438
+ })),
439
+ ] as unknown as Anthropic.MessageCreateParams['tools']
440
+
441
+ if (useMcpBeta) {
442
+ params.mcp_servers = mcpServers.map((s) => {
443
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
444
+ type: 'url',
445
+ name: s.name,
446
+ url: s.url,
447
+ }
448
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
449
+ return def
450
+ })
451
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
452
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
453
+ ? [...baseBetas]
454
+ : [...baseBetas, 'mcp-client-2025-11-20']
455
+ }
456
+
457
+ const stream = useMcpBeta
458
+ ? this.client.beta.messages.stream(
459
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
460
+ reqOpts(options),
461
+ )
462
+ : this.client.messages.stream(params, reqOpts(options))
463
+
464
+ // Track tool_use content blocks by their stream index so
465
+ // `input_json_delta` events can be paired with the correct id.
466
+ // Anthropic's streaming protocol issues a `content_block_start`
467
+ // carrying the tool's id + name, then a sequence of
468
+ // `input_json_delta`s with `partial_json` chunks, then a
469
+ // `content_block_stop`.
470
+ const toolBlockIdByIndex = new Map<number, string>()
471
+ for await (const event of stream) {
472
+ if (
473
+ event.type === 'content_block_start' &&
474
+ event.content_block.type === 'tool_use'
475
+ ) {
476
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
477
+ yield {
478
+ type: 'tool_use_start',
479
+ id: event.content_block.id,
480
+ name: event.content_block.name,
481
+ }
482
+ } else if (event.type === 'content_block_delta') {
483
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
484
+ yield { type: 'text', delta: event.delta.text }
485
+ } else if (event.delta.type === 'input_json_delta') {
486
+ const id = toolBlockIdByIndex.get(event.index)
487
+ if (id !== undefined && event.delta.partial_json.length > 0) {
488
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
489
+ }
490
+ }
491
+ }
492
+ }
493
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
494
+ addUsage(aggregated, final.usage)
495
+ const finishReason: string | null = final.stop_reason ?? null
496
+
497
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
498
+
499
+ workingMessages.push({
500
+ role: 'assistant',
501
+ content: fromAnthropicContent(final.content),
502
+ })
503
+
504
+ if (final.stop_reason !== 'tool_use') {
505
+ yield {
506
+ type: 'stop',
507
+ stopReason: finishReason ?? 'end_turn',
508
+ iterations,
509
+ usage: aggregated,
510
+ messages: workingMessages,
511
+ }
512
+ return
513
+ }
514
+
515
+ const toolUseBlocks = final.content.filter(
516
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
517
+ )
518
+ const resultBlocks: ContentBlock[] = []
519
+ for (const block of toolUseBlocks) {
520
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
521
+ const { content, isError } = await runToolWithRecovery(
522
+ toolMap.get(block.name),
523
+ block.name,
524
+ block.id,
525
+ block.input,
526
+ options,
527
+ )
528
+ resultBlocks.push({
529
+ type: 'tool_result',
530
+ toolUseId: block.id,
531
+ content,
532
+ ...(isError ? { isError: true } : {}),
533
+ } satisfies ToolResultBlock)
534
+ yield {
535
+ type: 'tool_result',
536
+ id: block.id,
537
+ name: block.name,
538
+ content,
539
+ isError,
540
+ }
541
+ }
542
+ workingMessages.push({ role: 'user', content: resultBlocks })
543
+
544
+ iterations++
545
+ if (iterations >= maxIterations) {
546
+ yield {
547
+ type: 'stop',
548
+ stopReason: 'max_iterations',
549
+ iterations,
550
+ usage: aggregated,
551
+ messages: workingMessages,
552
+ }
553
+ return
554
+ }
555
+ }
556
+ }
557
+
558
+ async *streamWithToolsAndSchema<T>(
559
+ messages: readonly Message[],
560
+ tools: readonly Tool[],
561
+ schema: OutputSchema<T>,
562
+ options: RunWithToolsOptions = {},
563
+ ): AsyncIterable<AgentStreamEvent<T>> {
564
+ const maxIterations = options.maxIterations ?? 10
565
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
566
+ const workingMessages: Message[] = [...messages]
567
+ const aggregated: ChatUsage = {
568
+ inputTokens: 0,
569
+ outputTokens: 0,
570
+ cacheReadTokens: 0,
571
+ cacheCreationTokens: 0,
572
+ }
573
+ let iterations = 0
574
+
575
+ const mcpServers = options.mcpServers ?? []
576
+ const useMcpBeta = mcpServers.length > 0
577
+
578
+ while (true) {
579
+ checkAborted(options.signal)
580
+ yield { type: 'iteration_start', iteration: iterations }
581
+
582
+ const params = this.buildParams(workingMessages, options) as Anthropic.MessageCreateParamsNonStreaming & {
583
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
584
+ }
585
+ params.tools = [
586
+ // Server tools placed first when present (from buildParams).
587
+ ...((params.tools ?? []) as Anthropic.ToolUnion[]),
588
+ ...tools.map((t) => ({
589
+ name: t.name,
590
+ description: t.description,
591
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
592
+ })),
593
+ ...mcpServers
594
+ .filter((s) => s.tools?.enabled !== false)
595
+ .map((s) => ({
596
+ type: 'mcp_toolset' as const,
597
+ mcp_server_name: s.name,
598
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
599
+ })),
600
+ ] as unknown as Anthropic.MessageCreateParams['tools']
601
+ params.output_config = {
602
+ ...(params.output_config ?? {}),
603
+ format: { type: 'json_schema', schema: schema.jsonSchema },
604
+ }
605
+
606
+ if (useMcpBeta) {
607
+ params.mcp_servers = mcpServers.map((s) => {
608
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
609
+ type: 'url',
610
+ name: s.name,
611
+ url: s.url,
612
+ }
613
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
614
+ return def
615
+ })
616
+ const baseBetas = (params as { betas?: readonly string[] }).betas ?? []
617
+ ;(params as { betas?: string[] }).betas = baseBetas.includes('mcp-client-2025-11-20')
618
+ ? [...baseBetas]
619
+ : [...baseBetas, 'mcp-client-2025-11-20']
620
+ }
621
+
622
+ const stream = useMcpBeta
623
+ ? this.client.beta.messages.stream(
624
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsStreaming,
625
+ reqOpts(options),
626
+ )
627
+ : this.client.messages.stream(params, reqOpts(options))
628
+
629
+ // Track tool_use content blocks by their stream index so
630
+ // `input_json_delta` events can be paired with the correct id.
631
+ // Anthropic's streaming protocol issues a `content_block_start`
632
+ // carrying the tool's id + name, then a sequence of
633
+ // `input_json_delta`s with `partial_json` chunks, then a
634
+ // `content_block_stop`.
635
+ const toolBlockIdByIndex = new Map<number, string>()
636
+ for await (const event of stream) {
637
+ if (
638
+ event.type === 'content_block_start' &&
639
+ event.content_block.type === 'tool_use'
640
+ ) {
641
+ toolBlockIdByIndex.set(event.index, event.content_block.id)
642
+ yield {
643
+ type: 'tool_use_start',
644
+ id: event.content_block.id,
645
+ name: event.content_block.name,
646
+ }
647
+ } else if (event.type === 'content_block_delta') {
648
+ if (event.delta.type === 'text_delta' && event.delta.text.length > 0) {
649
+ yield { type: 'text', delta: event.delta.text }
650
+ } else if (event.delta.type === 'input_json_delta') {
651
+ const id = toolBlockIdByIndex.get(event.index)
652
+ if (id !== undefined && event.delta.partial_json.length > 0) {
653
+ yield { type: 'tool_use_delta', id, argsDelta: event.delta.partial_json }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ const final = (await stream.finalMessage()) as unknown as Anthropic.Message
659
+ addUsage(aggregated, final.usage)
660
+ const finishReason: string | null = final.stop_reason ?? null
661
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
662
+
663
+ workingMessages.push({
664
+ role: 'assistant',
665
+ content: fromAnthropicContent(final.content),
666
+ })
667
+
668
+ if (final.stop_reason !== 'tool_use') {
669
+ const text = collectText(final.content)
670
+ const value = parseGenerated(text, schema)
671
+ yield {
672
+ type: 'stop',
673
+ stopReason: finishReason ?? 'end_turn',
674
+ iterations,
675
+ usage: aggregated,
676
+ messages: workingMessages,
677
+ value,
678
+ text,
679
+ } as AgentStreamEvent<T>
680
+ return
681
+ }
682
+
683
+ const toolUseBlocks = final.content.filter(
684
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
685
+ )
686
+ const resultBlocks: ContentBlock[] = []
687
+ for (const block of toolUseBlocks) {
688
+ yield { type: 'tool_use', id: block.id, name: block.name, input: block.input }
689
+ const { content, isError } = await runToolWithRecovery(
690
+ toolMap.get(block.name),
691
+ block.name,
692
+ block.id,
693
+ block.input,
694
+ options,
695
+ )
696
+ resultBlocks.push({
697
+ type: 'tool_result',
698
+ toolUseId: block.id,
699
+ content,
700
+ ...(isError ? { isError: true } : {}),
701
+ } satisfies ToolResultBlock)
702
+ yield {
703
+ type: 'tool_result',
704
+ id: block.id,
705
+ name: block.name,
706
+ content,
707
+ isError,
708
+ }
709
+ }
710
+ workingMessages.push({ role: 'user', content: resultBlocks })
711
+
712
+ iterations++
713
+ if (iterations >= maxIterations) {
714
+ const text = collectText(final.content)
715
+ const value = parseGenerated(text, schema)
716
+ yield {
717
+ type: 'stop',
718
+ stopReason: 'max_iterations',
719
+ iterations,
720
+ usage: aggregated,
721
+ messages: workingMessages,
722
+ value,
723
+ text,
724
+ } as AgentStreamEvent<T>
725
+ return
726
+ }
727
+ }
728
+ }
729
+
268
730
  async generate<T>(
269
731
  messages: readonly Message[],
270
732
  schema: OutputSchema<T>,
@@ -275,7 +737,7 @@ export class AnthropicProvider implements Provider {
275
737
  ...(params.output_config ?? {}),
276
738
  format: { type: 'json_schema', schema: schema.jsonSchema },
277
739
  }
278
- const response = await this.client.messages.create(params)
740
+ const response = await this.client.messages.create(params, reqOpts(options))
279
741
  const text = collectText(response.content)
280
742
  const value = parseGenerated(text, schema)
281
743
  return {
@@ -325,6 +787,10 @@ export class AnthropicProvider implements Provider {
325
787
  ;(params as { betas?: readonly string[] }).betas = betas
326
788
  }
327
789
 
790
+ if (options.serverTools && options.serverTools.length > 0) {
791
+ params.tools = anthropicServerTools(options.serverTools)
792
+ }
793
+
328
794
  return params
329
795
  }
330
796
 
@@ -345,6 +811,18 @@ export class AnthropicProvider implements Provider {
345
811
 
346
812
  // ─── Shape converters ─────────────────────────────────────────────────────
347
813
 
814
+ /** Build the request-options bag forwarded to the SDK. Only `signal` for now. */
815
+ function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
816
+ return options.signal !== undefined ? { signal: options.signal } : undefined
817
+ }
818
+
819
+ /** Throw a DOMException-shaped abort error if the signal has fired. */
820
+ function checkAborted(signal: AbortSignal | undefined): void {
821
+ if (signal?.aborted) {
822
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError')
823
+ }
824
+ }
825
+
348
826
  function toUsage(u: Anthropic.Usage): ChatUsage {
349
827
  return {
350
828
  inputTokens: u.input_tokens,
@@ -392,6 +870,41 @@ function toMessageParam(message: Message): Anthropic.MessageParam {
392
870
  if (block.isError) param.is_error = true
393
871
  return param
394
872
  }
873
+ if (block.type === 'image') {
874
+ return {
875
+ type: 'image',
876
+ source:
877
+ block.source.type === 'base64'
878
+ ? {
879
+ type: 'base64',
880
+ media_type:
881
+ block.source.mediaType as Anthropic.Base64ImageSource['media_type'],
882
+ data: block.source.data,
883
+ }
884
+ : { type: 'url', url: block.source.url },
885
+ } satisfies Anthropic.ImageBlockParam
886
+ }
887
+ if (block.type === 'document') {
888
+ const documentParam: Anthropic.DocumentBlockParam = {
889
+ type: 'document',
890
+ source:
891
+ block.source.type === 'base64'
892
+ ? {
893
+ type: 'base64',
894
+ media_type: 'application/pdf',
895
+ data: block.source.data,
896
+ }
897
+ : { type: 'url', url: block.source.url },
898
+ }
899
+ if (block.title !== undefined) documentParam.title = block.title
900
+ return documentParam
901
+ }
902
+ if (block.type === 'audio') {
903
+ throw new BrainError(
904
+ "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.",
905
+ { context: { provider: 'anthropic' } },
906
+ )
907
+ }
395
908
  const text: Anthropic.TextBlockParam = { type: 'text', text: block.text }
396
909
  if (block.cache) text.cache_control = EPHEMERAL_CACHE
397
910
  return text
@@ -416,6 +929,61 @@ function toSystemParam(
416
929
  return [param]
417
930
  }
418
931
 
932
+ /**
933
+ * Translate framework `ServerTool[]` into Anthropic's typed
934
+ * server-tool entries. Uses the latest SDK-known versions; the
935
+ * Anthropic backend is backward-compatible to older clients
936
+ * pinning earlier dates, but we standardize on current. Web fetch
937
+ * is Anthropic-only; `url_context` is rejected (Gemini-only).
938
+ */
939
+ function anthropicServerTools(serverTools: readonly ServerTool[]): Anthropic.ToolUnion[] {
940
+ const out: Anthropic.ToolUnion[] = []
941
+ for (const t of serverTools) {
942
+ if (t.type === 'web_search') {
943
+ const tool: Anthropic.WebSearchTool20260209 = {
944
+ type: 'web_search_20260209',
945
+ name: 'web_search',
946
+ }
947
+ if (t.maxUses !== undefined) {
948
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
949
+ }
950
+ if (t.allowedDomains !== undefined) {
951
+ tool.allowed_domains = [...t.allowedDomains]
952
+ }
953
+ if (t.blockedDomains !== undefined) {
954
+ tool.blocked_domains = [...t.blockedDomains]
955
+ }
956
+ out.push(tool)
957
+ } else if (t.type === 'code_execution') {
958
+ out.push({
959
+ type: 'code_execution_20260120',
960
+ name: 'code_execution',
961
+ } satisfies Anthropic.CodeExecutionTool20260120)
962
+ } else if (t.type === 'web_fetch') {
963
+ const tool: Anthropic.WebFetchTool20260309 = {
964
+ type: 'web_fetch_20260309',
965
+ name: 'web_fetch',
966
+ }
967
+ if (t.maxUses !== undefined) {
968
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
969
+ }
970
+ if (t.allowedDomains !== undefined) {
971
+ tool.allowed_domains = [...t.allowedDomains]
972
+ }
973
+ if (t.blockedDomains !== undefined) {
974
+ tool.blocked_domains = [...t.blockedDomains]
975
+ }
976
+ out.push(tool)
977
+ } else if (t.type === 'url_context') {
978
+ throw new BrainError(
979
+ 'AnthropicProvider: server tool `url_context` is Gemini-only. Use `web_fetch` for Anthropic or route the call to Gemini.',
980
+ { context: { provider: 'anthropic' } },
981
+ )
982
+ }
983
+ }
984
+ return out
985
+ }
986
+
419
987
  function mergeBetas(
420
988
  providerBetas: readonly string[],
421
989
  callBetas: readonly string[] | undefined,