@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.
Files changed (45) hide show
  1. package/package.json +3 -3
  2. package/src/agent_runner.ts +1 -1
  3. package/src/{provider.ts → brain_driver.ts} +11 -10
  4. package/src/brain_error.ts +86 -10
  5. package/src/brain_manager.ts +30 -7
  6. package/src/brain_provider.ts +16 -16
  7. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  8. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  9. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  10. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  11. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  12. package/src/drivers/anthropic/index.ts +1 -0
  13. package/src/{providers/deepseek_provider.ts → drivers/deepseek/deepseek_brain_driver.ts} +10 -10
  14. package/src/drivers/deepseek/index.ts +1 -0
  15. package/src/{providers/gemini_provider.ts → drivers/gemini/gemini_brain_driver.ts} +21 -21
  16. package/src/drivers/gemini/index.ts +1 -0
  17. package/src/drivers/ollama/index.ts +1 -0
  18. package/src/{providers/ollama_provider.ts → drivers/ollama/ollama_brain_driver.ts} +5 -5
  19. package/src/drivers/openai/index.ts +1 -0
  20. package/src/{providers/openai_provider.ts → drivers/openai/openai_brain_driver.ts} +152 -591
  21. package/src/drivers/openai/openai_helpers.ts +58 -0
  22. package/src/drivers/openai/openai_message_builder.ts +187 -0
  23. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  24. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  25. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  26. package/src/drivers/openai_compat/index.ts +1 -0
  27. package/src/{providers/openai_compat_provider.ts → drivers/openai_compat/openai_compat_brain_driver.ts} +16 -16
  28. package/src/drivers/openai_responses/index.ts +1 -0
  29. package/src/{providers/openai_responses_provider.ts → drivers/openai_responses/openai_responses_brain_driver.ts} +24 -24
  30. package/src/index.ts +18 -12
  31. package/src/mcp/pool.ts +1 -1
  32. package/src/persistence/brain_message.ts +1 -1
  33. package/src/persistence/brain_message_repository.ts +3 -11
  34. package/src/persistence/brain_suspended_run.ts +1 -1
  35. package/src/persistence/brain_suspended_run_repository.ts +2 -11
  36. package/src/persistence/brain_thread.ts +1 -1
  37. package/src/persistence/brain_thread_repository.ts +2 -11
  38. package/src/persistence/index.ts +1 -1
  39. package/src/tool_runner.ts +1 -1
  40. package/src/types.ts +2 -2
  41. package/src/providers/anthropic_provider.ts +0 -1194
  42. /package/src/persistence/{schema → schemas}/brain_message_schema.ts +0 -0
  43. /package/src/persistence/{schema → schemas}/brain_suspended_run_schema.ts +0 -0
  44. /package/src/persistence/{schema → schemas}/brain_thread_schema.ts +0 -0
  45. /package/src/persistence/{schema → schemas}/index.ts +0 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Pure-function builders for the Anthropic messages-create request
