@proteinjs/conversation 2.7.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/src/CodegenConversation.js +1 -1
  8. package/dist/src/CodegenConversation.js.map +1 -1
  9. package/dist/src/Conversation.d.ts +173 -99
  10. package/dist/src/Conversation.d.ts.map +1 -1
  11. package/dist/src/Conversation.js +903 -502
  12. package/dist/src/Conversation.js.map +1 -1
  13. package/dist/src/OpenAi.d.ts +20 -0
  14. package/dist/src/OpenAi.d.ts.map +1 -1
  15. package/dist/src/OpenAi.js +16 -0
  16. package/dist/src/OpenAi.js.map +1 -1
  17. package/dist/src/OpenAiStreamProcessor.d.ts +9 -3
  18. package/dist/src/OpenAiStreamProcessor.d.ts.map +1 -1
  19. package/dist/src/OpenAiStreamProcessor.js +5 -3
  20. package/dist/src/OpenAiStreamProcessor.js.map +1 -1
  21. package/dist/src/UsageData.d.ts.map +1 -1
  22. package/dist/src/UsageData.js +22 -0
  23. package/dist/src/UsageData.js.map +1 -1
  24. package/dist/src/code_template/Code.d.ts.map +1 -1
  25. package/dist/src/code_template/Code.js +8 -2
  26. package/dist/src/code_template/Code.js.map +1 -1
  27. package/dist/src/resolveModel.d.ts +17 -0
  28. package/dist/src/resolveModel.d.ts.map +1 -0
  29. package/dist/src/resolveModel.js +121 -0
  30. package/dist/src/resolveModel.js.map +1 -0
  31. package/dist/test/conversation/conversation.generateObject.test.d.ts +2 -0
  32. package/dist/test/conversation/conversation.generateObject.test.d.ts.map +1 -0
  33. package/dist/test/conversation/conversation.generateObject.test.js +153 -0
  34. package/dist/test/conversation/conversation.generateObject.test.js.map +1 -0
  35. package/dist/test/conversation/conversation.generateResponse.test.d.ts +2 -0
  36. package/dist/test/conversation/conversation.generateResponse.test.d.ts.map +1 -0
  37. package/dist/test/conversation/conversation.generateResponse.test.js +167 -0
  38. package/dist/test/conversation/conversation.generateResponse.test.js.map +1 -0
  39. package/dist/test/conversation/conversation.generateStream.test.d.ts +2 -0
  40. package/dist/test/conversation/conversation.generateStream.test.d.ts.map +1 -0
  41. package/dist/test/conversation/conversation.generateStream.test.js +255 -0
  42. package/dist/test/conversation/conversation.generateStream.test.js.map +1 -0
  43. package/index.ts +5 -0
  44. package/package.json +7 -2
  45. package/src/CodegenConversation.ts +1 -1
  46. package/src/Conversation.ts +938 -496
  47. package/src/OpenAi.ts +20 -0
  48. package/src/OpenAiStreamProcessor.ts +9 -3
  49. package/src/UsageData.ts +25 -0
  50. package/src/code_template/Code.ts +5 -1
  51. package/src/resolveModel.ts +130 -0
  52. package/test/conversation/conversation.generateObject.test.ts +132 -0
  53. package/test/conversation/conversation.generateResponse.test.ts +132 -0
  54. package/test/conversation/conversation.generateStream.test.ts +173 -0
@@ -1,22 +1,31 @@
1
- import { ChatCompletionMessageParam } from 'openai/resources/chat';
2
- import { DEFAULT_MODEL, OpenAi, ToolInvocationProgressEvent } from './OpenAi';
3
- import { OpenAI as OpenAIApi } from 'openai';
4
- import { MessageHistory } from './history/MessageHistory';
5
- import { Function } from './Function';
1
+ import type { LanguageModel, ToolSet, LanguageModelUsage, ReasoningOutput } from 'ai';
2
+ import type { LanguageModelV3Source } from '@ai-sdk/provider';
3
+ import { streamText, generateObject as aiGenerateObject, jsonSchema, stepCountIs } from 'ai';
4
+ import type { RepairTextFunction } from 'ai';
6
5
  import { Logger, LogLevel } from '@proteinjs/logger';
7
- import { Fs } from '@proteinjs/util-node';
8
- import { MessageModerator } from './history/MessageModerator';
9
6
  import { ConversationModule } from './ConversationModule';
10
- import { TiktokenModel, encoding_for_model } from 'tiktoken';
11
- import { searchLibrariesFunctionName } from './fs/package/PackageFunctions';
12
- import { UsageData, UsageDataAccumulator } from './UsageData';
13
- import type { ModelMessage, LanguageModel, GenerateObjectResult, JSONValue } from 'ai';
14
- import { generateObject as aiGenerateObject, jsonSchema } from 'ai';
7
+ import { Function } from './Function';
8
+ import { MessageModerator } from './history/MessageModerator';
9
+ import { MessageHistory } from './history/MessageHistory';
10
+ import { UsageData, UsageDataAccumulator, TokenUsage } from './UsageData';
11
+ import { resolveModel, inferProvider } from './resolveModel';
12
+ import type { ToolInvocationProgressEvent, ToolInvocationResult } from './OpenAi';
13
+ import type { OpenAiResponses, OpenAiServiceTier } from './OpenAiResponses';
14
+ import type { ChatCompletionMessageParam } from 'openai/resources/chat';
15
+ import { TiktokenModel } from 'tiktoken';
16
+
17
+ // Re-export for convenience
18
+ export type { ToolInvocationProgressEvent, ToolInvocationResult } from './OpenAi';
19
+
20
+ // ────────────────────────────────────────────────────────────────
21
+ // Public types
22
+ // ────────────────────────────────────────────────────────────────
15
23
 
