@strav/brain 1.0.0-alpha.22 → 1.0.0-alpha.25
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,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
|
-
* `
|
|
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 '
|
|
22
|
-
import type { DeepSeekProviderConfig } from '
|
|
23
|
-
import type { ResolveMcpToolsOptions } from '
|
|
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 '
|
|
32
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
`
|
|
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'
|