@strav/brain 1.0.0-alpha.22 → 1.0.0-alpha.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/agent_runner.ts +1 -1
- package/src/{provider.ts → brain_driver.ts} +11 -10
- package/src/brain_error.ts +86 -10
- package/src/brain_manager.ts +30 -7
- package/src/brain_provider.ts +16 -16
- package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
- package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
- package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
- package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
- package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
- package/src/drivers/anthropic/index.ts +1 -0
- package/src/{providers/deepseek_provider.ts → drivers/deepseek/deepseek_brain_driver.ts} +10 -10
- package/src/drivers/deepseek/index.ts +1 -0
- package/src/{providers/gemini_provider.ts → drivers/gemini/gemini_brain_driver.ts} +21 -21
- package/src/drivers/gemini/index.ts +1 -0
- package/src/drivers/ollama/index.ts +1 -0
- package/src/{providers/ollama_provider.ts → drivers/ollama/ollama_brain_driver.ts} +5 -5
- package/src/drivers/openai/index.ts +1 -0
- package/src/{providers/openai_provider.ts → drivers/openai/openai_brain_driver.ts} +152 -591
- package/src/drivers/openai/openai_helpers.ts +58 -0
- package/src/drivers/openai/openai_message_builder.ts +187 -0
- package/src/drivers/openai/openai_response_mapper.ts +70 -0
- package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
- package/src/drivers/openai/openai_tool_loop.ts +191 -0
- package/src/drivers/openai_compat/index.ts +1 -0
- package/src/{providers/openai_compat_provider.ts → drivers/openai_compat/openai_compat_brain_driver.ts} +16 -16
- package/src/drivers/openai_responses/index.ts +1 -0
- package/src/{providers/openai_responses_provider.ts → drivers/openai_responses/openai_responses_brain_driver.ts} +24 -24
- package/src/index.ts +18 -12
- package/src/mcp/pool.ts +1 -1
- package/src/persistence/brain_message.ts +1 -1
- package/src/persistence/brain_message_repository.ts +3 -11
- package/src/persistence/brain_suspended_run.ts +1 -1
- package/src/persistence/brain_suspended_run_repository.ts +2 -11
- package/src/persistence/brain_thread.ts +1 -1
- package/src/persistence/brain_thread_repository.ts +2 -11
- package/src/persistence/index.ts +1 -1
- package/src/tool_runner.ts +1 -1
- package/src/types.ts +2 -2
- package/src/providers/anthropic_provider.ts +0 -1194
- /package/src/persistence/{schema → schemas}/brain_message_schema.ts +0 -0
- /package/src/persistence/{schema → schemas}/brain_suspended_run_schema.ts +0 -0
- /package/src/persistence/{schema → schemas}/brain_thread_schema.ts +0 -0
- /package/src/persistence/{schema → schemas}/index.ts +0 -0
|
@@ -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
|
+
}
|