feat: extractive memory fallback when LLM flush fails

Add regex-based extraction as safety net when LLM memory flush returns
NO_REPLY or fails. Extracts decisions, key facts, URLs, file paths,
dates, and user preferences from conversation history before compaction.

- Wire extractiveMemoryFallback into runMemoryFlush (NO_REPLY + LLM error)
- Add memStore to Loop for direct memory writes
- Use store.MemoryUserID(ctx) for correct shared memory scoping
- Limit regex input to last 20 messages for performance
- Drop FTS language config (tsv column is stored with 'simple', changing
  query language without matching stored config breaks search)

Closes #328
This commit is contained in:
viettranx committed 2026-03-21 13:46:30 +07:00
1 parent 90716a2694
commit 13139e08ca
6 files changed
+213 -1

No files matched your search

+1
View File
@@ -157,6 +157,7 @@ func wireExtras(
MediaStore: mediaStore,
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
MemoryStore: stores.Memory,
OnEvent: func(event agent.AgentEvent) {
msgBus.Broadcast(bus.Event{
Name: protocol.EventAgent,
+155
View File
@@ -0,0 +1,155 @@
package agent
import (
"regexp"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// Regex patterns for extractive memory fallback.
var (
// Decisions: "decided to", "let's go with", "approved", "agreed on", "chose", "we'll use"
reDecision = regexp.MustCompile(`(?i)(?:decided\s+to|let'?s\s+go\s+with|approved|agreed\s+on|chose|we'?ll\s+use)\s+.{5,120}`)
// User preferences: "I prefer", "don't do", "always", "never", "I want", "please remember"
rePreference = regexp.MustCompile(`(?i)(?:I\s+prefer|don'?t\s+do|always\s+|never\s+|I\s+want|please\s+remember)\s+.{5,120}`)
// Technical facts: "the API is", "endpoint is", "version is", "uses X for Y"
reTechFact = regexp.MustCompile(`(?i)(?:the\s+API\s+is|endpoint\s+is|version\s+is|uses?\s+\S+\s+for)\s+.{3,120}`)
// URLs
reURL = regexp.MustCompile(`https?://[^\s)<>]{8,200}`)
// File paths (Unix-style and common project paths)
reFilePath = regexp.MustCompile(`(?:^|\s)([/~][\w./-]{5,120}|[\w-]+/[\w./-]{5,80})`)
// Dates in common formats
reDate = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
)
// ExtractiveMemoryFallback extracts key information from conversation history
// using regex patterns. Used as a safety net when LLM-based memory flush
// returns NO_REPLY or produces no output, preventing context loss during compaction.
func ExtractiveMemoryFallback(history []providers.Message) string {
if len(history) == 0 {
return ""
}
// Collect only user and assistant content (skip system, tool)
var texts []string
for _, msg := range history {
if msg.Role == "user" || msg.Role == "assistant" {
if content := strings.TrimSpace(msg.Content); content != "" {
texts = append(texts, content)
}
}
}
if len(texts) == 0 {
return ""
}
combined := strings.Join(texts, "\n")
// Extract by category, dedup with a set
decisions := extractUnique(reDecision, combined)
preferences := extractUnique(rePreference, combined)
// Technical facts = regex matches + URLs + dates
techFacts := extractUnique(reTechFact, combined)
for _, u := range extractUnique(reURL, combined) {
techFacts = appendIfAbsent(techFacts, u)
}
for _, fp := range extractUniqueSubmatch(reFilePath, combined, 1) {
techFacts = appendIfAbsent(techFacts, fp)
}
for _, d := range extractUnique(reDate, combined) {
techFacts = appendIfAbsent(techFacts, d)
}
// Build output — only include non-empty sections
if len(decisions) == 0 && len(techFacts) == 0 && len(preferences) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("## Extracted Context (auto-saved before compaction)\n")
if len(decisions) > 0 {
sb.WriteString("\n### Decisions\n")
for _, d := range decisions {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(d))
sb.WriteByte('\n')
}
}
if len(techFacts) > 0 {
sb.WriteString("\n### Key Facts\n")
for _, f := range techFacts {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(f))
sb.WriteByte('\n')
}
}
if len(preferences) > 0 {
sb.WriteString("\n### User Preferences\n")
for _, p := range preferences {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(p))
sb.WriteByte('\n')
}
}
return sb.String()
}
// extractUnique returns deduplicated matches from a regex pattern.
func extractUnique(re *regexp.Regexp, text string) []string {
matches := re.FindAllString(text, -1)
return dedup(matches)
}
// extractUniqueSubmatch returns deduplicated submatch captures (by group index).
func extractUniqueSubmatch(re *regexp.Regexp, text string, group int) []string {
matches := re.FindAllStringSubmatch(text, -1)
var results []string
for _, m := range matches {
if group < len(m) {
s := strings.TrimSpace(m[group])
if s != "" {
results = append(results, s)
}
}
}
return dedup(results)
}
// dedup removes duplicate strings while preserving order.
func dedup(items []string) []string {
seen := make(map[string]struct{}, len(items))
var result []string
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
// appendIfAbsent appends s to slice only if not already present.
func appendIfAbsent(slice []string, s string) []string {
for _, existing := range slice {
if existing == s {
return slice
}
}
return append(slice, s)
}
+7
View File
@@ -137,6 +137,9 @@ type Loop struct {
// Budget enforcement: monthly spending limit in cents (0 = unlimited)
budgetMonthlyCents int
tracingStore store.TracingStore
// Memory store for extractive memory fallback (writes directly when LLM flush fails)
memStore store.MemoryStore
}
// AgentEvent is emitted during agent execution for WS broadcasting.
@@ -254,6 +257,9 @@ type LoopConfig struct {
// Budget enforcement
BudgetMonthlyCents int
TracingStore store.TracingStore
// Memory store for extractive memory fallback (writes directly when LLM flush fails)
MemoryStore store.MemoryStore
}
const defaultMaxTokens = config.DefaultMaxTokens
@@ -342,6 +348,7 @@ func NewLoop(cfg LoopConfig) *Loop {
modelPricing: cfg.ModelPricing,
budgetMonthlyCents: cfg.BudgetMonthlyCents,
tracingStore: cfg.TracingStore,
memStore: cfg.MemoryStore,
}
}
+45 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Default memory flush prompts matching TS memory-flush.ts.
@@ -163,6 +164,7 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
})
if err != nil {
slog.Warn("memory flush: LLM call failed", "error", err)
l.extractiveMemoryFallback(flushCtx, sessionKey, history, "LLM error")
break
}
@@ -170,7 +172,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
if len(resp.ToolCalls) == 0 {
content := SanitizeAssistantContent(resp.Content)
if IsSilentReply(content) {
slog.Info("memory flush: NO_REPLY (nothing to save)")
slog.Info("memory flush: NO_REPLY, trying extractive fallback")
l.extractiveMemoryFallback(flushCtx, sessionKey, history, "NO_REPLY")
} else if content != "" {
slog.Info("memory flush: completed with response", "content_len", len(content))
}
@@ -205,3 +208,44 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
slog.Info("memory flush: completed", "session", sessionKey)
}
// extractiveMemoryFallback runs the regex-based extraction on conversation history
// and writes the result directly to the memory store when the LLM flush produced no output.
func (l *Loop) extractiveMemoryFallback(ctx context.Context, sessionKey string, history []providers.Message, reason string) {
if l.memStore == nil {
return
}
// Limit input to last 20 messages to avoid regex over unbounded text
if len(history) > 20 {
history = history[len(history)-20:]
}
extracted := ExtractiveMemoryFallback(history)
if extracted == "" {
slog.Info("memory flush: extractive fallback produced no content", "session", sessionKey, "reason", reason)
return
}
agentID := l.agentUUID.String()
userID := store.MemoryUserID(ctx)
docPath := fmt.Sprintf("memory/%s-auto-extract.md", time.Now().Format("2006-01-02"))
// Append to existing document if it exists
existing, err := l.memStore.GetDocument(ctx, agentID, userID, docPath)
if err == nil && existing != "" {
extracted = existing + "\n\n---\n\n" + extracted
}
if err := l.memStore.PutDocument(ctx, agentID, userID, docPath, extracted); err != nil {
slog.Warn("memory flush: extractive fallback write failed", "session", sessionKey, "error", err)
return
}
if err := l.memStore.IndexDocument(ctx, agentID, userID, docPath); err != nil {
slog.Warn("memory flush: extractive fallback index failed", "session", sessionKey, "error", err)
// Non-fatal: document was saved
}
slog.Info("memory flush: extractive fallback saved", "session", sessionKey, "reason", reason, "path", docPath, "content_len", len(extracted))
}
+4
View File
@@ -86,6 +86,9 @@ type ResolverDeps struct {
// Tracing store for budget enforcement queries
TracingStore store.TracingStore
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
@@ -356,6 +359,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
ModelPricing: deps.ModelPricing,
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
TracingStore: deps.TracingStore,
MemoryStore: deps.MemoryStore,
})
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
+1
View File
@@ -215,3 +215,4 @@ func hybridMerge(fts, vec []scoredChunk, textWeight, vectorWeight float64, curre
return results
}