148 lines
4.3 KiB
Go
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])
|
|
}
|