mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 16:10:59 +00:00
cd2e407b29
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
506 lines
12 KiB
Go
506 lines
12 KiB
Go
package sessions
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// Session stores conversation history for one agent+scope combination.
|
|
type Session struct {
|
|
Key string `json:"key"` // agent:{agentId}:{sessionKey}
|
|
Messages []providers.Message `json:"messages"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
|
|
// Metadata (matching TS SessionEntry subset)
|
|
Model string `json:"model,omitempty"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
InputTokens int64 `json:"inputTokens,omitempty"`
|
|
OutputTokens int64 `json:"outputTokens,omitempty"`
|
|
CompactionCount int `json:"compactionCount,omitempty"`
|
|
MemoryFlushCompactionCount int `json:"memoryFlushCompactionCount,omitempty"`
|
|
MemoryFlushAt int64 `json:"memoryFlushAt,omitempty"` // unix ms
|
|
Label string `json:"label,omitempty"`
|
|
SpawnedBy string `json:"spawnedBy,omitempty"`
|
|
SpawnDepth int `json:"spawnDepth,omitempty"`
|
|
|
|
ContextWindow int `json:"contextWindow,omitempty"`
|
|
LastPromptTokens int `json:"lastPromptTokens,omitempty"`
|
|
LastMessageCount int `json:"lastMessageCount,omitempty"`
|
|
}
|
|
|
|
// Manager handles session lifecycle, persistence, and lookup.
|
|
type Manager struct {
|
|
sessions map[string]*Session
|
|
mu sync.RWMutex
|
|
storage string
|
|
}
|
|
|
|
func NewManager(storage string) *Manager {
|
|
m := &Manager{
|
|
sessions: make(map[string]*Session),
|
|
storage: storage,
|
|
}
|
|
if storage != "" {
|
|
os.MkdirAll(storage, 0755)
|
|
m.loadAll()
|
|
}
|
|
return m
|
|
}
|
|
|
|
// SessionKey builds a composite session key: agent:{agentId}:{scopeKey}
|
|
func SessionKey(agentID, scopeKey string) string {
|
|
return fmt.Sprintf("agent:%s:%s", agentID, scopeKey)
|
|
}
|
|
|
|
// GetOrCreate returns an existing session or creates a new one.
|
|
func (m *Manager) GetOrCreate(key string) *Session {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s
|
|
}
|
|
|
|
s := &Session{
|
|
Key: key,
|
|
Messages: []providers.Message{},
|
|
Created: time.Now(),
|
|
Updated: time.Now(),
|
|
}
|
|
m.sessions[key] = s
|
|
return s
|
|
}
|
|
|
|
// AddMessage appends a message to a session.
|
|
func (m *Manager) AddMessage(key string, msg providers.Message) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
s, ok := m.sessions[key]
|
|
if !ok {
|
|
s = &Session{
|
|
Key: key,
|
|
Messages: []providers.Message{},
|
|
Created: time.Now(),
|
|
}
|
|
m.sessions[key] = s
|
|
}
|
|
|
|
s.Messages = append(s.Messages, msg)
|
|
s.Updated = time.Now()
|
|
}
|
|
|
|
// GetHistory returns a copy of the message history.
|
|
func (m *Manager) GetHistory(key string) []providers.Message {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
s, ok := m.sessions[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
msgs := make([]providers.Message, len(s.Messages))
|
|
copy(msgs, s.Messages)
|
|
return msgs
|
|
}
|
|
|
|
// GetSummary returns the session summary.
|
|
func (m *Manager) GetSummary(key string) string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s.Summary
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetSummary updates the session summary.
|
|
func (m *Manager) SetSummary(key, summary string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.Summary = summary
|
|
s.Updated = time.Now()
|
|
}
|
|
}
|
|
|
|
// SetLabel updates the session label.
|
|
func (m *Manager) SetLabel(key, label string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.Label = label
|
|
s.Updated = time.Now()
|
|
}
|
|
}
|
|
|
|
// UpdateMetadata sets model/provider/channel metadata on a session.
|
|
func (m *Manager) UpdateMetadata(key, model, provider, channel string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
if model != "" {
|
|
s.Model = model
|
|
}
|
|
if provider != "" {
|
|
s.Provider = provider
|
|
}
|
|
if channel != "" {
|
|
s.Channel = channel
|
|
}
|
|
}
|
|
}
|
|
|
|
// AccumulateTokens adds token counts from a completed run.
|
|
func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.InputTokens += inputTokens
|
|
s.OutputTokens += outputTokens
|
|
}
|
|
}
|
|
|
|
// IncrementCompaction bumps the compaction counter after summarization.
|
|
func (m *Manager) IncrementCompaction(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.CompactionCount++
|
|
}
|
|
}
|
|
|
|
// GetCompactionCount returns the current compaction count for a session.
|
|
func (m *Manager) GetCompactionCount(key string) int {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s.CompactionCount
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetMemoryFlushCompactionCount returns the compaction count at which memory flush last ran.
|
|
func (m *Manager) GetMemoryFlushCompactionCount(key string) int {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s.MemoryFlushCompactionCount
|
|
}
|
|
return -1 // never flushed
|
|
}
|
|
|
|
// SetMemoryFlushDone records that memory flush completed at the current compaction count.
|
|
func (m *Manager) SetMemoryFlushDone(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.MemoryFlushCompactionCount = s.CompactionCount
|
|
s.MemoryFlushAt = time.Now().UnixMilli()
|
|
}
|
|
}
|
|
|
|
// SetSpawnInfo sets subagent origin metadata on a session.
|
|
func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.SpawnedBy = spawnedBy
|
|
s.SpawnDepth = depth
|
|
}
|
|
}
|
|
|
|
// SetContextWindow caches the agent's context window on the session.
|
|
func (m *Manager) SetContextWindow(key string, cw int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.ContextWindow = cw
|
|
}
|
|
}
|
|
|
|
// GetContextWindow returns the cached context window for a session (0 if unset).
|
|
func (m *Manager) GetContextWindow(key string) int {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s.ContextWindow
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// SetLastPromptTokens records actual prompt tokens from the last LLM response.
|
|
func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.LastPromptTokens = tokens
|
|
s.LastMessageCount = msgCount
|
|
}
|
|
}
|
|
|
|
// GetLastPromptTokens returns the last known prompt tokens and message count.
|
|
func (m *Manager) GetLastPromptTokens(key string) (int, int) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s, ok := m.sessions[key]; ok {
|
|
return s.LastPromptTokens, s.LastMessageCount
|
|
}
|
|
return 0, 0
|
|
}
|
|
|
|
// TruncateHistory keeps only the last N messages.
|
|
func (m *Manager) TruncateHistory(key string, keepLast int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
s, ok := m.sessions[key]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if keepLast <= 0 {
|
|
s.Messages = []providers.Message{}
|
|
} else if len(s.Messages) > keepLast {
|
|
s.Messages = s.Messages[len(s.Messages)-keepLast:]
|
|
}
|
|
s.Updated = time.Now()
|
|
}
|
|
|
|
// SetHistory replaces a session's message history with the given slice.
|
|
func (m *Manager) SetHistory(key string, msgs []providers.Message) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.Messages = msgs
|
|
s.Updated = time.Now()
|
|
}
|
|
}
|
|
|
|
// Reset clears a session's history and summary.
|
|
func (m *Manager) Reset(key string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if s, ok := m.sessions[key]; ok {
|
|
s.Messages = []providers.Message{}
|
|
s.Summary = ""
|
|
s.Updated = time.Now()
|
|
}
|
|
}
|
|
|
|
// Delete removes a session entirely.
|
|
func (m *Manager) Delete(key string) error {
|
|
m.mu.Lock()
|
|
delete(m.sessions, key)
|
|
m.mu.Unlock()
|
|
|
|
if m.storage != "" {
|
|
filename := sanitizeFilename(key) + ".json"
|
|
path := filepath.Join(m.storage, filename)
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns metadata for all sessions, optionally filtered by agent ID.
|
|
func (m *Manager) List(agentID string) []SessionInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var result []SessionInfo
|
|
prefix := ""
|
|
if agentID != "" {
|
|
prefix = "agent:" + agentID + ":"
|
|
}
|
|
|
|
for key, s := range m.sessions {
|
|
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
result = append(result, SessionInfo{
|
|
Key: key,
|
|
MessageCount: len(s.Messages),
|
|
Created: s.Created,
|
|
Updated: s.Updated,
|
|
Label: s.Label,
|
|
Channel: s.Channel,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// LastUsedChannel finds the most recently updated channel session for an agent
|
|
// and extracts channel + chatID from the key. Returns ("", "") if none found.
|
|
func (m *Manager) LastUsedChannel(agentID string) (channel, chatID string) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
prefix := "agent:" + agentID + ":"
|
|
var bestKey string
|
|
var bestUpdated time.Time
|
|
|
|
for key, s := range m.sessions {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
// Skip non-channel sessions (cron, subagent)
|
|
rest := key[len(prefix):]
|
|
if strings.HasPrefix(rest, "cron:") || strings.HasPrefix(rest, "subagent:") {
|
|
continue
|
|
}
|
|
if s.Updated.After(bestUpdated) {
|
|
bestUpdated = s.Updated
|
|
bestKey = key
|
|
}
|
|
}
|
|
|
|
if bestKey == "" {
|
|
return "", ""
|
|
}
|
|
|
|
// Parse: agent:{agentId}:{channel}:{peerKind}:{chatId}
|
|
parts := strings.SplitN(bestKey, ":", 5)
|
|
if len(parts) >= 5 {
|
|
return parts[2], parts[4]
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// SessionInfo is a lightweight session descriptor for listing.
|
|
type SessionInfo struct {
|
|
Key string `json:"key"`
|
|
MessageCount int `json:"messageCount"`
|
|
Created time.Time `json:"created"`
|
|
Updated time.Time `json:"updated"`
|
|
Label string `json:"label,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
}
|
|
|
|
// Save persists a session to disk atomically.
|
|
func (m *Manager) Save(key string) error {
|
|
if m.storage == "" {
|
|
return nil
|
|
}
|
|
|
|
m.mu.RLock()
|
|
s, ok := m.sessions[key]
|
|
if !ok {
|
|
m.mu.RUnlock()
|
|
return nil
|
|
}
|
|
|
|
// Snapshot under lock
|
|
snapshot := Session{
|
|
Key: s.Key,
|
|
Summary: s.Summary,
|
|
Created: s.Created,
|
|
Updated: s.Updated,
|
|
Model: s.Model,
|
|
Provider: s.Provider,
|
|
Channel: s.Channel,
|
|
InputTokens: s.InputTokens,
|
|
OutputTokens: s.OutputTokens,
|
|
CompactionCount: s.CompactionCount,
|
|
MemoryFlushCompactionCount: s.MemoryFlushCompactionCount,
|
|
MemoryFlushAt: s.MemoryFlushAt,
|
|
Label: s.Label,
|
|
SpawnedBy: s.SpawnedBy,
|
|
SpawnDepth: s.SpawnDepth,
|
|
ContextWindow: s.ContextWindow,
|
|
LastPromptTokens: s.LastPromptTokens,
|
|
LastMessageCount: s.LastMessageCount,
|
|
}
|
|
if len(s.Messages) > 0 {
|
|
snapshot.Messages = make([]providers.Message, len(s.Messages))
|
|
copy(snapshot.Messages, s.Messages)
|
|
} else {
|
|
snapshot.Messages = []providers.Message{}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
data, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filename := sanitizeFilename(key)
|
|
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
|
return os.ErrInvalid
|
|
}
|
|
|
|
sessionPath := filepath.Join(m.storage, filename+".json")
|
|
|
|
// Atomic write: temp file → rename
|
|
tmpFile, err := os.CreateTemp(m.storage, "session-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
cleanup := true
|
|
defer func() {
|
|
if cleanup {
|
|
os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
tmpFile.Close()
|
|
return err
|
|
}
|
|
if err := tmpFile.Sync(); err != nil {
|
|
tmpFile.Close()
|
|
return err
|
|
}
|
|
tmpFile.Close()
|
|
|
|
if err := os.Rename(tmpPath, sessionPath); err != nil {
|
|
return err
|
|
}
|
|
cleanup = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) loadAll() {
|
|
files, err := os.ReadDir(m.storage)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, f := range files {
|
|
if f.IsDir() || filepath.Ext(f.Name()) != ".json" {
|
|
continue
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(m.storage, f.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var s Session
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
continue
|
|
}
|
|
|
|
m.sessions[s.Key] = &s
|
|
}
|
|
}
|
|
|
|
func sanitizeFilename(key string) string {
|
|
return strings.ReplaceAll(key, ":", "_")
|
|
}
|