Fix: user messages copied without content

This commit is contained in:
Grail Finder
2025-12-08 15:38:52 +03:00
parent 02bf308452
commit 9b2fee37ab
5 changed files with 200 additions and 10 deletions

View File

@@ -30,7 +30,7 @@ download-whisper-model: ## Download Whisper model for STT in batteries directory
echo "Please run 'make setup-whisper' first to clone the repository."; \ echo "Please run 'make setup-whisper' first to clone the repository."; \
exit 1; \ exit 1; \
fi fi
@cd batteries/whisper.cpp && make large-v3-turbo @cd batteries/whisper.cpp && bash ./models/download-ggml-model.sh large-v3-turbo-q5_0
@echo "Whisper model downloaded successfully!" @echo "Whisper model downloaded successfully!"
# Docker targets for STT/TTS services (in batteries directory) # Docker targets for STT/TTS services (in batteries directory)

33
bot.go
View File

@@ -74,6 +74,9 @@ func cleanNullMessages(messages []models.RoleMsg) []models.RoleMsg {
// Include message if it has content or if it's a tool response (which might have tool_call_id) // Include message if it has content or if it's a tool response (which might have tool_call_id)
if msg.HasContent() || msg.ToolCallID != "" { if msg.HasContent() || msg.ToolCallID != "" {
cleaned = append(cleaned, msg) cleaned = append(cleaned, msg)
} else {
// Log filtered messages for debugging
logger.Warn("filtering out message during cleaning", "role", msg.Role, "content", msg.Content, "tool_call_id", msg.ToolCallID, "has_content", msg.HasContent())
} }
} }
return consolidateConsecutiveAssistantMessages(cleaned) return consolidateConsecutiveAssistantMessages(cleaned)
@@ -103,9 +106,12 @@ func consolidateConsecutiveAssistantMessages(messages []models.RoleMsg) []models
if currentAssistantMsg.IsContentParts() || msg.IsContentParts() { if currentAssistantMsg.IsContentParts() || msg.IsContentParts() {
// Handle structured content // Handle structured content
if !currentAssistantMsg.IsContentParts() { if !currentAssistantMsg.IsContentParts() {
// Preserve the original ToolCallID before conversion
originalToolCallID := currentAssistantMsg.ToolCallID
// Convert existing content to content parts // Convert existing content to content parts
currentAssistantMsg = models.NewMultimodalMsg(currentAssistantMsg.Role, []interface{}{models.TextContentPart{Type: "text", Text: currentAssistantMsg.Content}}) currentAssistantMsg = models.NewMultimodalMsg(currentAssistantMsg.Role, []interface{}{models.TextContentPart{Type: "text", Text: currentAssistantMsg.Content}})
currentAssistantMsg.ToolCallID = msg.ToolCallID // Restore the original ToolCallID to preserve tool call linking
currentAssistantMsg.ToolCallID = originalToolCallID
} }
if msg.IsContentParts() { if msg.IsContentParts() {
currentAssistantMsg.ContentParts = append(currentAssistantMsg.ContentParts, msg.GetContentParts()...) currentAssistantMsg.ContentParts = append(currentAssistantMsg.ContentParts, msg.GetContentParts()...)
@@ -119,6 +125,7 @@ func consolidateConsecutiveAssistantMessages(messages []models.RoleMsg) []models
} else { } else {
currentAssistantMsg.Content = msg.Content currentAssistantMsg.Content = msg.Content
} }
// ToolCallID is already preserved since we're not creating a new message object when just concatenating content
} }
} }
} else { } else {
@@ -556,9 +563,19 @@ out:
}) })
} }
logger.Debug("chatRound: before cleanChatBody", "messages_before_clean", len(chatBody.Messages))
for i, msg := range chatBody.Messages {
logger.Debug("chatRound: before cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID)
}
// Clean null/empty messages to prevent API issues with endpoints like llama.cpp jinja template // Clean null/empty messages to prevent API issues with endpoints like llama.cpp jinja template
cleanChatBody() cleanChatBody()
logger.Debug("chatRound: after cleanChatBody", "messages_after_clean", len(chatBody.Messages))
for i, msg := range chatBody.Messages {
logger.Debug("chatRound: after cleaning", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID)
}
colorText() colorText()
updateStatusLine() updateStatusLine()
// bot msg is done; // bot msg is done;
@@ -574,8 +591,17 @@ out:
func cleanChatBody() { func cleanChatBody() {
if chatBody != nil && chatBody.Messages != nil { if chatBody != nil && chatBody.Messages != nil {
originalLen := len(chatBody.Messages) originalLen := len(chatBody.Messages)
logger.Debug("cleanChatBody: before cleaning", "message_count", originalLen)
for i, msg := range chatBody.Messages {
logger.Debug("cleanChatBody: before clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID)
}
chatBody.Messages = cleanNullMessages(chatBody.Messages) chatBody.Messages = cleanNullMessages(chatBody.Messages)
logger.Debug("cleaned chat body", "original_len", originalLen, "new_len", len(chatBody.Messages))
logger.Debug("cleanChatBody: after cleaning", "original_len", originalLen, "new_len", len(chatBody.Messages))
for i, msg := range chatBody.Messages {
logger.Debug("cleanChatBody: after clean", "index", i, "role", msg.Role, "content_len", len(msg.Content), "has_content", msg.HasContent(), "tool_call_id", msg.ToolCallID)
}
} }
} }
@@ -621,6 +647,7 @@ func findCall(msg, toolCall string, tv *tview.TextView) {
Content: fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err), Content: fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err),
} }
chatBody.Messages = append(chatBody.Messages, toolResponseMsg) chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
logger.Debug("findCall: added tool error response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "message_count_after_add", len(chatBody.Messages))
// Trigger the assistant to continue processing with the error message // Trigger the assistant to continue processing with the error message
chatRound("", cfg.AssistantRole, tv, false, false) chatRound("", cfg.AssistantRole, tv, false, false)
return return
@@ -637,6 +664,7 @@ func findCall(msg, toolCall string, tv *tview.TextView) {
ToolCallID: lastToolCallID, // Use the stored tool call ID ToolCallID: lastToolCallID, // Use the stored tool call ID
} }
chatBody.Messages = append(chatBody.Messages, toolResponseMsg) chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
logger.Debug("findCall: added tool not implemented response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages))
// Clear the stored tool call ID after using it // Clear the stored tool call ID after using it
lastToolCallID = "" lastToolCallID = ""
@@ -657,6 +685,7 @@ func findCall(msg, toolCall string, tv *tview.TextView) {
ToolCallID: lastToolCallID, // Use the stored tool call ID ToolCallID: lastToolCallID, // Use the stored tool call ID
} }
chatBody.Messages = append(chatBody.Messages, toolResponseMsg) chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
logger.Debug("findCall: added actual tool response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages))
// Clear the stored tool call ID after using it // Clear the stored tool call ID after using it
lastToolCallID = "" lastToolCallID = ""
// Trigger the assistant to continue processing with the new tool response // Trigger the assistant to continue processing with the new tool response

155
bot_test.go Normal file
View File

@@ -0,0 +1,155 @@
package main
import (
"gf-lt/config"
"gf-lt/models"
"reflect"
"testing"
)
func TestConsolidateConsecutiveAssistantMessages(t *testing.T) {
// Mock config for testing
testCfg := &config.Config{
AssistantRole: "assistant",
WriteNextMsgAsCompletionAgent: "",
}
cfg = testCfg
tests := []struct {
name string
input []models.RoleMsg
expected []models.RoleMsg
}{
{
name: "no consecutive assistant messages",
input: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
{Role: "user", Content: "How are you?"},
},
expected: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
{Role: "user", Content: "How are you?"},
},
},
{
name: "consecutive assistant messages should be consolidated",
input: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "First part"},
{Role: "assistant", Content: "Second part"},
{Role: "user", Content: "Thanks"},
},
expected: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "First part\nSecond part"},
{Role: "user", Content: "Thanks"},
},
},
{
name: "multiple sets of consecutive assistant messages",
input: []models.RoleMsg{
{Role: "user", Content: "First question"},
{Role: "assistant", Content: "First answer part 1"},
{Role: "assistant", Content: "First answer part 2"},
{Role: "user", Content: "Second question"},
{Role: "assistant", Content: "Second answer part 1"},
{Role: "assistant", Content: "Second answer part 2"},
{Role: "assistant", Content: "Second answer part 3"},
},
expected: []models.RoleMsg{
{Role: "user", Content: "First question"},
{Role: "assistant", Content: "First answer part 1\nFirst answer part 2"},
{Role: "user", Content: "Second question"},
{Role: "assistant", Content: "Second answer part 1\nSecond answer part 2\nSecond answer part 3"},
},
},
{
name: "single assistant message (no consolidation needed)",
input: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
},
expected: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
},
},
{
name: "only assistant messages",
input: []models.RoleMsg{
{Role: "assistant", Content: "First"},
{Role: "assistant", Content: "Second"},
{Role: "assistant", Content: "Third"},
},
expected: []models.RoleMsg{
{Role: "assistant", Content: "First\nSecond\nThird"},
},
},
{
name: "user messages at the end are preserved",
input: []models.RoleMsg{
{Role: "assistant", Content: "First"},
{Role: "assistant", Content: "Second"},
{Role: "user", Content: "Final user message"},
},
expected: []models.RoleMsg{
{Role: "assistant", Content: "First\nSecond"},
{Role: "user", Content: "Final user message"},
},
},
{
name: "tool call ids preserved in consolidation",
input: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "First part", ToolCallID: "call_123"},
{Role: "assistant", Content: "Second part", ToolCallID: "call_123"}, // Same ID
{Role: "user", Content: "Thanks"},
},
expected: []models.RoleMsg{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "First part\nSecond part", ToolCallID: "call_123"},
{Role: "user", Content: "Thanks"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := consolidateConsecutiveAssistantMessages(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d messages, got %d", len(tt.expected), len(result))
t.Logf("Result: %+v", result)
t.Logf("Expected: %+v", tt.expected)
return
}
for i, expectedMsg := range tt.expected {
if i >= len(result) {
t.Errorf("Result has fewer messages than expected at index %d", i)
continue
}
actualMsg := result[i]
if actualMsg.Role != expectedMsg.Role {
t.Errorf("Message %d: expected role '%s', got '%s'", i, expectedMsg.Role, actualMsg.Role)
}
if actualMsg.Content != expectedMsg.Content {
t.Errorf("Message %d: expected content '%s', got '%s'", i, expectedMsg.Content, actualMsg.Content)
}
if actualMsg.ToolCallID != expectedMsg.ToolCallID {
t.Errorf("Message %d: expected ToolCallID '%s', got '%s'", i, expectedMsg.ToolCallID, actualMsg.ToolCallID)
}
}
// Additional check: ensure no messages were lost
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Result does not match expected:\nResult: %+v\nExpected: %+v", result, tt.expected)
}
})
}
}

