@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.
- package/package.json +4 -2
- package/src/agent_generate_result.ts +2 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +80 -4
- package/src/brain_manager.ts +119 -2
- package/src/index.ts +20 -2
- package/src/mcp/client.ts +17 -0
- package/src/mcp/index.ts +1 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +25 -7
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +106 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +68 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +65 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schema/brain_message_schema.ts +61 -0
- package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schema/brain_thread_schema.ts +50 -0
- package/src/persistence/schema/index.ts +3 -0
- package/src/provider.ts +36 -1
- package/src/providers/anthropic_provider.ts +140 -23
- package/src/providers/gemini_provider.ts +55 -32
- package/src/providers/openai_compat_provider.ts +452 -23
- package/src/providers/openai_provider.ts +87 -32
- package/src/providers/openai_responses_provider.ts +365 -50
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/types.ts +110 -0
|
@@ -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` —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
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
|
|
47
|
-
import type {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
// ───
|
|
448
|
+
// ─── generate / runWithToolsAndSchema / streamWithToolsAndSchema ────────
|
|
402
449
|
|
|
403
450
|
override async generate<T>(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
451
|
+
messages: readonly Message[],
|
|
452
|
+
schema: OutputSchema<T>,
|
|
453
|
+
options: ChatOptions = {},
|
|
407
454
|
): Promise<GenerateResult<T>> {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
472
|
+
messages: readonly Message[],
|
|
473
|
+
tools: readonly Tool[],
|
|
474
|
+
schema: OutputSchema<T>,
|
|
475
|
+
options: RunWithToolsOptions = {},
|
|
419
476
|
): Promise<AgentGenerateResult<T>> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
583
|
+
messages: readonly Message[],
|
|
584
|
+
tools: readonly Tool[],
|
|
585
|
+
schema: OutputSchema<T>,
|
|
586
|
+
options: RunWithToolsOptions = {},
|
|
431
587
|
): AsyncIterable<AgentStreamEvent<T>> {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
+
}
|