3
+ * payload. Separated from `AnthropicBrainDriver` so the wire-shape
4
+ * translation can be unit-tested without instantiating an SDK
5
+ * client.
6
+ */
7
+
8
+ import type Anthropic from '@anthropic-ai/sdk'
9
+ import { BrainError } from '../../brain_error.ts'
10
+ import type { ChatOptions } from '../../types.ts'
11
+ import type {
12
+ ContentBlock,
13
+ MCPToolResultBlock,
14
+ MCPToolUseBlock,
15
+ Message,
16
+ ServerTool,
17
+ SystemPrompt,
18
+ } from '../../types.ts'
19
+ import { mergeBetas } from './anthropic_helpers.ts'
20
+
21
+ /** Compaction beta — required header + `edits[].type` for `compact-2026-01-12`. */
22
+ export const COMPACT_BETA = 'compact-2026-01-12'
23
+ export const COMPACT_EDIT_TYPE = 'compact_20260112'
24
+
25
+ const EPHEMERAL_CACHE = { type: 'ephemeral' } as const
26
+
27
+ /** Defaults the driver injects when the call site omits them. */
28
+ export interface AnthropicBuildDefaults {
29
+ defaultModel: string
30
+ defaultMaxTokens: number
31
+ betas: readonly string[]
32
+ }
33
+
34
+ export function toMessageParam(message: Message): Anthropic.MessageParam {
35
+ if (typeof message.content === 'string') {
36
+ return { role: message.role, content: message.content }
37
+ }
38
+ return {
39
+ role: message.role,
40
+ content: message.content
41
+ // MCP blocks are inbound-only — Anthropic produces them, we
42
+ // surface them on `result.messages` for observability, but we
43
+ // never echo them back to the model. The backend tracks MCP
44
+ // tool state on its side.
45
+ .filter(
46
+ (b): b is Exclude<ContentBlock, MCPToolUseBlock | MCPToolResultBlock> =>
47
+ b.type !== 'mcp_tool_use' && b.type !== 'mcp_tool_result',
48
+ )
49
+ .map((block): Anthropic.ContentBlockParam => {
50
+ if (block.type === 'tool_use') {
51
+ return {
52
+ type: 'tool_use',
53
+ id: block.id,
54
+ name: block.name,
55
+ input: block.input as Record<string, unknown>,
56
+ }
57
+ }
58
+ if (block.type === 'tool_result') {
59
+ const param: Anthropic.ToolResultBlockParam = {
60
+ type: 'tool_result',
61
+ tool_use_id: block.toolUseId,
62
+ content:
63
+ typeof block.content === 'string'
64
+ ? block.content
65
+ : block.content.map(
66
+ (b) => ({ type: 'text', text: b.text }) as Anthropic.TextBlockParam,
67
+ ),
68
+ }
69
+ if (block.isError) param.is_error = true
70
+ return param
71
+ }
72
+ if (block.type === 'image') {
73
+ return {
74
+ type: 'image',
75
+ source:
76
+ block.source.type === 'base64'
77
+ ? {
78
+ type: 'base64',
79
+ media_type:
80
+ block.source.mediaType as Anthropic.Base64ImageSource['media_type'],
81
+ data: block.source.data,
82
+ }
83
+ : { type: 'url', url: block.source.url },
84
+ } satisfies Anthropic.ImageBlockParam
85
+ }
86
+ if (block.type === 'document') {
87
+ const documentParam: Anthropic.DocumentBlockParam = {
88
+ type: 'document',
89
+ source:
90
+ block.source.type === 'base64'
91
+ ? {
92
+ type: 'base64',
93
+ media_type: 'application/pdf',
94
+ data: block.source.data,
95
+ }
96
+ : { type: 'url', url: block.source.url },
97
+ }
98
+ if (block.title !== undefined) documentParam.title = block.title
99
+ return documentParam
100
+ }
101
+ if (block.type === 'audio') {
102
+ throw new BrainError(
103
+ "AnthropicBrainDriver: 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.",
104
+ { context: { provider: 'anthropic' } },
105
+ )
106
+ }
107
+ if (block.type === 'compaction') {
108
+ // Round-trip the compaction block verbatim — the server uses
109
+ // the opaque `encrypted_content` to stitch prior compactions
110
+ // together; mutating either field would invalidate the
111
+ // history. Untyped on the stable SDK surface; cast through
112
+ // the beta type shape.
113
+ const param: Record<string, unknown> = { type: 'compaction' }
114
+ if (block.content !== null) param.content = block.content
115
+ if (block.encryptedContent !== null) {
116
+ param.encrypted_content = block.encryptedContent
117
+ }
118
+ return param as unknown as Anthropic.ContentBlockParam
119
+ }
120
+ const text: Anthropic.TextBlockParam = { type: 'text', text: block.text }
121
+ if (block.cache) text.cache_control = EPHEMERAL_CACHE
122
+ return text
123
+ }),
124
+ }
125
+ }
126
+
127
+ export function toSystemParam(
128
+ system: SystemPrompt | undefined,
129
+ ): string | Anthropic.TextBlockParam[] | undefined {
130
+ if (system === undefined) return undefined
131
+ if (typeof system === 'string') return system
132
+ if (Array.isArray(system)) {
133
+ return system.map((block) => {
134
+ const param: Anthropic.TextBlockParam = { type: 'text', text: block.text }
135
+ if (block.cache) param.cache_control = EPHEMERAL_CACHE
136
+ return param
137
+ })
138
+ }
139
+ const param: Anthropic.TextBlockParam = { type: 'text', text: system.text }
140
+ if (system.cache) param.cache_control = EPHEMERAL_CACHE
141
+ return [param]
142
+ }
143
+
144
+ /**
145
+ * Translate framework `ServerTool[]` into Anthropic's typed
146
+ * server-tool entries. Uses the latest SDK-known versions; the
147
+ * Anthropic backend is backward-compatible to older clients
148
+ * pinning earlier dates, but we standardize on current. Web fetch
149
+ * is Anthropic-only; `url_context` is rejected (Gemini-only).
150
+ */
151
+ export function anthropicServerTools(serverTools: readonly ServerTool[]): Anthropic.ToolUnion[] {
152
+ const out: Anthropic.ToolUnion[] = []
153
+ for (const t of serverTools) {
154
+ if (t.type === 'web_search') {
155
+ const tool: Anthropic.WebSearchTool20260209 = {
156
+ type: 'web_search_20260209',
157
+ name: 'web_search',
158
+ }
159
+ if (t.maxUses !== undefined) {
160
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
161
+ }
162
+ if (t.allowedDomains !== undefined) {
163
+ tool.allowed_domains = [...t.allowedDomains]
164
+ }
165
+ if (t.blockedDomains !== undefined) {
166
+ tool.blocked_domains = [...t.blockedDomains]
167
+ }
168
+ out.push(tool)
169
+ } else if (t.type === 'code_execution') {
170
+ out.push({
171
+ type: 'code_execution_20260120',
172
+ name: 'code_execution',
173
+ } satisfies Anthropic.CodeExecutionTool20260120)
174
+ } else if (t.type === 'web_fetch') {
175
+ const tool: Anthropic.WebFetchTool20260309 = {
176
+ type: 'web_fetch_20260309',
177
+ name: 'web_fetch',
178
+ }
179
+ if (t.maxUses !== undefined) {
180
+ ;(tool as { max_uses?: number }).max_uses = t.maxUses
181
+ }
182
+ if (t.allowedDomains !== undefined) {
183
+ tool.allowed_domains = [...t.allowedDomains]
184
+ }
185
+ if (t.blockedDomains !== undefined) {
186
+ tool.blocked_domains = [...t.blockedDomains]
187
+ }
188
+ out.push(tool)
189
+ } else if (t.type === 'url_context') {
190
+ throw new BrainError(
191
+ 'AnthropicBrainDriver: server tool `url_context` is Gemini-only. Use `web_fetch` for Anthropic or route the call to Gemini.',
192
+ { context: { provider: 'anthropic' } },
193
+ )
194
+ }
195
+ }
196
+ return out
197
+ }
198
+
199
+ export function buildAnthropicMessageParams(
200
+ messages: readonly Message[],
201
+ options: ChatOptions,
202
+ defaults: AnthropicBuildDefaults,
203
+ ): Anthropic.MessageCreateParamsNonStreaming {
204
+ const model = options.model ?? defaults.defaultModel
205
+ const params: Anthropic.MessageCreateParamsNonStreaming = {
206
+ model,
207
+ max_tokens: options.maxTokens ?? defaults.defaultMaxTokens,
208
+ messages: messages.map(toMessageParam),
209
+ }
210
+
211
+ const system = toSystemParam(options.system)
212
+ if (system !== undefined) params.system = system
213
+
214
+ if (options.thinking === 'adaptive') {
215
+ params.thinking = { type: 'adaptive' }
216
+ } else if (options.thinking === 'disabled') {
217
+ params.thinking = { type: 'disabled' }
218
+ }
219
+
220
+ if (options.effort !== undefined) {
221
+ params.output_config = { effort: options.effort }
222
+ }
223
+
224
+ if (options.cache === true) {
225
+ // Top-level auto-cache the last cacheable block. Maps to the
226
+ // SDK's `cache_control` shorthand on the request body.
227
+ ;(params as { cache_control?: { type: 'ephemeral' } }).cache_control = EPHEMERAL_CACHE
228
+ }
229
+
230
+ // Compaction — emits the beta `edits` entry + flips the
231
+ // `compact-2026-01-12` beta header so the request goes through
232
+ // the SDK's beta surface (same routing as MCP).
233
+ const baseBetas = mergeBetas(defaults.betas, options.betas)
234
+ const betas =
235
+ options.compact !== undefined ? mergeBetas(baseBetas, [COMPACT_BETA]) : baseBetas
236
+ if (options.compact !== undefined) {
237
+ const edit: Record<string, unknown> = { type: COMPACT_EDIT_TYPE }
238
+ if (options.compact.trigger !== undefined) {
239
+ edit.trigger = { type: 'input_tokens', value: options.compact.trigger }
240
+ }
241
+ if (options.compact.instructions !== undefined) {
242
+ edit.instructions = options.compact.instructions
243
+ }
244
+ if (options.compact.pauseAfterCompaction !== undefined) {
245
+ edit.pause_after_compaction = options.compact.pauseAfterCompaction
246
+ }
247
+ ;(params as { edits?: unknown[] }).edits = [edit]
248
+ }
249
+ if (betas.length > 0) {
250
+ ;(params as { betas?: readonly string[] }).betas = betas
251
+ }
252
+
253
+ if (options.serverTools && options.serverTools.length > 0) {
254
+ params.tools = anthropicServerTools(options.serverTools)
255
+ }
256
+
257
+ return params
258
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Pure-function mappers for Anthropic message responses back into
3
+ * framework shapes (`ContentBlock[]`, `ChatUsage`, `ChatResult`).
4
+ * Extracted from `AnthropicBrainDriver` so the response-shape
5
+ * translation can be unit-tested in isolation.
6
+ */
7
+
8
+ import type Anthropic from '@anthropic-ai/sdk'
9
+ import type {
10
+ ChatResult,
11
+ ChatUsage,
12
+ CompactionBlock,
13
+ ContentBlock,
14
+ MCPToolResultBlock,
15
+ MCPToolUseBlock,
16
+ TextBlock,
17
+ ToolUseBlock,
18
+ } from '../../types.ts'
19
+ import { collectText } from './anthropic_helpers.ts'
20
+
21
+ export function toAnthropicUsage(u: Anthropic.Usage): ChatUsage {
22
+ return {
23
+ inputTokens: u.input_tokens,
24
+ outputTokens: u.output_tokens,
25
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
26
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
27
+ }
28
+ }
29
+
30
+ export function addAnthropicUsage(acc: ChatUsage, u: Anthropic.Usage): void {
31
+ acc.inputTokens += u.input_tokens
32
+ acc.outputTokens += u.output_tokens
33
+ acc.cacheReadTokens += u.cache_read_input_tokens ?? 0
34
+ acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0
35
+ }
36
+
37
+ export function toAnthropicChatResult(
38
+ message: Anthropic.Message,
39
+ ): ChatResult<Anthropic.Message> {
40
+ const text = collectText(message.content)
41
+ const result: ChatResult<Anthropic.Message> = {
42
+ text,
43
+ model: message.model,
44
+ stopReason: message.stop_reason,
45
+ usage: toAnthropicUsage(message.usage),
46
+ raw: message,
47
+ }
48
+ // Surface structured content when the turn carries blocks
49
+ // beyond plain text (compaction today; reasoning blocks in a
50
+ // future slice). Apps that persist conversations push this
51
+ // onto the message history so round-trippable blocks survive
52
+ // subsequent requests.
53
+ const blocks = fromAnthropicContent(message.content)
54
+ if (blocks.some((b) => b.type !== 'text')) {
55
+ result.content = blocks
56
+ }
57
+ return result
58
+ }
59
+
60
+ /**
61
+ * Translate the SDK's response content blocks back into framework
62
+ * `ContentBlock`s for storage in `workingMessages`. We preserve
63
+ * `text` and `tool_use` blocks verbatim; other server-side block
64
+ * types (thinking, server tool blocks) are dropped — V1 doesn't
65
+ * surface them, and re-sending them as part of the assistant turn
66
+ * could confuse the model.
67
+ */
68
+ export function fromAnthropicContent(
69
+ content: ReadonlyArray<Anthropic.ContentBlock | { type: string; [k: string]: unknown }>,
70
+ ): ContentBlock[] {
71
+ const out: ContentBlock[] = []
72
+ for (const block of content) {
73
+ if (block.type === 'text') {
74
+ out.push({ type: 'text', text: (block as { text: string }).text } satisfies TextBlock)
75
+ } else if (block.type === 'tool_use') {
76
+ const u = block as { id: string; name: string; input: unknown }
77
+ out.push({
78
+ type: 'tool_use',
79
+ id: u.id,
80
+ name: u.name,
81
+ input: u.input,
82
+ } satisfies ToolUseBlock)
83
+ } else if (block.type === 'mcp_tool_use') {
84
+ const m = block as unknown as {
85
+ id: string
86
+ server_name: string
87
+ name: string
88
+ input: unknown
89
+ }
90
+ out.push({
91
+ type: 'mcp_tool_use',
92
+ id: m.id,
93
+ serverName: m.server_name,
94
+ name: m.name,
95
+ input: m.input,
96
+ } satisfies MCPToolUseBlock)
97
+ } else if (block.type === 'mcp_tool_result') {
98
+ const r = block as unknown as {
99
+ tool_use_id: string
100
+ content: string | Array<{ type: 'text'; text: string }>
101
+ is_error?: boolean
102
+ }
103
+ const result: MCPToolResultBlock = {
104
+ type: 'mcp_tool_result',
105
+ toolUseId: r.tool_use_id,
106
+ content:
107
+ typeof r.content === 'string'
108
+ ? r.content
109
+ : r.content.map((c) => ({ type: 'text', text: c.text }) satisfies TextBlock),
110
+ }
111
+ if (r.is_error) result.isError = true
112
+ out.push(result)
113
+ } else if (block.type === 'compaction') {
114
+ const c = block as { content?: string | null; encrypted_content?: string | null }
115
+ out.push({
116
+ type: 'compaction',
117
+ content: c.content ?? null,
118
+ encryptedContent: c.encrypted_content ?? null,
119
+ } satisfies CompactionBlock)
120
+ }
121
+ }
122
+ return out
123
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Shared non-streaming tool-loop iteration for Anthropic.
3
+ *
4
+ * Mirrors `openai_tool_loop.ts` — extracts the per-iteration body
5
+ * so `runWithTools` and `runWithToolsAndSchema` become thin
6
+ * orchestrators that only encode their own terminal return shape
7
+ * (`AgentResult | SuspendedRun` vs `AgentGenerateResult<T>`).
8
+ *
9
+ * Also exports `injectToolsAndMCP`, the local-tools + MCP-toolset +
10
+ * beta-header injection block that both runners apply after
11
+ * `buildParams`. Anthropic's MCP connector requires the
12
+ * `mcp-client-2025-11-20` beta header; the injector flips it
13
+ * automatically when MCP servers are declared.
14
+ *
15
+ * Streaming variants are not unified here — same rationale as
16
+ * OpenAI: yielding events mid-iteration requires an async-generator
17
+ * wrapper with reader-complexity cost that outweighs the LOC win.
18
+ */
19
+
20
+ import type Anthropic from '@anthropic-ai/sdk'
21
+ import type { RunWithToolsOptions } from '../../brain_driver.ts'
22
+ import type { MCPServer } from '../../mcp_server.ts'
23
+ import type { Tool } from '../../tool.ts'
24
+ import { runToolWithRecovery } from '../../tool_runner.ts'
25
+ import type {
26
+ ChatUsage,
27
+ ContentBlock,
28
+ Message,
29
+ ToolResultBlock,
30
+ ToolUseBlock,
31
+ } from '../../types.ts'
32
+ import {
33
+ checkAborted,
34
+ collectText,
35
+ needsBetaRouting,
36
+ reqOpts,
37
+ } from './anthropic_helpers.ts'
38
+ import {
39
+ addAnthropicUsage,
40
+ fromAnthropicContent,
41
+ } from './anthropic_response_mapper.ts'
42
+
43
+ const MCP_BETA = 'mcp-client-2025-11-20'
44
+
45
+ /** Params shape with the optional MCP-beta field surfaced for inline mutation. */
46
+ type ParamsWithMcp = Anthropic.MessageCreateParamsNonStreaming & {
47
+ mcp_servers?: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition[]
48
+ }
49
+
50
+ /**
51
+ * Inject local tools + MCP toolsets into `params.tools`, declare MCP
52
+ * servers, and flip the `mcp-client-2025-11-20` beta header when MCP
53
+ * is in play. Mutates `params` in place; returns the same reference
54
+ * for chaining.
55
+ *
56
+ * Both runners call this once per iteration after `buildParams` and
57
+ * after any runner-specific augmentation (e.g. `output_config.format`
58
+ * for the schema variant).
59
+ */
60
+ export function injectToolsAndMCP(
61
+ params: Anthropic.MessageCreateParamsNonStreaming,
62
+ args: { tools: readonly Tool[]; mcpServers: readonly MCPServer[] },
63
+ ): ParamsWithMcp {
64
+ const p = params as ParamsWithMcp
65
+ p.tools = [
66
+ // Server tools placed first when present (from buildParams).
67
+ ...((p.tools ?? []) as Anthropic.ToolUnion[]),
68
+ ...args.tools.map((t) => ({
69
+ name: t.name,
70
+ description: t.description,
71
+ input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
72
+ })),
73
+ // MCP toolsets — one per declared server. The model sees the
74
+ // server's tools via Anthropic's connector, not via our local
75
+ // `tools` list.
76
+ ...args.mcpServers
77
+ .filter((s) => s.tools?.enabled !== false)
78
+ .map((s) => ({
79
+ type: 'mcp_toolset' as const,
80
+ mcp_server_name: s.name,
81
+ ...(s.tools?.allowedTools ? { allowed_tools: [...s.tools.allowedTools] } : {}),
82
+ })),
83
+ ] as unknown as Anthropic.MessageCreateParams['tools']
84
+
85
+ if (args.mcpServers.length > 0) {
86
+ p.mcp_servers = args.mcpServers.map((s) => {
87
+ const def: Anthropic.Beta.Messages.BetaRequestMCPServerURLDefinition = {
88
+ type: 'url',
89
+ name: s.name,
90
+ url: s.url,
91
+ }
92
+ if (s.authorizationToken !== undefined) def.authorization_token = s.authorizationToken
93
+ return def
94
+ })
95
+ const baseBetas = (p as { betas?: readonly string[] }).betas ?? []
96
+ ;(p as { betas?: string[] }).betas = baseBetas.includes(MCP_BETA)
97
+ ? [...baseBetas]
98
+ : [...baseBetas, MCP_BETA]
99
+ }
100
+ return p
101
+ }
102
+
103
+ /** Per-iteration mutable state. The helper mutates this in place. */
104
+ export interface NonStreamLoopState {
105
+ workingMessages: Message[]
106
+ aggregated: ChatUsage
107
+ iterations: number
108
+ lastStopReason: string | null
109
+ }
110
+
111
+ export function createNonStreamLoopState(messages: readonly Message[]): NonStreamLoopState {
112
+ return {
113
+ workingMessages: [...messages],
114
+ aggregated: {
115
+ inputTokens: 0,
116
+ outputTokens: 0,
117
+ cacheReadTokens: 0,
118
+ cacheCreationTokens: 0,
119
+ },
120
+ iterations: 0,
121
+ lastStopReason: null,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Per-iteration outcome the orchestrator branches on. `assistantText`
127
+ * is `collectText(response.content)` — the schema variant feeds it
128
+ * through `parseGenerated`; the plain variant returns it as-is.
129
+ */
130
+ export type NonStreamIterationOutcome =
131
+ | { kind: 'continue' }
132
+ | { kind: 'stop'; assistantText: string; stopReason: string }
133
+ | { kind: 'max_iterations'; assistantText: string }
134
+ | { kind: 'suspended'; pendingToolCalls: ToolUseBlock[] }
135
+
136
+ export interface NonStreamIterationArgs {
137
+ state: NonStreamLoopState
138
+ toolMap: Map<string, Tool>
139
+ maxIterations: number
140
+ client: Anthropic
141
+ /**
142
+ * Built per iteration because `workingMessages` grows each round.
143
+ * Schema variant's closure adds `output_config.format: json_schema`
144
+ * after the driver's base `buildParams` runs. Caller is responsible
145
+ * for the `injectToolsAndMCP` step.
146
+ */
147
+ buildParams: (msgs: readonly Message[]) => Anthropic.MessageCreateParamsNonStreaming
148
+ options: RunWithToolsOptions
149
+ /**
150
+ * Human-in-the-loop gate. Pass `options.shouldSuspend` to enable;
151
+ * pass `undefined` to disable (schema callers don't support
152
+ * suspension).
153
+ */
154
+ suspendCheck: NonNullable<RunWithToolsOptions['shouldSuspend']> | undefined
155
+ }
156
+
157
+ /**
158
+ * One round-trip of the Anthropic agentic loop. Routes through the
159
+ * beta surface when MCP servers or compaction are in play; otherwise
160
+ * stays on the stable `client.messages.create`. Returns a
161
+ * discriminated outcome the orchestrator branches on; `state` is
162
+ * mutated in place.
163
+ */
164
+ export async function runAnthropicNonStreamIteration(
165
+ args: NonStreamIterationArgs,
166
+ ): Promise<NonStreamIterationOutcome> {
167
+ const { state, toolMap, maxIterations, client, buildParams, options, suspendCheck } = args
168
+ checkAborted(options.signal)
169
+ const params = buildParams(state.workingMessages)
170
+
171
+ // Route via beta when MCP servers OR compaction are in play.
172
+ const response: Anthropic.Message = needsBetaRouting(params)
173
+ ? ((await client.beta.messages.create(
174
+ params as unknown as Anthropic.Beta.Messages.MessageCreateParamsNonStreaming,
175
+ reqOpts(options),
176
+ )) as unknown as Anthropic.Message)
177
+ : await client.messages.create(params, reqOpts(options))
178
+ addAnthropicUsage(state.aggregated, response.usage)
179
+ state.lastStopReason = response.stop_reason ?? null
180
+
181
+ // Append the assistant turn verbatim from the SDK shape so tool_use
182
+ // blocks survive to the next request unchanged.
183
+ state.workingMessages.push({
184
+ role: 'assistant',
185
+ content: fromAnthropicContent(response.content),
186
+ })
187
+
188
+ const assistantText = collectText(response.content)
189
+
190
+ if (response.stop_reason !== 'tool_use') {
191
+ return {
192
+ kind: 'stop',
193
+ assistantText,
194
+ stopReason: state.lastStopReason ?? 'end_turn',
195
+ }
196
+ }
197
+
198
+ // Execute every tool_use block; all results land in one user turn
199
+ // per the SDK contract.
200
+ const toolUseBlocks = response.content.filter(
201
+ (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
202
+ )
203
+ const resultBlocks: ContentBlock[] = []
204
+ for (let i = 0; i < toolUseBlocks.length; i++) {
205
+ const block = toolUseBlocks[i]!
206
+ if (suspendCheck) {
207
+ const frameworkCall: ToolUseBlock = {
208
+ type: 'tool_use',
209
+ id: block.id,
210
+ name: block.name,
211
+ input: block.input as Record<string, unknown>,
212
+ }
213
+ if (await suspendCheck(frameworkCall, options.context)) {
214
+ return {
215
+ kind: 'suspended',
216
+ pendingToolCalls: toolUseBlocks.slice(i).map((b) => ({
217
+ type: 'tool_use',
218
+ id: b.id,
219
+ name: b.name,
220
+ input: b.input as Record<string, unknown>,
221
+ })),
222
+ }
223
+ }
224
+ }
225
+ const { content, isError } = await runToolWithRecovery(
226
+ toolMap.get(block.name),
227
+ block.name,
228
+ block.id,
229
+ block.input,
230
+ options,
231
+ )
232
+ resultBlocks.push({
233
+ type: 'tool_result',
234
+ toolUseId: block.id,
235
+ content,
236
+ ...(isError ? { isError: true } : {}),
237
+ } satisfies ToolResultBlock)
238
+ }
239
+ state.workingMessages.push({ role: 'user', content: resultBlocks })
240
+
241
+ state.iterations++
242
+ if (state.iterations >= maxIterations) {
243
+ return { kind: 'max_iterations', assistantText }
244
+ }
245
+ return { kind: 'continue' }
246
+ }
@@ -0,0 +1 @@
1
+ export { AnthropicBrainDriver } from './anthropic_brain_driver.ts'
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `DeepSeekProvider` — `OpenAICompatProvider` pointed at DeepSeek's
2
+ * `DeepSeekBrainDriver` — `OpenAICompatBrainDriver` pointed at DeepSeek's
3
3
  * OpenAI-compatible `/v1/chat/completions` endpoint.
4
4
  *
5
5
  * Inherits the OpenAI-compat overrides (strip `reasoning_effort`,
@@ -18,9 +18,9 @@
18
18
  */
19
19
 
20
20
  import type OpenAI from 'openai'
21
- import { BrainError } from '../brain_error.ts'
22
- import type { DeepSeekProviderConfig } from '../brain_config.ts'
23
- import type { ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
21
+ import { BrainError } from '../../brain_error.ts'
22
+ import type { DeepSeekProviderConfig } from '../../brain_config.ts'
23
+ import type { ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
24
24
  import type {
25
25
  AudioSource,
26
26
  ChatUsage,
@@ -28,8 +28,8 @@ import type {
28
28
  EmbedResult,
29
29
  TranscribeOptions,
30
30
  TranscribeResult,
31
- } from '../types.ts'
32
- import { OpenAICompatProvider } from './openai_compat_provider.ts'
31
+ } from '../../types.ts'
32
+ import { OpenAICompatBrainDriver } from '../openai_compat/openai_compat_brain_driver.ts'
33
33
 
34
34
  const DEFAULT_DEEPSEEK_MODEL = 'deepseek-chat'
35
35
  const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1'
@@ -44,7 +44,7 @@ export interface DeepSeekProviderOptions {
44
44
  mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
45
45
  }
46
46
 
47
- export class DeepSeekProvider extends OpenAICompatProvider {
47
+ export class DeepSeekBrainDriver extends OpenAICompatBrainDriver {
48
48
  constructor(
49
49
  name: string,
50
50
  config: DeepSeekProviderConfig,
@@ -67,7 +67,7 @@ export class DeepSeekProvider extends OpenAICompatProvider {
67
67
 
68
68
  /**
69
69
  * DeepSeek doesn't expose an audio transcription endpoint.
70
- * Override the inherited `transcribe` (from OpenAIProvider) to
70
+ * Override the inherited `transcribe` (from OpenAIBrainDriver) to
71
71
  * throw clearly rather than 404 at the wire.
72
72
  */
73
73
  override async transcribe(
@@ -75,7 +75,7 @@ export class DeepSeekProvider extends OpenAICompatProvider {
75
75
  _options?: TranscribeOptions,
76
76
  ): Promise<TranscribeResult<OpenAI.Audio.TranscriptionCreateResponse>> {
77
77
  throw new BrainError(
78
- "DeepSeekProvider.transcribe: DeepSeek's API does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
78
+ "DeepSeekBrainDriver.transcribe: DeepSeek's API does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
79
79
  { context: { provider: this.name } },
80
80
  )
81
81
  }
@@ -91,7 +91,7 @@ export class DeepSeekProvider extends OpenAICompatProvider {
91
91
  _options?: EmbedOptions,
92
92
  ): Promise<EmbedResult<OpenAI.CreateEmbeddingResponse>> {
93
93
  throw new BrainError(
94
- `DeepSeekProvider.embed: DeepSeek's API does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
94
+ `DeepSeekBrainDriver.embed: DeepSeek's API does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
95
95
  { context: { provider: this.name } },
96
96
  )
97
97
  }
@@ -0,0 +1 @@
1
+ export { DeepSeekBrainDriver } from './deepseek_brain_driver.ts'