16
24
  export type ConversationParams = {
17
25
  name: string;
18
26
  modules?: ConversationModule[];
19
27
  logLevel?: LogLevel;
28
+ defaultModel?: LanguageModel | string;
20
29
  limits?: {
21
30
  enforceLimits?: boolean;
22
31
  maxMessagesInHistory?: number;
@@ -24,47 +33,122 @@ export type ConversationParams = {
24
33
  };
25
34
  };
26
35
 
27
- /** Object-only generation (no tool calls in this run). */
28
- export type GenerateObjectParams<S> = {
29
- /** Same input contract as generateResponse */
30
- messages: (string | ChatCompletionMessageParam)[];
36
+ /** Message format accepted by Conversation methods. */
37
+ export type ConversationMessage =
38
+ | string
39
+ | {
40
+ role: 'system' | 'user' | 'assistant' | 'developer' | 'tool' | 'function' | (string & {});
41
+ content?: string | null | unknown;
42
+ };
31
43
 
32
- /** A ready AI SDK model, e.g., openai('gpt-5') / openai('gpt-4o') */
33
- model: LanguageModel;
44
+ /** @deprecated Use `GenerateObjectResult` instead. */
45
+ export type GenerateObjectOutcome<T> = GenerateObjectResult<T>;
34
46
 
47
+ export type ReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max' | 'xhigh';
48
+
49
+ export type GenerateStreamParams = {
50
+ messages: ConversationMessage[];
51
+ model?: LanguageModel | string;
52
+ reasoningEffort?: ReasoningEffort;
53
+ webSearch?: boolean;
54
+ tools?: Function[];
55
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
56
+ onUsageData?: (usageData: UsageData) => Promise<void>;
35
57
  abortSignal?: AbortSignal;
58
+ maxToolCalls?: number;
36
59
 
37
- /** Zod schema or JSON Schema */
38
- schema: S;
60
+ // OpenAI-specific
61
+ backgroundMode?: boolean;
62
+ serviceTier?: OpenAiServiceTier;
63
+ maxBackgroundWaitMs?: number;
64
+ };
65
+
66
+ /** A single part emitted by the interleaved full stream. */
67
+ export type StreamPart =
68
+ | { type: 'text-delta'; textDelta: string }
69
+ | { type: 'reasoning-delta'; textDelta: string }
70
+ | { type: 'source'; source: StreamSource };
71
+
72
+ /** The result of generateStream. All properties are available immediately for streaming consumption. */
73
+ export type StreamResult = {
74
+ /** Async iterable of text content chunks. */
75
+ textStream: AsyncIterable<string>;
76
+ /** Async iterable of reasoning/thinking chunks (empty if model doesn't support reasoning). */
77
+ reasoningStream: AsyncIterable<string>;
78
+ /**
79
+ * Interleaved stream of all parts (text, reasoning, sources) for real-time
80
+ * consumption. Prefer this over consuming `textStream` and `reasoningStream`
81
+ * separately, since those may share the same underlying data source and
82
+ * cannot be consumed concurrently.
83
+ */
84
+ fullStream: AsyncIterable<StreamPart>;
85
+ /** Resolves to the full text when generation completes. */
86
+ text: Promise<string>;
87
+ /** Resolves to the full reasoning text when generation completes. */
88
+ reasoning: Promise<string>;
89
+ /** Resolves to source citations (web search results, etc.). */
90
+ sources: Promise<StreamSource[]>;
91
+ /** Resolves to usage data when generation completes. */
92
+ usage: Promise<UsageData>;
93
+ /** Resolves to tool invocation results. */
94
+ toolInvocations: Promise<ToolInvocationResult[]>;
95
+ };
39
96
 
40
- /** Sampling & limits */
97
+ export type StreamSource = {
98
+ url?: string;
99
+ title?: string;
100
+ };
101
+
102
+ export type GenerateObjectParams<T> = {
103
+ messages: ConversationMessage[];
104
+ model?: LanguageModel | string;
105
+ schema: any; // Zod schema or JSON Schema
106
+ reasoningEffort?: ReasoningEffort;
41
107
  temperature?: number;
42
108
  topP?: number;
43
109
  maxTokens?: number;
44
-
45
- /** Usage callback */
110
+ abortSignal?: AbortSignal;
46
111
  onUsageData?: (usageData: UsageData) => Promise<void>;
47
-
48
- /** Append final JSON to history as assistant text; default true */
49
112
  recordInHistory?: boolean;
50
113
 
51
- /** Per-call override for reasoning effort (reasoning models only). */
52
- reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
114
+ // OpenAI-specific
115
+ backgroundMode?: boolean;
116
+ serviceTier?: OpenAiServiceTier;
117
+ maxBackgroundWaitMs?: number;
53
118
  };
54
119
 
55
- export type GenerateObjectOutcome<T> = {
56
- object: T; // validated final object
57
- usageData: UsageData;
120
+ export type GenerateObjectResult<T> = {
121
+ object: T;
122
+ usage: UsageData;
123
+ reasoning?: string;
124
+ toolInvocations: ToolInvocationResult[];
58
125
  };
59
126
 
127
+ export type GenerateResponseResult = {
128
+ text: string;
129
+ reasoning?: string;
130
+ sources: StreamSource[];
131
+ usage: UsageData;
132
+ toolInvocations: ToolInvocationResult[];
133
+ };
134
+
135
+ // ────────────────────────────────────────────────────────────────
136
+ // Default constants
137
+ // ────────────────────────────────────────────────────────────────
138
+
139
+ const DEFAULT_MODEL = 'gpt-4o' as TiktokenModel;
140
+ const DEFAULT_TOKEN_LIMIT = 50_000;
141
+
142
+ // ────────────────────────────────────────────────────────────────
143
+ // Conversation class
144
+ // ────────────────────────────────────────────────────────────────
145
+
60
146
  export class Conversation {
61
- private tokenLimit = 50000;
62
- private history;
63
- private systemMessages: ChatCompletionMessageParam[] = [];
147
+ private tokenLimit: number;
148
+ private history: MessageHistory;
149
+ private systemMessages: ConversationMessage[] = [];
64
150
  private functions: Function[] = [];
65
151
  private messageModerators: MessageModerator[] = [];
66
- private generatedCode = false;
67
- private generatedList = false;
68
152
  private logger: Logger;
69
153
  private params: ConversationParams;
70
154
  private modulesProcessed = false;
@@ -72,41 +156,312 @@ export class Conversation {
72
156
 
73
157
  constructor(params: ConversationParams) {
74
158
  this.params = params;
159
+ this.tokenLimit = params.limits?.tokenLimit ?? DEFAULT_TOKEN_LIMIT;
75
160
  this.history = new MessageHistory({
76
161
  maxMessages: params.limits?.maxMessagesInHistory,
77
162
  enforceMessageLimit: params.limits?.enforceLimits,
78
163
  });
79
164
  this.logger = new Logger({ name: params.name, logLevel: params.logLevel });
165
+ }
166
+
167
+ // ────────────────────────────────────────────────────────────
168
+ // Public API
169
+ // ────────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Stream a text response from the model.
173
+ *
174
+ * Returns a `StreamResult` with async iterables for text and reasoning chunks,
175
+ * plus promises that resolve when generation completes.
176
+ *
177
+ * For OpenAI models with high reasoning effort or pro models, this may
178
+ * fall back to background/polling mode via `OpenAiResponses` and return
179
+ * the full result as a single-chunk stream.
180
+ */
181
+ async generateStream(params: GenerateStreamParams): Promise<StreamResult> {
182
+ await this.ensureModulesProcessed();
183
+
184
+ const model = this.resolveModelInstance(params.model);
185
+ const modelString = this.getModelString(params.model);
186
+ const provider = inferProvider(params.model ?? this.params.defaultModel ?? DEFAULT_MODEL);
187
+
188
+ this.logger.info({
189
+ message: `generateStream`,
190
+ obj: { model: modelString, provider, reasoningEffort: params.reasoningEffort, webSearch: params.webSearch },
191
+ });
192
+
193
+ // Check if we should use background/polling mode (OpenAI-specific)
194
+ if (provider === 'openai' && this.shouldUseBackgroundMode(modelString, params)) {
195
+ return this.generateStreamViaPolling(params, modelString);
196
+ }
197
+
198
+ // Build messages for the AI SDK
199
+ let messages = this.buildAiSdkMessages(params.messages);
200
+
201
+ // Google requires all system messages at the beginning of the conversation.
202
+ // Reorder so system messages come first, preserving relative order within
203
+ // each group.
204
+ if (provider === 'google') {
205
+ const system = messages.filter((m) => m.role === 'system');
206
+ const nonSystem = messages.filter((m) => m.role !== 'system');
207
+ messages = [...system, ...nonSystem];
208
+ }
209
+
210
+ // Build tools from module functions + any extra tools
211
+ const allFunctions = [...this.functions, ...(params.tools ?? [])];
212
+ const tools = this.buildAiSdkTools(allFunctions);
213
+
214
+ // Build provider options
215
+ const providerOptions = this.buildProviderOptions(provider, params, modelString);
216
+
217
+ const result = streamText({
218
+ model,
219
+ messages,
220
+ tools: Object.keys(tools).length > 0 ? tools : undefined,
221
+ stopWhen: stepCountIs(params.maxToolCalls ?? 50),
222
+ abortSignal: params.abortSignal,
223
+ providerOptions,
224
+ ...(params.webSearch && provider === 'openai' ? { toolChoice: 'auto' as const } : {}),
225
+ });
226
+
227
+ // Build the StreamResult
228
+ const usagePromise = this.buildUsagePromise(result, modelString, params);
229
+ const toolInvocationsPromise = this.buildToolInvocationsPromise(result);
230
+
231
+ // Wrap ALL AI SDK promises with catch handlers to prevent unhandled rejections
232
+ // when the stream is aborted mid-generation. The AI SDK's internal transform
233
+ // stream throws NoOutputGeneratedError on flush if aborted before any output,
234
+ // rejecting finishReason, totalUsage, steps, text, etc. Any uncaught rejection
235
+ // crashes the Node process.
236
+ const safeText = Promise.resolve(result.text).catch(() => '');
237
+ const safeReasoning = Promise.resolve(result.reasoning)
238
+ .then((parts: ReasoningOutput[]) =>
239
+ parts
240
+ ? parts
241
+ .filter((part) => part.type === 'reasoning')
242
+ .map((part) => part.text)
243
+ .join('')
244
+ : ''
245
+ )
246
+ .catch(() => '');
247
+ const safeSources = Promise.resolve(result.sources)
248
+ .then((s: LanguageModelV3Source[]) =>
249
+ (s ?? []).map((source) => ({
250
+ url: source.sourceType === 'url' ? source.url : undefined,
251
+ title: source.sourceType === 'url' ? source.title : undefined,
252
+ }))
253
+ )
254
+ .catch(() => [] as StreamSource[]);
255
+ const safeUsage = usagePromise.catch(
256
+ () =>
257
+ ({
258
+ model: modelString,
259
+ initialRequestTokenUsage: {
260
+ inputTokens: 0,
261
+ cachedInputTokens: 0,
262
+ reasoningTokens: 0,
263
+ outputTokens: 0,
264
+ totalTokens: 0,
265
+ },
266
+ initialRequestCostUsd: { inputUsd: 0, cachedInputUsd: 0, reasoningUsd: 0, outputUsd: 0, totalUsd: 0 },
267
+ totalTokenUsage: {
268
+ inputTokens: 0,
269
+ cachedInputTokens: 0,
270
+ reasoningTokens: 0,
271
+ outputTokens: 0,
272
+ totalTokens: 0,
273
+ },
274
+ totalCostUsd: { inputUsd: 0, cachedInputUsd: 0, reasoningUsd: 0, outputUsd: 0, totalUsd: 0 },
275
+ totalRequestsToAssistant: 0,
276
+ callsPerTool: {},
277
+ totalToolCalls: 0,
278
+ }) as UsageData
279
+ );
280
+ const safeToolInvocations = toolInvocationsPromise.catch(() => [] as ToolInvocationResult[]);
281
+
282
+ // Catch remaining AI SDK promises that are rejected by NoOutputGeneratedError
283
+ // on flush when the stream is aborted before any output. The AI SDK's internal
284
+ // flush rejects _finishReason, _rawFinishReason, _totalUsage, and _steps.
285
+ // We already catch totalUsage and steps above; these catch the rest so the
286
+ // unhandled rejections don't crash the Node process.
287
+ Promise.resolve(result.finishReason).catch(() => {});
288
+ Promise.resolve((result as any).rawFinishReason).catch(() => {});
289
+ Promise.resolve(result.response).catch(() => {});
290
+
291
+ return {
292
+ textStream: result.textStream,
293
+ reasoningStream: (async function* () {
294
+ // Reasoning is available via the promise after generation completes.
295
+ // For real-time streaming, use fullStream instead.
296
+ })(),
297
+ fullStream: this.mapFullStream(result.fullStream),
298
+ text: safeText,
299
+ reasoning: safeReasoning,
300
+ sources: safeSources,
301
+ usage: safeUsage,
302
+ toolInvocations: safeToolInvocations,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Generate a strongly-typed structured object from the model.
308
+ *
309
+ * This is promise-based (not streaming-first) to guarantee the
310
+ * type contract. Reasoning is available on the result after completion.
311
+ *
312
+ * For OpenAI models with high reasoning or pro models, this uses
313
+ * `OpenAiResponses` with background/polling mode.
314
+ */
315
+ async generateObject<T>(params: GenerateObjectParams<T>): Promise<GenerateObjectResult<T>> {
316
+ await this.ensureModulesProcessed();
317
+
318
+ const model = this.resolveModelInstance(params.model);
319
+ const modelString = this.getModelString(params.model);
320
+ const provider = inferProvider(params.model ?? this.params.defaultModel ?? DEFAULT_MODEL);
321
+
322
+ // Check if we should use background/polling mode (OpenAI-specific)
323
+ if (provider === 'openai' && this.shouldUseBackgroundMode(modelString, params)) {
324
+ return this.generateObjectViaPolling(params, modelString);
325
+ }
326
+
327
+ let messages = this.buildAiSdkMessages(params.messages);
328
+
329
+ // Google requires all system messages at the beginning
330
+ if (provider === 'google') {
331
+ const system = messages.filter((m) => m.role === 'system');
332
+ const nonSystem = messages.filter((m) => m.role !== 'system');
333
+ messages = [...system, ...nonSystem];
334
+ }
335
+
336
+ // Schema normalization
337
+ const isZod = this.isZodSchema(params.schema);
338
+ const normalizedSchema = isZod ? params.schema : jsonSchema(this.strictifyJsonSchema(params.schema));
339
+
340
+ const result = await aiGenerateObject({
341
+ model,
342
+ messages,
343
+ schema: normalizedSchema,
344
+ abortSignal: params.abortSignal,
345
+ maxOutputTokens: params.maxTokens,
346
+ temperature: params.temperature,
347
+ topP: params.topP,
348
+ providerOptions: this.buildProviderOptions(provider, params, modelString),
349
+ experimental_repairText: (async ({ text }) => {
350
+ const cleaned = String(text ?? '')
351
+ .trim()
352
+ .replace(/^```(?:json)?/i, '')
353
+ .replace(/```$/, '');
354
+ try {
355
+ JSON.parse(cleaned);
356
+ return cleaned;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }) as RepairTextFunction,
361
+ });
362
+
363
+ // Record in history
364
+ if (params.recordInHistory !== false) {
365
+ try {
366
+ const toRecord = typeof result?.object === 'object' ? JSON.stringify(result.object) : '';
367
+ if (toRecord) {
368
+ this.addAssistantMessagesToHistory([toRecord]);
369
+ }
370
+ } catch {
371
+ /* ignore */
372
+ }
373
+ }
80
374
 
81
- if (params?.limits?.enforceLimits) {
82
- this.addFunctions('Conversation', [summarizeConversationHistoryFunction(this)]);
375
+ const usage = this.processAiSdkUsage(result, modelString);
376
+
377
+ if (params.onUsageData) {
378
+ await params.onUsageData(usage);
83
379
  }
84
380
 
85
- if (params.limits?.tokenLimit) {
86
- this.tokenLimit = params.limits.tokenLimit;
381
+ // Extract reasoning if available
382
+ const reasoning = this.extractReasoningFromResult(result);
383
+
384
+ return {
385
+ object: (result?.object ?? {}) as T,
386
+ usage,
387
+ reasoning: reasoning || undefined,
388
+ toolInvocations: [],
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Non-streaming convenience: generates a text response and waits for completion.
394
+ */
395
+ async generateResponse(params: GenerateStreamParams): Promise<GenerateResponseResult> {
396
+ const stream = await this.generateStream(params);
397
+ const [text, reasoning, sources, usage, toolInvocations] = await Promise.all([
398
+ stream.text,
399
+ stream.reasoning,
400
+ stream.sources,
401
+ stream.usage,
402
+ stream.toolInvocations,
403
+ ]);
404
+ return { text, reasoning: reasoning || undefined, sources, usage, toolInvocations };
405
+ }
406
+
407
+ // ────────────────────────────────────────────────────────────
408
+ // History management (public, for callers like ThoughtConversation)
409
+ // ────────────────────────────────────────────────────────────
410
+
411
+ addSystemMessagesToHistory(messages: string[], unshift = false) {
412
+ const formatted: ConversationMessage[] = messages.map((m) => ({ role: 'system' as const, content: m }));
413
+ this.addMessagesToHistory(formatted, unshift);
414
+ }
415
+
416
+ addAssistantMessagesToHistory(messages: string[], unshift = false) {
417
+ const formatted: ConversationMessage[] = messages.map((m) => ({ role: 'assistant' as const, content: m }));
418
+ this.addMessagesToHistory(formatted, unshift);
419
+ }
420
+
421
+ addUserMessagesToHistory(messages: string[], unshift = false) {
422
+ const formatted: ConversationMessage[] = messages.map((m) => ({ role: 'user' as const, content: m }));
423
+ this.addMessagesToHistory(formatted, unshift);
424
+ }
425
+
426
+ addMessagesToHistory(messages: ConversationMessage[], unshift = false) {
427
+ // Convert to the format MessageHistory expects (ChatCompletionMessageParam-like)
428
+ const historyMessages = messages.map((m) => {
429
+ if (typeof m === 'string') {
430
+ return { role: 'user' as const, content: m };
431
+ }
432
+ return m;
433
+ });
434
+
435
+ const systemMsgs = historyMessages.filter((m) => m.role === 'system');
436
+
437
+ if (unshift) {
438
+ this.history.getMessages().unshift(...(historyMessages as any[]));
439
+ this.history.prune();
440
+ this.systemMessages.unshift(...systemMsgs);
441
+ } else {
442
+ this.history.push(historyMessages as any[]);
443
+ this.systemMessages.push(...systemMsgs);
87
444
  }
88
445
  }
89
446
 
447
+ // ────────────────────────────────────────────────────────────
448
+ // Module system
449
+ // ────────────────────────────────────────────────────────────
450
+
90
451
  private async ensureModulesProcessed(): Promise<void> {
91
- // If modules are already processed, return immediately
92
452
  if (this.modulesProcessed) {
93
453
  return;
94
454
  }
95
-
96
- // If modules are currently being processed, wait for that to complete
97
455
  if (this.processingModulesPromise) {
98
456
  return this.processingModulesPromise;
99
457
  }
100
458
 
101
- // Start processing modules and keep a reference to the promise
102
459
  this.processingModulesPromise = this.processModules();
103
-
104
460
  try {
105
461
  await this.processingModulesPromise;
106
462
  this.modulesProcessed = true;
107
463
  } catch (error) {
108
464
  this.logger.error({ message: 'Error processing modules', obj: { error } });
109
- // Reset the promise so we can try again
110
465
  this.processingModulesPromise = null;
111
466
  throw error;
112
467
  }
@@ -118,334 +473,613 @@ export class Conversation {
118
473
  }
119
474
 
120
475
  for (const module of this.params.modules) {
121
- // Get system messages and handle potential Promise
122
- const moduleSystemMessagesResult = module.getSystemMessages();
123
- let moduleSystemMessages: string[] | string;
476
+ const moduleName = module.getName();
477
+
478
+ // System messages
479
+ const rawSystem = await Promise.resolve(module.getSystemMessages());
480
+ const sysArr = Array.isArray(rawSystem) ? rawSystem : rawSystem ? [rawSystem] : [];
481
+ const trimmed = sysArr.map((s) => String(s ?? '').trim()).filter(Boolean);
482
+
483
+ if (trimmed.length > 0) {
484
+ const formatted = trimmed.join('. ');
485
+ this.addSystemMessagesToHistory([
486
+ `The following are instructions from the ${moduleName} module:\n${formatted}`,
487
+ ]);
488
+ }
489
+
490
+ // Functions
491
+ const moduleFunctions = module.getFunctions();
492
+ this.functions.push(...moduleFunctions);
493
+
494
+ // Function instructions
495
+ let functionInstructions = `The following are instructions from functions in the ${moduleName} module:`;
496
+ let hasInstructions = false;
497
+ for (const f of moduleFunctions) {
498
+ if (f.instructions && f.instructions.length > 0) {
499
+ hasInstructions = true;
500
+ const paragraph = f.instructions.join('. ');
501
+ functionInstructions += ` ${f.definition.name}: ${paragraph}.`;
502
+ }
503
+ }
504
+ if (hasInstructions) {
505
+ this.addSystemMessagesToHistory([functionInstructions]);
506
+ }
124
507
 
125
- // Check if the result is a Promise and await it if needed
126
- if (moduleSystemMessagesResult instanceof Promise) {
127
- moduleSystemMessages = await moduleSystemMessagesResult;
508
+ // Message moderators
509
+ this.messageModerators.push(...module.getMessageModerators());
510
+ }
511
+ }
512
+
513
+ // ────────────────────────────────────────────────────────────
514
+ // AI SDK message building
515
+ // ────────────────────────────────────────────────────────────
516
+
517
+ private buildAiSdkMessages(
518
+ callMessages: ConversationMessage[]
519
+ ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
520
+ const result: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
521
+
522
+ // Add history messages
523
+ for (const msg of this.history.getMessages()) {
524
+ const m = msg as any;
525
+ const rawRole = String(m.role ?? 'user');
526
+ // Map non-standard roles to the closest AI SDK role
527
+ const role = (rawRole === 'system' ? 'system' : rawRole === 'assistant' ? 'assistant' : 'user') as
528
+ | 'system'
529
+ | 'user'
530
+ | 'assistant';
531
+ const content = typeof m.content === 'string' ? m.content : this.extractTextFromContent(m.content);
532
+ if (content.trim()) {
533
+ result.push({ role, content });
534
+ }
535
+ }
536
+
537
+ // Add call messages
538
+ for (const msg of callMessages) {
539
+ if (typeof msg === 'string') {
540
+ result.push({ role: 'user', content: msg });
128
541
  } else {
129
- moduleSystemMessages = moduleSystemMessagesResult;
542
+ const rawRole = String(msg.role ?? 'user');
543
+ const role = (rawRole === 'system' ? 'system' : rawRole === 'assistant' ? 'assistant' : 'user') as
544
+ | 'system'
545
+ | 'user'
546
+ | 'assistant';
547
+ result.push({ role, content: typeof msg.content === 'string' ? msg.content : '' });
130
548
  }
549
+ }
550
+
551
+ return result;
552
+ }
131
553
 
132
- if (!moduleSystemMessages || (Array.isArray(moduleSystemMessages) && moduleSystemMessages.length < 1)) {
554
+ private extractTextFromContent(content: any): string {
555
+ if (typeof content === 'string') {
556
+ return content;
557
+ }
558
+ if (Array.isArray(content)) {
559
+ return content
560
+ .map((p: any) => {
561
+ if (typeof p === 'string') {
562
+ return p;
563
+ }
564
+ if (p?.type === 'text') {
565
+ return p.text;
566
+ }
567
+ return '';
568
+ })
569
+ .join('\n');
570
+ }
571
+ return '';
572
+ }
573
+
574
+ // ────────────────────────────────────────────────────────────
575
+ // AI SDK tool building
576
+ // ────────────────────────────────────────────────────────────
577
+
578
+ private buildAiSdkTools(functions: Function[]): ToolSet {
579
+ const tools: ToolSet = {};
580
+
581
+ for (const f of functions) {
582
+ const def = f.definition;
583
+ if (!def?.name) {
133
584
  continue;
134
585
  }
135
586
 
136
- const formattedSystemMessages = Array.isArray(moduleSystemMessages)
137
- ? moduleSystemMessages.join('. ')
138
- : moduleSystemMessages;
587
+ tools[def.name] = {
588
+ description: def.description,
589
+ inputSchema: jsonSchema(this.normalizeToolParameters(def.parameters)),
590
+ execute: async (args: any) => {
591
+ const result = await f.call(args);
592
+ if (typeof result === 'undefined') {
593
+ return { result: 'Function executed successfully' };
594
+ }
595
+ return result;
596
+ },
597
+ } as any;
598
+ }
599
+
600
+ return tools;
601
+ }
602
+
603
+ /**
604
+ * Normalize tool parameter schemas to ensure they are valid JSON Schema
605
+ * with `type: "object"`. Handles missing, null, or invalid schemas
606
+ * (e.g. `type: "None"` which some functions produce).
607
+ */
608
+ private normalizeToolParameters(parameters: any): Record<string, any> {
609
+ const emptySchema = { type: 'object', properties: {} };
139
610
 
140
- this.addSystemMessagesToHistory([
141
- `The following are instructions from the ${module.getName()} module:\n${formattedSystemMessages}`,
142
- ]);
143
- this.addFunctions(module.getName(), module.getFunctions());
144
- this.addMessageModerators(module.getMessageModerators());
611
+ if (!parameters || typeof parameters !== 'object') {
612
+ return emptySchema;
145
613
  }
614
+
615
+ // If type is missing, not a string, or not a valid JSON Schema type, default to object
616
+ const validTypes = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
617
+ if (
618
+ !parameters.type ||
619
+ typeof parameters.type !== 'string' ||
620
+ !validTypes.includes(parameters.type.toLowerCase())
621
+ ) {
622
+ return { ...emptySchema, ...parameters, type: 'object' };
623
+ }
624
+
625
+ return parameters;
146
626
  }
147
627
 
148
- private addFunctions(moduleName: string, functions: Function[]) {
149
- this.functions.push(...functions);
150
- let functionInstructions = `The following are instructions from functions in the ${moduleName} module:`;
151
- let functionInstructionsAdded = false;
152
- for (const f of functions) {
153
- if (f.instructions) {
154
- if (!f.instructions || f.instructions.length < 1) {
155
- continue;
156
- }
628
+ // ────────────────────────────────────────────────────────────
629
+ // Provider options
630
+ // ────────────────────────────────────────────────────────────
631
+
632
+ private buildProviderOptions(
633
+ provider: string,
634
+ params: { reasoningEffort?: ReasoningEffort; webSearch?: boolean; serviceTier?: OpenAiServiceTier },
635
+ modelString?: string
636
+ ): Record<string, any> {
637
+ const options: Record<string, any> = {};
638
+ const effort = params.reasoningEffort;
639
+
640
+ if (provider === 'openai') {
641
+ const openaiOpts: Record<string, any> = {};
642
+ if (effort) {
643
+ // OpenAI accepts: none | low | medium | high | xhigh
644
+ // 'max' → 'xhigh' (OpenAI's highest)
645
+ openaiOpts.reasoningEffort = effort === 'max' ? 'xhigh' : effort;
646
+ }
647
+ if (params.serviceTier) {
648
+ openaiOpts.serviceTier = params.serviceTier;
649
+ }
650
+ options.openai = openaiOpts;
651
+ }
157
652
 
158
- functionInstructionsAdded = true;
159
- const instructionsParagraph = f.instructions.join('. ');
160
- functionInstructions += ` ${f.definition.name}: ${instructionsParagraph}.`;
653
+ if (provider === 'anthropic') {
654
+ const anthropicOpts: Record<string, any> = {};
655
+ if (effort && effort !== 'none') {
656
+ // Use adaptive thinking (Sonnet 4.6+, Opus 4.6+) with effort level.
657
+ // Anthropic accepts effort: low | medium | high | max
658
+ // 'xhigh' has no Anthropic equivalent → map to 'max'
659
+ anthropicOpts.thinking = { type: 'adaptive' };
660
+ anthropicOpts.effort = effort === 'xhigh' ? 'max' : effort;
161
661
  }
662
+ options.anthropic = anthropicOpts;
162
663
  }
163
664
 
164
- if (!functionInstructionsAdded) {
165
- return;
665
+ if (provider === 'google') {
666
+ const googleOpts: Record<string, any> = {};
667
+ if (effort && effort !== 'none') {
668
+ // Google accepts thinkingLevel: minimal | low | medium | high
669
+ // Our 'max'/'xhigh' have no Google equivalent → map to 'high'
670
+ const levelMap: Record<string, string> = {
671
+ low: 'low',
672
+ medium: 'medium',
673
+ high: 'high',
674
+ xhigh: 'high',
675
+ max: 'high',
676
+ };
677
+ googleOpts.thinkingConfig = {
678
+ thinkingLevel: levelMap[effort] ?? 'medium',
679
+ };
680
+ }
681
+ options.google = googleOpts;
166
682
  }
167
683
 
168
- this.addSystemMessagesToHistory([functionInstructions]);
169
- }
684
+ if (provider === 'xai') {
685
+ const xaiOpts: Record<string, any> = {};
686
+ // Only models with reasoning support accept the reasoningEffort parameter.
687
+ // Models like grok-4 (no "-fast" suffix) reject it with a 400 error.
688
+ const xaiSupportsReasoning = modelString ? /fast/i.test(modelString) : false;
689
+ if (effort && effort !== 'none' && xaiSupportsReasoning) {
690
+ // xAI accepts: low | high
691
+ // Map everything to the closest valid value
692
+ const xaiEffort = effort === 'low' ? 'low' : 'high';
693
+ xaiOpts.reasoningEffort = xaiEffort;
694
+ }
695
+ options.xai = xaiOpts;
696
+ }
170
697
 
171
- private addMessageModerators(messageModerators: MessageModerator[]) {
172
- this.messageModerators.push(...messageModerators);
698
+ return options;
173
699
  }
174
700
 
175
- private async enforceTokenLimit(messages: (string | ChatCompletionMessageParam)[], model?: TiktokenModel) {
176
- if (!this.params.limits?.enforceLimits) {
177
- return;
178
- }
701
+ // ────────────────────────────────────────────────────────────
702
+ // Background/polling escape hatch (OpenAI-specific)
703
+ // ────────────────────────────────────────────────────────────
179
704
 
180
- const resolvedModel = model ? model : DEFAULT_MODEL;
181
- const encoder = encoding_for_model(resolvedModel);
182
- const conversation =
183
- this.history.toString() +
184
- messages
185
- .map((message) => {
186
- if (typeof message === 'string') {
187
- return message;
188
- } else {
189
- // Extract content from ChatCompletionMessageParam
190
- const contentParts = Array.isArray(message.content) ? message.content : [message.content];
191
- return contentParts
192
- .map((part) => {
193
- if (typeof part === 'string') {
194
- return part;
195
- } else if (part?.type === 'text') {
196
- return part.text;
197
- } else {
198
- return ''; // Handle non-text content types as empty string
199
- }
200
- })
201
- .join(' ');
202
- }
203
- })
204
- .join('. ');
205
- const encoded = encoder.encode(conversation);
206
- console.log(`current tokens: ${encoded.length}`);
207
- if (encoded.length < this.tokenLimit) {
208
- return;
705
+ private shouldUseBackgroundMode(
706
+ modelString: string,
707
+ params: { backgroundMode?: boolean; reasoningEffort?: ReasoningEffort }
708
+ ): boolean {
709
+ if (typeof params.backgroundMode === 'boolean') {
710
+ return params.backgroundMode;
711
+ }
712
+ if (this.isProModel(modelString)) {
713
+ return true;
714
+ }
715
+ if (this.isHighReasoningEffort(params.reasoningEffort)) {
716
+ return true;
209
717
  }
718
+ return false;
719
+ }
210
720
 
211
- const summarizeConversationRequest = `First, call the ${summarizeConversationHistoryFunctionName} function`;
212
- await new OpenAi({
213
- history: this.history,
214
- functions: this.functions,
215
- messageModerators: this.messageModerators,
216
- logLevel: this.params.logLevel,
217
- }).generateResponse({ messages: [summarizeConversationRequest], model });
218
- const referenceSummaryRequest = `If there's a file mentioned in the conversation summary, find and read the file to better respond to my next request. If that doesn't find anything, call the ${searchLibrariesFunctionName} function on other keywords in the conversation summary to find a file to read`;
219
- await new OpenAi({
220
- history: this.history,
221
- functions: this.functions,
222
- messageModerators: this.messageModerators,
223
- logLevel: this.params.logLevel,
224
- }).generateResponse({ messages: [referenceSummaryRequest], model });
721
+ private isProModel(model: string): boolean {
722
+ return /(^|[-_.])pro($|[-_.])/.test(String(model ?? '').toLowerCase());
225
723
  }
226
724
 
227
- summarizeConversationHistory(summary: string) {
228
- this.clearHistory();
229
- this.history.push([{ role: 'assistant', content: `Previous conversation summary: ${summary}` }]);
725
+ private isHighReasoningEffort(effort?: ReasoningEffort): boolean {
726
+ return effort === 'high' || effort === 'xhigh' || effort === 'max';
230
727
  }
231
728
 
232
- private clearHistory() {
233
- this.history = new MessageHistory();
234
- this.history.push(this.systemMessages);
729
+ /**
730
+ * Map our ReasoningEffort to OpenAI's accepted values.
731
+ * OpenAI accepts: none | low | medium | high | xhigh
732
+ * 'max' → 'xhigh' (OpenAI's highest).
733
+ */
734
+ private mapReasoningEffortForOpenAi(
735
+ effort?: ReasoningEffort
736
+ ): 'none' | 'low' | 'medium' | 'high' | 'xhigh' | undefined {
737
+ if (!effort) {
738
+ return undefined;
739
+ }
740
+ if (effort === 'max') {
741
+ return 'xhigh';
742
+ }
743
+ return effort as 'none' | 'low' | 'medium' | 'high' | 'xhigh';
235
744
  }
236
745
 
237
- addSystemMessagesToHistory(messages: string[], unshift = false) {
238
- const chatCompletions: ChatCompletionMessageParam[] = messages.map((message) => {
239
- return { role: 'system', content: message };
746
+ /**
747
+ * Fall back to OpenAiResponses for background/polling mode.
748
+ * Returns a StreamResult where the text arrives as a single chunk after polling completes.
749
+ */
750
+ private async generateStreamViaPolling(params: GenerateStreamParams, modelString: string): Promise<StreamResult> {
751
+ const responses = this.createOpenAiResponses(params);
752
+
753
+ // Convert messages to the format OpenAiResponses expects
754
+ const messages = this.convertToOpenAiMessages(params.messages);
755
+
756
+ const result = await responses.generateText({
757
+ messages,
758
+ model: modelString as TiktokenModel,
759
+ abortSignal: params.abortSignal,
760
+ onToolInvocation: params.onToolInvocation,
761
+ onUsageData: params.onUsageData,
762
+ reasoningEffort: this.mapReasoningEffortForOpenAi(params.reasoningEffort),
763
+ maxToolCalls: params.maxToolCalls,
764
+ backgroundMode: params.backgroundMode,
765
+ maxBackgroundWaitMs: params.maxBackgroundWaitMs,
766
+ serviceTier: params.serviceTier,
240
767
  });
241
- this.addMessagesToHistory(chatCompletions, unshift);
768
+
769
+ // Wrap the polling result as a StreamResult
770
+ const text = result.message;
771
+ const usage = result.usagedata;
772
+ const toolInvocations = result.toolInvocations;
773
+
774
+ return {
775
+ textStream: (async function* () {
776
+ yield text;
777
+ })(),
778
+ reasoningStream: (async function* () {
779
+ // Reasoning not available via polling mode
780
+ })(),
781
+ fullStream: (async function* () {
782
+ yield { type: 'text-delta' as const, textDelta: text };
783
+ })(),
784
+ text: Promise.resolve(text),
785
+ reasoning: Promise.resolve(''),
786
+ sources: Promise.resolve([]),
787
+ usage: Promise.resolve(usage),
788
+ toolInvocations: Promise.resolve(toolInvocations),
789
+ };
242
790
  }
243
791
 
244
- addAssistantMessagesToHistory(messages: string[], unshift = false) {
245
- const chatCompletions: ChatCompletionMessageParam[] = messages.map((message) => {
246
- return { role: 'assistant', content: message };
792
+ /**
793
+ * Fall back to OpenAiResponses for generateObject with background/polling.
794
+ */
795
+ private async generateObjectViaPolling<T>(
796
+ params: GenerateObjectParams<T>,
797
+ modelString: string
798
+ ): Promise<GenerateObjectResult<T>> {
799
+ const responses = this.createOpenAiResponses(params);
800
+
801
+ const messages = this.convertToOpenAiMessages(params.messages);
802
+
803
+ const result = await responses.generateObject<T>({
804
+ messages,
805
+ model: modelString as TiktokenModel,
806
+ schema: params.schema,
807
+ abortSignal: params.abortSignal,
808
+ onUsageData: params.onUsageData,
809
+ reasoningEffort: this.mapReasoningEffortForOpenAi(params.reasoningEffort),
810
+ temperature: params.temperature,
811
+ topP: params.topP,
812
+ maxTokens: params.maxTokens,
813
+ backgroundMode: params.backgroundMode,
814
+ maxBackgroundWaitMs: params.maxBackgroundWaitMs,
815
+ serviceTier: params.serviceTier,
247
816
  });
248
- this.addMessagesToHistory(chatCompletions, unshift);
817
+
818
+ return {
819
+ object: result.object,
820
+ usage: result.usageData,
821
+ reasoning: undefined,
822
+ toolInvocations: [],
823
+ };
249
824
  }
250
825
 
251
- addUserMessagesToHistory(messages: string[], unshift = false) {
252
- const chatCompletions: ChatCompletionMessageParam[] = messages.map((message) => {
253
- return { role: 'user', content: message };
826
+ private createOpenAiResponses(params: {
827
+ serviceTier?: OpenAiServiceTier;
828
+ maxBackgroundWaitMs?: number;
829
+ }): OpenAiResponses {
830
+ // Lazy require to avoid circular dependency and keep OpenAiResponses optional
831
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
832
+ const { OpenAiResponses: OAIResponses } = require('./OpenAiResponses');
833
+ return new OAIResponses({
834
+ modules: this.params.modules,
835
+ logLevel: this.params.logLevel,
836
+ defaultModel: this.getModelString(this.params.defaultModel) as TiktokenModel,
254
837
  });
255
- this.addMessagesToHistory(chatCompletions, unshift);
256
838
  }
257
839
 
258
- addMessagesToHistory(messages: ChatCompletionMessageParam[], unshift = false) {
259
- const systemMessages = messages.filter((message) => message.role === 'system');
260
- if (unshift) {
261
- this.history.getMessages().unshift(...messages);
262
- this.history.prune();
263
- this.systemMessages.unshift(...systemMessages);
264
- } else {
265
- this.history.push(messages);
266
- this.systemMessages.push(...systemMessages);
267
- }
268
- }
269
-
270
- async generateResponse({
271
- messages,
272
- model,
273
- maxToolCalls,
274
- ...rest
275
- }: {
276
- messages: (string | ChatCompletionMessageParam)[];
277
- model?: TiktokenModel;
278
- abortSignal?: AbortSignal;
279
- onUsageData?: (usageData: UsageData) => Promise<void>;
280
- onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
281
- reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
282
- maxToolCalls?: number;
283
- }) {
284
- await this.ensureModulesProcessed();
285
- await this.enforceTokenLimit(messages, model);
840
+ private convertToOpenAiMessages(messages: ConversationMessage[]): Array<string | ChatCompletionMessageParam> {
841
+ const result: Array<string | ChatCompletionMessageParam> = [];
286
842
 
287
- this.logger.debug({ message: `=============== Conversation.generateResponse (start) ===============` });
288
- this.logger.debug({ message: `Message history`, obj: { history: this.history.getMessages(), messages } });
289
- this.logger.debug({ message: `=============== Conversation.generateResponse (end) ===============` });
843
+ // Include history
844
+ for (const msg of this.history.getMessages()) {
845
+ const m = msg as any;
846
+ result.push({
847
+ role: m.role as 'system' | 'user' | 'assistant',
848
+ content: typeof m.content === 'string' ? m.content : this.extractTextFromContent(m.content),
849
+ });
850
+ }
290
851
 
291
- return await new OpenAi({
292
- history: this.history,
293
- functions: this.functions,
294
- messageModerators: this.messageModerators,
295
- logLevel: this.params.logLevel,
296
- ...(typeof maxToolCalls !== 'undefined' ? { maxFunctionCalls: maxToolCalls } : {}),
297
- }).generateResponse({ messages, model, ...rest });
298
- }
299
-
300
- async generateStreamingResponse({
301
- messages,
302
- model,
303
- maxToolCalls,
304
- ...rest
305
- }: {
306
- messages: (string | ChatCompletionMessageParam)[];
307
- model?: TiktokenModel;
308
- abortSignal?: AbortSignal;
309
- onUsageData?: (usageData: UsageData) => Promise<void>;
310
- onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
311
- reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
312
- maxToolCalls?: number;
313
- }) {
314
- await this.ensureModulesProcessed();
315
- await this.enforceTokenLimit(messages, model);
316
- return await new OpenAi({
317
- history: this.history,
318
- functions: this.functions,
319
- messageModerators: this.messageModerators,
320
- logLevel: this.params.logLevel,
321
- ...(typeof maxToolCalls !== 'undefined' ? { maxFunctionCalls: maxToolCalls } : {}),
322
- }).generateStreamingResponse({ messages, model, ...rest });
852
+ // Include call messages
853
+ for (const msg of messages) {
854
+ if (typeof msg === 'string') {
855
+ result.push(msg);
856
+ } else {
857
+ result.push({
858
+ role: msg.role as 'system' | 'user' | 'assistant',
859
+ content: msg.content as string,
860
+ });
861
+ }
862
+ }
863
+
864
+ return result;
323
865
  }
324
866
 
325
- /**
326
- * Generate a validated JSON object (no tools in this run).
327
- * Uses AI SDK `generateObject` which leverages provider-native structured outputs when available.
328
- */
329
- async generateObject<T>({
330
- messages,
331
- model,
332
- abortSignal,
333
- schema,
334
- temperature,
335
- topP,
336
- maxTokens,
337
- onUsageData,
338
- recordInHistory = true,
339
- reasoningEffort,
340
- }: GenerateObjectParams<unknown>): Promise<GenerateObjectOutcome<T>> {
341
- await this.ensureModulesProcessed();
867
+ // ────────────────────────────────────────────────────────────
868
+ // Model resolution
869
+ // ────────────────────────────────────────────────────────────
342
870
 
343
- const combined: ModelMessage[] = [
344
- ...this.toModelMessages(this.history.getMessages()),
345
- ...this.toModelMessages(messages),
346
- ];
871
+ private resolveModelInstance(model?: LanguageModel | string): LanguageModel {
872
+ const m = model ?? this.params.defaultModel ?? DEFAULT_MODEL;
873
+ return resolveModel(m);
874
+ }
347
875
 
348
- // Schema normalization (Zod OR JSON Schema supported)
349
- const isZod =
350
- schema &&
351
- (typeof (schema as any).safeParse === 'function' ||
352
- (!!(schema as any)._def && typeof (schema as any)._def.typeName === 'string'));
353
- const normalizedSchema = isZod ? (schema as any) : jsonSchema(this.strictifyJsonSchema(schema as any));
876
+ private getModelString(model?: LanguageModel | string): string {
877
+ if (!model) {
878
+ const def = this.params.defaultModel;
879
+ if (!def) {
880
+ return DEFAULT_MODEL;
881
+ }
882
+ if (typeof def === 'string') {
883
+ return def;
884
+ }
885
+ return (def as any).modelId ?? DEFAULT_MODEL;
886
+ }
887
+ if (typeof model === 'string') {
888
+ return model;
889
+ }
890
+ return (model as any).modelId ?? 'unknown';
891
+ }
354
892
 
355
- this.logger.debug({ message: `=============== Conversation.generateObject (start) ===============` });
356
- this.logger.debug({ message: `Message history`, obj: { messages: combined } });
357
- this.logger.debug({ message: `=============== Conversation.generateObject (end) ===============` });
893
+ // ────────────────────────────────────────────────────────────
894
+ // Usage processing
895
+ // ────────────────────────────────────────────────────────────
358
896
 
359
- const result = await aiGenerateObject({
360
- model,
361
- abortSignal,
362
- messages: combined,
363
- schema: normalizedSchema,
364
- providerOptions: {
365
- openai: {
366
- strictJsonSchema: true,
367
- reasoningEffort,
368
- },
369
- },
370
- maxOutputTokens: maxTokens,
371
- temperature,
372
- topP,
373
- experimental_repairText: async ({ text }: any) => {
374
- const cleaned = String(text ?? '')
375
- .trim()
376
- .replace(/^```(?:json)?/i, '')
377
- .replace(/```$/, '');
378
- try {
379
- JSON.parse(cleaned);
380
- return cleaned;
381
- } catch {
382
- return null;
383
- }
384
- },
385
- } as any);
897
+ /**
898
+ * Build a usage promise from a streaming result.
899
+ * Uses `totalUsage` (accumulated across all steps in a tool-call loop)
900
+ * and populates tool call stats from the steps.
901
+ */
902
+ private async buildUsagePromise(
903
+ result: {
904
+ totalUsage: PromiseLike<LanguageModelUsage>;
905
+ steps: PromiseLike<Array<{ toolCalls?: Array<{ toolName?: string }> }>>;
906
+ },
907
+ modelString: string,
908
+ params: GenerateStreamParams
909
+ ): Promise<UsageData> {
910
+ const [sdkUsage, steps] = await Promise.all([result.totalUsage, result.steps]);
911
+ const usage = this.mapSdkUsage(sdkUsage, modelString, steps);
912
+
913
+ if (params.onUsageData) {
914
+ await params.onUsageData(usage);
915
+ }
386
916
 
387
- // Record user messages to history (parity with other methods)
388
- const chatCompletions: ChatCompletionMessageParam[] = messages.map((m) =>
389
- typeof m === 'string' ? ({ role: 'user', content: m } as ChatCompletionMessageParam) : m
390
- );
391
- this.addMessagesToHistory(chatCompletions);
917
+ return usage;
918
+ }
392
919
 
393
- // Optionally persist the final JSON in history
394
- if (recordInHistory) {
395
- try {
396
- const toRecord = typeof result?.object === 'object' ? JSON.stringify(result.object) : '';
397
- if (toRecord) {
398
- this.addAssistantMessagesToHistory([toRecord]);
399
- }
400
- } catch {
401
- /* ignore */
920
+ private async buildToolInvocationsPromise(result: {
921
+ steps: PromiseLike<
922
+ Array<{
923
+ toolCalls?: Array<{ toolCallId?: string; toolName?: string; args?: unknown }>;
924
+ toolResults?: Array<{ toolCallId?: string; result?: unknown }>;
925
+ }>
926
+ >;
927
+ }): Promise<ToolInvocationResult[]> {
928
+ const steps = await result.steps;
929
+ const invocations: ToolInvocationResult[] = [];
930
+
931
+ for (const step of steps ?? []) {
932
+ for (const toolCall of step.toolCalls ?? []) {
933
+ invocations.push({
934
+ id: toolCall.toolCallId ?? '',
935
+ name: toolCall.toolName ?? '',
936
+ startedAt: new Date(),
937
+ finishedAt: new Date(),
938
+ input: toolCall.args,
939
+ ok: true,
940
+ data: (step.toolResults ?? []).find((r) => r.toolCallId === toolCall.toolCallId)?.result,
941
+ });
402
942
  }
403
943
  }
404
944
 
405
- const usageData = this.processUsageData({
406
- result,
407
- model,
408
- });
945
+ return invocations;
946
+ }
947
+
948
+ /**
949
+ * Map AI SDK's `LanguageModelUsage` to our `UsageData`.
950
+ *
951
+ * The AI SDK v6 provides cached/reasoning token breakdowns directly in
952
+ * `LanguageModelUsage.inputTokenDetails` and `outputTokenDetails`, so we
953
+ * use those first and only fall back to provider metadata for older providers.
954
+ */
955
+ private mapSdkUsage(
956
+ sdkUsage: LanguageModelUsage,
957
+ modelString: string,
958
+ steps?: Array<{ toolCalls?: Array<{ toolName?: string }> }>
959
+ ): UsageData {
960
+ const inputTokens = sdkUsage?.inputTokens ?? 0;
961
+ const outputTokens = sdkUsage?.outputTokens ?? 0;
962
+ const totalTokens = sdkUsage?.totalTokens ?? inputTokens + outputTokens;
963
+
964
+ // AI SDK v6 provides structured token details
965
+ const cachedInputTokens = sdkUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
966
+ const reasoningTokens = sdkUsage?.outputTokenDetails?.reasoningTokens ?? 0;
967
+
968
+ const tokenUsage: TokenUsage = {
969
+ inputTokens,
970
+ cachedInputTokens,
971
+ reasoningTokens,
972
+ outputTokens,
973
+ totalTokens,
974
+ };
409
975
 
410
- if (onUsageData) {
411
- await onUsageData(usageData);
976
+ // Count steps as individual requests to the assistant
977
+ const stepCount = steps?.length ?? 1;
978
+ const acc = new UsageDataAccumulator({ model: modelString as TiktokenModel });
979
+ acc.addTokenUsage(tokenUsage);
980
+
981
+ // Populate tool call stats from steps
982
+ const callsPerTool: Record<string, number> = {};
983
+ let totalToolCalls = 0;
984
+ for (const step of steps ?? []) {
985
+ for (const toolCall of step.toolCalls ?? []) {
986
+ const name = toolCall.toolName ?? 'unknown';
987
+ callsPerTool[name] = (callsPerTool[name] ?? 0) + 1;
988
+ totalToolCalls++;
989
+ }
412
990
  }
413
991
 
414
992
  return {
415
- object: (result?.object ?? ({} as any)) as T,
416
- usageData,
993
+ ...acc.usageData,
994
+ totalRequestsToAssistant: stepCount,
995
+ callsPerTool,
996
+ totalToolCalls,
417
997
  };
418
998
  }
419
999
 
420
- /** Convert (string | ChatCompletionMessageParam)[] -> AI SDK ModelMessage[] */
421
- private toModelMessages(input: (string | ChatCompletionMessageParam)[]): ModelMessage[] {
422
- return input.map((m) => {
423
- if (typeof m === 'string') {
424
- return { role: 'user', content: m };
1000
+ /**
1001
+ * Process usage from a generateObject result (single-step, no tool calls).
1002
+ */
1003
+ private processAiSdkUsage(result: { usage: LanguageModelUsage }, modelString: string): UsageData {
1004
+ return this.mapSdkUsage(result.usage, modelString);
1005
+ }
1006
+
1007
+ // ────────────────────────────────────────────────────────────
1008
+ // Full stream mapping
1009
+ // ────────────────────────────────────────────────────────────
1010
+
1011
+ /**
1012
+ * Maps the AI SDK's `fullStream` (which emits all event types) into our
1013
+ * `StreamPart` union. This is the primary way to consume streaming output
1014
+ * in real-time, since it yields text, reasoning, and source events in the
1015
+ * order the model produces them.
1016
+ */
1017
+ private mapFullStream(aiSdkFullStream: AsyncIterable<any>): AsyncIterable<StreamPart> {
1018
+ return {
1019
+ async *[Symbol.asyncIterator]() {
1020
+ for await (const part of aiSdkFullStream) {
1021
+ if (part.type === 'text-delta' && part.textDelta) {
1022
+ yield { type: 'text-delta' as const, textDelta: part.textDelta };
1023
+ } else if (part.type === 'reasoning' && part.textDelta) {
1024
+ yield { type: 'reasoning-delta' as const, textDelta: part.textDelta };
1025
+ } else if (part.type === 'source') {
1026
+ yield {
1027
+ type: 'source' as const,
1028
+ source: {
1029
+ url: part.sourceType === 'url' ? part.url : undefined,
1030
+ title: part.sourceType === 'url' ? part.title : undefined,
1031
+ },
1032
+ };
1033
+ }
1034
+ }
1035
+ },
1036
+ };
1037
+ }
1038
+
1039
+ private extractReasoningFromResult(result: any): string {
1040
+ try {
1041
+ // Try to get reasoning from provider metadata or response
1042
+ const reasoning = result?.reasoning;
1043
+ if (typeof reasoning === 'string') {
1044
+ return reasoning;
425
1045
  }
426
- const text = Array.isArray(m.content)
427
- ? m.content.map((p: any) => (typeof p === 'string' ? p : p?.text ?? '')).join('\n')
428
- : (m.content as string | undefined) ?? '';
429
- const role = m.role === 'system' || m.role === 'user' || m.role === 'assistant' ? m.role : 'user';
430
- return { role, content: text };
431
- });
1046
+ if (Array.isArray(reasoning)) {
1047
+ return reasoning
1048
+ .filter((r: any) => r.type === 'reasoning')
1049
+ .map((r: any) => r.text)
1050
+ .join('');
1051
+ }
1052
+ } catch {
1053
+ // ignore
1054
+ }
1055
+ return '';
1056
+ }
1057
+
1058
+ // ────────────────────────────────────────────────────────────
1059
+ // Schema utilities
1060
+ // ────────────────────────────────────────────────────────────
1061
+
1062
+ private isZodSchema(schema: unknown): boolean {
1063
+ if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) {
1064
+ return false;
1065
+ }
1066
+ return (
1067
+ typeof (schema as any).safeParse === 'function' ||
1068
+ (!!(schema as any)._def && typeof (schema as any)._def.typeName === 'string')
1069
+ );
432
1070
  }
433
1071
 
434
1072
  /**
435
- * Strictifies a plain JSON Schema for OpenAI Structured Outputs (strict mode):
436
- * - Ensures every object has `additionalProperties: false`
437
- * - Ensures every object has a `required` array that includes **all** keys in `properties`
438
- * - Adds missing `type: "object"` / `type: "array"` where implied by keywords
1073
+ * Strictifies a JSON Schema for OpenAI Structured Outputs (strict mode).
439
1074
  */
440
1075
  private strictifyJsonSchema(schema: any): any {
441
- const root = JSON.parse(JSON.stringify(schema));
1076
+ const root = JSON.parse(JSON.stringify(schema ?? {}));
442
1077
 
443
1078
  const visit = (node: any) => {
444
1079
  if (!node || typeof node !== 'object') {
445
1080
  return;
446
1081
  }
447
1082
 
448
- // If keywords imply a type but it's missing, add it (helps downstream validators)
449
1083
  if (!node.type) {
450
1084
  if (node.properties || node.additionalProperties || node.patternProperties) {
451
1085
  node.type = 'object';
@@ -456,34 +1090,23 @@ export class Conversation {
456
1090
 
457
1091
  const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : [];
458
1092
 
459
- // Objects: enforce strict requirements
460
1093
  if (types.includes('object')) {
461
- // 1) additionalProperties: false
462
1094
  if (node.additionalProperties !== false) {
463
1095
  node.additionalProperties = false;
464
1096
  }
465
-
466
- // 2) required must exist and include every key in properties
467
1097
  if (node.properties && typeof node.properties === 'object') {
468
1098
  const propKeys = Object.keys(node.properties);
469
1099
  const currentReq: string[] = Array.isArray(node.required) ? node.required.slice() : [];
470
- const union = Array.from(new Set([...currentReq, ...propKeys]));
471
- node.required = union;
472
-
473
- // Recurse into each property schema
1100
+ node.required = Array.from(new Set([...currentReq, ...propKeys]));
474
1101
  for (const k of propKeys) {
475
1102
  visit(node.properties[k]);
476
1103
  }
477
1104
  }
478
-
479
- // Recurse into patternProperties
480
1105
  if (node.patternProperties && typeof node.patternProperties === 'object') {
481
1106
  for (const k of Object.keys(node.patternProperties)) {
482
1107
  visit(node.patternProperties[k]);
483
1108
  }
484
1109
  }
485
-
486
- // Recurse into $defs / definitions
487
1110
  for (const defsKey of ['$defs', 'definitions']) {
488
1111
  if (node[defsKey] && typeof node[defsKey] === 'object') {
489
1112
  for (const key of Object.keys(node[defsKey])) {
@@ -493,7 +1116,6 @@ export class Conversation {
493
1116
  }
494
1117
  }
495
1118
 
496
- // Arrays: recurse into items/prefixItems
497
1119
  if (types.includes('array')) {
498
1120
  if (node.items) {
499
1121
  if (Array.isArray(node.items)) {
@@ -507,14 +1129,11 @@ export class Conversation {
507
1129
  }
508
1130
  }
509
1131
 
510
- // Combinators
511
1132
  for (const k of ['oneOf', 'anyOf', 'allOf']) {
512
1133
  if (Array.isArray(node[k])) {
513
1134
  node[k].forEach(visit);
514
1135
  }
515
1136
  }
516
-
517
- // Negation
518
1137
  if (node.not) {
519
1138
  visit(node.not);
520
1139
  }
@@ -523,181 +1142,4 @@ export class Conversation {
523
1142
  visit(root);
524
1143
  return root;
525
1144
  }
526
-
527
- // ---- Usage + provider metadata normalization ----
528
-
529
- private processUsageData(args: {
530
- result: GenerateObjectResult<JSONValue>;
531
- model?: LanguageModel;
532
- toolCounts?: Map<string, number>;
533
- toolLedgerLen?: number;
534
- }): UsageData {
535
- const { result, model, toolCounts, toolLedgerLen } = args;
536
-
537
- const u = result?.usage;
538
-
539
- // Provider-specific extras (OpenAI Responses variants)
540
- const { cachedInputTokens, reasoningTokens } = this.extractOpenAiUsageDetails?.(result) ?? {};
541
-
542
- const input = Number.isFinite(u?.inputTokens) ? Number(u.inputTokens) : 0;
543
- const reasoning = Number.isFinite(reasoningTokens) ? Number(reasoningTokens) : 0;
544
- const output = Number.isFinite(u?.outputTokens) ? Number(u.outputTokens) : 0;
545
- const total = Number.isFinite(u?.totalTokens) ? Number(u.totalTokens) : input + output;
546
- const cached = Number.isFinite(cachedInputTokens) ? Number(cachedInputTokens) : 0;
547
-
548
- // Resolve model id for pricing/telemetry
549
- const modelId: any = model?.toString();
550
-
551
- const resolvedModel = typeof modelId === 'string' && modelId.trim().length > 0 ? modelId : 'unknown';
552
-
553
- const tokenUsage = {
554
- inputTokens: input,
555
- reasoningTokens: reasoning,
556
- cachedInputTokens: cached,
557
- outputTokens: output,
558
- totalTokens: total,
559
- };
560
-
561
- const uda = new UsageDataAccumulator({ model: modelId });
562
- uda.addTokenUsage(tokenUsage);
563
-
564
- const callsPerTool = toolCounts ? Object.fromEntries(toolCounts) : {};
565
- const totalToolCalls =
566
- typeof toolLedgerLen === 'number' ? toolLedgerLen : Object.values(callsPerTool).reduce((a, b) => a + (b || 0), 0);
567
-
568
- return {
569
- ...uda.usageData,
570
- totalRequestsToAssistant: 1,
571
- totalToolCalls,
572
- callsPerTool,
573
- };
574
- }
575
-
576
- // Pull OpenAI-specific cached/extra usage from provider metadata or raw usage.
577
- // Safe across providers; returns undefined if not available.
578
- private extractOpenAiUsageDetails(result: any): {
579
- cachedInputTokens?: number;
580
- reasoningTokens?: number;
581
- } {
582
- try {
583
- const md = result?.providerMetadata?.openai ?? result?.response?.providerMetadata?.openai;
584
- const usage = md?.usage ?? result?.response?.usage ?? result?.usage;
585
-
586
- // OpenAI Responses API has used different shapes over time; try both:
587
- const cachedInputTokens =
588
- usage?.input_tokens_details?.cached_tokens ??
589
- usage?.prompt_tokens_details?.cached_tokens ??
590
- usage?.cached_input_tokens;
591
-
592
- // Reasoning tokens (when available on reasoning models)
593
- const reasoningTokens =
594
- usage?.output_tokens_details?.reasoning_tokens ??
595
- usage?.completion_tokens_details?.reasoning_tokens ??
596
- usage?.reasoning_tokens;
597
-
598
- return {
599
- cachedInputTokens: typeof cachedInputTokens === 'number' ? cachedInputTokens : undefined,
600
- reasoningTokens: typeof reasoningTokens === 'number' ? reasoningTokens : undefined,
601
- };
602
- } catch {
603
- return {};
604
- }
605
- }
606
-
607
- async generateCode({ description, model }: { description: string[]; model?: TiktokenModel }) {
608
- this.logger.debug({ message: `Generating code`, obj: { description } });
609
- await this.ensureModulesProcessed();
610
- const code = await new OpenAi({
611
- history: this.history,
612
- functions: this.functions,
613
- messageModerators: this.messageModerators,
614
- logLevel: this.params.logLevel,
615
- }).generateCode({
616
- messages: description,
617
- model,
618
- includeSystemMessages: !this.generatedCode,
619
- });
620
- this.logger.debug({ message: `Generated code`, obj: { code } });
621
- this.generatedCode = true;
622
- return code;
623
- }
624
-
625
- async updateCodeFromFile({
626
- codeToUpdateFilePath,
627
- dependencyCodeFilePaths,
628
- description,
629
- model,
630
- }: {
631
- codeToUpdateFilePath: string;
632
- dependencyCodeFilePaths: string[];
633
- description: string;
634
- model?: TiktokenModel;
635
- }) {
636
- await this.ensureModulesProcessed();
637
- const codeToUpdate = await Fs.readFile(codeToUpdateFilePath);
638
- let dependencyDescription = `Assume the following exists:\n`;
639
- for (const dependencyCodeFilePath of dependencyCodeFilePaths) {
640
- const dependencCode = await Fs.readFile(dependencyCodeFilePath);
641
- dependencyDescription += dependencCode + '\n\n';
642
- }
643
-
644
- this.logger.debug({ message: `Updating code from file`, obj: { codeToUpdateFilePath } });
645
- return await this.updateCode({ code: codeToUpdate, description: dependencyDescription + description, model });
646
- }
647
-
648
- async updateCode({ code, description, model }: { code: string; description: string; model?: TiktokenModel }) {
649
- this.logger.debug({ message: `Updating code`, obj: { description, code } });
650
- await this.ensureModulesProcessed();
651
- const updatedCode = await new OpenAi({
652
- history: this.history,
653
- functions: this.functions,
654
- messageModerators: this.messageModerators,
655
- logLevel: this.params.logLevel,
656
- }).updateCode({
657
- code,
658
- description,
659
- model,
660
- includeSystemMessages: !this.generatedCode,
661
- });
662
- this.logger.debug({ message: `Updated code`, obj: { updatedCode } });
663
- this.generatedCode = true;
664
- return updatedCode;
665
- }
666
-
667
- async generateList({ description, model }: { description: string[]; model?: TiktokenModel }) {
668
- await this.ensureModulesProcessed();
669
- const list = await new OpenAi({
670
- history: this.history,
671
- functions: this.functions,
672
- messageModerators: this.messageModerators,
673
- logLevel: this.params.logLevel,
674
- }).generateList({
675
- messages: description,
676
- model,
677
- includeSystemMessages: !this.generatedList,
678
- });
679
- this.generatedList = true;
680
- return list;
681
- }
682
1145
  }
683
-
684
- export const summarizeConversationHistoryFunctionName = 'summarizeConversationHistory';
685
- export const summarizeConversationHistoryFunction = (conversation: Conversation) => {
686
- return {
687
- definition: {
688
- name: summarizeConversationHistoryFunctionName,
689
- description: 'Clear the conversation history and summarize what was in it',
690
- parameters: {
691
- type: 'object',
692
- properties: {
693
- summary: {
694
- type: 'string',
695
- description: 'A 1-3 sentence summary of the current chat history',
696
- },
697
- },
698
- required: ['summary'],
699
- },
700
- },
701
- call: async (params: { summary: string }) => conversation.summarizeConversationHistory(params.summary),
702
- };
703
- };