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:
William Valentin
2026-02-10 22:04:17 -08:00
parent 2f6d045e2a
commit 6761dca1c2
6 changed files with 318 additions and 43 deletions
+72
View File
@@ -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") */