@strav/brain 1.0.0-alpha.9 → 1.0.2

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 (73) hide show
  1. package/package.json +23 -7
  2. package/src/agent.ts +43 -5
  3. package/src/agent_generate_result.ts +32 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +218 -14
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +218 -1
  8. package/src/brain_driver.ts +247 -0
  9. package/src/brain_error.ts +86 -10
  10. package/src/brain_manager.ts +359 -11
  11. package/src/brain_provider.ts +79 -9
  12. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  13. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  14. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  15. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  16. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  17. package/src/drivers/anthropic/index.ts +1 -0
  18. package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
  19. package/src/drivers/deepseek/index.ts +1 -0
  20. package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
  21. package/src/drivers/gemini/index.ts +1 -0
  22. package/src/drivers/minimax/index.ts +1 -0
  23. package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
  24. package/src/drivers/ollama/index.ts +1 -0
  25. package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
  26. package/src/drivers/openai/index.ts +1 -0
  27. package/src/drivers/openai/openai_brain_driver.ts +796 -0
  28. package/src/drivers/openai/openai_helpers.ts +58 -0
  29. package/src/drivers/openai/openai_message_builder.ts +187 -0
  30. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  31. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  32. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  33. package/src/drivers/openai_compat/index.ts +1 -0
  34. package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
  35. package/src/drivers/openai_responses/index.ts +1 -0
  36. package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
  37. package/src/drivers/openrouter/index.ts +1 -0
  38. package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
  39. package/src/drivers/qwen/index.ts +1 -0
  40. package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
  41. package/src/index.ts +75 -11
  42. package/src/mcp/client.ts +243 -0
  43. package/src/mcp/index.ts +23 -0
  44. package/src/mcp/oauth.ts +227 -0
  45. package/src/mcp/pool.ts +106 -0
  46. package/src/mcp/resolve_mcp_tools.ts +108 -0
  47. package/src/mcp_server.ts +63 -0
  48. package/src/output_schema.ts +72 -0
  49. package/src/persistence/brain_message.ts +34 -0
  50. package/src/persistence/brain_message_repository.ts +98 -0
  51. package/src/persistence/brain_store.ts +166 -0
  52. package/src/persistence/brain_suspended_run.ts +30 -0
  53. package/src/persistence/brain_suspended_run_repository.ts +59 -0
  54. package/src/persistence/brain_thread.ts +30 -0
  55. package/src/persistence/brain_thread_repository.ts +56 -0
  56. package/src/persistence/database_brain_store.ts +190 -0
  57. package/src/persistence/index.ts +48 -0
  58. package/src/persistence/schemas/brain_message_schema.ts +61 -0
  59. package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
  60. package/src/persistence/schemas/brain_thread_schema.ts +50 -0
  61. package/src/persistence/schemas/index.ts +3 -0
  62. package/src/suspended_run.ts +153 -0
  63. package/src/thread.ts +40 -1
  64. package/src/tool.ts +7 -0
  65. package/src/tool_runner.ts +81 -0
  66. package/src/translate/index.ts +19 -0
  67. package/src/translate/translate_cache.ts +78 -0
  68. package/src/translate/translate_provider.ts +46 -0
  69. package/src/translate/translator.ts +271 -0
  70. package/src/types.ts +398 -1
  71. package/src/zod/index.ts +121 -0
  72. package/src/provider.ts +0 -74
  73. 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'