Files
goclaw/internal/agent/memoryflush.go
T
Viet Tran cd2e407b29 fix: auto-persist cleaned history when orphan tool messages detected (#128)
sanitizeHistory now returns dropped count so callers know when orphaned
tool_use/tool_result messages were removed. When orphans are found in
buildMessages, the full session history is sanitized and persisted,
preventing repeated warnings on every request.

- Add SetHistory() to SessionStore interface and both implementations
- Adapt memoryflush caller to new two-return signature
- Change sanitize log level from Warn to Debug
2026-03-11 07:57:54 +07:00

208 lines
5.9 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// Default memory flush prompts matching TS memory-flush.ts.
const (
DefaultMemoryFlushPrompt = "Pre-compaction memory flush. " +
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed). " +
"IMPORTANT: If the file already exists, APPEND new content only and do not overwrite existing entries. " +
"If nothing to store, reply with NO_REPLY."
DefaultMemoryFlushSystemPrompt = "Pre-compaction memory flush turn. " +
"The session is near auto-compaction; capture durable memories to disk. " +
"You may reply, but usually NO_REPLY is correct."
)
// MemoryFlushSettings holds resolved flush config with defaults applied.
type MemoryFlushSettings struct {
Enabled bool
Prompt string
SystemPrompt string
}
// ResolveMemoryFlushSettings resolves flush settings from config, applying defaults.
// Returns nil if disabled.
func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlushSettings {
if compaction == nil || compaction.MemoryFlush == nil {
// Default: enabled
return &MemoryFlushSettings{
Enabled: true,
Prompt: DefaultMemoryFlushPrompt,
SystemPrompt: DefaultMemoryFlushSystemPrompt,
}
}
mf := compaction.MemoryFlush
if mf.Enabled != nil && !*mf.Enabled {
return nil
}
settings := &MemoryFlushSettings{
Enabled: true,
Prompt: DefaultMemoryFlushPrompt,
SystemPrompt: DefaultMemoryFlushSystemPrompt,
}
if mf.Prompt != "" {
settings.Prompt = mf.Prompt
}
if mf.SystemPrompt != "" {
settings.SystemPrompt = mf.SystemPrompt
}
return settings
}
// shouldRunMemoryFlush checks whether a memory flush should run before compaction.
// Flush always runs when compaction triggers (called inside maybeSummarize),
// gated only by enabled/memory checks and a dedup guard per compaction cycle.
func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
if settings == nil || !settings.Enabled || !l.hasMemory {
return false
}
if totalTokens <= 0 {
return false
}
// Deduplication: skip if already flushed in this compaction cycle.
compactionCount := l.sessions.GetCompactionCount(sessionKey)
lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(sessionKey)
if lastFlushAt >= 0 && lastFlushAt == compactionCount {
return false
}
return true
}
// runMemoryFlush executes a memory flush turn: sends flush prompt to LLM with tools
// so it can write memory files. Matching TS agent-runner-memory.ts.
func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *MemoryFlushSettings) {
slog.Info("memory flush: starting", "session", sessionKey)
flushCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
// Build messages: system prompt + history summary + flush prompt
history := l.sessions.GetHistory(sessionKey)
summary := l.sessions.GetSummary(sessionKey)
var messages []providers.Message
// System prompt: combine agent's normal system prompt context with flush system prompt
systemPrompt := BuildSystemPrompt(SystemPromptConfig{
AgentID: l.id,
Model: l.model,
Workspace: l.workspace,
Mode: PromptMinimal,
ToolNames: l.filteredToolNames(),
HasMemory: l.hasMemory,
})
systemPrompt += "\n\n" + settings.SystemPrompt
messages = append(messages, providers.Message{
Role: "system",
Content: systemPrompt,
})
// Include conversation summary for context
if summary != "" {
messages = append(messages, providers.Message{
Role: "user",
Content: fmt.Sprintf("[Previous conversation summary]\n%s", summary),
})
messages = append(messages, providers.Message{
Role: "assistant",
Content: "Understood.",
})
}
// Include recent history (last 10 messages for context)
recentHistory := history
if len(recentHistory) > 10 {
recentHistory = recentHistory[len(recentHistory)-10:]
}
sanitized, _ := sanitizeHistory(recentHistory)
messages = append(messages, sanitized...)
// Flush prompt
messages = append(messages, providers.Message{
Role: "user",
Content: settings.Prompt,
})
// Build tool list — only file tools needed for memory flush
var toolDefs []providers.ToolDefinition
if l.toolPolicy != nil {
toolDefs = l.toolPolicy.FilterTools(l.tools, l.id, l.provider.Name(), nil, nil, false, false)
} else {
toolDefs = l.tools.ProviderDefs()
}
// Run LLM iteration loop (max 5 iterations for flush)
maxFlushIter := 5
for range maxFlushIter {
resp, err := l.provider.Chat(flushCtx, providers.ChatRequest{
Messages: messages,
Tools: toolDefs,
Model: l.model,
Options: map[string]any{
"max_tokens": 4096,
"temperature": 0.3,
},
})
if err != nil {
slog.Warn("memory flush: LLM call failed", "error", err)
break
}
// No tool calls → done
if len(resp.ToolCalls) == 0 {
content := SanitizeAssistantContent(resp.Content)
if IsSilentReply(content) {
slog.Info("memory flush: NO_REPLY (nothing to save)")
} else if content != "" {
slog.Info("memory flush: completed with response", "content_len", len(content))
}
break
}
// Process tool calls
assistantMsg := providers.Message{
Role: "assistant",
Content: resp.Content,
ToolCalls: resp.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range resp.ToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
slog.Info("memory flush: tool call", "tool", tc.Name, "args_len", len(argsJSON))
result := l.tools.ExecuteWithContext(flushCtx, tc.Name, tc.Arguments, "", "", "", sessionKey, nil)
messages = append(messages, providers.Message{
Role: "tool",
Content: result.ForLLM,
ToolCallID: tc.ID,
})
}
}
// Mark flush as done
l.sessions.SetMemoryFlushDone(sessionKey)
l.sessions.Save(sessionKey)
slog.Info("memory flush: completed", "session", sessionKey)
}