Compare commits

...

3 Commits

Author SHA1 Message Date
dal 7b9afdbb2f
Enhance tool result conversion by validating toolCallId
- Added checks to ensure toolCallId exists and is a valid string before processing tool results.
- Preserves the original part if toolCallId is missing or invalid, improving robustness in message conversion.
2025-08-24 21:51:13 -06:00
dal 980a786d21
fix build errors 2025-08-24 21:42:38 -06:00
dal 455888cdcd
ai sdk v4 to v5 conversion 2025-08-24 21:31:16 -06:00
4 changed files with 391 additions and 48 deletions

View File

@ -17,6 +17,7 @@ import { type PermissionedDataset, getPermissionedDatasets } from '@buster/acces
// AI package imports
import { type AnalystWorkflowInput, runAnalystWorkflow } from '@buster/ai';
import type { ModelMessage } from 'ai';
import type { messagePostProcessingTask } from '../message-post-processing/message-post-processing';
/**
@ -339,19 +340,20 @@ export const analystAgentTask: ReturnType<
logPerformanceMetrics('post-data-load', payload.message_id, taskStartTime, resourceTracker);
// Task 4: Prepare workflow input with conversation history
// Convert conversation history to messages format expected by the workflow
const messages =
// The conversation history from getChatConversationHistory is already in ModelMessage[] format
const modelMessages: ModelMessage[] =
conversationHistory.length > 0
? conversationHistory
: [
{
role: 'user' as const,
role: 'user',
// v5 supports string content directly for user messages
content: messageContext.requestMessage,
},
];
const workflowInput: AnalystWorkflowInput = {
messages,
messages: modelMessages,
messageId: payload.message_id,
chatId: messageContext.chatId,
userId: messageContext.userId,

View File

@ -1,2 +1,5 @@
// Tool repair utilities
export * from './tool-call-repair';
// Message conversion utilities
export * from './message-conversion';

View File

@ -0,0 +1,225 @@
import type { ModelMessage } from 'ai';
/**
* Converts AI SDK v4 CoreMessage to v5 ModelMessage
*
* Key differences:
* - ImagePart: mimeType mediaType
* - FilePart: mimeType mediaType, added optional filename
* - ToolResultPart: result output (with structured type)
* - ToolCallPart: remains the same (args field)
*/
export function convertCoreMessageToModelMessage(message: unknown): ModelMessage {
// Handle null/undefined
if (!message || typeof message !== 'object' || !('role' in message)) {
return message as ModelMessage;
}
const msg = message as Record<string, unknown>;
const { role, content } = msg;
switch (role) {
case 'system':
// System messages remain string-based
return {
role: 'system',
content: typeof content === 'string' ? content : '',
};
case 'user':
// User messages can be string or array of parts
if (typeof content === 'string') {
return {
role: 'user',
content,
};
}
if (Array.isArray(content)) {
return {
role: 'user',
// biome-ignore lint/suspicious/noExplicitAny: necessary for v4 to v5 conversion
content: content.map(convertContentPart) as any,
};
}
return { role: 'user', content: '' };
case 'assistant':
// Assistant messages can be string or array of parts
if (typeof content === 'string') {
return {
role: 'assistant',
content,
};
}
if (Array.isArray(content)) {
return {
role: 'assistant',
// biome-ignore lint/suspicious/noExplicitAny: necessary for v4 to v5 conversion
content: content.map(convertContentPart) as any,
};
}
return { role: 'assistant', content: '' };
case 'tool':
// Tool messages are always arrays of ToolResultPart
if (Array.isArray(content)) {
return {
role: 'tool',
// biome-ignore lint/suspicious/noExplicitAny: necessary for v4 to v5 conversion
content: content.map(convertToolResultPart) as any,
};
}
return { role: 'tool', content: [] };
default:
// Pass through unknown roles
return message as ModelMessage;
}
}
/**
* Convert content parts from v4 to v5 format
*/
function convertContentPart(part: unknown): unknown {
if (!part || typeof part !== 'object' || !('type' in part)) {
return part;
}
const p = part as Record<string, unknown>;
switch (p.type) {
case 'text':
// Text parts remain the same
return part;
case 'image':
// Convert mimeType → mediaType
if ('mimeType' in p) {
const { mimeType, ...rest } = p;
return {
...rest,
mediaType: mimeType,
};
}
return part;
case 'file':
// Convert mimeType → mediaType
if ('mimeType' in p) {
const { mimeType, ...rest } = p;
return {
...rest,
mediaType: mimeType,
};
}
return part;
case 'tool-call':
// Tool calls: args → input (AI SDK v5 change)
if ('args' in p) {
const { args, ...rest } = p;
return { ...rest, input: args };
}
return part;
default:
return part;
}
}
/**
* Convert tool result parts from v4 to v5 format
*/
function convertToolResultPart(part: unknown): unknown {
if (!part || typeof part !== 'object') {
return part;
}
const p = part as Record<string, unknown>;
if (p.type !== 'tool-result') {
return part;
}
// Convert result → output with proper structure
if ('result' in p && !('output' in p)) {
const { result, experimental_content, isError, ...rest } = p;
// Convert result to structured output format
let output: { type: string; value: unknown };
if (isError) {
// Error results
if (typeof result === 'string') {
output = { type: 'error-text', value: result };
} else {
output = { type: 'error-json', value: result };
}
} else {
// Success results
if (typeof result === 'string') {
output = { type: 'text', value: result };
} else {
output = { type: 'json', value: result };
}
}
// Ensure toolCallId exists and is valid
// If it's missing or invalid, preserve the original part
if (!rest.toolCallId || typeof rest.toolCallId !== 'string') {
return part;
}
return {
...rest,
output,
};
}
return part;
}
/**
* Converts an array of v4 CoreMessages to v5 ModelMessages
*/
export function convertCoreMessagesToModelMessages(messages: unknown[]): ModelMessage[] {
if (!Array.isArray(messages)) {
return [];
}
return messages.map(convertCoreMessageToModelMessage);
}
/**
* Helper to check if content is already in v5 format
* Useful when migrating codebases that might have mixed formats
*/
export function isV5ContentFormat(content: unknown): boolean {
// Check for tool result with v5 output field
if (Array.isArray(content) && content.length > 0) {
const firstItem = content[0];
if (firstItem?.type === 'tool-result' && 'output' in firstItem) {
return true;
}
}
return false;
}
/**
* Smart conversion that handles both v4 and v5 formats
* Useful during migration when you might have mixed message formats
*/
export function ensureModelMessageFormat(message: unknown): ModelMessage {
// Already in v5 format if tool messages have 'output' field
if (message && typeof message === 'object' && 'role' in message && 'content' in message) {
const msg = message as Record<string, unknown>;
if (msg.role === 'tool' && isV5ContentFormat(msg.content)) {
return message as ModelMessage;
}
}
// Convert from v4 format
return convertCoreMessageToModelMessage(message);
}

