Files
Cyrene_v2/internal/runtime/memory/working.go
T

148 lines
4.3 KiB
Go

package memory
import (
"strconv"
"strings"
"sync"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Working memory sizing and summarization constants.
const (
// defaultWorkingWindow is the default number of messages retained in the
// sliding window when NewWorkingMemory is called with a non-positive size.
defaultWorkingWindow = 20
// summaryLastTextMax is the maximum rune length of the trailing message text
// included in a naive summary, keeping self-summaries compact.
summaryLastTextMax = 80
)
// WorkingMemory is the per-session short-term memory: a sliding window of the
// current conversation's messages plus a summary (design §0 layer ①). It is the
// immediate context an agent reasons over.
//
// The window is bounded: adding beyond the maximum evicts the oldest message.
// Summarize returns the summary set via SetSummary, or a naive structural
// summary computed on demand. LLM-generated summaries are deferred to M4; the
// plumbing is in place so a summarizer can call SetSummary.
//
// WorkingMemory is safe for concurrent use; Add/SetSummary serialize with a
// write lock and Window/Summarize read under a read lock.
type WorkingMemory struct {
mu sync.RWMutex
window []model.Message
max int
summary string
}
// NewWorkingMemory builds a working memory with a sliding window of up to
// maxMessages messages. A non-positive maxMessages selects defaultWorkingWindow.
func NewWorkingMemory(maxMessages int) *WorkingMemory {
if maxMessages <= 0 {
maxMessages = defaultWorkingWindow
}
return &WorkingMemory{max: maxMessages}
}
// Add appends msg to the window and evicts the oldest message when the window
// exceeds its limit.
func (w *WorkingMemory) Add(msg model.Message) {
w.mu.Lock()
defer w.mu.Unlock()
w.window = append(w.window, msg)
if len(w.window) > w.max {
overflow := len(w.window) - w.max
copy(w.window, w.window[overflow:])
w.window = w.window[:w.max]
}
}
// Window returns a deep copy of the current message window, oldest first. The
// returned slice and each message's content parts are detached from internal
// state, so callers may mutate them freely.
func (w *WorkingMemory) Window() []model.Message {
w.mu.RLock()
defer w.mu.RUnlock()
out := make([]model.Message, len(w.window))
for i, m := range w.window {
out[i] = cloneMessage(m)
}
return out
}
// cloneMessage returns a copy of m whose content parts are detached from the
// source. Every field of model.Content is a value type, so copying the parts
// slice is sufficient.
func cloneMessage(m model.Message) model.Message {
parts := make([]model.Content, len(m.Content))
copy(parts, m.Content)
m.Content = parts
return m
}
// SetSummary records an explicit summary (e.g. produced by an LLM in M4).
func (w *WorkingMemory) SetSummary(s string) {
w.mu.Lock()
defer w.mu.Unlock()
w.summary = s
}
// Summarize returns the stored summary when one was set, otherwise a naive
// structural summary derived from the current window. It never mutates state, so
// repeated calls return a value consistent with the window at call time.
func (w *WorkingMemory) Summarize() string {
w.mu.RLock()
defer w.mu.RUnlock()
if w.summary != "" {
return w.summary
}
return w.naiveSummaryLocked()
}
// naiveSummaryLocked builds "{n} 条消息, {role} {count}, ...; 最近: {text}". It
// requires w.mu (read or write) to be held.
func (w *WorkingMemory) naiveSummaryLocked() string {
if len(w.window) == 0 {
return ""
}
counts := make(map[model.Role]int)
last := ""
for _, m := range w.window {
counts[m.Role]++
last = messageText(m)
}
var b strings.Builder
b.WriteString(strconv.Itoa(len(w.window)))
b.WriteString(" 条消息")
for _, role := range []model.Role{model.RoleSystem, model.RoleUser, model.RoleAssistant, model.RoleTool} {
if c := counts[role]; c > 0 {
b.WriteString(", ")
b.WriteString(string(role))
b.WriteByte(' ')
b.WriteString(strconv.Itoa(c))
}
}
if last != "" {
b.WriteString("; 最近: ")
b.WriteString(truncateRunes(last, summaryLastTextMax))
}
return b.String()
}
// truncateRunes shortens s to at most n runes. Unicode-safe: it never slices a
// multi-byte rune in half.
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
runes := []rune(s)
return string(runes[:n])
}