View File

@@ -48,7 +48,7 @@ type KokoroOrator struct {
func (o *KokoroOrator) stoproutine() { func (o *KokoroOrator) stoproutine() {
<-TTSDoneChan <-TTSDoneChan
o.logger.Info("orator got done signal") o.logger.Debug("orator got done signal")
o.Stop() o.Stop()
// drain the channel // drain the channel
for len(TTSTextChan) > 0 { for len(TTSTextChan) > 0 {
@@ -72,7 +72,7 @@ func (o *KokoroOrator) readroutine() {
} }
text := o.textBuffer.String() text := o.textBuffer.String()
sentences := tokenizer.Tokenize(text) sentences := tokenizer.Tokenize(text)
o.logger.Info("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences)) o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
for i, sentence := range sentences { for i, sentence := range sentences {
if i == len(sentences)-1 { // last sentence if i == len(sentences)-1 { // last sentence
o.textBuffer.Reset() o.textBuffer.Reset()
@@ -83,13 +83,13 @@ func (o *KokoroOrator) readroutine() {
} }
continue // if only one (often incomplete) sentence; wait for next chunk continue // if only one (often incomplete) sentence; wait for next chunk
} }
o.logger.Info("calling Speak with sentence", "sent", sentence.Text) o.logger.Debug("calling Speak with sentence", "sent", sentence.Text)
if err := o.Speak(sentence.Text); err != nil { if err := o.Speak(sentence.Text); err != nil {
o.logger.Error("tts failed", "sentence", sentence.Text, "error", err) o.logger.Error("tts failed", "sentence", sentence.Text, "error", err)
} }
} }
case <-TTSFlushChan: case <-TTSFlushChan:
o.logger.Info("got flushchan signal start") o.logger.Debug("got flushchan signal start")
// lln is done get the whole message out // lln is done get the whole message out
if len(TTSTextChan) > 0 { // otherwise might get stuck if len(TTSTextChan) > 0 { // otherwise might get stuck
for chunk := range TTSTextChan { for chunk := range TTSTextChan {
@@ -110,7 +110,7 @@ func (o *KokoroOrator) readroutine() {
remaining := o.textBuffer.String() remaining := o.textBuffer.String()
o.textBuffer.Reset() o.textBuffer.Reset()
if remaining != "" { if remaining != "" {
o.logger.Info("calling Speak with remainder", "rem", remaining) o.logger.Debug("calling Speak with remainder", "rem", remaining)
if err := o.Speak(remaining); err != nil { if err := o.Speak(remaining); err != nil {
o.logger.Error("tts failed", "sentence", remaining, "error", err) o.logger.Error("tts failed", "sentence", remaining, "error", err)
} }
@@ -171,7 +171,7 @@ func (o *KokoroOrator) requestSound(text string) (io.ReadCloser, error) {
} }
func (o *KokoroOrator) Speak(text string) error { func (o *KokoroOrator) Speak(text string) error {
o.logger.Info("fn: Speak is called", "text-len", len(text)) o.logger.Debug("fn: Speak is called", "text-len", len(text))
body, err := o.requestSound(text) body, err := o.requestSound(text)
if err != nil { if err != nil {
o.logger.Error("request failed", "error", err) o.logger.Error("request failed", "error", err)
@@ -202,7 +202,7 @@ func (o *KokoroOrator) Speak(text string) error {
func (o *KokoroOrator) Stop() { func (o *KokoroOrator) Stop() {
// speaker.Clear() // speaker.Clear()
o.logger.Info("attempted to stop orator", "orator", o) o.logger.Debug("attempted to stop orator", "orator", o)
speaker.Lock() speaker.Lock()
defer speaker.Unlock() defer speaker.Unlock()
if o.currentStream != nil { if o.currentStream != nil {

6
llm.go
View File

@@ -211,6 +211,8 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
newMsg = models.NewRoleMsg(role, msg) newMsg = models.NewRoleMsg(role, msg)
} }
chatBody.Messages = append(chatBody.Messages, newMsg) chatBody.Messages = append(chatBody.Messages, newMsg)
logger.Debug("LCPChat FormMsg: added message to chatBody", "role", newMsg.Role, "content_len", len(newMsg.Content), "message_count_after_add", len(chatBody.Messages))
// if rag - add as system message to avoid conflicts with tool usage // if rag - add as system message to avoid conflicts with tool usage
if cfg.RAGEnabled { if cfg.RAGEnabled {
ragResp, err := chatRagUse(newMsg.Content) ragResp, err := chatRagUse(newMsg.Content)
@@ -221,6 +223,7 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
// Use system role for RAG context to avoid conflicts with tool usage // Use system role for RAG context to avoid conflicts with tool usage
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp} ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
chatBody.Messages = append(chatBody.Messages, ragMsg) chatBody.Messages = append(chatBody.Messages, ragMsg)
logger.Debug("LCPChat FormMsg: added RAG message to chatBody", "role", ragMsg.Role, "rag_content_len", len(ragMsg.Content), "message_count_after_rag", len(chatBody.Messages))
} }
} }
// openai /v1/chat does not support custom roles; needs to be user, assistant, system // openai /v1/chat does not support custom roles; needs to be user, assistant, system
@@ -231,6 +234,7 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
} }
for i, msg := range chatBody.Messages { for i, msg := range chatBody.Messages {
if msg.Role == cfg.UserRole { if msg.Role == cfg.UserRole {
bodyCopy.Messages[i] = msg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "user"
} else { } else {
bodyCopy.Messages[i] = msg bodyCopy.Messages[i] = msg
@@ -382,6 +386,7 @@ func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
} }
for i, msg := range chatBody.Messages { for i, msg := range chatBody.Messages {
if msg.Role == cfg.UserRole || i == 1 { if msg.Role == cfg.UserRole || i == 1 {
bodyCopy.Messages[i] = msg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "user"
} else { } else {
bodyCopy.Messages[i] = msg bodyCopy.Messages[i] = msg
@@ -559,6 +564,7 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
bodyCopy.Messages[i] = msg bodyCopy.Messages[i] = msg
// Standardize role if it's a user role // Standardize role if it's a user role
if bodyCopy.Messages[i].Role == cfg.UserRole { if bodyCopy.Messages[i].Role == cfg.UserRole {
bodyCopy.Messages[i] = msg
bodyCopy.Messages[i].Role = "user" bodyCopy.Messages[i].Role = "user"
} }
} }