@strav/brain 1.0.0-alpha.9 → 1.0.1
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 +23 -7
- package/src/agent.ts +43 -5
- package/src/agent_generate_result.ts +32 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +218 -14
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +218 -1
- package/src/brain_driver.ts +247 -0
- package/src/brain_error.ts +86 -10
- package/src/brain_manager.ts +359 -11
- package/src/brain_provider.ts +79 -9
- 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/drivers/deepseek/deepseek_brain_driver.ts +117 -0
- package/src/drivers/deepseek/index.ts +1 -0
- package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
- package/src/drivers/gemini/index.ts +1 -0
- package/src/drivers/minimax/index.ts +1 -0
- package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
- package/src/drivers/ollama/index.ts +1 -0
- package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
- package/src/drivers/openai/index.ts +1 -0
- package/src/drivers/openai/openai_brain_driver.ts +796 -0
- 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/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
- package/src/drivers/openai_responses/index.ts +1 -0
- package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
- package/src/drivers/openrouter/index.ts +1 -0
- package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
- package/src/drivers/qwen/index.ts +1 -0
- package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
- package/src/index.ts +75 -11
- package/src/mcp/client.ts +243 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +108 -0
- package/src/mcp_server.ts +63 -0
- package/src/output_schema.ts +72 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +98 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +59 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +56 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schemas/brain_message_schema.ts +61 -0
- package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schemas/brain_thread_schema.ts +50 -0
- package/src/persistence/schemas/index.ts +3 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/translate/index.ts +19 -0
- package/src/translate/translate_cache.ts +78 -0
- package/src/translate/translate_provider.ts +46 -0
- package/src/translate/translator.ts +271 -0
- package/src/types.ts +398 -1
- package/src/zod/index.ts +121 -0
- package/src/provider.ts +0 -74
- package/src/providers/anthropic_provider.ts +0 -397
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small utilities shared by `OpenAIBrainDriver` and its subclasses.
|
|
3
|
+
* Kept separate from the message builder / response mapper because
|
|
4
|
+
* these are content-agnostic — SDK request-options forwarding, MIME
|
|
5
|
+
* sniffing, abort-signal probing, and the audio-source upload helper
|
|
6
|
+
* used by `transcribe`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BrainError } from '../../brain_error.ts'
|
|
10
|
+
import type { AudioSource } from '../../types.ts'
|
|
11
|
+
|
|
12
|
+
/** Build the request-options bag forwarded to the SDK. Only `signal` for now. */
|
|
13
|
+
export function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
|
|
14
|
+
return options.signal !== undefined ? { signal: options.signal } : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Throw a DOMException-shaped abort error if the signal has fired. */
|
|
18
|
+
export function checkAborted(signal: AbortSignal | undefined): void {
|
|
19
|
+
if (signal?.aborted) {
|
|
20
|
+
throw signal.reason ?? new DOMException('Aborted', 'AbortError')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function extFromMime(mime: string): string {
|
|
25
|
+
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
|
|
26
|
+
if (m === 'audio/mp3' || m === 'audio/mpeg' || m === 'audio/mpga') return 'mp3'
|
|
27
|
+
if (m === 'audio/wav' || m === 'audio/x-wav') return 'wav'
|
|
28
|
+
if (m === 'audio/ogg') return 'ogg'
|
|
29
|
+
if (m === 'audio/flac') return 'flac'
|
|
30
|
+
if (m === 'audio/webm') return 'webm'
|
|
31
|
+
if (m === 'audio/aac' || m === 'audio/x-aac' || m === 'audio/mp4' || m === 'audio/m4a') return 'm4a'
|
|
32
|
+
return 'mp3'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Materialize an `AudioSource` as a `File` the OpenAI SDK's
|
|
37
|
+
* `Uploadable` shape accepts. Base64 → in-memory File; URL →
|
|
38
|
+
* fetch + wrap. The SDK wants a filename; we synthesize one
|
|
39
|
+
* since `AudioSource` doesn't carry one. The extension lets the
|
|
40
|
+
* SDK pick the right content-type for the multipart upload.
|
|
41
|
+
*/
|
|
42
|
+
export async function audioSourceToFile(audio: AudioSource): Promise<File> {
|
|
43
|
+
if (audio.type === 'base64') {
|
|
44
|
+
const bytes = Buffer.from(audio.data, 'base64')
|
|
45
|
+
const ext = extFromMime(audio.mediaType)
|
|
46
|
+
return new File([bytes], `audio.${ext}`, { type: audio.mediaType })
|
|
47
|
+
}
|
|
48
|
+
const response = await fetch(audio.url)
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new BrainError(
|
|
51
|
+
`OpenAIBrainDriver.transcribe: failed to fetch audio at ${audio.url}: ${response.status} ${response.statusText}.`,
|
|
52
|
+
{ context: { url: audio.url, status: response.status } },
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const buf = await response.arrayBuffer()
|
|
56
|
+
const mime = response.headers.get('content-type') ?? 'audio/mpeg'
|
|
57
|
+
return new File([buf], `audio.${extFromMime(mime)}`, { type: mime })
|
|
58
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-function builders for the OpenAI chat-completions request
|
|
3
|
+
* payload. Separated from `OpenAIBrainDriver` so the wire-shape
|
|
4
|
+
* translation can be unit-tested without instantiating an SDK
|
|
5
|
+
* client, and so OpenAI-compat subclasses can reuse the same
|
|
6
|
+
* primitives without inheriting through a 1000+ LOC base file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type OpenAI from 'openai'
|
|
10
|
+
import { BrainError } from '../../brain_error.ts'
|
|
11
|
+
import type { ChatOptions } from '../../types.ts'
|
|
12
|
+
import type { Tool } from '../../tool.ts'
|
|
13
|
+
import type {
|
|
14
|
+
ImageBlock,
|
|
15
|
+
Message,
|
|
16
|
+
SystemPrompt,
|
|
17
|
+
TextBlock,
|
|
18
|
+
ToolUseBlock,
|
|
19
|
+
} from '../../types.ts'
|
|
20
|
+
|
|
21
|
+
/** Defaults the driver injects when the call site omits them. */
|
|
22
|
+
export interface OpenAIBuildDefaults {
|
|
23
|
+
defaultModel: string
|
|
24
|
+
defaultMaxTokens: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function systemPromptText(system: SystemPrompt | undefined): string {
|
|
28
|
+
if (system === undefined) return ''
|
|
29
|
+
if (typeof system === 'string') return system
|
|
30
|
+
if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
|
|
31
|
+
return system.text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function toOpenAIMessage(message: Message): OpenAI.Chat.ChatCompletionMessageParam {
|
|
35
|
+
if (typeof message.content === 'string') {
|
|
36
|
+
return { role: message.role, content: message.content } as OpenAI.Chat.ChatCompletionMessageParam
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Assistant turns may contain text + tool_use blocks; we need to
|
|
40
|
+
// split tool_use blocks into the `tool_calls` field and put the
|
|
41
|
+
// remaining text into `content`.
|
|
42
|
+
if (message.role === 'assistant') {
|
|
43
|
+
const text = message.content
|
|
44
|
+
.filter((b): b is TextBlock => b.type === 'text')
|
|
45
|
+
.map((b) => b.text)
|
|
46
|
+
.join('')
|
|
47
|
+
const toolUses = message.content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
|
|
48
|
+
const param: OpenAI.Chat.ChatCompletionAssistantMessageParam = { role: 'assistant' }
|
|
49
|
+
if (text.length > 0) param.content = text
|
|
50
|
+
if (toolUses.length > 0) {
|
|
51
|
+
param.tool_calls = toolUses.map((b) => ({
|
|
52
|
+
id: b.id,
|
|
53
|
+
type: 'function',
|
|
54
|
+
function: {
|
|
55
|
+
name: b.name,
|
|
56
|
+
arguments: JSON.stringify(b.input ?? {}),
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
return param
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const block of message.content) {
|
|
64
|
+
if (block.type === 'document') {
|
|
65
|
+
throw new BrainError(
|
|
66
|
+
"OpenAIBrainDriver: document blocks are not supported on OpenAI's chat completions API. For PDFs, split the document to images (one per page) and send them as ImageBlocks on a vision-capable model (gpt-5 / gpt-4o family); or route document workloads to Anthropic / Gemini, which accept PDF blocks natively.",
|
|
67
|
+
{ context: { provider: 'openai' } },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
if (block.type === 'audio') {
|
|
71
|
+
throw new BrainError(
|
|
72
|
+
"OpenAIBrainDriver: audio blocks are not supported on OpenAI's chat completions API. Transcribe audio upstream via OpenAI's Whisper / gpt-4o-transcribe and send the resulting text; or route audio workloads to Gemini, which accepts audio blocks natively.",
|
|
73
|
+
{ context: { provider: 'openai' } },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const images = message.content.filter((b): b is ImageBlock => b.type === 'image')
|
|
79
|
+
if (images.length > 0) {
|
|
80
|
+
const parts: OpenAI.Chat.ChatCompletionContentPart[] = []
|
|
81
|
+
for (const block of message.content) {
|
|
82
|
+
if (block.type === 'text') {
|
|
83
|
+
parts.push({ type: 'text', text: block.text })
|
|
84
|
+
} else if (block.type === 'image') {
|
|
85
|
+
const url =
|
|
86
|
+
block.source.type === 'base64'
|
|
87
|
+
? `data:${block.source.mediaType};base64,${block.source.data}`
|
|
88
|
+
: block.source.url
|
|
89
|
+
parts.push({ type: 'image_url', image_url: { url } })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { role: 'user', content: parts }
|
|
93
|
+
}
|
|
94
|
+
const text = message.content
|
|
95
|
+
.filter((b): b is TextBlock => b.type === 'text')
|
|
96
|
+
.map((b) => b.text)
|
|
97
|
+
.join('')
|
|
98
|
+
return { role: 'user', content: text }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function toOpenAIMessages(
|
|
102
|
+
system: SystemPrompt | undefined,
|
|
103
|
+
messages: readonly Message[],
|
|
104
|
+
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
|
105
|
+
const out: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
|
106
|
+
const systemText = systemPromptText(system)
|
|
107
|
+
if (systemText.length > 0) {
|
|
108
|
+
out.push({ role: 'system', content: systemText })
|
|
109
|
+
}
|
|
110
|
+
for (const message of messages) {
|
|
111
|
+
// User-role messages with tool results in their content fan
|
|
112
|
+
// out into one `tool`-role message per result — OpenAI's
|
|
113
|
+
// contract is "one tool_call_id per tool message," not a
|
|
114
|
+
// single user message carrying multiple results.
|
|
115
|
+
if (
|
|
116
|
+
message.role === 'user' &&
|
|
117
|
+
Array.isArray(message.content) &&
|
|
118
|
+
message.content.some((b) => b.type === 'tool_result')
|
|
119
|
+
) {
|
|
120
|
+
const remainingText: string[] = []
|
|
121
|
+
for (const block of message.content) {
|
|
122
|
+
if (block.type === 'tool_result') {
|
|
123
|
+
out.push({
|
|
124
|
+
role: 'tool',
|
|
125
|
+
tool_call_id: block.toolUseId,
|
|
126
|
+
content:
|
|
127
|
+
typeof block.content === 'string'
|
|
128
|
+
? block.content
|
|
129
|
+
: block.content.map((t) => t.text).join(''),
|
|
130
|
+
})
|
|
131
|
+
} else if (block.type === 'text') {
|
|
132
|
+
remainingText.push(block.text)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (remainingText.length > 0) {
|
|
136
|
+
out.push({ role: 'user', content: remainingText.join('') })
|
|
137
|
+
}
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
out.push(toOpenAIMessage(message))
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildOpenAIChatParams(
|
|
146
|
+
messages: readonly Message[],
|
|
147
|
+
options: ChatOptions,
|
|
148
|
+
tools: readonly Tool[],
|
|
149
|
+
defaults: OpenAIBuildDefaults,
|
|
150
|
+
): OpenAI.Chat.ChatCompletionCreateParamsNonStreaming {
|
|
151
|
+
if (options.serverTools && options.serverTools.length > 0) {
|
|
152
|
+
throw new BrainError(
|
|
153
|
+
"OpenAIBrainDriver: server tools (web_search / code_execution / web_fetch / url_context) are not supported on OpenAI's chat completions API. OpenAI's server tools live on the Responses API (separate provider slice). Run them as framework-local tools, route to Anthropic / Gemini, or wait for the OpenAIResponsesBrainDriver slice.",
|
|
154
|
+
{ context: { provider: 'openai' } },
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const model = options.model ?? defaults.defaultModel
|
|
158
|
+
const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
|
|
159
|
+
model,
|
|
160
|
+
max_completion_tokens: options.maxTokens ?? defaults.defaultMaxTokens,
|
|
161
|
+
messages: toOpenAIMessages(options.system, messages),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (tools.length > 0) {
|
|
165
|
+
params.tools = tools.map((t) => ({
|
|
166
|
+
type: 'function',
|
|
167
|
+
function: {
|
|
168
|
+
name: t.name,
|
|
169
|
+
description: t.description,
|
|
170
|
+
parameters: t.inputSchema as Record<string, unknown>,
|
|
171
|
+
},
|
|
172
|
+
}))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Reasoning controls — only emitted when explicitly set so
|
|
176
|
+
// non-reasoning models don't get rejected.
|
|
177
|
+
if (options.effort !== undefined) {
|
|
178
|
+
params.reasoning_effort = options.effort as OpenAI.ReasoningEffort
|
|
179
|
+
} else if (options.thinking === 'adaptive') {
|
|
180
|
+
params.reasoning_effort = 'medium' as OpenAI.ReasoningEffort
|
|
181
|
+
} else if (options.thinking === 'disabled') {
|
|
182
|
+
params.reasoning_effort = 'minimal' as OpenAI.ReasoningEffort
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `cache` is a no-op on OpenAI — prompt caching is automatic.
|
|
186
|
+
return params
|
|
187
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-function mappers for OpenAI chat-completions responses back
|
|
3
|
+
* into framework shapes (`ContentBlock[]`, `ChatUsage`, `ChatResult`).
|
|
4
|
+
* Extracted from `OpenAIBrainDriver` so the response-shape translation
|
|
5
|
+
* can be unit-tested in isolation and reused by the OpenAI-compat
|
|
6
|
+
* subclasses without going through the driver class.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type OpenAI from 'openai'
|
|
10
|
+
import type {
|
|
11
|
+
ChatResult,
|
|
12
|
+
ChatUsage,
|
|
13
|
+
ContentBlock,
|
|
14
|
+
ToolUseBlock,
|
|
15
|
+
} from '../../types.ts'
|
|
16
|
+
|
|
17
|
+
export function fromOpenAIAssistantMessage(
|
|
18
|
+
msg: OpenAI.Chat.ChatCompletionMessage,
|
|
19
|
+
): string | ContentBlock[] {
|
|
20
|
+
const blocks: ContentBlock[] = []
|
|
21
|
+
if (msg.content) blocks.push({ type: 'text', text: msg.content })
|
|
22
|
+
if (msg.tool_calls) {
|
|
23
|
+
for (const call of msg.tool_calls) {
|
|
24
|
+
if (call.type !== 'function') continue
|
|
25
|
+
let parsedInput: unknown = {}
|
|
26
|
+
try {
|
|
27
|
+
parsedInput = call.function.arguments ? JSON.parse(call.function.arguments) : {}
|
|
28
|
+
} catch {
|
|
29
|
+
parsedInput = call.function.arguments ?? {}
|
|
30
|
+
}
|
|
31
|
+
blocks.push({
|
|
32
|
+
type: 'tool_use',
|
|
33
|
+
id: call.id,
|
|
34
|
+
name: call.function.name,
|
|
35
|
+
input: parsedInput,
|
|
36
|
+
} satisfies ToolUseBlock)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
|
|
40
|
+
return blocks
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toOpenAIUsage(u: OpenAI.CompletionUsage | undefined): ChatUsage {
|
|
44
|
+
return {
|
|
45
|
+
inputTokens: u?.prompt_tokens ?? 0,
|
|
46
|
+
outputTokens: u?.completion_tokens ?? 0,
|
|
47
|
+
cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
|
|
48
|
+
cacheCreationTokens: 0,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function addOpenAIUsage(acc: ChatUsage, u: OpenAI.CompletionUsage | undefined): void {
|
|
53
|
+
if (!u) return
|
|
54
|
+
acc.inputTokens += u.prompt_tokens
|
|
55
|
+
acc.outputTokens += u.completion_tokens
|
|
56
|
+
acc.cacheReadTokens += u.prompt_tokens_details?.cached_tokens ?? 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toOpenAIChatResult(
|
|
60
|
+
response: OpenAI.Chat.ChatCompletion,
|
|
61
|
+
): ChatResult<OpenAI.Chat.ChatCompletion> {
|
|
62
|
+
const choice = response.choices[0]
|
|
63
|
+
return {
|
|
64
|
+
text: choice?.message?.content ?? '',
|
|
65
|
+
model: response.model,
|
|
66
|
+
stopReason: choice?.finish_reason ?? null,
|
|
67
|
+
usage: toOpenAIUsage(response.usage),
|
|
68
|
+
raw: response,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-dispatch helpers for the OpenAI loops
|
|
3
|
+
* (`_runLoop`, `_runLoopWithSchema`, `_streamLoop`,
|
|
4
|
+
* `_streamLoopWithSchema`).
|
|
5
|
+
*
|
|
6
|
+
* The 4 loops each iterate an agentic round-trip with subtly different
|
|
7
|
+
* control flow — suspend-aware vs not, streaming vs not, schema vs not
|
|
8
|
+
* — but the inner "parse JSON args, recover, run with recovery" and
|
|
9
|
+
* the "materialize assistant turn from streamed chunks" sequences are
|
|
10
|
+
* identical. They live here so each loop is the orchestrator, not the
|
|
11
|
+
* implementer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { RunWithToolsOptions } from '../../brain_driver.ts'
|
|
15
|
+
import type { Tool } from '../../tool.ts'
|
|
16
|
+
import { ToolExecutionError } from '../../tool_execution_error.ts'
|
|
17
|
+
import { recoverOrThrow, runToolWithRecovery } from '../../tool_runner.ts'
|
|
18
|
+
import type { ContentBlock, TextBlock, ToolUseBlock } from '../../types.ts'
|
|
19
|
+
|
|
20
|
+
/** Outcome of `parseToolCallArgs`. `parseFailed` is set if JSON.parse threw and the error recovered. */
|
|
21
|
+
export interface ParsedToolCall {
|
|
22
|
+
parsedInput: unknown
|
|
23
|
+
parseFailed: { content: string; isError: boolean } | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a tool call's JSON-encoded arguments. On parse failure, runs
|
|
28
|
+
* `onToolError` recovery — when it returns a string, that becomes the
|
|
29
|
+
* `tool_result` content; when it returns undefined, the inner
|
|
30
|
+
* `recoverOrThrow` rethrows and the loop aborts. Callers either short-
|
|
31
|
+
* circuit on `parseFailed` (skipping the actual tool call) or proceed
|
|
32
|
+
* with `parsedInput`.
|
|
33
|
+
*/
|
|
34
|
+
export function parseToolCallArgs(
|
|
35
|
+
callName: string,
|
|
36
|
+
callId: string,
|
|
37
|
+
callArgs: string,
|
|
38
|
+
options: RunWithToolsOptions,
|
|
39
|
+
): ParsedToolCall {
|
|
40
|
+
try {
|
|
41
|
+
const parsedInput = callArgs ? JSON.parse(callArgs) : {}
|
|
42
|
+
return { parsedInput, parseFailed: undefined }
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const parseFailed = recoverOrThrow(
|
|
45
|
+
new ToolExecutionError(
|
|
46
|
+
callName,
|
|
47
|
+
callId,
|
|
48
|
+
new Error(`Failed to parse tool input JSON: ${(err as Error).message}`),
|
|
49
|
+
),
|
|
50
|
+
options,
|
|
51
|
+
)
|
|
52
|
+
return { parsedInput: callArgs, parseFailed }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Execute one parsed tool call. If `parseFailed` is present (from
|
|
58
|
+
* `parseToolCallArgs`), short-circuits with the recovered error content.
|
|
59
|
+
* Otherwise dispatches via `runToolWithRecovery`.
|
|
60
|
+
*/
|
|
61
|
+
export async function executeToolCall(
|
|
62
|
+
callName: string,
|
|
63
|
+
callId: string,
|
|
64
|
+
parsedInput: unknown,
|
|
65
|
+
parseFailed: ParsedToolCall['parseFailed'],
|
|
66
|
+
toolMap: Map<string, Tool>,
|
|
67
|
+
options: RunWithToolsOptions,
|
|
68
|
+
): Promise<{ content: string; isError: boolean }> {
|
|
69
|
+
if (parseFailed) return parseFailed
|
|
70
|
+
return runToolWithRecovery(
|
|
71
|
+
toolMap.get(callName),
|
|
72
|
+
callName,
|
|
73
|
+
callId,
|
|
74
|
+
parsedInput,
|
|
75
|
+
options,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One streamed-chunk entry, accumulated across deltas by index. */
|
|
80
|
+
export interface StreamedCallEntry {
|
|
81
|
+
id?: string
|
|
82
|
+
name?: string
|
|
83
|
+
args: string
|
|
84
|
+
started: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Sort the chunked tool_calls map by index and drop the bookkeeping. */
|
|
88
|
+
export function orderStreamedCalls(
|
|
89
|
+
toolCallsByIndex: Map<number, StreamedCallEntry>,
|
|
90
|
+
): StreamedCallEntry[] {
|
|
91
|
+
return [...toolCallsByIndex.entries()]
|
|
92
|
+
.sort(([a], [b]) => a - b)
|
|
93
|
+
.map(([, v]) => v)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the assistant-turn content from a streaming round-trip's
|
|
98
|
+
* accumulated text + ordered tool calls. Mirrors what
|
|
99
|
+
* `fromOpenAIAssistantMessage` does for non-streaming responses.
|
|
100
|
+
*
|
|
101
|
+
* Collapses to a bare string when there's only text — keeps the
|
|
102
|
+
* `messages` array on simple turns clean.
|
|
103
|
+
*/
|
|
104
|
+
export function assistantTurnFromStream(
|
|
105
|
+
textBuf: string,
|
|
106
|
+
orderedCalls: readonly StreamedCallEntry[],
|
|
107
|
+
): string | ContentBlock[] {
|
|
108
|
+
const blocks: ContentBlock[] = []
|
|
109
|
+
if (textBuf.length > 0) blocks.push({ type: 'text', text: textBuf } satisfies TextBlock)
|
|
110
|
+
for (const call of orderedCalls) {
|
|
111
|
+
if (!call.id || !call.name) continue
|
|
112
|
+
let parsedInput: unknown = {}
|
|
113
|
+
try {
|
|
114
|
+
parsedInput = call.args ? JSON.parse(call.args) : {}
|
|
115
|
+
} catch {
|
|
116
|
+
parsedInput = call.args
|
|
117
|
+
}
|
|
118
|
+
blocks.push({
|
|
119
|
+
type: 'tool_use',
|
|
120
|
+
id: call.id,
|
|
121
|
+
name: call.name,
|
|
122
|
+
input: parsedInput,
|
|
123
|
+
} satisfies ToolUseBlock)
|
|
124
|
+
}
|
|
125
|
+
if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
|
|
126
|
+
return blocks
|
|
127
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared non-streaming tool-loop iteration for OpenAI.
|
|
3
|
+
*
|
|
4
|
+
* The 4 OpenAI agentic loops (`_runLoop`, `_runLoopWithSchema`,
|
|
5
|
+
* `_streamLoop`, `_streamLoopWithSchema`) all run the same outer
|
|
6
|
+
* sequence: build params → call SDK → push assistant turn → check
|
|
7
|
+
* terminal → dispatch tool calls → push tool results → increment
|
|
8
|
+
* iteration. This module extracts the non-streaming half so
|
|
9
|
+
* `_runLoop` and `_runLoopWithSchema` become thin orchestrators
|
|
10
|
+
* that handle only their own terminal return shape
|
|
11
|
+
* (`AgentResult | SuspendedRun` vs `AgentGenerateResult<T>`).
|
|
12
|
+
*
|
|
13
|
+
* Streaming variants are not unified here yet — yielding events
|
|
14
|
+
* mid-iteration requires an async-generator wrapper that's a bigger
|
|
15
|
+
* structural change.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type OpenAI from 'openai'
|
|
19
|
+
import type { RunWithToolsOptions } from '../../brain_driver.ts'
|
|
20
|
+
import { BrainError } from '../../brain_error.ts'
|
|
21
|
+
import type { Tool } from '../../tool.ts'
|
|
22
|
+
import type {
|
|
23
|
+
ChatUsage,
|
|
24
|
+
ContentBlock,
|
|
25
|
+
Message,
|
|
26
|
+
ToolResultBlock,
|
|
27
|
+
ToolUseBlock,
|
|
28
|
+
} from '../../types.ts'
|
|
29
|
+
import { checkAborted, reqOpts } from './openai_helpers.ts'
|
|
30
|
+
import { addOpenAIUsage, fromOpenAIAssistantMessage } from './openai_response_mapper.ts'
|
|
31
|
+
import { executeToolCall, parseToolCallArgs } from './openai_tool_dispatch.ts'
|
|
32
|
+
|
|
33
|
+
/** Per-iteration mutable state. The helper mutates this in place. */
|
|
34
|
+
export interface NonStreamLoopState {
|
|
35
|
+
workingMessages: Message[]
|
|
36
|
+
aggregated: ChatUsage
|
|
37
|
+
iterations: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createNonStreamLoopState(messages: readonly Message[]): NonStreamLoopState {
|
|
41
|
+
return {
|
|
42
|
+
workingMessages: [...messages],
|
|
43
|
+
aggregated: {
|
|
44
|
+
inputTokens: 0,
|
|
45
|
+
outputTokens: 0,
|
|
46
|
+
cacheReadTokens: 0,
|
|
47
|
+
cacheCreationTokens: 0,
|
|
48
|
+
},
|
|
49
|
+
iterations: 0,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-iteration outcome the orchestrator branches on. `stop` and
|
|
55
|
+
* `max_iterations` both terminate; the caller maps them to its own
|
|
56
|
+
* return type (e.g. setting `stopReason: 'max_iterations'`).
|
|
57
|
+
*/
|
|
58
|
+
export type NonStreamIterationOutcome =
|
|
59
|
+
| { kind: 'continue' }
|
|
60
|
+
| { kind: 'stop'; assistantText: string; stopReason: string }
|
|
61
|
+
| { kind: 'max_iterations'; assistantText: string }
|
|
62
|
+
| { kind: 'suspended'; pendingToolCalls: ToolUseBlock[] }
|
|
63
|
+
|
|
64
|
+
export interface NonStreamIterationArgs {
|
|
65
|
+
state: NonStreamLoopState
|
|
66
|
+
toolMap: Map<string, Tool>
|
|
67
|
+
maxIterations: number
|
|
68
|
+
client: OpenAI
|
|
69
|
+
/**
|
|
70
|
+
* Built per iteration because `workingMessages` grows each round.
|
|
71
|
+
* The schema variant's closure adds `response_format` after the
|
|
72
|
+
* driver's base `buildParams` runs.
|
|
73
|
+
*/
|
|
74
|
+
buildParams: (msgs: readonly Message[]) => OpenAI.Chat.ChatCompletionCreateParamsNonStreaming
|
|
75
|
+
options: RunWithToolsOptions
|
|
76
|
+
/**
|
|
77
|
+
* Human-in-the-loop gate. Pass `options.shouldSuspend` to enable;
|
|
78
|
+
* pass `undefined` to disable (schema callers don't support
|
|
79
|
+
* suspension — the manager throws before reaching the loop).
|
|
80
|
+
*/
|
|
81
|
+
suspendCheck: NonNullable<RunWithToolsOptions['shouldSuspend']> | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One round-trip of the OpenAI agentic loop. Returns a discriminated
|
|
86
|
+
* outcome the orchestrator branches on; `state` is mutated in place
|
|
87
|
+
* (workingMessages append, aggregated usage, iteration counter).
|
|
88
|
+
*/
|
|
89
|
+
export async function runOpenAINonStreamIteration(
|
|
90
|
+
args: NonStreamIterationArgs,
|
|
91
|
+
): Promise<NonStreamIterationOutcome> {
|
|
92
|
+
const { state, toolMap, maxIterations, client, buildParams, options, suspendCheck } = args
|
|
93
|
+
checkAborted(options.signal)
|
|
94
|
+
const params = buildParams(state.workingMessages)
|
|
95
|
+
const response = await client.chat.completions.create(params, reqOpts(options))
|
|
96
|
+
addOpenAIUsage(state.aggregated, response.usage)
|
|
97
|
+
|
|
98
|
+
const choice = response.choices[0]
|
|
99
|
+
if (!choice) {
|
|
100
|
+
throw new BrainError('OpenAIBrainDriver: response had no choices.')
|
|
101
|
+
}
|
|
102
|
+
const assistantMessage = choice.message
|
|
103
|
+
const assistantText = assistantMessage.content ?? ''
|
|
104
|
+
|
|
105
|
+
// Append assistant turn so the next round-trip sends it back verbatim.
|
|
106
|
+
state.workingMessages.push({
|
|
107
|
+
role: 'assistant',
|
|
108
|
+
content: fromOpenAIAssistantMessage(assistantMessage),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const toolCalls = assistantMessage.tool_calls ?? []
|
|
112
|
+
if (toolCalls.length === 0 || choice.finish_reason !== 'tool_calls') {
|
|
113
|
+
return { kind: 'stop', assistantText, stopReason: choice.finish_reason ?? 'stop' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resultBlocks: ContentBlock[] = []
|
|
117
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
118
|
+
const call = toolCalls[i]!
|
|
119
|
+
if (call.type !== 'function') continue
|
|
120
|
+
const { parsedInput, parseFailed } = parseToolCallArgs(
|
|
121
|
+
call.function.name,
|
|
122
|
+
call.id,
|
|
123
|
+
call.function.arguments,
|
|
124
|
+
options,
|
|
125
|
+
)
|
|
126
|
+
if (suspendCheck && !parseFailed) {
|
|
127
|
+
const frameworkCall: ToolUseBlock = {
|
|
128
|
+
type: 'tool_use',
|
|
129
|
+
id: call.id,
|
|
130
|
+
name: call.function.name,
|
|
131
|
+
input: (parsedInput ?? {}) as Record<string, unknown>,
|
|
132
|
+
}
|
|
133
|
+
if (await suspendCheck(frameworkCall, options.context)) {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'suspended',
|
|
136
|
+
pendingToolCalls: collectPendingToolCalls(toolCalls, i),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const { content, isError } = await executeToolCall(
|
|
141
|
+
call.function.name,
|
|
142
|
+
call.id,
|
|
143
|
+
parsedInput,
|
|
144
|
+
parseFailed,
|
|
145
|
+
toolMap,
|
|
146
|
+
options,
|
|
147
|
+
)
|
|
148
|
+
resultBlocks.push({
|
|
149
|
+
type: 'tool_result',
|
|
150
|
+
toolUseId: call.id,
|
|
151
|
+
content,
|
|
152
|
+
...(isError ? { isError: true } : {}),
|
|
153
|
+
} satisfies ToolResultBlock)
|
|
154
|
+
}
|
|
155
|
+
state.workingMessages.push({ role: 'user', content: resultBlocks })
|
|
156
|
+
|
|
157
|
+
state.iterations++
|
|
158
|
+
if (state.iterations >= maxIterations) {
|
|
159
|
+
return { kind: 'max_iterations', assistantText }
|
|
160
|
+
}
|
|
161
|
+
return { kind: 'continue' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Collect the suspended-mid-batch pending calls. The suspend path
|
|
166
|
+
* doesn't run `onToolError` for unparsable args — the human-in-the-
|
|
167
|
+
* loop reviewer sees the raw string and decides what to do.
|
|
168
|
+
*/
|
|
169
|
+
function collectPendingToolCalls(
|
|
170
|
+
toolCalls: readonly OpenAI.Chat.ChatCompletionMessageToolCall[],
|
|
171
|
+
startIndex: number,
|
|
172
|
+
): ToolUseBlock[] {
|
|
173
|
+
const pending: ToolUseBlock[] = []
|
|
174
|
+
for (let j = startIndex; j < toolCalls.length; j++) {
|
|
175
|
+
const c = toolCalls[j]!
|
|
176
|
+
if (c.type !== 'function') continue
|
|
177
|
+
let pInput: unknown = {}
|
|
178
|
+
try {
|
|
179
|
+
pInput = c.function.arguments ? JSON.parse(c.function.arguments) : {}
|
|
180
|
+
} catch {
|
|
181
|
+
pInput = c.function.arguments ?? {}
|
|
182
|
+
}
|
|
183
|
+
pending.push({
|
|
184
|
+
type: 'tool_use',
|
|
185
|
+
id: c.id,
|
|
186
|
+
name: c.function.name,
|
|
187
|
+
input: pInput as Record<string, unknown>,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
return pending
|
|
191
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenAICompatBrainDriver } from './openai_compat_brain_driver.ts'
|