Skip to content

Commit 7cd12ae

Browse files
committed
serialize steps via content instead of messages
1 parent a1d71ac commit 7cd12ae

File tree

1 file changed

+182
-32
lines changed

1 file changed

+182
-32
lines changed

src/mapping.ts

Lines changed: 182 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
type vToolResultPart,
3636
type SourcePart,
3737
vToolResultOutput,
38+
vAssistantContent,
39+
vToolContent,
3840
} from "./validators.js";
3941
import type { ActionCtx, AgentComponent } from "./client/types.js";
4042
import type { RunMutationCtx } from "./client/types.js";
@@ -47,10 +49,12 @@ import {
4749
} from "@ai-sdk/provider-utils";
4850
import { parse, validate } from "convex-helpers/validators";
4951
import {
52+
extractText,
5053
getModelName,
5154
getProviderName,
5255
type ModelOrMetadata,
5356
} from "./shared.js";
57+
import { pick } from "convex-helpers";
5458
export type AIMessageWithoutId = Omit<AIMessage, "id">;
5559

5660
export 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+
256406
export async function serializeContent(
257407
ctx: ActionCtx | RunMutationCtx,
258408
component: AgentComponent,

0 commit comments

Comments
 (0)