UNPKG

@convex-dev/agent

Version:

A agent component for Convex.

465 lines 17.2 kB
import { convertToModelMessages, } from "ai"; import { toModelMessage, fromModelMessage, toUIFilePart } from "./mapping.js"; import { extractReasoning, extractText, isTool, joinText, sorted, } from "./shared.js"; import { omit, pick } from "convex-helpers"; /** * Converts a list of UIMessages to MessageDocs, along with extra metadata that * may be available to associate with the MessageDocs. * @param messages - The UIMessages to convert to MessageDocs. * @param meta - The metadata to add to the MessageDocs. * @returns */ export function fromUIMessages(messages, meta) { return messages.flatMap((uiMessage) => { const stepOrder = uiMessage.stepOrder; const commonFields = { ...pick(meta, [ "threadId", "userId", "model", "provider", "providerOptions", "metadata", ]), ...omit(uiMessage, ["parts", "role", "key", "text"]), status: uiMessage.status === "streaming" ? "pending" : "success", streaming: uiMessage.status === "streaming", // to override _id: uiMessage.id, tool: false, }; const modelMessages = convertToModelMessages([uiMessage]); return modelMessages .map((modelMessage, i) => { if (modelMessage.content.length === 0) { return undefined; } const message = fromModelMessage(modelMessage); const tool = isTool(message); const doc = { ...commonFields, _id: uiMessage.id + `-${i}`, stepOrder: stepOrder + i, message, tool, text: extractText(message), reasoning: extractReasoning(message), finishReason: tool ? "tool-calls" : "stop", sources: fromSourceParts(uiMessage.parts), }; if (Array.isArray(modelMessage.content)) { const providerOptions = modelMessage.content.find((c) => c.providerOptions)?.providerOptions; if (providerOptions) { // convertToModelMessages changes providerMetadata to providerOptions doc.providerMetadata = providerOptions; doc.providerOptions ??= providerOptions; } } return doc; }) .filter((d) => d !== undefined); }); } function fromSourceParts(parts) { return parts .map((part) => { if (part.type === "source-url") { return { type: "source", sourceType: "url", url: part.url, id: part.sourceId, providerMetadata: part.providerMetadata, title: part.title, }; } if (part.type === "source-document") { return { type: "source", sourceType: "document", mediaType: part.mediaType, id: part.sourceId, providerMetadata: part.providerMetadata, title: part.title, }; } return undefined; }) .filter((p) => p !== undefined); } /** * Converts a list of MessageDocs to UIMessages. * This is somewhat lossy, as many fields are not supported by UIMessages, e.g. * the model, provider, userId, etc. * The UIMessage type is the augmented type that includes more fields such as * key, order, stepOrder, status, agentName, text, etc. */ export function toUIMessages(messages) { // Group assistant and tool messages together const assistantGroups = groupAssistantMessages(sorted(messages)); const uiMessages = []; for (const group of assistantGroups) { if (group.role === "system") { uiMessages.push(createSystemUIMessage(group.message)); } else if (group.role === "user") { uiMessages.push(createUserUIMessage(group.message)); } else { // Assistant/tool group uiMessages.push(createAssistantUIMessage(group.messages)); } } return uiMessages; } function groupAssistantMessages(messages) { const groups = []; let currentAssistantGroup = []; let currentOrder; for (const message of messages) { const coreMessage = message.message && toModelMessage(message.message); if (!coreMessage) continue; if (coreMessage.role === "user" || coreMessage.role === "system") { // Finish any current assistant group if (currentAssistantGroup.length > 0) { groups.push({ role: "assistant", messages: currentAssistantGroup, }); currentAssistantGroup = []; currentOrder = undefined; } // Add singleton group groups.push({ role: coreMessage.role, message, }); } else { // Assistant or tool message // Start new group if order changes or this is the first assistant/tool message if (currentOrder !== undefined && message.order !== currentOrder) { if (currentAssistantGroup.length > 0) { groups.push({ role: "assistant", messages: currentAssistantGroup, }); currentAssistantGroup = []; } } currentOrder = message.order; currentAssistantGroup.push(message); // End group if this is an assistant message without tool calls if (coreMessage.role === "assistant" && !message.tool) { groups.push({ role: "assistant", messages: currentAssistantGroup, }); currentAssistantGroup = []; currentOrder = undefined; } } } // Add any remaining assistant group if (currentAssistantGroup.length > 0) { groups.push({ role: "assistant", messages: currentAssistantGroup, }); } return groups; } function createSystemUIMessage(message) { const text = extractTextFromMessageDoc(message); const partCommon = { state: message.streaming ? "streaming" : "done", ...(message.providerMetadata ? { providerMetadata: message.providerMetadata } : {}), }; return { id: message._id, _creationTime: message._creationTime, order: message.order, stepOrder: message.stepOrder, status: message.streaming ? "streaming" : message.status, key: `${message.threadId}-${message.order}-${message.stepOrder}`, text, role: "system", agentName: message.agentName, parts: [{ type: "text", text, ...partCommon }], metadata: message.metadata, }; } function extractTextFromMessageDoc(message) { return ((message.message && extractText(message.message)) || message.text || ""); } function createUserUIMessage(message) { const text = extractTextFromMessageDoc(message); const coreMessage = toModelMessage(message.message); const content = coreMessage.content; const nonStringContent = content && typeof content !== "string" ? content : []; const partCommon = { state: message.streaming ? "streaming" : "done", ...(message.providerMetadata ? { providerMetadata: message.providerMetadata } : {}), }; const parts = []; if (text && !nonStringContent.length) { parts.push({ type: "text", text }); } for (const contentPart of nonStringContent) { switch (contentPart.type) { case "text": parts.push({ type: "text", text: contentPart.text, ...partCommon }); break; case "file": case "image": parts.push(toUIFilePart(contentPart)); break; default: console.warn("Unknown content part type for user", contentPart); break; } } return { id: message._id, _creationTime: message._creationTime, order: message.order, stepOrder: message.stepOrder, status: message.streaming ? "streaming" : message.status, key: `${message.threadId}-${message.order}-${message.stepOrder}`, text, role: "user", parts, metadata: message.metadata, }; } function createAssistantUIMessage(groupUnordered) { const group = sorted(groupUnordered); const firstMessage = group[0]; // Use first message for special fields const common = { id: firstMessage._id, _creationTime: firstMessage._creationTime, order: firstMessage.order, stepOrder: firstMessage.stepOrder, key: `${firstMessage.threadId}-${firstMessage.order}-${firstMessage.stepOrder}`, agentName: firstMessage.agentName, }; // Get status from last message const lastMessage = group[group.length - 1]; const status = lastMessage.streaming ? "streaming" : lastMessage.status; // Collect all parts from all messages const allParts = []; for (const message of group) { const coreMessage = message.message && toModelMessage(message.message); if (!coreMessage) continue; const content = coreMessage.content; const nonStringContent = content && typeof content !== "string" ? content : []; const text = extractTextFromMessageDoc(message); const partCommon = { state: message.streaming ? "streaming" : "done", ...(message.providerMetadata ? { providerMetadata: message.providerMetadata } : {}), }; // Add reasoning parts if (message.reasoning && !nonStringContent.some((c) => c.type === "reasoning")) { allParts.push({ type: "reasoning", text: message.reasoning, ...partCommon, }); } // Add text parts if no structured content if (text && !nonStringContent.length) { allParts.push({ type: "text", text: text, ...partCommon, }); } // Add all structured content parts for (const contentPart of nonStringContent) { switch (contentPart.type) { case "text": allParts.push({ ...partCommon, ...contentPart, }); break; case "reasoning": allParts.push({ ...partCommon, ...contentPart, }); break; case "file": case "image": allParts.push(toUIFilePart(contentPart)); break; case "tool-call": { allParts.push({ type: "step-start", }); const toolPart = { type: `tool-${contentPart.toolName}`, toolCallId: contentPart.toolCallId, input: contentPart.input, providerExecuted: contentPart.providerExecuted, ...(message.streaming ? { state: "input-streaming" } : { state: "input-available", callProviderMetadata: message.providerMetadata, }), }; allParts.push(toolPart); break; } case "tool-result": { const output = typeof contentPart.output?.type === "string" ? contentPart.output.value : contentPart.output; const call = allParts.find((part) => part.type === `tool-${contentPart.toolName}` && "toolCallId" in part && part.toolCallId === contentPart.toolCallId); if (call) { if (message.error) { call.state = "output-error"; call.errorText = message.error; call.output = output; } else { call.state = "output-available"; call.output = output; } } else { console.warn("Tool result without preceding tool call.. adding anyways", contentPart); if (message.error) { allParts.push({ type: `tool-${contentPart.toolName}`, toolCallId: contentPart.toolCallId, state: "output-error", input: undefined, errorText: message.error, callProviderMetadata: message.providerMetadata, }); } else { allParts.push({ type: `tool-${contentPart.toolName}`, toolCallId: contentPart.toolCallId, state: "output-available", input: undefined, output, callProviderMetadata: message.providerMetadata, }); } } break; } default: { const maybeSource = contentPart; if (maybeSource.type === "source") { allParts.push(toSourcePart(maybeSource)); } else { console.warn("Unknown content part type for assistant", contentPart); } } } } // Add source parts for (const source of message.sources ?? []) { allParts.push(toSourcePart(source)); } } return { ...common, role: "assistant", text: joinText(allParts), status, parts: allParts, metadata: group.find((m) => m.metadata)?.metadata, }; } function toSourcePart(part) { if (part.sourceType === "url") { return { type: "source-url", url: part.url, sourceId: part.id, providerMetadata: part.providerMetadata, title: part.title, }; } return { type: "source-document", mediaType: part.mediaType, sourceId: part.id, title: part.title, filename: part.filename, providerMetadata: part.providerMetadata, }; } export function combineUIMessages(messages) { const combined = messages.reduce((acc, message) => { if (!acc.length) { return [message]; } const previous = acc.at(-1); if (message.order !== previous.order || previous.role !== message.role || message.role !== "assistant") { acc.push(message); return acc; } // We will replace it with a combined message acc.pop(); const newParts = [...previous.parts]; for (const part of message.parts) { const toolCallId = getToolCallId(part); if (!toolCallId) { newParts.push(part); continue; } const previousPartIndex = newParts.findIndex((p) => getToolCallId(p) === toolCallId); const previousPart = newParts.splice(previousPartIndex, 1)[0]; if (!previousPart) { newParts.push(part); continue; } newParts.push(mergeParts(previousPart, part)); } acc.push({ ...previous, ...pick(message, ["status", "metadata", "agentName"]), parts: newParts, text: joinText(newParts), }); return acc; }, []); return combined; } function getToolCallId(part) { return part.toolCallId; } function mergeParts(previousPart, part) { const merged = { ...previousPart }; for (const [key, value] of Object.entries(part)) { if (value !== undefined) { merged[key] = value; } } return merged; } //# sourceMappingURL=UIMessages.js.map