@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,1015 @@
1
+ /**
2
+ * `OpenAIResponsesBrainDriver` — implementation of `Provider` backed
3
+ * by the `openai` SDK's Responses API
4
+ * (`client.responses.create`).
5
+ *
6
+ * Use when an app needs:
7
+ * - OpenAI's server-side tools: `web_search`, `code_interpreter`
8
+ * (via the framework's `ChatOptions.serverTools` union).
9
+ * - The Responses API's reasoning surfaces (gpt-5 / o-series).
10
+ *
11
+ * For everything else (plain chat, embeddings, transcription,
12
+ * function calling without server tools), the standard
13
+ * `OpenAIBrainDriver` (driver `'openai'`) is simpler. Apps that
14
+ * use both register them as two separate providers and route
15
+ * per-call.
16
+ *
17
+ * Inherits `embed` + `transcribe` from `OpenAIBrainDriver`
18
+ * (embeddings + Whisper live on different endpoints unchanged).
19
+ *
20
+ * V1 coverage:
21
+ * - `chat` / `stream` via `responses.create` (with `stream: true`
22
+ * for the streaming variant).
23
+ * - `runWithTools` / `streamWithTools` — function-calling loop
24
+ * against the Responses API. Local tools + MCP tools + server
25
+ * tools all combine.
26
+ * - `generate` / `runWithToolsAndSchema` /
27
+ * `streamWithToolsAndSchema` — structured output via the
28
+ * Responses API's `text.format: { type: 'json_schema' }`. The
29
+ * non-streaming `generate` is a single call; the schema-aware
30
+ * loops emit `text.format` on every request and parse the
31
+ * final assistant text into `value`.
32
+ *
33
+ * The Responses API's message shape (`input_items`) is different
34
+ * from chat completions' `messages`, so this is a separate
35
+ * provider class rather than a strategy inside `OpenAIBrainDriver`.
36
+ * Translation lives in this file.
37
+ */
38
+
39
+ import OpenAI from 'openai'
40
+ import type { AgentGenerateResult } from '../../agent_generate_result.ts'
41
+ import type { AgentResult } from '../../agent_result.ts'
42
+ import type { AgentStreamEvent } from '../../agent_stream_event.ts'
43
+ import { BrainError } from '../../brain_error.ts'
44
+ import type { OpenAIResponsesProviderConfig } from '../../brain_config.ts'
45
+ import { resolveMcpTools, type ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
46
+ import type { MCPServer } from '../../mcp_server.ts'
47
+ import { parseGenerated, type OutputSchema } from '../../output_schema.ts'
48
+ import type {
49
+ BrainDriver,
50
+ RunWithToolsOptions,
51
+ RunWithToolsOptionsWithSuspend,
52
+ } from '../../brain_driver.ts'
53
+ import type { SuspendedRun } from '../../suspended_run.ts'
54
+ import type { Tool } from '../../tool.ts'
55
+ import { runToolWithRecovery } from '../../tool_runner.ts'
56
+ import type {
57
+ ChatOptions,
58
+ ChatResult,
59
+ ChatUsage,
60
+ ContentBlock,
61
+ GenerateResult,
62
+ Message,
63
+ ServerTool,
64
+ StreamEvent,
65
+ SystemPrompt,
66
+ TextBlock,
67
+ ToolResultBlock,
68
+ ToolUseBlock,
69
+ } from '../../types.ts'
70
+ import { OpenAIBrainDriver } from '../openai/openai_brain_driver.ts'
71
+
72
+ const DEFAULT_OPENAI_MODEL = 'gpt-5'
73
+ const DEFAULT_OPENAI_MAX_TOKENS = 4096
74
+
75
+ export interface OpenAIResponsesProviderOptions {
76
+ client?: OpenAI
77
+ /** Internal seam — tests inject a stub MCP client factory. */
78
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
79
+ /** See `OpenAIProviderOptions.mcpPool` — same semantics. */
80
+ mcpPool?: ResolveMcpToolsOptions['pool']
81
+ }
82
+
83
+ /** Translation: framework `ServerTool` → Responses API tool entry. */
84
+ type ResponsesTool = Record<string, unknown>
85
+
86
+ export class OpenAIResponsesBrainDriver extends OpenAIBrainDriver implements BrainDriver {
87
+ constructor(
88
+ name: string,
89
+ config: OpenAIResponsesProviderConfig,
90
+ options: OpenAIResponsesProviderOptions = {},
91
+ ) {
92
+ // Reuse OpenAIBrainDriver's constructor for the SDK client + the
93
+ // chat / embed / transcribe model defaults. Inheritance keeps
94
+ // `client`, `defaultEmbedModel`, `defaultTranscribeModel`
95
+ // working unchanged.
96
+ super(
97
+ name,
98
+ {
99
+ driver: 'openai',
100
+ apiKey: config.apiKey,
101
+ ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
102
+ ...(config.organization !== undefined ? { organization: config.organization } : {}),
103
+ defaultModel: config.defaultModel ?? DEFAULT_OPENAI_MODEL,
104
+ defaultMaxTokens: config.defaultMaxTokens ?? DEFAULT_OPENAI_MAX_TOKENS,
105
+ ...(config.defaultEmbedModel !== undefined
106
+ ? { defaultEmbedModel: config.defaultEmbedModel }
107
+ : {}),
108
+ ...(config.defaultTranscribeModel !== undefined
109
+ ? { defaultTranscribeModel: config.defaultTranscribeModel }
110
+ : {}),
111
+ },
112
+ options,
113
+ )
114
+ }
115
+
116
+ // ─── chat / stream ──────────────────────────────────────────────────────
117
+
118
+ override async chat(
119
+ messages: readonly Message[],
120
+ options: ChatOptions = {},
121
+ ): Promise<ChatResult> {
122
+ const params = this.buildResponsesParams(messages, options, [])
123
+ const response = await this.client.responses.create(
124
+ params,
125
+ reqOpts(options),
126
+ )
127
+ return this.toChatResultFromResponse(response, params.model as string)
128
+ }
129
+
130
+ override async *stream(
131
+ messages: readonly Message[],
132
+ options: ChatOptions = {},
133
+ ): AsyncIterable<StreamEvent> {
134
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
135
+ ...this.buildResponsesParams(messages, options, []),
136
+ stream: true,
137
+ }
138
+ const stream = await this.client.responses.create(params, reqOpts(options))
139
+ let finishReason: string | null = null
140
+ let usage: OpenAI.Responses.ResponseUsage | undefined
141
+ for await (const event of stream) {
142
+ // Text deltas — `output_text.delta` is the streaming chunk
143
+ // for the model's text output.
144
+ if (event.type === 'response.output_text.delta') {
145
+ const delta = (event as { delta: string }).delta
146
+ if (delta && delta.length > 0) yield { type: 'text', delta }
147
+ } else if (event.type === 'response.completed') {
148
+ const completed = (event as { response: OpenAI.Responses.Response }).response
149
+ usage = completed.usage
150
+ // Responses API doesn't have a finish_reason field directly;
151
+ // the response.status === 'completed' is the signal.
152
+ finishReason = completed.status ?? null
153
+ }
154
+ }
155
+ yield {
156
+ type: 'stop',
157
+ stopReason: finishReason,
158
+ usage: toUsage(usage),
159
+ }
160
+ }
161
+
162
+ // ─── runWithTools / streamWithTools ─────────────────────────────────────
163
+
164
+ override runWithTools(
165
+ messages: readonly Message[],
166
+ tools: readonly Tool[],
167
+ options: RunWithToolsOptionsWithSuspend,
168
+ ): Promise<AgentResult | SuspendedRun>
169
+ override runWithTools(
170
+ messages: readonly Message[],
171
+ tools: readonly Tool[],
172
+ options?: RunWithToolsOptions,
173
+ ): Promise<AgentResult>
174
+ override async runWithTools(
175
+ messages: readonly Message[],
176
+ tools: readonly Tool[],
177
+ options: RunWithToolsOptions = {},
178
+ ): Promise<AgentResult | SuspendedRun> {
179
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
180
+ try {
181
+ return await this._runResponsesLoop(messages, [...tools, ...resolved.tools], options)
182
+ } finally {
183
+ await resolved.close()
184
+ }
185
+ }
186
+
187
+ private async _runResponsesLoop(
188
+ messages: readonly Message[],
189
+ tools: readonly Tool[],
190
+ options: RunWithToolsOptions,
191
+ ): Promise<AgentResult | SuspendedRun> {
192
+ const maxIterations = options.maxIterations ?? 10
193
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
194
+ const workingMessages: Message[] = [...messages]
195
+ const aggregated: ChatUsage = {
196
+ inputTokens: 0,
197
+ outputTokens: 0,
198
+ cacheReadTokens: 0,
199
+ cacheCreationTokens: 0,
200
+ }
201
+ let iterations = 0
202
+
203
+ while (true) {
204
+ checkAborted(options.signal)
205
+ const params = this.buildResponsesParams(workingMessages, options, tools)
206
+ const response = await this.client.responses.create(params, reqOpts(options))
207
+ addUsage(aggregated, response.usage)
208
+
209
+ const assistantBlocks = fromResponsesOutput(response.output)
210
+ const toolCalls = response.output.filter(
211
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
212
+ )
213
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
214
+
215
+ if (toolCalls.length === 0) {
216
+ const text = textFromOutput(response.output)
217
+ const out: AgentResult = {
218
+ text,
219
+ messages: workingMessages,
220
+ iterations,
221
+ stopReason: response.status ?? 'completed',
222
+ usage: aggregated,
223
+ }
224
+ if (response.id) out.responseId = response.id
225
+ return out
226
+ }
227
+
228
+ const resultBlocks: ContentBlock[] = []
229
+ for (let i = 0; i < toolCalls.length; i++) {
230
+ const call = toolCalls[i]!
231
+ let parsedInput: unknown = {}
232
+ let parseFailed: { content: string; isError: boolean } | undefined
233
+ try {
234
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
235
+ } catch (err) {
236
+ parseFailed = await tryRecoverParseError(
237
+ call.name,
238
+ call.call_id,
239
+ err as Error,
240
+ options,
241
+ )
242
+ }
243
+ if (options.shouldSuspend && !parseFailed) {
244
+ const frameworkCall: ToolUseBlock = {
245
+ type: 'tool_use',
246
+ id: call.call_id,
247
+ name: call.name,
248
+ input: (parsedInput ?? {}) as Record<string, unknown>,
249
+ }
250
+ if (await options.shouldSuspend(frameworkCall, options.context)) {
251
+ const pending: ToolUseBlock[] = []
252
+ for (let j = i; j < toolCalls.length; j++) {
253
+ const c = toolCalls[j]!
254
+ let pInput: unknown = {}
255
+ try {
256
+ pInput = c.arguments ? JSON.parse(c.arguments) : {}
257
+ } catch {
258
+ pInput = c.arguments ?? {}
259
+ }
260
+ pending.push({
261
+ type: 'tool_use',
262
+ id: c.call_id,
263
+ name: c.name,
264
+ input: pInput as Record<string, unknown>,
265
+ })
266
+ }
267
+ return {
268
+ status: 'suspended',
269
+ pendingToolCalls: pending,
270
+ state: {
271
+ messages: workingMessages,
272
+ iterations,
273
+ usage: aggregated,
274
+ ...(response.id ? { responseId: response.id } : {}),
275
+ },
276
+ }
277
+ }
278
+ }
279
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
280
+ toolMap.get(call.name),
281
+ call.name,
282
+ call.call_id,
283
+ parsedInput,
284
+ options,
285
+ )
286
+ resultBlocks.push({
287
+ type: 'tool_result',
288
+ toolUseId: call.call_id,
289
+ content,
290
+ ...(isError ? { isError: true } : {}),
291
+ } satisfies ToolResultBlock)
292
+ }
293
+ workingMessages.push({ role: 'user', content: resultBlocks })
294
+
295
+ iterations++
296
+ if (iterations >= maxIterations) {
297
+ const text = textFromOutput(response.output)
298
+ const out: AgentResult = {
299
+ text,
300
+ messages: workingMessages,
301
+ iterations,
302
+ stopReason: 'max_iterations',
303
+ usage: aggregated,
304
+ }
305
+ if (response.id) out.responseId = response.id
306
+ return out
307
+ }
308
+ }
309
+ }
310
+
311
+ override async *streamWithTools(
312
+ messages: readonly Message[],
313
+ tools: readonly Tool[],
314
+ options: RunWithToolsOptions = {},
315
+ ): AsyncIterable<AgentStreamEvent> {
316
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
317
+ try {
318
+ yield* this._streamResponsesLoop(messages, [...tools, ...resolved.tools], options)
319
+ } finally {
320
+ await resolved.close()
321
+ }
322
+ }
323
+
324
+ private async *_streamResponsesLoop(
325
+ messages: readonly Message[],
326
+ tools: readonly Tool[],
327
+ options: RunWithToolsOptions,
328
+ ): AsyncIterable<AgentStreamEvent> {
329
+ const maxIterations = options.maxIterations ?? 10
330
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
331
+ const workingMessages: Message[] = [...messages]
332
+ const aggregated: ChatUsage = {
333
+ inputTokens: 0,
334
+ outputTokens: 0,
335
+ cacheReadTokens: 0,
336
+ cacheCreationTokens: 0,
337
+ }
338
+ let iterations = 0
339
+
340
+ while (true) {
341
+ checkAborted(options.signal)
342
+ yield { type: 'iteration_start', iteration: iterations }
343
+
344
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
345
+ ...this.buildResponsesParams(workingMessages, options, tools),
346
+ stream: true,
347
+ }
348
+ const stream = await this.client.responses.create(params, reqOpts(options))
349
+ let finishReason: string | null = null
350
+ let finalResponse: OpenAI.Responses.Response | undefined
351
+
352
+ for await (const event of stream) {
353
+ if (event.type === 'response.output_text.delta') {
354
+ const delta = (event as { delta: string }).delta
355
+ if (delta && delta.length > 0) yield { type: 'text', delta }
356
+ } else if (event.type === 'response.completed') {
357
+ const completed = (event as { response: OpenAI.Responses.Response }).response
358
+ finalResponse = completed
359
+ finishReason = completed.status ?? null
360
+ }
361
+ }
362
+
363
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
364
+
365
+ if (!finalResponse) {
366
+ // The stream ended without a completion event — surface the
367
+ // best stop we have and bail.
368
+ yield {
369
+ type: 'stop',
370
+ stopReason: finishReason ?? 'incomplete',
371
+ iterations,
372
+ usage: aggregated,
373
+ messages: workingMessages,
374
+ }
375
+ return
376
+ }
377
+
378
+ addUsage(aggregated, finalResponse.usage)
379
+ const assistantBlocks = fromResponsesOutput(finalResponse.output)
380
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
381
+
382
+ const toolCalls = finalResponse.output.filter(
383
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
384
+ )
385
+ if (toolCalls.length === 0) {
386
+ yield {
387
+ type: 'stop',
388
+ stopReason: finishReason ?? 'completed',
389
+ iterations,
390
+ usage: aggregated,
391
+ messages: workingMessages,
392
+ }
393
+ return
394
+ }
395
+
396
+ const resultBlocks: ContentBlock[] = []
397
+ for (const call of toolCalls) {
398
+ let parsedInput: unknown = {}
399
+ let parseFailed: { content: string; isError: boolean } | undefined
400
+ try {
401
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
402
+ } catch (err) {
403
+ parseFailed = await tryRecoverParseError(
404
+ call.name,
405
+ call.call_id,
406
+ err as Error,
407
+ options,
408
+ )
409
+ }
410
+ yield { type: 'tool_use', id: call.call_id, name: call.name, input: parsedInput }
411
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
412
+ toolMap.get(call.name),
413
+ call.name,
414
+ call.call_id,
415
+ parsedInput,
416
+ options,
417
+ )
418
+ resultBlocks.push({
419
+ type: 'tool_result',
420
+ toolUseId: call.call_id,
421
+ content,
422
+ ...(isError ? { isError: true } : {}),
423
+ } satisfies ToolResultBlock)
424
+ yield {
425
+ type: 'tool_result',
426
+ id: call.call_id,
427
+ name: call.name,
428
+ content,
429
+ isError,
430
+ }
431
+ }
432
+ workingMessages.push({ role: 'user', content: resultBlocks })
433
+
434
+ iterations++
435
+ if (iterations >= maxIterations) {
436
+ yield {
437
+ type: 'stop',
438
+ stopReason: 'max_iterations',
439
+ iterations,
440
+ usage: aggregated,
441
+ messages: workingMessages,
442
+ }
443
+ return
444
+ }
445
+ }
446
+ }
447
+
448
+ // ─── generate / runWithToolsAndSchema / streamWithToolsAndSchema ────────
449
+
450
+ override async generate<T>(
451
+ messages: readonly Message[],
452
+ schema: OutputSchema<T>,
453
+ options: ChatOptions = {},
454
+ ): Promise<GenerateResult<T>> {
455
+ const params = this.buildResponsesParams(messages, options, [], schema)
456
+ const response = await this.client.responses.create(params, reqOpts(options))
457
+ const text = textFromOutput(response.output)
458
+ const value = parseGenerated(text, schema)
459
+ const result: GenerateResult<T, OpenAI.Responses.Response> = {
460
+ value,
461
+ text,
462
+ model: response.model ?? (params.model as string),
463
+ stopReason: response.status ?? null,
464
+ usage: toUsage(response.usage),
465
+ raw: response,
466
+ }
467
+ if (response.id) result.responseId = response.id
468
+ return result
469
+ }
470
+
471
+ override async runWithToolsAndSchema<T>(
472
+ messages: readonly Message[],
473
+ tools: readonly Tool[],
474
+ schema: OutputSchema<T>,
475
+ options: RunWithToolsOptions = {},
476
+ ): Promise<AgentGenerateResult<T>> {
477
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
478
+ try {
479
+ return await this._runResponsesLoopWithSchema(
480
+ messages,
481
+ [...tools, ...resolved.tools],
482
+ schema,
483
+ options,
484
+ )
485
+ } finally {
486
+ await resolved.close()
487
+ }
488
+ }
489
+
490
+ private async _runResponsesLoopWithSchema<T>(
491
+ messages: readonly Message[],
492
+ tools: readonly Tool[],
493
+ schema: OutputSchema<T>,
494
+ options: RunWithToolsOptions,
495
+ ): Promise<AgentGenerateResult<T>> {
496
+ const maxIterations = options.maxIterations ?? 10
497
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
498
+ const workingMessages: Message[] = [...messages]
499
+ const aggregated: ChatUsage = {
500
+ inputTokens: 0,
501
+ outputTokens: 0,
502
+ cacheReadTokens: 0,
503
+ cacheCreationTokens: 0,
504
+ }
505
+ let iterations = 0
506
+
507
+ while (true) {
508
+ checkAborted(options.signal)
509
+ const params = this.buildResponsesParams(workingMessages, options, tools, schema)
510
+ const response = await this.client.responses.create(params, reqOpts(options))
511
+ addUsage(aggregated, response.usage)
512
+
513
+ const assistantBlocks = fromResponsesOutput(response.output)
514
+ const toolCalls = response.output.filter(
515
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
516
+ )
517
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
518
+
519
+ if (toolCalls.length === 0) {
520
+ const text = textFromOutput(response.output)
521
+ const value = parseGenerated(text, schema)
522
+ const out: AgentGenerateResult<T> = {
523
+ value,
524
+ text,
525
+ messages: workingMessages,
526
+ iterations,
527
+ stopReason: response.status ?? 'completed',
528
+ usage: aggregated,
529
+ }
530
+ if (response.id) out.responseId = response.id
531
+ return out
532
+ }
533
+
534
+ const resultBlocks: ContentBlock[] = []
535
+ for (const call of toolCalls) {
536
+ let parsedInput: unknown = {}
537
+ let parseFailed: { content: string; isError: boolean } | undefined
538
+ try {
539
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
540
+ } catch (err) {
541
+ parseFailed = await tryRecoverParseError(
542
+ call.name,
543
+ call.call_id,
544
+ err as Error,
545
+ options,
546
+ )
547
+ }
548
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
549
+ toolMap.get(call.name),
550
+ call.name,
551
+ call.call_id,
552
+ parsedInput,
553
+ options,
554
+ )
555
+ resultBlocks.push({
556
+ type: 'tool_result',
557
+ toolUseId: call.call_id,
558
+ content,
559
+ ...(isError ? { isError: true } : {}),
560
+ } satisfies ToolResultBlock)
561
+ }
562
+ workingMessages.push({ role: 'user', content: resultBlocks })
563
+
564
+ iterations++
565
+ if (iterations >= maxIterations) {
566
+ const text = textFromOutput(response.output)
567
+ const value = parseGenerated(text, schema)
568
+ const out: AgentGenerateResult<T> = {
569
+ value,
570
+ text,
571
+ messages: workingMessages,
572
+ iterations,
573
+ stopReason: 'max_iterations',
574
+ usage: aggregated,
575
+ }
576
+ if (response.id) out.responseId = response.id
577
+ return out
578
+ }
579
+ }
580
+ }
581
+
582
+ override async *streamWithToolsAndSchema<T>(
583
+ messages: readonly Message[],
584
+ tools: readonly Tool[],
585
+ schema: OutputSchema<T>,
586
+ options: RunWithToolsOptions = {},
587
+ ): AsyncIterable<AgentStreamEvent<T>> {
588
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
589
+ try {
590
+ yield* this._streamResponsesLoopWithSchema(
591
+ messages,
592
+ [...tools, ...resolved.tools],
593
+ schema,
594
+ options,
595
+ )
596
+ } finally {
597
+ await resolved.close()
598
+ }
599
+ }
600
+
601
+ private async *_streamResponsesLoopWithSchema<T>(
602
+ messages: readonly Message[],
603
+ tools: readonly Tool[],
604
+ schema: OutputSchema<T>,
605
+ options: RunWithToolsOptions,
606
+ ): AsyncIterable<AgentStreamEvent<T>> {
607
+ const maxIterations = options.maxIterations ?? 10
608
+ const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
609
+ const workingMessages: Message[] = [...messages]
610
+ const aggregated: ChatUsage = {
611
+ inputTokens: 0,
612
+ outputTokens: 0,
613
+ cacheReadTokens: 0,
614
+ cacheCreationTokens: 0,
615
+ }
616
+ let iterations = 0
617
+
618
+ while (true) {
619
+ checkAborted(options.signal)
620
+ yield { type: 'iteration_start', iteration: iterations }
621
+
622
+ const params: OpenAI.Responses.ResponseCreateParamsStreaming = {
623
+ ...this.buildResponsesParams(workingMessages, options, tools, schema),
624
+ stream: true,
625
+ }
626
+ const stream = await this.client.responses.create(params, reqOpts(options))
627
+ let finishReason: string | null = null
628
+ let finalResponse: OpenAI.Responses.Response | undefined
629
+
630
+ for await (const event of stream) {
631
+ if (event.type === 'response.output_text.delta') {
632
+ const delta = (event as { delta: string }).delta
633
+ if (delta && delta.length > 0) yield { type: 'text', delta }
634
+ } else if (event.type === 'response.completed') {
635
+ const completed = (event as { response: OpenAI.Responses.Response }).response
636
+ finalResponse = completed
637
+ finishReason = completed.status ?? null
638
+ }
639
+ }
640
+
641
+ yield { type: 'iteration_end', iteration: iterations, stopReason: finishReason }
642
+
643
+ if (!finalResponse) {
644
+ // Stream ended without a completion event — no text to parse;
645
+ // surface the best stop we have. We can't synthesize a `value`
646
+ // without text, so degrade to the base stop shape.
647
+ yield {
648
+ type: 'stop',
649
+ stopReason: finishReason ?? 'incomplete',
650
+ iterations,
651
+ usage: aggregated,
652
+ messages: workingMessages,
653
+ } as AgentStreamEvent<T>
654
+ return
655
+ }
656
+
657
+ addUsage(aggregated, finalResponse.usage)
658
+ const assistantBlocks = fromResponsesOutput(finalResponse.output)
659
+ workingMessages.push({ role: 'assistant', content: assistantBlocks })
660
+
661
+ const toolCalls = finalResponse.output.filter(
662
+ (o): o is OpenAI.Responses.ResponseFunctionToolCall => o.type === 'function_call',
663
+ )
664
+ if (toolCalls.length === 0) {
665
+ const text = textFromOutput(finalResponse.output)
666
+ const value = parseGenerated(text, schema)
667
+ yield {
668
+ type: 'stop',
669
+ stopReason: finishReason ?? 'completed',
670
+ iterations,
671
+ usage: aggregated,
672
+ messages: workingMessages,
673
+ value,
674
+ text,
675
+ } as AgentStreamEvent<T>
676
+ return
677
+ }
678
+
679
+ const resultBlocks: ContentBlock[] = []
680
+ for (const call of toolCalls) {
681
+ let parsedInput: unknown = {}
682
+ let parseFailed: { content: string; isError: boolean } | undefined
683
+ try {
684
+ parsedInput = call.arguments ? JSON.parse(call.arguments) : {}
685
+ } catch (err) {
686
+ parseFailed = await tryRecoverParseError(
687
+ call.name,
688
+ call.call_id,
689
+ err as Error,
690
+ options,
691
+ )
692
+ }
693
+ yield { type: 'tool_use', id: call.call_id, name: call.name, input: parsedInput }
694
+ const { content, isError } = parseFailed ?? await runToolWithRecovery(
695
+ toolMap.get(call.name),
696
+ call.name,
697
+ call.call_id,
698
+ parsedInput,
699
+ options,
700
+ )
701
+ resultBlocks.push({
702
+ type: 'tool_result',
703
+ toolUseId: call.call_id,
704
+ content,
705
+ ...(isError ? { isError: true } : {}),
706
+ } satisfies ToolResultBlock)
707
+ yield {
708
+ type: 'tool_result',
709
+ id: call.call_id,
710
+ name: call.name,
711
+ content,
712
+ isError,
713
+ }
714
+ }
715
+ workingMessages.push({ role: 'user', content: resultBlocks })
716
+
717
+ iterations++
718
+ if (iterations >= maxIterations) {
719
+ const text = textFromOutput(finalResponse.output)
720
+ const value = parseGenerated(text, schema)
721
+ yield {
722
+ type: 'stop',
723
+ stopReason: 'max_iterations',
724
+ iterations,
725
+ usage: aggregated,
726
+ messages: workingMessages,
727
+ value,
728
+ text,
729
+ } as AgentStreamEvent<T>
730
+ return
731
+ }
732
+ }
733
+ }
734
+
735
+ // ─── Param translation ──────────────────────────────────────────────────
736
+
737
+ private buildResponsesParams(
738
+ messages: readonly Message[],
739
+ options: ChatOptions,
740
+ tools: readonly Tool[],
741
+ schema?: OutputSchema<unknown>,
742
+ ): OpenAI.Responses.ResponseCreateParamsNonStreaming {
743
+ const model = options.model ?? this.defaultModel
744
+ const params: OpenAI.Responses.ResponseCreateParamsNonStreaming = {
745
+ model,
746
+ input: messages.flatMap((m) => {
747
+ const r = toResponsesInputItem(m)
748
+ return Array.isArray(r) ? r : [r]
749
+ }) as unknown as OpenAI.Responses.ResponseInput,
750
+ max_output_tokens: options.maxTokens ?? this.defaultMaxTokens,
751
+ }
752
+ if (options.previousResponseId !== undefined) {
753
+ params.previous_response_id = options.previousResponseId
754
+ }
755
+ const systemText = systemPromptText(options.system)
756
+ if (systemText.length > 0) params.instructions = systemText
757
+
758
+ const toolEntries: ResponsesTool[] = []
759
+ for (const t of tools) {
760
+ toolEntries.push({
761
+ type: 'function',
762
+ name: t.name,
763
+ description: t.description,
764
+ parameters: t.inputSchema,
765
+ strict: false,
766
+ })
767
+ }
768
+ if (options.serverTools && options.serverTools.length > 0) {
769
+ toolEntries.push(...responsesServerTools(options.serverTools))
770
+ }
771
+ if (toolEntries.length > 0) {
772
+ params.tools = toolEntries as unknown as OpenAI.Responses.ResponseCreateParams['tools']
773
+ }
774
+
775
+ if (schema !== undefined) {
776
+ params.text = {
777
+ format: {
778
+ type: 'json_schema',
779
+ name: schema.name,
780
+ schema: schema.jsonSchema as { [key: string]: unknown },
781
+ strict: true,
782
+ ...(schema.description !== undefined ? { description: schema.description } : {}),
783
+ },
784
+ }
785
+ }
786
+
787
+ // Reasoning controls — gpt-5 and o-series only. Emit when set;
788
+ // non-reasoning models reject.
789
+ if (options.effort !== undefined) {
790
+ params.reasoning = { effort: options.effort } as OpenAI.Responses.ResponseCreateParams['reasoning']
791
+ } else if (options.thinking === 'adaptive') {
792
+ params.reasoning = { effort: 'medium' } as OpenAI.Responses.ResponseCreateParams['reasoning']
793
+ } else if (options.thinking === 'disabled') {
794
+ params.reasoning = { effort: 'minimal' } as OpenAI.Responses.ResponseCreateParams['reasoning']
795
+ }
796
+
797
+ return params
798
+ }
799
+
800
+ private toChatResultFromResponse(
801
+ response: OpenAI.Responses.Response,
802
+ requestedModel: string,
803
+ ): ChatResult<OpenAI.Responses.Response> {
804
+ const result: ChatResult<OpenAI.Responses.Response> = {
805
+ text: textFromOutput(response.output),
806
+ model: response.model ?? requestedModel,
807
+ stopReason: response.status ?? null,
808
+ usage: toUsage(response.usage),
809
+ raw: response,
810
+ }
811
+ if (response.id) result.responseId = response.id
812
+ return result
813
+ }
814
+ }
815
+
816
+ // ─── Translation helpers ──────────────────────────────────────────────────
817
+
818
+ function systemPromptText(system: SystemPrompt | undefined): string {
819
+ if (system === undefined) return ''
820
+ if (typeof system === 'string') return system
821
+ if (Array.isArray(system)) return system.map((b) => b.text).join('\n')
822
+ return system.text
823
+ }
824
+
825
+ /**
826
+ * Translate a framework `Message` into a Responses API input item.
827
+ * V1 covers text + tool_use + tool_result; other content blocks
828
+ * (image / document / audio) fall back to text concatenation until
829
+ * the Responses API multimodal slice ships.
830
+ */
831
+ function toResponsesInputItem(message: Message): unknown {
832
+ if (typeof message.content === 'string') {
833
+ return { role: message.role, content: message.content }
834
+ }
835
+ // For user-role tool results, emit one `function_call_output` per
836
+ // tool_result block. The Responses API wants each result as its
837
+ // own input item, NOT bundled in a message turn.
838
+ if (message.role === 'user') {
839
+ const toolResults = message.content.filter((b): b is ToolResultBlock => b.type === 'tool_result')
840
+ if (toolResults.length > 0) {
841
+ // Multi-item return — caller handles arrays in input.
842
+ const items: unknown[] = []
843
+ const remainingText: string[] = []
844
+ for (const block of message.content) {
845
+ if (block.type === 'tool_result') {
846
+ const content = typeof block.content === 'string'
847
+ ? block.content
848
+ : block.content.map((t) => t.text).join('')
849
+ items.push({
850
+ type: 'function_call_output',
851
+ call_id: block.toolUseId,
852
+ output: content,
853
+ })
854
+ } else if (block.type === 'text') {
855
+ remainingText.push(block.text)
856
+ }
857
+ }
858
+ if (remainingText.length > 0) {
859
+ items.unshift({ role: 'user', content: remainingText.join('') })
860
+ }
861
+ return items
862
+ }
863
+ // Plain user message with mixed blocks → flatten text.
864
+ return {
865
+ role: 'user',
866
+ content: message.content
867
+ .filter((b): b is TextBlock => b.type === 'text')
868
+ .map((b) => b.text)
869
+ .join(''),
870
+ }
871
+ }
872
+ // Assistant turn with tool_use blocks → emit function_call items.
873
+ const items: unknown[] = []
874
+ const textParts: string[] = []
875
+ for (const block of message.content) {
876
+ if (block.type === 'text') {
877
+ textParts.push(block.text)
878
+ } else if (block.type === 'tool_use') {
879
+ items.push({
880
+ type: 'function_call',
881
+ call_id: block.id,
882
+ name: block.name,
883
+ arguments: JSON.stringify(block.input ?? {}),
884
+ })
885
+ }
886
+ }
887
+ if (textParts.length > 0) {
888
+ items.unshift({ role: 'assistant', content: textParts.join('') })
889
+ }
890
+ return items.length === 1 ? items[0] : items
891
+ }
892
+
893
+ /**
894
+ * Extract framework `ContentBlock[]` from a Responses API output
895
+ * array — text from `output_message.content[].text`, tool calls
896
+ * from `function_call` items. Server-tool calls (web_search,
897
+ * code_interpreter) are not surfaced as blocks; they live on
898
+ * `response.output` and apps inspect via `raw` for now.
899
+ */
900
+ function fromResponsesOutput(
901
+ output: readonly OpenAI.Responses.ResponseOutputItem[],
902
+ ): string | ContentBlock[] {
903
+ const blocks: ContentBlock[] = []
904
+ for (const item of output) {
905
+ if (item.type === 'message' && item.role === 'assistant') {
906
+ for (const part of item.content) {
907
+ if (part.type === 'output_text') {
908
+ blocks.push({ type: 'text', text: part.text })
909
+ }
910
+ }
911
+ } else if (item.type === 'function_call') {
912
+ let parsed: unknown = {}
913
+ try {
914
+ parsed = item.arguments ? JSON.parse(item.arguments) : {}
915
+ } catch {
916
+ parsed = item.arguments ?? {}
917
+ }
918
+ blocks.push({
919
+ type: 'tool_use',
920
+ id: item.call_id,
921
+ name: item.name,
922
+ input: parsed,
923
+ } satisfies ToolUseBlock)
924
+ }
925
+ // Server-tool result items (web_search_call, code_interpreter_call,
926
+ // etc.) are surfaced on `raw` — V1 doesn't add framework blocks
927
+ // for them; apps inspect raw when they care.
928
+ }
929
+ if (blocks.length === 1 && blocks[0]?.type === 'text') return blocks[0].text
930
+ return blocks
931
+ }
932
+
933
+ function textFromOutput(output: readonly OpenAI.Responses.ResponseOutputItem[]): string {
934
+ const parts: string[] = []
935
+ for (const item of output) {
936
+ if (item.type === 'message' && item.role === 'assistant') {
937
+ for (const p of item.content) {
938
+ if (p.type === 'output_text') parts.push(p.text)
939
+ }
940
+ }
941
+ }
942
+ return parts.join('')
943
+ }
944
+
945
+ function responsesServerTools(serverTools: readonly ServerTool[]): ResponsesTool[] {
946
+ const out: ResponsesTool[] = []
947
+ for (const t of serverTools) {
948
+ if (t.type === 'web_search') {
949
+ out.push({ type: 'web_search' })
950
+ } else if (t.type === 'code_execution') {
951
+ out.push({ type: 'code_interpreter', container: { type: 'auto' } })
952
+ } else if (t.type === 'web_fetch') {
953
+ throw new BrainError(
954
+ 'OpenAIResponsesBrainDriver: server tool `web_fetch` is Anthropic-only. Use `web_search` for OpenAI, or route to Anthropic.',
955
+ { context: { provider: 'openai-responses' } },
956
+ )
957
+ } else if (t.type === 'url_context') {
958
+ throw new BrainError(
959
+ 'OpenAIResponsesBrainDriver: server tool `url_context` is Gemini-only. Route to Gemini, or include the URL in the prompt and use `web_search`.',
960
+ { context: { provider: 'openai-responses' } },
961
+ )
962
+ }
963
+ }
964
+ return out
965
+ }
966
+
967
+ function toUsage(u: OpenAI.Responses.ResponseUsage | undefined): ChatUsage {
968
+ return {
969
+ inputTokens: u?.input_tokens ?? 0,
970
+ outputTokens: u?.output_tokens ?? 0,
971
+ cacheReadTokens: u?.input_tokens_details?.cached_tokens ?? 0,
972
+ cacheCreationTokens: 0,
973
+ }
974
+ }
975
+
976
+ function addUsage(acc: ChatUsage, u: OpenAI.Responses.ResponseUsage | undefined): void {
977
+ if (!u) return
978
+ acc.inputTokens += u.input_tokens ?? 0
979
+ acc.outputTokens += u.output_tokens ?? 0
980
+ acc.cacheReadTokens += u.input_tokens_details?.cached_tokens ?? 0
981
+ }
982
+
983
+ function reqOpts(options: { signal?: AbortSignal }): { signal?: AbortSignal } | undefined {
984
+ return options.signal !== undefined ? { signal: options.signal } : undefined
985
+ }
986
+
987
+ function checkAborted(signal: AbortSignal | undefined): void {
988
+ if (signal?.aborted) {
989
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError')
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Handle a JSON.parse failure on a `function_call.arguments` field
995
+ * through the standard `onToolError` recovery hook. Returns a
996
+ * recovery result or rethrows as ToolExecutionError. Kept inline
997
+ * (not in tool_runner.ts) because the call shape — error-only,
998
+ * pre-execute — differs from the standard path.
999
+ */
1000
+ async function tryRecoverParseError(
1001
+ toolName: string,
1002
+ callId: string,
1003
+ cause: Error,
1004
+ options: RunWithToolsOptions,
1005
+ ): Promise<{ content: string; isError: boolean }> {
1006
+ const { ToolExecutionError } = await import('../../tool_execution_error.ts')
1007
+ const err = new ToolExecutionError(
1008
+ toolName,
1009
+ callId,
1010
+ new Error(`Failed to parse tool input JSON: ${cause.message}`),
1011
+ )
1012
+ const recovered = options.onToolError?.(err)
1013
+ if (typeof recovered !== 'string') throw err
1014
+ return { content: recovered, isError: true }
1015
+ }