fix: normalize message roles for local model backends (llama.cpp, Ollama)
Local backends using strict chat templates (e.g. Mistral 3) rejected Flynn's Anthropic-style tool_use/tool_result content blocks, causing 'roles must alternate' errors. Added getMessageTextWithTools() and normalizeMessagesForLocal() to serialize structured blocks to plain text, drop empty messages, and merge consecutive same-role messages. Also fixed compaction to ensure kept messages start with user role.
This commit is contained in:
@@ -120,6 +120,78 @@ export function getMessageText(message: Message): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a message's content to a plain string, including tool_use and
|
||||
* tool_result structured blocks that getMessageText() would discard.
|
||||
* This is needed for local model backends (llama.cpp, Ollama) whose chat
|
||||
* templates don't understand Anthropic-style structured content blocks.
|
||||
*/
|
||||
export function getMessageTextWithTools(message: Message): string {
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const block of message.content as Record<string, unknown>[]) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text);
|
||||
} else if (block.type === 'tool_use') {
|
||||
const name = block.name as string;
|
||||
let argsStr: string;
|
||||
try {
|
||||
argsStr = JSON.stringify(block.input);
|
||||
} catch {
|
||||
argsStr = String(block.input);
|
||||
}
|
||||
parts.push(`[Calling tool: ${name}(${argsStr})]`);
|
||||
} else if (block.type === 'tool_result') {
|
||||
const content = (block.content as string) ?? '';
|
||||
const isError = block.is_error ? ' (error)' : '';
|
||||
parts.push(`[Tool result${isError}: ${content}]`);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
interface SimpleMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a message array for local model backends that require strict
|
||||
* role alternation (system? -> user -> assistant -> user -> ...).
|
||||
*
|
||||
* 1. Serializes structured tool_use/tool_result content blocks to text
|
||||
* 2. Drops empty messages
|
||||
* 3. Merges consecutive same-role messages with a newline separator
|
||||
*/
|
||||
export function normalizeMessagesForLocal(
|
||||
system: string | undefined,
|
||||
messages: Message[],
|
||||
): SimpleMessage[] {
|
||||
const result: SimpleMessage[] = [];
|
||||
|
||||
if (system) {
|
||||
result.push({ role: 'system', content: system });
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const text = getMessageTextWithTools(msg);
|
||||
if (!text) continue; // drop empty messages
|
||||
|
||||
const last = result.length > 0 ? result[result.length - 1] : undefined;
|
||||
if (last && last.role === msg.role) {
|
||||
// Merge consecutive same-role messages
|
||||
last.content += '\n\n' + text;
|
||||
} else {
|
||||
result.push({ role: msg.role, content: text });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Configuration for audio transcription via Whisper-compatible API. */
|
||||
export interface AudioTranscriptionConfig {
|
||||
/** Whisper-compatible API endpoint (e.g. "https://api.openai.com/v1/audio/transcriptions") */
|
||||
|
||||
Reference in New Issue
Block a user