@@ -35,6 +35,8 @@ import {
3535 type vToolResultPart ,
3636 type SourcePart ,
3737 vToolResultOutput ,
38+ vAssistantContent ,
39+ vToolContent ,
3840} from "./validators.js" ;
3941import type { ActionCtx , AgentComponent } from "./client/types.js" ;
4042import type { RunMutationCtx } from "./client/types.js" ;
@@ -47,10 +49,12 @@ import {
4749} from "@ai-sdk/provider-utils" ;
4850import { parse , validate } from "convex-helpers/validators" ;
4951import {
52+ extractText ,
5053 getModelName ,
5154 getProviderName ,
5255 type ModelOrMetadata ,
5356} from "./shared.js" ;
57+ import { pick } from "convex-helpers" ;
5458export type AIMessageWithoutId = Omit < AIMessage , "id" > ;
5559
5660export type SerializeUrlsAndUint8Arrays < T > = T extends URL
@@ -93,9 +97,7 @@ export async function serializeMessage(
9397
9498// Similar to serializeMessage, but doesn't save any files and is looser
9599// For use on the frontend / in synchronous environments.
96- export function fromModelMessage (
97- message : ModelMessage ,
98- ) : Message {
100+ export function fromModelMessage ( message : ModelMessage ) : Message {
99101 const content = fromModelMessageContent ( message . content ) ;
100102 return {
101103 role : message . role ,
@@ -181,36 +183,67 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
181183 step : StepResult < TOOLS > ,
182184 model : ModelOrMetadata | undefined ,
183185) : 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 ,
212190 ) ;
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+ }
214247 return { messages } ;
215248}
216249
@@ -253,6 +286,123 @@ function getMimeOrMediaType(part: { mediaType?: string; mimeType?: string }) {
253286 return undefined ;
254287}
255288
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+
256406export async function serializeContent (
257407 ctx : ActionCtx | RunMutationCtx ,
258408 component : AgentComponent ,
0 commit comments