From 13139e08ca5c15240a7b2c931c53b33c60b54cd5 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 21 Mar 2026 13:46:30 +0700 Subject: [PATCH] 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 --- cmd/gateway_managed.go | 1 + internal/agent/extractive_memory.go | 155 ++++++++++++++++++++++++++++ internal/agent/loop_types.go | 7 ++ internal/agent/memoryflush.go | 46 ++++++++- internal/agent/resolver.go | 4 + internal/store/pg/memory_search.go | 1 + 6 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 internal/agent/extractive_memory.go diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 84cff83e..84508a14 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -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, diff --git a/internal/agent/extractive_memory.go b/internal/agent/extractive_memory.go new file mode 100644 index 00000000..24aefd42 --- /dev/null +++ b/internal/agent/extractive_memory.go @@ -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) +} diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index a7977070..00c509bf 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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, } } diff --git a/internal/agent/memoryflush.go b/internal/agent/memoryflush.go index a870c501..1abed466 100644 --- a/internal/agent/memoryflush.go +++ b/internal/agent/memoryflush.go @@ -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)) +} diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 548fe548..137fde47 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -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) diff --git a/internal/store/pg/memory_search.go b/internal/store/pg/memory_search.go index f5ee6c0e..aec71906 100644 --- a/internal/store/pg/memory_search.go +++ b/internal/store/pg/memory_search.go @@ -215,3 +215,4 @@ func hybridMerge(fts, vec []scoredChunk, textWeight, vectorWeight float64, curre return results } +