@@ -35,6 +35,8 @@ import {
35
35
type vToolResultPart ,
36
36
type SourcePart ,
37
37
vToolResultOutput ,
38
+ vAssistantContent ,
39
+ vToolContent ,
38
40
} from "./validators.js" ;
39
41
import type { ActionCtx , AgentComponent } from "./client/types.js" ;
40
42
import type { RunMutationCtx } from "./client/types.js" ;
@@ -47,10 +49,12 @@ import {
47
49
} from "@ai-sdk/provider-utils" ;
48
50
import { parse , validate } from "convex-helpers/validators" ;
49
51
import {
52
+ extractText ,
50
53
getModelName ,
51
54
getProviderName ,
52
55
type ModelOrMetadata ,
53
56
} from "./shared.js" ;
57
+ import { pick } from "convex-helpers" ;
54
58
export type AIMessageWithoutId = Omit < AIMessage , "id" > ;
55
59
56
60
export type SerializeUrlsAndUint8Arrays < T > = T extends URL
@@ -93,9 +97,7 @@ export async function serializeMessage(
93
97
94
98
// Similar to serializeMessage, but doesn't save any files and is looser
95
99
// For use on the frontend / in synchronous environments.
96
- export function fromModelMessage (
97
- message : ModelMessage ,
98
- ) : Message {
100
+ export function fromModelMessage ( message : ModelMessage ) : Message {
99
101
const content = fromModelMessageContent ( message . content ) ;
100
102
return {
101
103
role : message . role ,
@@ -181,36 +183,67 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
181
183
step : StepResult < TOOLS > ,
182
184
model : ModelOrMetadata | undefined ,
183
185
) : Promise < { messages : MessageWithMetadata [ ] } > {
184
- // If there are tool results, there's another message with the tool results
185
- // ref: https://github.com/vercel/ai/blob/main/packages/ai/core/generate-text/to-response-messages.ts
186
- const assistantFields = {
187
- model : model ? getModelName ( model ) : undefined ,
188
- provider : model ? getProviderName ( model ) : undefined ,
189
- providerMetadata : step . providerMetadata ,
190
- reasoning : step . reasoningText ,
191
- reasoningDetails : step . reasoning ,
192
- usage : serializeUsage ( step . usage ) ,
193
- warnings : serializeWarnings ( step . warnings ) ,
194
- finishReason : step . finishReason ,
195
- // Only store the sources on one message
196
- sources : step . toolResults . length === 0 ? step . sources : undefined ,
197
- } satisfies Omit < MessageWithMetadata , "message" | "text" | "fileIds" > ;
198
- const toolFields = { sources : step . sources } ;
199
- const messages : MessageWithMetadata [ ] = await Promise . all (
200
- ( step . toolResults . length > 0
201
- ? step . response . messages . slice ( - 2 )
202
- : step . response . messages . slice ( - 1 )
203
- ) . map ( async ( msg ) : Promise < MessageWithMetadata > => {
204
- const { message, fileIds } = await serializeMessage ( ctx , component , msg ) ;
205
- return parse ( vMessageWithMetadata , {
206
- message,
207
- ...( message . role === "tool" ? toolFields : assistantFields ) ,
208
- text : step . text ,
209
- fileIds,
210
- } ) ;
211
- } ) ,
186
+ const toolResultIndex = step . content . findIndex (
187
+ ( c ) =>
188
+ ( c . type === "tool-result" || c . type === "tool-error" ) &&
189
+ ! c . providerExecuted ,
212
190
) ;
213
- // TODO: capture step.files separately?
191
+ const hasToolResults = toolResultIndex !== - 1 ;
192
+ const assistantContent = hasToolResults
193
+ ? step . content . slice ( 0 , toolResultIndex )
194
+ : step . content ;
195
+ const { content, fileIds } = await serializeLanguageModelV2Content (
196
+ ctx ,
197
+ component ,
198
+ assistantContent ,
199
+ ) ;
200
+ const message = {
201
+ role : "assistant" as const ,
202
+ content : content as Infer < typeof vAssistantContent > ,
203
+ providerOptions : ( hasToolResults
204
+ ? step . response . messages . at ( - 2 )
205
+ : step . response . messages . at ( - 1 )
206
+ ) ?. providerOptions ,
207
+ } satisfies Message ;
208
+ const messages = [
209
+ parse ( vMessageWithMetadata , {
210
+ model : model ? getModelName ( model ) : undefined ,
211
+ provider : model ? getProviderName ( model ) : undefined ,
212
+ providerMetadata : step . providerMetadata ,
213
+ reasoning : step . reasoningText ,
214
+ reasoningDetails : step . reasoning ,
215
+ usage : serializeUsage ( step . usage ) ,
216
+ warnings : serializeWarnings ( step . warnings ) ,
217
+ finishReason : step . finishReason ,
218
+ fileIds,
219
+ // Only store the sources on one message
220
+ sources : assistantContent . filter ( ( c ) => c . type === "source" ) ,
221
+ message,
222
+ text : extractText ( message ) || step . text ,
223
+ } ) ,
224
+ ] ;
225
+
226
+ if ( hasToolResults ) {
227
+ const toolContent = step . content . slice ( toolResultIndex ) ;
228
+ const { content, fileIds } = await serializeLanguageModelV2Content (
229
+ ctx ,
230
+ component ,
231
+ toolContent ,
232
+ ) ;
233
+ const toolMessage = {
234
+ role : "tool" as const ,
235
+ content : content as Infer < typeof vToolContent > ,
236
+ providerOptions : step . response . messages . at ( - 1 ) ?. providerOptions ,
237
+ } satisfies Message ;
238
+ messages . push (
239
+ parse ( vMessageWithMetadata , {
240
+ message : toolMessage ,
241
+ sources : toolContent . filter ( ( c ) => c . type === "source" ) ,
242
+ fileIds,
243
+ finishReason : step . finishReason ,
244
+ } ) ,
245
+ ) ;
246
+ }
214
247
return { messages } ;
215
248
}
216
249
@@ -253,6 +286,123 @@ function getMimeOrMediaType(part: { mediaType?: string; mimeType?: string }) {
253
286
return undefined ;
254
287
}
255
288
289
+ export async function serializeLanguageModelV2Content (
290
+ ctx : ActionCtx ,
291
+ component : AgentComponent ,
292
+ content : StepResult < ToolSet > [ "content" ] ,
293
+ ) : Promise < { content : SerializedContent ; fileIds ?: string [ ] } > {
294
+ const fileIds : string [ ] = [ ] ;
295
+ const serialized = await Promise . all (
296
+ content . map ( async ( part ) => {
297
+ const metadata : {
298
+ providerOptions ?: ProviderOptions ;
299
+ providerMetadata ?: ProviderMetadata ;
300
+ } = { } ;
301
+ if ( "providerOptions" in part ) {
302
+ metadata . providerOptions = part . providerOptions as ProviderOptions ;
303
+ }
304
+ if ( "providerMetadata" in part ) {
305
+ metadata . providerMetadata = part . providerMetadata as ProviderMetadata ;
306
+ }
307
+ switch ( part . type ) {
308
+ case "text" : {
309
+ return part satisfies Infer < typeof vTextPart > ;
310
+ }
311
+ case "reasoning" :
312
+ return part satisfies Infer < typeof vReasoningPart > ;
313
+ case "source" :
314
+ return part satisfies Infer < typeof vSourcePart > ;
315
+ case "tool-call" : {
316
+ return {
317
+ ...pick ( part , [
318
+ "type" ,
319
+ "toolCallId" ,
320
+ "toolName" ,
321
+ "providerExecuted" ,
322
+ "dynamic" ,
323
+ "error" ,
324
+ "invalid" ,
325
+ ] ) ,
326
+ args : part . input ,
327
+ ...metadata ,
328
+ } satisfies Infer < typeof vToolCallPart > ;
329
+ }
330
+ case "tool-result" :
331
+ return {
332
+ ...pick ( part , [
333
+ "type" ,
334
+ "toolCallId" ,
335
+ "toolName" ,
336
+ "output" ,
337
+ "dynamic" ,
338
+ "preliminary" ,
339
+ "providerExecuted" ,
340
+ ] ) ,
341
+ args : part . input ,
342
+ ...metadata ,
343
+ } satisfies Infer < typeof vToolResultPart > ;
344
+ case "tool-error" :
345
+ return {
346
+ ...pick ( part , [
347
+ "toolCallId" ,
348
+ "toolName" ,
349
+ "dynamic" ,
350
+ "providerExecuted" ,
351
+ ] ) ,
352
+ type : "tool-result" ,
353
+ args : part . input ,
354
+ output :
355
+ part . error instanceof Error
356
+ ? {
357
+ type : "error-text" ,
358
+ value : part . error . message ,
359
+ }
360
+ : typeof part . error === "object"
361
+ ? {
362
+ type : "error-json" ,
363
+ value : part . error ,
364
+ }
365
+ : {
366
+ type : "error-text" ,
367
+ value : String ( part . error ) ,
368
+ } ,
369
+ ...metadata ,
370
+ } satisfies Infer < typeof vToolResultPart > ;
371
+ case "file" : {
372
+ const uint8Array = part . file . uint8Array ;
373
+ let data : ArrayBuffer | string = uint8Array . buffer . slice (
374
+ uint8Array . byteOffset ,
375
+ uint8Array . byteOffset + uint8Array . byteLength ,
376
+ ) as ArrayBuffer ;
377
+ const mimeType = getMimeOrMediaType ( part . file ) ! ;
378
+ if ( data . byteLength > MAX_FILE_SIZE ) {
379
+ const { file } = await storeFile (
380
+ ctx ,
381
+ component ,
382
+ new Blob ( [ data ] , { type : mimeType } ) ,
383
+ ) ;
384
+ data = file . url ;
385
+ fileIds . push ( file . fileId ) ;
386
+ }
387
+ return {
388
+ type : part . type ,
389
+ data,
390
+ mimeType,
391
+ ...metadata ,
392
+ } satisfies Infer < typeof vFilePart > ;
393
+ }
394
+
395
+ default :
396
+ return part satisfies Infer < typeof vContent > ;
397
+ }
398
+ } ) ,
399
+ ) ;
400
+ return {
401
+ content : serialized as SerializedContent ,
402
+ fileIds : fileIds . length > 0 ? fileIds : undefined ,
403
+ } ;
404
+ }
405
+
256
406
export async function serializeContent (
257
407
ctx : ActionCtx | RunMutationCtx ,
258
408
component : AgentComponent ,
0 commit comments