View File

@ -6,10 +6,11 @@ import { messages } from '../../schema';
/**
* Convert messages from old CoreMessage format (v4) to ModelMessage format (v5)
* Main changes: tool calls 'args' 'input', tool results 'result' 'output'
*
* Since we're dealing with unknown data from the database, we cast to ModelMessage[]
* and let the runtime handle any actual format differences.
* Key changes:
* - Tool calls: 'args' 'input'
* - Tool results: 'result' structured 'output' object
* - Image/File parts: 'mimeType' 'mediaType'
* - User/Assistant string content remains as string (v5 supports both)
*/
export function convertCoreToModel(messages: unknown): ModelMessage[] {
if (!Array.isArray(messages)) {
@ -23,51 +24,163 @@ export function convertCoreToModel(messages: unknown): ModelMessage[] {
}
const msg = message as Record<string, unknown>;
const { role, content } = msg;
// For assistant messages, update tool call args → input
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
return {
...msg,
content: msg.content.map((part: unknown) => {
if (
part &&
typeof part === 'object' &&
'type' in part &&
part.type === 'tool-call' &&
'args' in part
) {
const { args, ...rest } = part as Record<string, unknown>;
return { ...rest, input: args };
}
return part;
}),
} as ModelMessage;
switch (role) {
case 'system':
// System messages remain string-based in both v4 and v5
return {
...msg,
content: typeof content === 'string' ? content : '',
} as ModelMessage;
case 'user':
// User messages: handle both string and array content
if (typeof content === 'string') {
// v5 supports string content directly for user messages
return msg as ModelMessage;
}
if (Array.isArray(content)) {
// Convert any image/file parts
return {
...msg,
content: content.map(convertContentPart),
} as ModelMessage;
}
return { ...msg, content: '' } as ModelMessage;
case 'assistant':
// Assistant messages: handle both string and array content
if (typeof content === 'string') {
// v5 supports string content directly for assistant messages
return msg as ModelMessage;
}
if (Array.isArray(content)) {
// Convert tool calls and other parts
return {
...msg,
content: content.map(convertContentPart),
} as ModelMessage;
}
return { ...msg, content: '' } as ModelMessage;
case 'tool':
// Tool messages: convert result to structured output
if (Array.isArray(content)) {
return {
...msg,
content: content.map(convertToolResultPart),
} as ModelMessage;
}
return { role: 'tool', content: [] } as ModelMessage;
default:
return msg as ModelMessage;
}
// For tool messages, update result → output
if (msg.role === 'tool' && Array.isArray(msg.content)) {
return {
...msg,
content: msg.content.map((part: unknown) => {
if (
part &&
typeof part === 'object' &&
'type' in part &&
part.type === 'tool-result' &&
'result' in part
) {
const { result, ...rest } = part as Record<string, unknown>;
return { ...rest, output: result };
}
return part;
}),
} as ModelMessage;
}
return message as ModelMessage;
});
}
/**
* Convert content parts from v4 to v5 format
*/
function convertContentPart(part: unknown): unknown {
if (!part || typeof part !== 'object') {
return part;
}
const p = part as Record<string, unknown>;
switch (p.type) {
case 'text':
// Text parts remain the same
return part;
case 'image':
// Convert mimeType → mediaType
if ('mimeType' in p) {
const { mimeType, ...rest } = p;
return {
...rest,
mediaType: mimeType,
};
}
return part;
case 'file':
// Convert mimeType → mediaType
if ('mimeType' in p) {
const { mimeType, ...rest } = p;
return {
...rest,
mediaType: mimeType,
};
}
return part;
case 'tool-call':
// Tool calls: args → input (AI SDK v5 change)
if ('args' in p) {
const { args, ...rest } = p;
return { ...rest, input: args };
}
return part;
default:
return part;
}
}
/**
* Convert tool result parts from v4 to v5 format
*/
function convertToolResultPart(part: unknown): unknown {
if (!part || typeof part !== 'object') {
return part;
}
const p = part as Record<string, unknown>;
if (p.type !== 'tool-result') {
return part;
}
// Convert result → structured output
if ('result' in p && !('output' in p)) {
const { result, experimental_content, isError, ...rest } = p;
// Convert to v5's structured output format
let output: { type: string; value: unknown };
if (isError) {
// Error results
if (typeof result === 'string') {
output = { type: 'error-text', value: result };
} else {
output = { type: 'error-json', value: result };
}
} else {
// Success results
if (typeof result === 'string') {
output = { type: 'text', value: result };
} else {
output = { type: 'json', value: result };
}
}
// Ensure toolCallId exists and is valid
// If it's missing or invalid, preserve the original part
if (!rest.toolCallId || typeof rest.toolCallId !== 'string') {
return part;
}
return {
...rest,
output,
};
}
return part;
}
// Helper function to get chatId from messageId
async function getChatIdFromMessage(messageId: string): Promise<string> {
let messageResult: Array<{ chatId: string }>;