e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
"go-micro.dev/v6/store"
|
|
)
|
|
|
|
// Memory is an agent's conversation memory. Like the rest of the
|
|
// framework it is pluggable: the default is store-backed and durable
|
|
// across restarts, but any implementation can be supplied with
|
|
// WithMemory — in-process, a database, or a semantic/vector store.
|
|
type Memory interface {
|
|
// Add appends a message to the conversation.
|
|
Add(role, content string)
|
|
// Messages returns the retained conversation, oldest first.
|
|
Messages() []ai.Message
|
|
// Clear resets the conversation.
|
|
Clear()
|
|
}
|
|
|
|
// MemorySummaryFunc turns older conversation messages into a compact
|
|
// replacement message for active context. It is called while the default
|
|
// memory is locked, so implementations should be deterministic and avoid
|
|
// calling back into the same memory instance.
|
|
type MemorySummaryFunc func([]ai.Message) ai.Message
|
|
|
|
// MemoryCompaction configures deterministic, store-backed context compaction
|
|
// for the default memory implementation. When the retained conversation grows
|
|
// past MaxMessages, older turns are collapsed into a summary message while the
|
|
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
|
|
type MemoryCompaction struct {
|
|
MaxMessages int
|
|
KeepRecent int
|
|
Summarize MemorySummaryFunc
|
|
}
|
|
|
|
// MemoryRecall is implemented by memory backends that can retrieve durable
|
|
// prior context relevant to a new turn without replaying every stored message.
|
|
type MemoryRecall interface {
|
|
Recall(query string, limit int) []ai.Message
|
|
}
|
|
|
|
// MemorySummary is implemented by memory backends that expose their current
|
|
// compacted summary for inspection. It lets long-running agents make memory
|
|
// compaction observable without coupling callers to a concrete store.
|
|
type MemorySummary interface {
|
|
Summary() string
|
|
}
|
|
|
|
// Summary returns the current compacted-memory summary for m, when supported.
|
|
// It returns an empty string for memory backends that have not compacted or do
|
|
// not expose an inspectable summary.
|
|
func Summary(m Memory) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
summarizer, ok := m.(MemorySummary)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return summarizer.Summary()
|
|
}
|
|
|
|
// NewMemory returns the default store-backed memory: an in-process
|
|
// conversation buffer (truncated to limit) that persists to the store
|
|
// under key, so an agent picks up where it left off after a restart.
|
|
// A nil store or empty key yields non-persistent memory.
|
|
func NewMemory(s store.Store, key string, limit int) Memory {
|
|
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(limit)}
|
|
m.load()
|
|
return m
|
|
}
|
|
|
|
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
|
|
// conversation and archives every turn for retrieval. It is useful when callers
|
|
// want relevant durable recall without summary compaction in the active context.
|
|
// A nil store or empty key keeps only the active in-process buffer.
|
|
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
|
|
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
|
|
m.load()
|
|
return m
|
|
}
|
|
|
|
// NewCompactingMemory returns store-backed memory with explicit compaction and
|
|
// retrieval controls. It keeps all messages in the backing store, compacts older
|
|
// turns into a deterministic summary when the conversation exceeds maxMessages,
|
|
// and lets callers recall relevant prior turns with Recall.
|
|
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
|
|
return NewCompactingMemoryWithOptions(s, key, MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent})
|
|
}
|
|
|
|
// NewCompactingMemoryWithOptions returns store-backed memory configured with
|
|
// explicit compaction options, including an optional summarization hook.
|
|
func NewCompactingMemoryWithOptions(s store.Store, key string, compaction MemoryCompaction) Memory {
|
|
maxMessages := compaction.MaxMessages
|
|
keepRecent := compaction.KeepRecent
|
|
if keepRecent <= 0 {
|
|
keepRecent = maxMessages / 2
|
|
}
|
|
if keepRecent < 1 {
|
|
keepRecent = 1
|
|
}
|
|
m := &storeMemory{
|
|
store: s,
|
|
key: key,
|
|
// Use an unlimited buffer here; compaction, not truncation, decides
|
|
// what remains in active context so a summary can preserve older turns.
|
|
hist: ai.NewHistory(0),
|
|
compaction: MemoryCompaction{
|
|
MaxMessages: maxMessages,
|
|
KeepRecent: keepRecent,
|
|
Summarize: compaction.Summarize,
|
|
},
|
|
}
|
|
m.load()
|
|
m.compact()
|
|
return m
|
|
}
|
|
|
|
// NewInMemory returns conversation memory that is not persisted.
|
|
func NewInMemory(limit int) Memory {
|
|
return &storeMemory{hist: ai.NewHistory(limit)}
|
|
}
|
|
|
|
// storeMemory is the default Memory: an ai.History buffer optionally
|
|
// persisted to a store.
|
|
type storeMemory struct {
|
|
mu sync.Mutex
|
|
store store.Store
|
|
key string
|
|
hist *ai.History
|
|
compaction MemoryCompaction
|
|
archive []ai.Message
|
|
summary string
|
|
retrieveAll bool
|
|
}
|
|
|
|
func (m *storeMemory) Add(role, content string) {
|
|
m.mu.Lock()
|
|
if m.retrieveAll {
|
|
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
|
|
}
|
|
m.hist.Add(role, content)
|
|
m.mu.Unlock()
|
|
m.compact()
|
|
m.save()
|
|
}
|
|
|
|
func (m *storeMemory) Messages() []ai.Message {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.hist.Messages()
|
|
}
|
|
|
|
func (m *storeMemory) Clear() {
|
|
m.mu.Lock()
|
|
m.hist.Reset()
|
|
m.archive = nil
|
|
m.summary = ""
|
|
m.mu.Unlock()
|
|
m.save()
|
|
}
|
|
|
|
// Summary returns the latest compacted summary text, if this memory has
|
|
// compacted older turns. The returned value is safe to show in debug UIs or
|
|
// checkpoints because it is exactly the summary retained in active context.
|
|
func (m *storeMemory) Summary() string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.summary
|
|
}
|
|
|
|
// Recall returns archived messages whose content contains words from query.
|
|
// It is deterministic and provider-neutral: no embeddings or model calls are
|
|
// required, but semantic/vector stores can replace Memory for richer retrieval.
|
|
// When created with NewRetrievalMemory the archive contains every persisted
|
|
// turn; when created with NewCompactingMemory it contains compacted older turns.
|
|
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if limit <= 0 {
|
|
limit = 5
|
|
}
|
|
terms := recallTerms(query)
|
|
type match struct {
|
|
msg ai.Message
|
|
score int
|
|
index int
|
|
}
|
|
matches := make([]match, 0, len(m.archive))
|
|
for i := len(m.archive) - 1; i >= 0; i-- {
|
|
msg := m.archive[i]
|
|
if score := recallScore(msg, terms); score > 0 {
|
|
matches = append(matches, match{msg: msg, score: score, index: i})
|
|
}
|
|
}
|
|
sort.SliceStable(matches, func(i, j int) bool {
|
|
if matches[i].score != matches[j].score {
|
|
return matches[i].score > matches[j].score
|
|
}
|
|
return matches[i].index > matches[j].index
|
|
})
|
|
if len(matches) > limit {
|
|
matches = matches[:limit]
|
|
}
|
|
out := make([]ai.Message, 0, len(matches))
|
|
for _, match := range matches {
|
|
out = append(out, match.msg)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *storeMemory) load() {
|
|
if m.store == nil || m.key == "" {
|
|
return
|
|
}
|
|
recs, err := m.store.Read(m.key)
|
|
if err != nil || len(recs) == 0 {
|
|
return
|
|
}
|
|
var state memoryState
|
|
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
|
|
var msgs []ai.Message
|
|
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
|
|
return
|
|
}
|
|
state.Messages = msgs
|
|
}
|
|
m.mu.Lock()
|
|
m.archive = state.Archive
|
|
m.summary = state.Summary
|
|
if m.retrieveAll && len(m.archive) == 0 {
|
|
m.archive = append(m.archive, state.Messages...)
|
|
}
|
|
for _, msg := range state.Messages {
|
|
m.hist.Add(msg.Role, msg.Content)
|
|
}
|
|
if m.summary == "" {
|
|
m.summary = currentMemorySummary(state.Messages)
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *storeMemory) save() {
|
|
if m.store == nil || m.key == "" {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
data, err := json.Marshal(memoryState{
|
|
Messages: m.hist.Messages(),
|
|
Archive: m.archive,
|
|
Summary: m.summary,
|
|
})
|
|
m.mu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
|
|
}
|
|
|
|
func (m *storeMemory) compact() {
|
|
if m.compaction.MaxMessages <= 0 {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
msgs := m.hist.Messages()
|
|
if len(msgs) <= m.compaction.MaxMessages {
|
|
return
|
|
}
|
|
keep := m.compaction.KeepRecent
|
|
if keep <= 0 || keep >= m.compaction.MaxMessages {
|
|
keep = m.compaction.MaxMessages - 1
|
|
}
|
|
if keep < 1 {
|
|
keep = 1
|
|
}
|
|
cut := len(msgs) - keep
|
|
older := msgs[:cut]
|
|
recent := msgs[cut:]
|
|
m.archive = append(m.archive, older...)
|
|
summarize := m.compaction.Summarize
|
|
if summarize == nil {
|
|
summarize = defaultMemorySummary
|
|
}
|
|
summary := summarize(older)
|
|
if summary.Role == "" {
|
|
summary.Role = "system"
|
|
}
|
|
m.summary = fmt.Sprint(summary.Content)
|
|
m.hist.Reset()
|
|
m.hist.Add(summary.Role, summary.Content)
|
|
for _, msg := range recent {
|
|
m.hist.Add(msg.Role, msg.Content)
|
|
}
|
|
}
|
|
|
|
func currentMemorySummary(msgs []ai.Message) string {
|
|
for _, msg := range msgs {
|
|
if msg.Role != "system" {
|
|
continue
|
|
}
|
|
text := fmt.Sprint(msg.Content)
|
|
if strings.HasPrefix(text, "Conversation memory summary:") {
|
|
return text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func defaultMemorySummary(msgs []ai.Message) ai.Message {
|
|
return ai.Message{
|
|
Role: "system",
|
|
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(msgs)),
|
|
}
|
|
}
|
|
|
|
func summarizeMessages(msgs []ai.Message) string {
|
|
var b strings.Builder
|
|
for i, msg := range msgs {
|
|
if i > 0 {
|
|
b.WriteString(" | ")
|
|
}
|
|
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func compactText(s string, max int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if max > 0 && len(s) > max {
|
|
return s[:max] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func recallScore(msg ai.Message, terms []string) int {
|
|
text := strings.ToLower(fmt.Sprint(msg.Content))
|
|
score := 0
|
|
for _, term := range terms {
|
|
if strings.Contains(text, term) {
|
|
score++
|
|
}
|
|
}
|
|
return score
|
|
}
|
|
|
|
func recallTerms(query string) []string {
|
|
seen := map[string]bool{}
|
|
var terms []string
|
|
for _, term := range strings.Fields(strings.ToLower(query)) {
|
|
term = strings.Trim(term, ".,!?;:\"'()[]{}")
|
|
if len(term) < 3 || seen[term] {
|
|
continue
|
|
}
|
|
seen[term] = true
|
|
terms = append(terms, term)
|
|
}
|
|
return terms
|
|
}
|
|
|
|
type memoryState struct {
|
|
Messages []ai.Message `json:"messages"`
|
|
Archive []ai.Message `json:"archive,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
}
|