fix(seahorse): repair missing reasoning_content during bootstrap
This commit is contained in:
parent
0fc364c1cd
commit
89e875bd7b
4 changed files with 199 additions and 0 deletions
|
|
@ -445,6 +445,16 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration repair path: old SeaHorse rows may be missing reasoning_content
|
||||||
|
// even though the canonical JSONL history already has it. Backfill those
|
||||||
|
// rows in place so we do not treat this as edited history and leave stale
|
||||||
|
// summaries/context behind after a partial raw-message rebuild.
|
||||||
|
if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap: repair reasoning_content: %w", err)
|
||||||
|
} else if repaired {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Find longest matching prefix from the start
|
// Find longest matching prefix from the start
|
||||||
anchor := -1
|
anchor := -1
|
||||||
compareLen := len(dbMsgs)
|
compareLen := len(dbMsgs)
|
||||||
|
|
@ -538,6 +548,51 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) {
|
||||||
|
if len(dbMsgs) != len(messages) || len(dbMsgs) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates []struct {
|
||||||
|
messageID int64
|
||||||
|
reasoningContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range messages {
|
||||||
|
if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
updates = append(updates, struct {
|
||||||
|
messageID int64
|
||||||
|
reasoningContent string
|
||||||
|
}{
|
||||||
|
messageID: dbMsgs[i].ID,
|
||||||
|
reasoningContent: messages[i].ReasoningContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, update := range updates {
|
||||||
|
if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{
|
||||||
|
"messages": len(updates),
|
||||||
|
})
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// truncate shortens a string for logging.
|
// truncate shortens a string for logging.
|
||||||
func truncate(s string, maxLen int) string {
|
func truncate(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
|
|
@ -555,6 +610,13 @@ func messageMatches(a, b Message) bool {
|
||||||
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent {
|
if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return messageMatchesIgnoringReasoning(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageMatchesIgnoringReasoning(a, b Message) bool {
|
||||||
|
if a.Role != b.Role {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// If either message has Parts, compare Parts
|
// If either message has Parts, compare Parts
|
||||||
if len(a.Parts) > 0 || len(b.Parts) > 0 {
|
if len(a.Parts) > 0 || len(b.Parts) > 0 {
|
||||||
return partsMatch(a.Parts, b.Parts)
|
return partsMatch(a.Parts, b.Parts)
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,96 @@ func TestBootstrapRepairsMissingReasoningContent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) {
|
||||||
|
eng := newTestEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
sessionKey := "agent:repair-reasoning-summary"
|
||||||
|
|
||||||
|
conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage user: %v", err)
|
||||||
|
}
|
||||||
|
assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = eng.store.AppendContextMessages(
|
||||||
|
ctx,
|
||||||
|
conv.ConversationID,
|
||||||
|
[]int64{userMsg.ID, assistantMsg.ID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AppendContextMessages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{
|
||||||
|
ConversationID: conv.ConversationID,
|
||||||
|
Kind: SummaryKindLeaf,
|
||||||
|
Depth: 0,
|
||||||
|
Content: "summary before repair",
|
||||||
|
TokenCount: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSummary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AppendContextSummary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = eng.Bootstrap(ctx, sessionKey, []Message{
|
||||||
|
{Role: "user", Content: "hello", TokenCount: 3},
|
||||||
|
{Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessages: %v", err)
|
||||||
|
}
|
||||||
|
if len(stored) != 2 {
|
||||||
|
t.Fatalf("stored messages = %d, want 2", len(stored))
|
||||||
|
}
|
||||||
|
if stored[1].ReasoningContent != "let me think this through" {
|
||||||
|
t.Errorf(
|
||||||
|
"stored[1].ReasoningContent = %q, want %q",
|
||||||
|
stored[1].ReasoningContent,
|
||||||
|
"let me think this through",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSummariesByConversation: %v", err)
|
||||||
|
}
|
||||||
|
if len(summaries) != 1 {
|
||||||
|
t.Fatalf("summaries = %d, want 1", len(summaries))
|
||||||
|
}
|
||||||
|
if summaries[0].SummaryID != summary.SummaryID {
|
||||||
|
t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := eng.store.GetContextItems(ctx, conv.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetContextItems: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Fatalf("context items = %d, want 3", len(items))
|
||||||
|
}
|
||||||
|
if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID {
|
||||||
|
t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEngineBootstrapDelta(t *testing.T) {
|
func TestEngineBootstrapDelta(t *testing.T) {
|
||||||
eng := newTestEngine(t)
|
eng := newTestEngine(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message,
|
||||||
return &msg, nil
|
return &msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateMessageReasoningContent updates reasoning_content for an existing message.
|
||||||
|
func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error {
|
||||||
|
result, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"UPDATE messages SET reasoning_content = ? WHERE message_id = ?",
|
||||||
|
reasoningContent,
|
||||||
|
messageID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update message reasoning_content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update message reasoning_content rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return fmt.Errorf("message %d not found", messageID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
|
func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
rows, err := s.db.QueryContext(ctx,
|
||||||
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
|
`SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,31 @@ func TestStoreGetMessageByID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateMessageReasoningContent(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning")
|
||||||
|
|
||||||
|
msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateMessageReasoningContent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := s.GetMessageByID(ctx, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMessageByID: %v", err)
|
||||||
|
}
|
||||||
|
if found.ReasoningContent != "thinking" {
|
||||||
|
t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Summary Operations ---
|
// --- Summary Operations ---
|
||||||
|
|
||||||
func TestStoreCreateAndGetSummary(t *testing.T) {
|
func TestStoreCreateAndGetSummary(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue