mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 00:15:05 +00:00
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:
1 parent
90716a2694
commit
13139e08ca
6 files changed
+213
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -215,3 +215,4 @@ func hybridMerge(fts, vec []scoredChunk, textWeight, vectorWeight float64, curre
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user