@strav/brain 1.0.0-alpha.17 → 1.0.0-alpha.18

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.
@@ -24,10 +24,11 @@
24
24
  * against the Responses API. Local tools + MCP tools + server
25
25
  * tools all combine.
26
26
  * - `generate` / `runWithToolsAndSchema` /
27
- * `streamWithToolsAndSchema` — throw `BrainError` with
28
- * "structured output via Responses API is a follow-up slice"
29
- * guidance. Apps that need structured output use
30
- * `OpenAIProvider` (driver `'openai'`).
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`.
31
32
  *
32
33
  * The Responses API's message shape (`input_items`) is different
33
34
  * from chat completions' `messages`, so this is a separate
@@ -43,8 +44,13 @@ import { BrainError } from '../brain_error.ts'
43
44
  import type { OpenAIResponsesProviderConfig } from '../brain_config.ts'
44
45
  import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
45
46
  import type { MCPServer } from '../mcp_server.ts'
46
- import type { OutputSchema } from '../output_schema.ts'
47
- import type { Provider, RunWithToolsOptions } from '../provider.ts'
47
+ import { parseGenerated, type OutputSchema } from '../output_schema.ts'
48
+ import type {
49
+ Provider,
50
+ RunWithToolsOptions,
51
+ RunWithToolsOptionsWithSuspend,
52
+ } from '../provider.ts'
53
+ import type { SuspendedRun } from '../suspended_run.ts'
48
54
  import type { Tool } from '../tool.ts'
49
55
  import { runToolWithRecovery } from '../tool_runner.ts'
50
56
  import type {
@@ -70,6 +76,8 @@ export interface OpenAIResponsesProviderOptions {
70
76
  client?: OpenAI
71
77
  /** Internal seam — tests inject a stub MCP client factory. */
72
78
  mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
79
+ /** See `OpenAIProviderOptions.mcpPool` — same semantics. */
80
+ mcpPool?: ResolveMcpToolsOptions['pool']
73
81
  }
74
82
 
75
83
  /** Translation: framework `ServerTool` → Responses API tool entry. */
@@ -153,18 +161,22 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
153
161
 
154
162
  // ─── runWithTools / streamWithTools ─────────────────────────────────────
155
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>
156
174
  override async runWithTools(
157
175
  messages: readonly Message[],
158
176
  tools: readonly Tool[],
159
177
  options: RunWithToolsOptions = {},
160
- ): Promise<AgentResult> {
161
- const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
162
- const resolved =
163
- mcpServers.length > 0
164
- ? await resolveMcpTools(mcpServers, {
165
- ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
166
- })
167
- : { tools: [] as Tool[], close: async () => {} }
178
+ ): Promise<AgentResult | SuspendedRun> {
179
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
168
180
  try {
169
181
  return await this._runResponsesLoop(messages, [...tools, ...resolved.tools], options)
170
182
  } finally {
@@ -176,7 +188,7 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
176
188
  messages: readonly Message[],
177
189
  tools: readonly Tool[],
178
190
  options: RunWithToolsOptions,
179
- ): Promise<AgentResult> {
191
+ ): Promise<AgentResult | SuspendedRun> {
180
192
  const maxIterations = options.maxIterations ?? 10
181
193
  const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
182
194
  const workingMessages: Message[] = [...messages]
@@ -202,17 +214,20 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
202
214
 
203
215
  if (toolCalls.length === 0) {
204
216
  const text = textFromOutput(response.output)
205
- return {
217
+ const out: AgentResult = {
206
218
  text,
207
219
  messages: workingMessages,
208
220
  iterations,
209
221
  stopReason: response.status ?? 'completed',
210
222
  usage: aggregated,
211
223
  }
224
+ if (response.id) out.responseId = response.id
225
+ return out
212
226
  }
213
227
 
214
228
  const resultBlocks: ContentBlock[] = []
215
- for (const call of toolCalls) {
229
+ for (let i = 0; i < toolCalls.length; i++) {
230
+ const call = toolCalls[i]!
216
231
  let parsedInput: unknown = {}
217
232
  let parseFailed: { content: string; isError: boolean } | undefined
218
233
  try {
@@ -225,6 +240,42 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
225
240
  options,
226
241
  )
227
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
+ }
228
279
  const { content, isError } = parseFailed ?? await runToolWithRecovery(
229
280
  toolMap.get(call.name),
230
281
  call.name,
@@ -244,13 +295,15 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
244
295
  iterations++
245
296
  if (iterations >= maxIterations) {
246
297
  const text = textFromOutput(response.output)
247
- return {
298
+ const out: AgentResult = {
248
299
  text,
249
300
  messages: workingMessages,
250
301
  iterations,
251
302
  stopReason: 'max_iterations',
252
303
  usage: aggregated,
253
304
  }
305
+ if (response.id) out.responseId = response.id
306
+ return out
254
307
  }
255
308
  }
256
309
  }
@@ -260,13 +313,7 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
260
313
  tools: readonly Tool[],
261
314
  options: RunWithToolsOptions = {},
262
315
  ): AsyncIterable<AgentStreamEvent> {
263
- const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
264
- const resolved =
265
- mcpServers.length > 0
266
- ? await resolveMcpTools(mcpServers, {
267
- ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
268
- })
269
- : { tools: [] as Tool[], close: async () => {} }
316
+ const resolved = await this.resolveMcp(options.mcpServers ?? [])
270
317
  try {
271
318
  yield* this._streamResponsesLoop(messages, [...tools, ...resolved.tools], options)
272
319
  } finally {
@@ -398,41 +445,291 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
398
445
  }
399
446
  }
400
447
 
401
- // ─── Schema variants throw deferred ──────────────────────────────────
448
+ // ─── generate / runWithToolsAndSchema / streamWithToolsAndSchema ────────
402
449
 
403
450
  override async generate<T>(
404
- _messages: readonly Message[],
405
- _schema: OutputSchema<T>,
406
- _options: ChatOptions = {},
451
+ messages: readonly Message[],
452
+ schema: OutputSchema<T>,
453
+ options: ChatOptions = {},
407
454
  ): Promise<GenerateResult<T>> {
408
- throw new BrainError(
409
- 'OpenAIResponsesProvider.generate: structured output via the Responses API is a follow-up slice. For json-schema structured output today, route the call to the chat completions provider (driver: "openai").',
410
- { context: { provider: this.name } },
411
- )
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
412
469
  }
413
470
 
414
471
  override async runWithToolsAndSchema<T>(
415
- _messages: readonly Message[],
416
- _tools: readonly Tool[],
417
- _schema: OutputSchema<T>,
418
- _options?: RunWithToolsOptions,
472
+ messages: readonly Message[],
473
+ tools: readonly Tool[],
474
+ schema: OutputSchema<T>,
475
+ options: RunWithToolsOptions = {},
419
476
  ): Promise<AgentGenerateResult<T>> {
420
- throw new BrainError(
421
- 'OpenAIResponsesProvider.runWithToolsAndSchema: combined tools + schema on the Responses API is a follow-up slice. Run runTools + generate as separate calls, or route to the chat completions provider for this combination.',
422
- { context: { provider: this.name } },
423
- )
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
+ }
424
580
  }
425
581
 
426
582
  override async *streamWithToolsAndSchema<T>(
427
- _messages: readonly Message[],
428
- _tools: readonly Tool[],
429
- _schema: OutputSchema<T>,
430
- _options?: RunWithToolsOptions,
583
+ messages: readonly Message[],
584
+ tools: readonly Tool[],
585
+ schema: OutputSchema<T>,
586
+ options: RunWithToolsOptions = {},
431
587
  ): AsyncIterable<AgentStreamEvent<T>> {
432
- throw new BrainError(
433
- 'OpenAIResponsesProvider.streamWithToolsAndSchema: streaming + tools + schema on the Responses API is a follow-up slice. Use streamTools without schema, or route to the chat completions provider.',
434
- { context: { provider: this.name } },
435
- )
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
+ }
436
733
  }
437
734
 
438
735
  // ─── Param translation ──────────────────────────────────────────────────
@@ -441,6 +738,7 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
441
738
  messages: readonly Message[],
442
739
  options: ChatOptions,
443
740
  tools: readonly Tool[],
741
+ schema?: OutputSchema<unknown>,
444
742
  ): OpenAI.Responses.ResponseCreateParamsNonStreaming {
445
743
  const model = options.model ?? this.defaultModel
446
744
  const params: OpenAI.Responses.ResponseCreateParamsNonStreaming = {
@@ -451,6 +749,9 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
451
749
  }) as unknown as OpenAI.Responses.ResponseInput,
452
750
  max_output_tokens: options.maxTokens ?? this.defaultMaxTokens,
453
751
  }
752
+ if (options.previousResponseId !== undefined) {
753
+ params.previous_response_id = options.previousResponseId
754
+ }
454
755
  const systemText = systemPromptText(options.system)
455
756
  if (systemText.length > 0) params.instructions = systemText
456
757
 
@@ -471,6 +772,18 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
471
772
  params.tools = toolEntries as unknown as OpenAI.Responses.ResponseCreateParams['tools']
472
773
  }
473
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
+
474
787
  // Reasoning controls — gpt-5 and o-series only. Emit when set;
475
788
  // non-reasoning models reject.
476
789
  if (options.effort !== undefined) {
@@ -488,13 +801,15 @@ export class OpenAIResponsesProvider extends OpenAIProvider implements Provider
488
801
  response: OpenAI.Responses.Response,
489
802
  requestedModel: string,
490
803
  ): ChatResult<OpenAI.Responses.Response> {
491
- return {
804
+ const result: ChatResult<OpenAI.Responses.Response> = {
492
805
  text: textFromOutput(response.output),
493
806
  model: response.model ?? requestedModel,
494
807
  stopReason: response.status ?? null,
495
808
  usage: toUsage(response.usage),
496
809
  raw: response,
497
810
  }
811
+ if (response.id) result.responseId = response.id
812
+ return result
498
813
  }
499
814
  }
500
815
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `SuspendedRun` — what `runWithTools` (and `runner.run()`) returns
3
+ * when the agentic loop pauses because `shouldSuspend(call)` returned
4
+ * `true` for a tool the model wants to call.
5
+ *
6
+ * Use case: human-in-the-loop gating. The integrator inspects
7
+ * `pendingToolCalls`, obtains results out-of-band (human approval,
8
+ * external worker, queued job, ...), and calls
9
+ * `brain.resumeTools(state, results, ...)` or
10
+ * `runner.resume(state, results)` to continue the conversation.
11
+ *
12
+ * State model:
13
+ * - `state.messages` contains every message exchanged up to and
14
+ * including the assistant turn that requested the pending tool
15
+ * calls. Resume picks up by appending tool_result blocks for
16
+ * each pending call and re-entering the loop — no special
17
+ * provider-level resume hook is needed.
18
+ * - `state` is plain JSON — apps persist it across process
19
+ * boundaries (e.g., one row per pending agent run in Postgres).
20
+ *
21
+ * Mid-batch invariant: when a tool call in a multi-call batch
22
+ * triggers suspension, ALL remaining calls in that same batch are
23
+ * captured together in `pendingToolCalls`. Apps MUST supply results
24
+ * for every entry on resume; otherwise the provider's
25
+ * tool_use / tool_result pairing becomes unbalanced and the next
26
+ * model call rejects.
27
+ */
28
+
29
+ import { BrainError } from './brain_error.ts'
30
+ import type {
31
+ ChatUsage,
32
+ ContentBlock,
33
+ Message,
34
+ ToolResultBlock,
35
+ ToolUseBlock,
36
+ } from './types.ts'
37
+
38
+ export interface SuspendedRun {
39
+ status: 'suspended'
40
+ /**
41
+ * The model's pending tool calls — the one that triggered the
42
+ * suspension, plus any unexecuted siblings from the same
43
+ * assistant turn. Match by `id` when supplying results.
44
+ */
45
+ pendingToolCalls: ToolUseBlock[]
46
+ /** JSON-serializable snapshot of the loop state at the suspension point. */
47
+ state: SuspendedState
48
+ }
49
+
50
+ export interface SuspendedState {
51
+ /** Full message history up to and including the suspending assistant turn. */
52
+ messages: Message[]
53
+ /** Iteration count at the suspension point — preserved across resume. */
54
+ iterations: number
55
+ /** Aggregated token usage across the iterations completed so far. */
56
+ usage: ChatUsage
57
+ /**
58
+ * Provider response id captured at the suspension point. When the
59
+ * provider supports stateful conversations (OpenAI Responses API),
60
+ * resume threads this back through `previousResponseId` so the
61
+ * model picks up exactly where it paused.
62
+ */
63
+ responseId?: string
64
+ }
65
+
66
+ /**
67
+ * Result of one pending tool call, supplied to `resumeTools`. The
68
+ * shape mirrors `ToolResultBlock` minus the `type` discriminator —
69
+ * the framework builds the block at resume time.
70
+ *
71
+ * To signal a failure (so the model adapts rather than crashing the
72
+ * loop), pass a string describing the error as `content` and set
73
+ * `isError: true`.
74
+ */
75
+ export interface ToolResultInput {
76
+ toolUseId: string
77
+ content: string
78
+ isError?: boolean
79
+ }
80
+
81
+ /**
82
+ * Type guard. Convenient at call sites that need to discriminate
83
+ * between a completed `AgentResult` and a `SuspendedRun`.
84
+ *
85
+ * ```ts
86
+ * const out = await brain.runTools(prompt, tools, { shouldSuspend })
87
+ * if (isSuspended(out)) {
88
+ * await persistForLater(out.pendingToolCalls, out.state)
89
+ * return
90
+ * }
91
+ * render(out.text)
92
+ * ```
93
+ */
94
+ export function isSuspended(value: unknown): value is SuspendedRun {
95
+ return (
96
+ typeof value === 'object' &&
97
+ value !== null &&
98
+ (value as { status?: unknown }).status === 'suspended'
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Append a `tool_result` user-role message to `state.messages` that
104
+ * carries one block per supplied result. Validates that the pending
105
+ * tool_use ids referenced in the latest assistant turn are all
106
+ * covered — missing results throw `BrainError` so the next provider
107
+ * call doesn't fail with an opaque "tool_use without tool_result"
108
+ * upstream error.
109
+ *
110
+ * Exported for `BrainManager.resumeTools` / `AgentRunner.resume`;
111
+ * tests can use it directly to verify resume mechanics without
112
+ * round-tripping through a provider.
113
+ */
114
+ export function appendResumeResults(
115
+ state: SuspendedState,
116
+ results: readonly ToolResultInput[],
117
+ ): Message[] {
118
+ const pending = collectPendingIds(state.messages)
119
+ for (const id of pending) {
120
+ if (!results.some((r) => r.toolUseId === id)) {
121
+ throw new BrainError(
122
+ `resumeTools: missing result for pending tool call id "${id}". Every pending tool_use in the suspending assistant turn must be answered on resume.`,
123
+ { context: { pendingIds: [...pending], suppliedIds: results.map((r) => r.toolUseId) } },
124
+ )
125
+ }
126
+ }
127
+ const resultBlocks: ContentBlock[] = results.map((r) => {
128
+ const block: ToolResultBlock = {
129
+ type: 'tool_result',
130
+ toolUseId: r.toolUseId,
131
+ content: r.content,
132
+ ...(r.isError ? { isError: true } : {}),
133
+ }
134
+ return block
135
+ })
136
+ return [...state.messages, { role: 'user', content: resultBlocks }]
137
+ }
138
+
139
+ /**
140
+ * Look at the latest assistant turn in `messages` and pull every
141
+ * tool_use block's id. Used to validate resume coverage.
142
+ */
143
+ function collectPendingIds(messages: readonly Message[]): string[] {
144
+ for (let i = messages.length - 1; i >= 0; i--) {
145
+ const m = messages[i]!
146
+ if (m.role !== 'assistant') continue
147
+ if (typeof m.content === 'string') return []
148
+ return m.content
149
+ .filter((b): b is ToolUseBlock => b.type === 'tool_use')
150
+ .map((b) => b.id)
151
+ }
152
+ return []
153
+ }