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
+19
View File
@@ -101,4 +101,23 @@ describe('compactHistory', () => {
expect(DEFAULT_COMPACTION_CONFIG.keepTurns).toBe(4);
expect(DEFAULT_COMPACTION_CONFIG.summaryMaxTokens).toBe(1024);
});
it('shifts leading assistant messages from toKeep into toCompact to ensure user-first', async () => {
// 9 messages: user(0), assistant(1), ..., user(8)
// With keepTurns=2 (keepCount=4), toKeep starts as [assistant(5), user(6), assistant(7), user(8)]
// The fix shifts assistant(5) into toCompact, so toKeep = [user(6), assistant(7), user(8)]
const messages = makeMessages(9);
const orchestrator = makeMockOrchestrator();
const result = await compactHistory({ messages, orchestrator, config });
// summary(assistant) + user(6) + assistant(7) + user(8) = 4 messages
expect(result.messages).toHaveLength(4);
expect(result.compactedCount).toBe(6);
// First is summary (assistant), second must be user
expect(result.messages[0].role).toBe('assistant');
expect(result.messages[0].content).toContain('[Summary of earlier conversation]');
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toBe('Message 6');
});
});
+6
View File
@@ -53,6 +53,12 @@ export async function compactHistory(opts: {
const toCompact = messages.slice(0, -keepCount);
const toKeep = messages.slice(-keepCount);
// Ensure toKeep starts with a user message to avoid assistant→assistant
// after the compaction summary (which has role 'assistant').
while (toKeep.length > 0 && toKeep[0].role === 'assistant') {
toCompact.push(toKeep.shift()!);
}
const formattedConversation = toCompact.map((msg) => `${msg.role}: ${getMessageText(msg)}`).join('\n\n');
const tier = orchestrator.getDelegationTier('compaction');