@strav/brain 0.4.31 → 1.0.0-alpha.9

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/src/helpers.ts DELETED
@@ -1,1082 +0,0 @@
1
- import BrainManager from './brain_manager.ts'
2
- import { Agent } from './agent.ts'
3
- import { Workflow } from './workflow.ts'
4
- import { zodToJsonSchema } from './utils/schema.ts'
5
- import { interpolateInstructions } from './utils/prompt.ts'
6
- import { MemoryManager } from './memory/memory_manager.ts'
7
- import { ContextBudget } from './memory/context_budget.ts'
8
- import type { MemoryConfig, SerializedMemoryThread, Fact } from './memory/types.ts'
9
- import type { SemanticMemory } from './memory/semantic_memory.ts'
10
- import type {
11
- AIProvider,
12
- CompletionRequest,
13
- CompletionResponse,
14
- StreamChunk,
15
- Message,
16
- ToolCall,
17
- ToolCallRecord,
18
- ToolDefinition,
19
- AgentResult,
20
- AgentEvent,
21
- Usage,
22
- JsonSchema,
23
- SerializedThread,
24
- SerializedAgentState,
25
- SuspendedRun,
26
- ToolCallResult,
27
- TranscribeRequest,
28
- TranscriptionResponse,
29
- } from './types.ts'
30
-
31
- // ── Shared tool executor ─────────────────────────────────────────────────────
32
-
33
- /** Execute a single tool call, returning the result and the tool message. */
34
- async function executeTool(
35
- tools: ToolDefinition[] | undefined,
36
- toolCall: ToolCall,
37
- context?: Record<string, unknown>
38
- ): Promise<{ result: unknown; message: Message }> {
39
- const toolDef = tools?.find(t => t.name === toolCall.name)
40
- let result: unknown
41
-
42
- if (!toolDef) {
43
- result = `Error: Tool "${toolCall.name}" not found`
44
- } else {
45
- try {
46
- result = await toolDef.execute(toolCall.arguments, context)
47
- } catch (err) {
48
- result = `Error: ${err instanceof Error ? err.message : String(err)}`
49
- }
50
- }
51
-
52
- return {
53
- result,
54
- message: {
55
- role: 'tool',
56
- toolCallId: toolCall.id,
57
- content: typeof result === 'string' ? result : JSON.stringify(result),
58
- },
59
- }
60
- }
61
-
62
- // ── Helper Options ───────────────────────────────────────────────────────────
63
-
64
- export interface ChatOptions {
65
- provider?: string
66
- model?: string
67
- system?: string
68
- maxTokens?: number
69
- temperature?: number
70
- }
71
-
72
- export interface GenerateOptions<T = any> {
73
- prompt: string
74
- schema: any
75
- provider?: string
76
- model?: string
77
- system?: string
78
- maxTokens?: number
79
- temperature?: number
80
- }
81
-
82
- export interface GenerateResult<T = any> {
83
- data: T
84
- text: string
85
- usage: Usage
86
- }
87
-
88
- export interface EmbedOptions {
89
- provider?: string
90
- model?: string
91
- }
92
-
93
- export interface TranscribeOptions {
94
- audio: Uint8Array | Blob
95
- contentType?: string
96
- language?: string
97
- prompt?: string
98
- filename?: string
99
- provider?: string
100
- model?: string
101
- }
102
-
103
- // ── brain Helper Object ─────────────────────────────────────────────────────
104
-
105
- export const brain = {
106
- /**
107
- * One-shot chat completion. Returns the text response.
108
- *
109
- * @example
110
- * const answer = await brain.chat('What is the capital of France?')
111
- * const answer = await brain.chat('Explain X', { provider: 'openai', model: 'gpt-4o-mini' })
112
- */
113
- async chat(prompt: string, options: ChatOptions = {}): Promise<string> {
114
- const config = BrainManager.config
115
- const providerName = options.provider ?? config.default
116
-
117
- const response = await BrainManager.complete(providerName, {
118
- model:
119
- (options.model ?? BrainManager.provider(providerName).name === 'anthropic')
120
- ? (BrainManager.config.providers[providerName]?.model ?? config.default)
121
- : (BrainManager.config.providers[providerName]?.model ?? ''),
122
- messages: [{ role: 'user', content: prompt }],
123
- system: options.system,
124
- maxTokens: options.maxTokens ?? config.maxTokens,
125
- temperature: options.temperature ?? config.temperature,
126
- })
127
-
128
- return response.content
129
- },
130
-
131
- /**
132
- * One-shot streaming completion.
133
- *
134
- * @example
135
- * for await (const chunk of brain.stream('Write a poem')) {
136
- * if (chunk.type === 'text') process.stdout.write(chunk.text!)
137
- * }
138
- */
139
- async *stream(prompt: string, options: ChatOptions = {}): AsyncIterable<StreamChunk> {
140
- const config = BrainManager.config
141
- const providerName = options.provider ?? config.default
142
- const provider = BrainManager.provider(providerName)
143
- const providerConfig = config.providers[providerName]
144
-
145
- yield* provider.stream({
146
- model: options.model ?? providerConfig?.model ?? '',
147
- messages: [{ role: 'user', content: prompt }],
148
- system: options.system,
149
- maxTokens: options.maxTokens ?? config.maxTokens,
150
- temperature: options.temperature ?? config.temperature,
151
- })
152
- },
153
-
154
- /**
155
- * Structured output completion. Returns typed data validated against the schema.
156
- *
157
- * @example
158
- * const { data } = await brain.generate({
159
- * prompt: 'Extract: "John is 30"',
160
- * schema: z.object({ name: z.string(), age: z.number() }),
161
- * })
162
- * // data.name === 'John', data.age === 30
163
- */
164
- async generate<T>(options: GenerateOptions<T>): Promise<GenerateResult<T>> {
165
- const config = BrainManager.config
166
- const providerName = options.provider ?? config.default
167
- const providerConfig = config.providers[providerName]
168
- const jsonSchema = zodToJsonSchema(options.schema)
169
-
170
- const response = await BrainManager.complete(providerName, {
171
- model: options.model ?? providerConfig?.model ?? '',
172
- messages: [{ role: 'user', content: options.prompt }],
173
- system: options.system,
174
- schema: jsonSchema,
175
- maxTokens: options.maxTokens ?? config.maxTokens,
176
- temperature: options.temperature ?? config.temperature,
177
- })
178
-
179
- // Extract JSON from potential markdown wrapper
180
- let jsonContent = response.content.trim()
181
- if (jsonContent.startsWith('```json') || jsonContent.startsWith('```')) {
182
- // Strip markdown code fence wrapper
183
- jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```\s*$/, '')
184
- }
185
-
186
- const parsed = JSON.parse(jsonContent)
187
- const data = options.schema?.parse ? options.schema.parse(parsed) : parsed
188
-
189
- return {
190
- data,
191
- text: response.content,
192
- usage: response.usage,
193
- }
194
- },
195
-
196
- /**
197
- * Generate embeddings for the given text(s).
198
- *
199
- * @example
200
- * const vectors = await brain.embed('Hello world', { provider: 'openai' })
201
- */
202
- async embed(input: string | string[], options: EmbedOptions = {}): Promise<number[][]> {
203
- const providerName = options.provider ?? BrainManager.config.default
204
- const provider = BrainManager.provider(providerName)
205
-
206
- if (!provider.embed) {
207
- throw new Error(`Provider "${providerName}" does not support embeddings.`)
208
- }
209
-
210
- const result = await provider.embed(input, options.model)
211
- return result.embeddings
212
- },
213
-
214
- /**
215
- * Transcribe audio (speech-to-text). Uses the OpenAI Whisper endpoint
216
- * by default; pass `provider: 'google'` to use Gemini's multimodal
217
- * generateContent endpoint instead. Both accept a `language` hint
218
- * (BCP-47) and a `prompt` to bias vocabulary.
219
- *
220
- * @example
221
- * // Voice note coming off a LINE inbound webhook
222
- * const { bytes, contentType } = await LineManager.client.downloadContent(messageId)
223
- * const { text } = await brain.transcribe({
224
- * audio: bytes,
225
- * contentType, // 'audio/m4a' from LINE
226
- * language: 'th',
227
- * prompt: 'Coffee shop menu items, Bangkok area names',
228
- * })
229
- */
230
- async transcribe(options: TranscribeOptions): Promise<TranscriptionResponse> {
231
- return BrainManager.transcribe(options.provider, {
232
- audio: options.audio,
233
- contentType: options.contentType,
234
- model: options.model,
235
- language: options.language,
236
- prompt: options.prompt,
237
- filename: options.filename,
238
- })
239
- },
240
-
241
- /** Create a fluent agent runner. */
242
- agent<T extends Agent>(AgentClass: new () => T): AgentRunner<T> {
243
- return new AgentRunner(AgentClass)
244
- },
245
-
246
- /** Create a multi-turn conversation thread. */
247
- thread(AgentClass?: new () => Agent): Thread {
248
- return new Thread(AgentClass)
249
- },
250
-
251
- /** Create a multi-agent workflow. */
252
- workflow(name: string): Workflow {
253
- return new Workflow(name)
254
- },
255
- }
256
-
257
- // ── AgentRunner ──────────────────────────────────────────────────────────────
258
-
259
- /**
260
- * Fluent builder for running an agent. Handles the tool-use loop,
261
- * structured output parsing, and lifecycle hooks.
262
- *
263
- * @example
264
- * const result = await brain.agent(SupportAgent)
265
- * .input('Where is my order #12345?')
266
- * .with({ orderId: '12345' })
267
- * .run()
268
- */
269
- export class AgentRunner<T extends Agent = Agent> {
270
- private _input = ''
271
- private _context: Record<string, unknown> = {}
272
- private _provider?: string
273
- private _model?: string
274
- private _tools?: ToolDefinition[]
275
-
276
- constructor(private AgentClass: new () => T) {}
277
-
278
- /** Set the user input / prompt for the agent. */
279
- input(text: string): this {
280
- this._input = text
281
- return this
282
- }
283
-
284
- /** Add context variables. Available as `{{key}}` in agent instructions. */
285
- with(context: Record<string, unknown>): this {
286
- Object.assign(this._context, context)
287
- return this
288
- }
289
-
290
- /** Override the provider (and optionally model) for this run. */
291
- using(provider: string, model?: string): this {
292
- this._provider = provider
293
- if (model) this._model = model
294
- return this
295
- }
296
-
297
- /** Set or override the tools available to the agent for this run. */
298
- tools(tools: ToolDefinition[]): this {
299
- this._tools = tools
300
- return this
301
- }
302
-
303
- /** Run the agent to completion (or until it suspends on a tool call). */
304
- async run(): Promise<AgentResult | SuspendedRun> {
305
- return this.runFromState(null)
306
- }
307
-
308
- /**
309
- * Resume a previously suspended agent run with the results of the pending
310
- * tool calls. Returns a completed `AgentResult` — or another `SuspendedRun`
311
- * if the continuation itself hits another suspending tool call.
312
- *
313
- * `toolResults` must contain one entry per call in the original
314
- * `SuspendedRun.pendingToolCalls`, matched by `toolCallId`. To signal a
315
- * rejection, pass a string or object describing the error as the
316
- * `result` — the model sees it as a normal tool failure and adapts.
317
- */
318
- async resume(
319
- state: SerializedAgentState,
320
- toolResults: ToolCallResult[]
321
- ): Promise<AgentResult | SuspendedRun> {
322
- const hydratedMessages: Message[] = [...state.messages]
323
- const hydratedToolCalls: ToolCallRecord[] = [...state.allToolCalls]
324
-
325
- for (const r of toolResults) {
326
- const originalCall = findToolCallInMessages(hydratedMessages, r.toolCallId)
327
-
328
- hydratedMessages.push({
329
- role: 'tool',
330
- toolCallId: r.toolCallId,
331
- content: typeof r.result === 'string' ? r.result : JSON.stringify(r.result),
332
- })
333
-
334
- hydratedToolCalls.push({
335
- name: originalCall?.name ?? '',
336
- arguments: originalCall?.arguments ?? {},
337
- result: r.result,
338
- duration: 0,
339
- })
340
- }
341
-
342
- return this.runFromState({
343
- messages: hydratedMessages,
344
- allToolCalls: hydratedToolCalls,
345
- totalUsage: { ...state.totalUsage },
346
- iterations: state.iterations,
347
- })
348
- }
349
-
350
- /** Shared loop body. Used by both `run()` (fresh state) and `resume()` (restored state). */
351
- private async runFromState(
352
- initial: SerializedAgentState | null
353
- ): Promise<AgentResult | SuspendedRun> {
354
- const agent = new this.AgentClass()
355
- const config = BrainManager.config
356
-
357
- // Runner-level tools override agent-level tools
358
- if (this._tools) {
359
- agent.tools = this._tools
360
- }
361
-
362
- const providerName = this._provider ?? agent.provider ?? config.default
363
- const providerConfig = config.providers[providerName]
364
- const model = this._model ?? agent.model ?? providerConfig?.model ?? ''
365
- const maxIterations = agent.maxIterations ?? config.maxIterations
366
- const maxTokens = agent.maxTokens ?? config.maxTokens
367
- const temperature = agent.temperature ?? config.temperature
368
-
369
- if (!initial) {
370
- try {
371
- await agent.onStart?.(this._input, this._context)
372
- } catch (err) {
373
- await agent.onError?.(err instanceof Error ? err : new Error(String(err)))
374
- throw err
375
- }
376
- }
377
-
378
- // Build system prompt with context interpolation. interpolateInstructions
379
- // warns when a context value looks like a prompt-injection attempt — see
380
- // packages/brain/CLAUDE.md ("Prompt-injection threat model").
381
- let system: string | undefined = agent.instructions || undefined
382
- if (system) {
383
- system = interpolateInstructions(system, this._context)
384
- }
385
-
386
- // Prepare structured output schema
387
- let schema: JsonSchema | undefined
388
- if (agent.output) {
389
- schema = zodToJsonSchema(agent.output)
390
- }
391
-
392
- const messages: Message[] = initial
393
- ? [...initial.messages]
394
- : [{ role: 'user', content: this._input }]
395
- const allToolCalls: ToolCallRecord[] = initial ? [...initial.allToolCalls] : []
396
- const totalUsage: Usage = initial
397
- ? { ...initial.totalUsage }
398
- : { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
399
- let iterations = initial?.iterations ?? 0
400
-
401
- // Tool loop
402
- while (iterations < maxIterations) {
403
- iterations++
404
-
405
- const request: CompletionRequest = {
406
- model,
407
- messages: [...messages],
408
- system,
409
- maxTokens,
410
- temperature,
411
- }
412
-
413
- // Only send tools if the agent has them
414
- if (agent.tools?.length) {
415
- request.tools = agent.tools
416
- }
417
-
418
- // Only send schema when we're not mid-tool-loop (avoid conflicting constraints)
419
- if (schema && (!agent.tools?.length || iterations > 1)) {
420
- request.schema = schema
421
- }
422
-
423
- let response: CompletionResponse
424
- try {
425
- response = await BrainManager.complete(providerName, request)
426
- } catch (err) {
427
- await agent.onError?.(err instanceof Error ? err : new Error(String(err)))
428
- throw err
429
- }
430
-
431
- // Accumulate usage
432
- totalUsage.inputTokens += response.usage.inputTokens
433
- totalUsage.outputTokens += response.usage.outputTokens
434
- totalUsage.totalTokens += response.usage.totalTokens
435
-
436
- // Append assistant message
437
- messages.push({
438
- role: 'assistant',
439
- content: response.content,
440
- toolCalls: response.toolCalls.length > 0 ? response.toolCalls : undefined,
441
- })
442
-
443
- // If no tool calls, we're done
444
- if (response.stopReason !== 'tool_use' || response.toolCalls.length === 0) {
445
- let data: any = response.content
446
- if (agent.output && response.content) {
447
- try {
448
- // Extract JSON from potential markdown wrapper
449
- let jsonContent = response.content.trim()
450
- if (jsonContent.startsWith('```json') || jsonContent.startsWith('```')) {
451
- // Strip markdown code fence wrapper
452
- jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```\s*$/, '')
453
- }
454
-
455
- const parsed = JSON.parse(jsonContent)
456
- data = agent.output.parse ? agent.output.parse(parsed) : parsed
457
- } catch {
458
- data = response.content
459
- }
460
- }
461
-
462
- const result: AgentResult = {
463
- data,
464
- text: response.content,
465
- toolCalls: allToolCalls,
466
- messages,
467
- usage: totalUsage,
468
- iterations,
469
- }
470
-
471
- await agent.onComplete?.(result)
472
- return result
473
- }
474
-
475
- // Execute tool calls (or suspend if the agent vetos)
476
- const suspension = await this.executeTools(
477
- agent,
478
- response.toolCalls,
479
- messages,
480
- allToolCalls,
481
- totalUsage,
482
- iterations
483
- )
484
- if (suspension) return suspension
485
- }
486
-
487
- // Max iterations reached — return what we have
488
- const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant')
489
- const text = typeof lastAssistant?.content === 'string' ? lastAssistant.content : ''
490
-
491
- const result: AgentResult = {
492
- data: null,
493
- text,
494
- toolCalls: allToolCalls,
495
- messages,
496
- usage: totalUsage,
497
- iterations,
498
- }
499
-
500
- await agent.onComplete?.(result)
501
- return result
502
- }
503
-
504
- /** Run the agent with streaming, yielding events for each text chunk and tool execution. */
505
- async *stream(): AsyncIterable<AgentEvent> {
506
- const agent = new this.AgentClass()
507
- const config = BrainManager.config
508
-
509
- // Runner-level tools override agent-level tools
510
- if (this._tools) {
511
- agent.tools = this._tools
512
- }
513
-
514
- const providerName = this._provider ?? agent.provider ?? config.default
515
- const providerConfig = config.providers[providerName]
516
- const model = this._model ?? agent.model ?? providerConfig?.model ?? ''
517
- const maxIterations = agent.maxIterations ?? config.maxIterations
518
- const maxTokens = agent.maxTokens ?? config.maxTokens
519
- const temperature = agent.temperature ?? config.temperature
520
- const provider = BrainManager.provider(providerName)
521
-
522
- await agent.onStart?.(this._input, this._context)
523
-
524
- let system: string | undefined = agent.instructions || undefined
525
- if (system) {
526
- system = interpolateInstructions(system, this._context)
527
- }
528
-
529
- let schema: JsonSchema | undefined
530
- if (agent.output) {
531
- schema = zodToJsonSchema(agent.output)
532
- }
533
-
534
- const messages: Message[] = [{ role: 'user', content: this._input }]
535
- const allToolCalls: ToolCallRecord[] = []
536
- const totalUsage: Usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
537
- let iterations = 0
538
-
539
- while (iterations < maxIterations) {
540
- iterations++
541
-
542
- if (iterations > 1) {
543
- yield { type: 'iteration', iteration: iterations }
544
- }
545
-
546
- const request: CompletionRequest = {
547
- model,
548
- messages: [...messages],
549
- system,
550
- maxTokens,
551
- temperature,
552
- }
553
-
554
- if (agent.tools?.length) request.tools = agent.tools
555
- if (schema && (!agent.tools?.length || iterations > 1)) request.schema = schema
556
-
557
- // Stream the response
558
- let fullText = ''
559
- const pendingToolCalls: Map<number, { id: string; name: string; args: string }> = new Map()
560
-
561
- for await (const chunk of provider.stream(request)) {
562
- if (chunk.type === 'text' && chunk.text) {
563
- fullText += chunk.text
564
- yield { type: 'text', text: chunk.text }
565
- } else if (chunk.type === 'tool_start' && chunk.toolCall) {
566
- pendingToolCalls.set(chunk.toolIndex ?? 0, {
567
- id: chunk.toolCall.id ?? '',
568
- name: chunk.toolCall.name ?? '',
569
- args: '',
570
- })
571
- } else if (chunk.type === 'tool_delta' && chunk.text) {
572
- const pending = pendingToolCalls.get(chunk.toolIndex ?? 0)
573
- if (pending) pending.args += chunk.text
574
- } else if (chunk.type === 'usage' && chunk.usage) {
575
- totalUsage.inputTokens += chunk.usage.inputTokens
576
- totalUsage.outputTokens += chunk.usage.outputTokens
577
- totalUsage.totalTokens += chunk.usage.totalTokens
578
- }
579
- }
580
-
581
- // Build tool calls from accumulated stream data
582
- const toolCalls: ToolCall[] = []
583
- for (const [, pending] of pendingToolCalls) {
584
- let args: Record<string, unknown> = {}
585
- try {
586
- args = JSON.parse(pending.args)
587
- } catch {
588
- args = pending.args ? { _raw: pending.args } : {}
589
- }
590
- toolCalls.push({ id: pending.id, name: pending.name, arguments: args })
591
- }
592
-
593
- // Append assistant message
594
- messages.push({
595
- role: 'assistant',
596
- content: fullText,
597
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
598
- })
599
-
600
- // If no tool calls, done
601
- if (toolCalls.length === 0) {
602
- let data: any = fullText
603
- if (agent.output && fullText) {
604
- try {
605
- const parsed = JSON.parse(fullText)
606
- data = agent.output.parse ? agent.output.parse(parsed) : parsed
607
- } catch {
608
- data = fullText
609
- }
610
- }
611
-
612
- const result: AgentResult = {
613
- data,
614
- text: fullText,
615
- toolCalls: allToolCalls,
616
- messages,
617
- usage: totalUsage,
618
- iterations,
619
- }
620
-
621
- await agent.onComplete?.(result)
622
- yield { type: 'done', result }
623
- return
624
- }
625
-
626
- // Execute tools and yield events (or suspend if the agent vetos)
627
- for (let i = 0; i < toolCalls.length; i++) {
628
- const toolCall = toolCalls[i]!
629
-
630
- if (agent.shouldSuspend) {
631
- const suspend = await agent.shouldSuspend(toolCall, this._context)
632
- if (suspend) {
633
- const suspended: SuspendedRun = {
634
- status: 'suspended',
635
- pendingToolCalls: toolCalls.slice(i),
636
- state: {
637
- messages: [...messages],
638
- allToolCalls: [...allToolCalls],
639
- totalUsage: { ...totalUsage },
640
- iterations,
641
- },
642
- }
643
- yield { type: 'suspended', suspended }
644
- return
645
- }
646
- }
647
-
648
- await agent.onToolCall?.(toolCall)
649
-
650
- const start = performance.now()
651
- const { result: toolResult, message } = await executeTool(agent.tools, toolCall)
652
- const duration = performance.now() - start
653
-
654
- const record: ToolCallRecord = {
655
- name: toolCall.name,
656
- arguments: toolCall.arguments,
657
- result: toolResult,
658
- duration,
659
- }
660
- allToolCalls.push(record)
661
- await agent.onToolResult?.(record)
662
-
663
- yield { type: 'tool_result', toolCall: record }
664
-
665
- messages.push(message)
666
- }
667
- }
668
-
669
- // Max iterations
670
- const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant')
671
- const text = typeof lastAssistant?.content === 'string' ? lastAssistant.content : ''
672
-
673
- const result: AgentResult = {
674
- data: null,
675
- text,
676
- toolCalls: allToolCalls,
677
- messages,
678
- usage: totalUsage,
679
- iterations,
680
- }
681
-
682
- await agent.onComplete?.(result)
683
- yield { type: 'done', result }
684
- }
685
-
686
- // ── Private ────────────────────────────────────────────────────────────────
687
-
688
- private async executeTools(
689
- agent: Agent,
690
- toolCalls: ToolCall[],
691
- messages: Message[],
692
- allToolCalls: ToolCallRecord[],
693
- totalUsage: Usage,
694
- iterations: number
695
- ): Promise<SuspendedRun | null> {
696
- for (let i = 0; i < toolCalls.length; i++) {
697
- const toolCall = toolCalls[i]!
698
-
699
- if (agent.shouldSuspend) {
700
- const suspend = await agent.shouldSuspend(toolCall, this._context)
701
- if (suspend) {
702
- // Capture this call + all remaining calls in the batch so the
703
- // provider's tool_use/tool_result contract stays balanced on resume.
704
- return {
705
- status: 'suspended',
706
- pendingToolCalls: toolCalls.slice(i),
707
- state: {
708
- messages: [...messages],
709
- allToolCalls: [...allToolCalls],
710
- totalUsage: { ...totalUsage },
711
- iterations,
712
- },
713
- }
714
- }
715
- }
716
-
717
- await agent.onToolCall?.(toolCall)
718
-
719
- const start = performance.now()
720
- const { result, message } = await executeTool(agent.tools, toolCall, this._context)
721
- const duration = performance.now() - start
722
-
723
- const record: ToolCallRecord = {
724
- name: toolCall.name,
725
- arguments: toolCall.arguments,
726
- result,
727
- duration,
728
- }
729
- allToolCalls.push(record)
730
- await agent.onToolResult?.(record)
731
-
732
- messages.push(message)
733
- }
734
- return null
735
- }
736
- }
737
-
738
- // ── Helpers for resume ───────────────────────────────────────────────────────
739
-
740
- /**
741
- * Walk `messages` backwards and find the `ToolCall` (on an assistant message)
742
- * whose id matches `toolCallId`. Returns undefined if not found.
743
- */
744
- function findToolCallInMessages(messages: Message[], toolCallId: string): ToolCall | undefined {
745
- for (let i = messages.length - 1; i >= 0; i--) {
746
- const m = messages[i]!
747
- if (m.role === 'assistant' && m.toolCalls) {
748
- const call = m.toolCalls.find(c => c.id === toolCallId)
749
- if (call) return call
750
- }
751
- }
752
- return undefined
753
- }
754
-
755
- // ── Thread ────────────────────────────────────────────────────────────────────
756
-
757
- /**
758
- * Multi-turn conversation thread with optional agent configuration.
759
- *
760
- * @example
761
- * const thread = brain.thread()
762
- * thread.system('You are a helpful assistant.')
763
- * const r1 = await thread.send('My name is Alice')
764
- * const r2 = await thread.send('What is my name?')
765
- *
766
- * // With agent
767
- * const thread = brain.thread(SupportAgent)
768
- * const r1 = await thread.send('I need help with order #123')
769
- */
770
- export class Thread {
771
- private messages: Message[] = []
772
- private _provider?: string
773
- private _model?: string
774
- private _system?: string
775
- private _tools?: ToolDefinition[]
776
- private _maxTokens?: number
777
- private _temperature?: number
778
- private _memoryManager?: MemoryManager
779
- private _id?: string
780
- private _autoPersist = false
781
-
782
- constructor(AgentClass?: new () => Agent) {
783
- if (AgentClass) {
784
- const agent = new AgentClass()
785
- this._provider = agent.provider
786
- this._model = agent.model
787
- this._system = agent.instructions || undefined
788
- this._tools = agent.tools
789
- this._maxTokens = agent.maxTokens
790
- this._temperature = agent.temperature
791
- }
792
- }
793
-
794
- /** Set or override the system prompt. */
795
- system(prompt: string): this {
796
- this._system = prompt
797
- return this
798
- }
799
-
800
- /** Override the provider (and optionally model). */
801
- using(provider: string, model?: string): this {
802
- this._provider = provider
803
- if (model) this._model = model
804
- return this
805
- }
806
-
807
- /** Set tools available in this thread. */
808
- tools(tools: ToolDefinition[]): this {
809
- this._tools = tools
810
- return this
811
- }
812
-
813
- /**
814
- * Enable memory management with optional config overrides.
815
- *
816
- * When enabled, the thread automatically:
817
- * - Tracks token usage against the context window budget
818
- * - Compacts older messages into summaries when approaching the limit
819
- * - Extracts and injects semantic facts into the system prompt
820
- *
821
- * Memory is opt-in — without calling `.memory()`, Thread behaves
822
- * exactly as before (sends full messages array every turn).
823
- */
824
- memory(config?: Partial<MemoryConfig>): this {
825
- const memConfig: MemoryConfig = { ...BrainManager.memoryConfig, ...config }
826
- const providerName = this._provider ?? BrainManager.config.default
827
- const providerConfig = BrainManager.config.providers[providerName]
828
- const model = this._model ?? providerConfig?.model ?? ''
829
- const budget = new ContextBudget(memConfig, model)
830
- this._memoryManager = new MemoryManager(memConfig, budget)
831
- return this
832
- }
833
-
834
- /** Set a thread ID (required for persistence). */
835
- id(threadId: string): this {
836
- this._id = threadId
837
- return this
838
- }
839
-
840
- /** Enable auto-persistence to the configured ThreadStore after each send(). */
841
- persist(auto = true): this {
842
- this._autoPersist = auto
843
- return this
844
- }
845
-
846
- /** Access the semantic memory (facts), if memory management is enabled. */
847
- get facts(): SemanticMemory | undefined {
848
- return this._memoryManager?.facts
849
- }
850
-
851
- /** Get the current episodic summary, if memory management is enabled. */
852
- get episodicSummary(): string | undefined {
853
- return this._memoryManager?.episodicSummary
854
- }
855
-
856
- /** Send a message and get the assistant's response. Handles tool calls automatically. */
857
- async send(message: string): Promise<string> {
858
- const config = BrainManager.config
859
- const providerName = this._provider ?? config.default
860
- const providerConfig = config.providers[providerName]
861
- const model = this._model ?? providerConfig?.model ?? ''
862
-
863
- this.messages.push({ role: 'user', content: message })
864
-
865
- const maxIterations = 10
866
- let iterations = 0
867
-
868
- while (iterations < maxIterations) {
869
- iterations++
870
-
871
- let contextSystem = this._system
872
- let contextMessages = [...this.messages]
873
-
874
- // Memory management: prepare context within budget
875
- if (this._memoryManager) {
876
- const prepared = await this._memoryManager.prepareContext(this._system, this.messages, {
877
- provider: providerName,
878
- model,
879
- })
880
- contextSystem = prepared.system
881
- contextMessages = prepared.messages
882
- }
883
-
884
- const request: CompletionRequest = {
885
- model,
886
- messages: contextMessages,
887
- system: contextSystem,
888
- maxTokens: this._maxTokens ?? config.maxTokens,
889
- temperature: this._temperature ?? config.temperature,
890
- }
891
-
892
- if (this._tools?.length) request.tools = this._tools
893
-
894
- const response = await BrainManager.complete(providerName, request)
895
-
896
- this.messages.push({
897
- role: 'assistant',
898
- content: response.content,
899
- toolCalls: response.toolCalls.length > 0 ? response.toolCalls : undefined,
900
- })
901
-
902
- // If no tool calls, return
903
- if (response.stopReason !== 'tool_use' || response.toolCalls.length === 0) {
904
- await this.autoPersist()
905
- return response.content
906
- }
907
-
908
- // Execute tools
909
- for (const toolCall of response.toolCalls) {
910
- const { message: toolMessage } = await executeTool(this._tools, toolCall)
911
- this.messages.push(toolMessage)
912
- }
913
- }
914
-
915
- // Return last assistant content
916
- const last = [...this.messages].reverse().find(m => m.role === 'assistant')
917
- await this.autoPersist()
918
- return typeof last?.content === 'string' ? last.content : ''
919
- }
920
-
921
- /** Stream a message response. Handles tool calls automatically for multi-turn. */
922
- async *stream(message: string): AsyncIterable<StreamChunk> {
923
- const config = BrainManager.config
924
- const providerName = this._provider ?? config.default
925
- const providerConfig = config.providers[providerName]
926
- const model = this._model ?? providerConfig?.model ?? ''
927
- const provider = BrainManager.provider(providerName)
928
-
929
- this.messages.push({ role: 'user', content: message })
930
-
931
- const maxIterations = 10
932
- let iterations = 0
933
-
934
- while (iterations < maxIterations) {
935
- iterations++
936
-
937
- let contextSystem = this._system
938
- let contextMessages = [...this.messages]
939
-
940
- // Memory management: prepare context within budget
941
- if (this._memoryManager) {
942
- const prepared = await this._memoryManager.prepareContext(this._system, this.messages, {
943
- provider: providerName,
944
- model,
945
- })
946
- contextSystem = prepared.system
947
- contextMessages = prepared.messages
948
- }
949
-
950
- let fullText = ''
951
- const pendingToolCalls: Map<number, { id: string; name: string; args: string }> = new Map()
952
-
953
- for await (const chunk of provider.stream({
954
- model,
955
- messages: contextMessages,
956
- system: contextSystem,
957
- tools: this._tools,
958
- maxTokens: this._maxTokens ?? config.maxTokens,
959
- temperature: this._temperature ?? config.temperature,
960
- })) {
961
- yield chunk
962
-
963
- if (chunk.type === 'text' && chunk.text) {
964
- fullText += chunk.text
965
- } else if (chunk.type === 'tool_start' && chunk.toolCall) {
966
- pendingToolCalls.set(chunk.toolIndex ?? 0, {
967
- id: chunk.toolCall.id ?? '',
968
- name: chunk.toolCall.name ?? '',
969
- args: '',
970
- })
971
- } else if (chunk.type === 'tool_delta' && chunk.text) {
972
- const pending = pendingToolCalls.get(chunk.toolIndex ?? 0)
973
- if (pending) pending.args += chunk.text
974
- }
975
- }
976
-
977
- // Build tool calls from accumulated stream data
978
- const toolCalls: ToolCall[] = []
979
- for (const [, pending] of pendingToolCalls) {
980
- let args: Record<string, unknown> = {}
981
- try {
982
- args = JSON.parse(pending.args)
983
- } catch {
984
- args = pending.args ? { _raw: pending.args } : {}
985
- }
986
- toolCalls.push({ id: pending.id, name: pending.name, arguments: args })
987
- }
988
-
989
- // Append assistant message
990
- this.messages.push({
991
- role: 'assistant',
992
- content: fullText,
993
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
994
- })
995
-
996
- // No tool calls — done
997
- if (toolCalls.length === 0) {
998
- await this.autoPersist()
999
- return
1000
- }
1001
-
1002
- // Execute tools
1003
- for (const toolCall of toolCalls) {
1004
- const { message: toolMessage } = await executeTool(this._tools, toolCall)
1005
- this.messages.push(toolMessage)
1006
- }
1007
- }
1008
-
1009
- await this.autoPersist()
1010
- }
1011
-
1012
- /** Get a copy of all messages in this thread. */
1013
- getMessages(): Message[] {
1014
- return [...this.messages]
1015
- }
1016
-
1017
- /** Serialize the thread for persistence (session, database, cache). */
1018
- serialize(): SerializedThread {
1019
- return {
1020
- messages: [...this.messages],
1021
- system: this._system,
1022
- }
1023
- }
1024
-
1025
- /** Restore a previously serialized thread. */
1026
- restore(data: SerializedThread): this {
1027
- this.messages = [...data.messages]
1028
- this._system = data.system
1029
- return this
1030
- }
1031
-
1032
- /**
1033
- * Extended serialization that includes memory state (summary, facts).
1034
- * Use this instead of serialize() when memory management is enabled.
1035
- */
1036
- serializeMemory(): SerializedMemoryThread {
1037
- const memState = this._memoryManager?.serialize()
1038
- return {
1039
- id: this._id ?? crypto.randomUUID(),
1040
- messages: [...this.messages],
1041
- system: this._system,
1042
- summary: memState?.summary,
1043
- facts: memState?.facts,
1044
- updatedAt: new Date().toISOString(),
1045
- createdAt: new Date().toISOString(),
1046
- }
1047
- }
1048
-
1049
- /**
1050
- * Restore from extended serialization that includes memory state.
1051
- * Use this instead of restore() when memory management is enabled.
1052
- */
1053
- restoreMemory(data: SerializedMemoryThread): this {
1054
- this.messages = [...data.messages]
1055
- this._system = data.system
1056
- this._id = data.id
1057
-
1058
- if (this._memoryManager && (data.summary || data.facts)) {
1059
- this._memoryManager.restore({
1060
- summary: data.summary,
1061
- facts: data.facts,
1062
- })
1063
- }
1064
-
1065
- return this
1066
- }
1067
-
1068
- /** Clear all messages and memory state from the thread. */
1069
- clear(): this {
1070
- this.messages = []
1071
- return this
1072
- }
1073
-
1074
- // ── Private ────────────────────────────────────────────────────────────────
1075
-
1076
- /** Persist the thread if auto-persist is enabled and a store is configured. */
1077
- private async autoPersist(): Promise<void> {
1078
- if (this._autoPersist && this._id && BrainManager.threadStore) {
1079
- await BrainManager.threadStore.save(this.serializeMemory())
1080
- }
1081
- }
1082
- }