Why: Old tool results were filling the window, compaction could split a tool group or commit a stale summary, and reminders arrived as extra user messages. Evidence: Compaction retries a conflict and opens the circuit after three failures; clearing keeps the last five tool results; a stale file write is rejected; RPC reports started, queued, or handled. Impact: Requests over the context window replace older tool results with a placeholder. Rewriting an existing file now requires a read in the same run. Subagent text over 32000 characters is capped. Refs: feature/context-engineering Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
4.0 KiB
Go
155 lines
4.0 KiB
Go
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"agent-native/runtime/llm"
|
|
)
|
|
|
|
// Stats 是转录文件的轻量元数据(扫描即得,无副作用)。
|
|
type Stats struct {
|
|
Lines int
|
|
Bytes int64
|
|
}
|
|
|
|
// Stats 统计当前转录行数与字节数(空行不计)。
|
|
func (s *Store) Stats() Stats {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
st := Stats{}
|
|
f, err := os.Open(s.path)
|
|
if err != nil {
|
|
return st
|
|
}
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
|
for sc.Scan() {
|
|
if len(sc.Bytes()) == 0 {
|
|
continue
|
|
}
|
|
st.Lines++
|
|
}
|
|
if info, err := f.Stat(); err == nil {
|
|
st.Bytes = info.Size()
|
|
}
|
|
return st
|
|
}
|
|
|
|
// SaveMeta 把会话元数据写进 <path>.meta.json(创建时间/模型等由调用方定义)。
|
|
func (s *Store) SaveMeta(meta map[string]string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
raw, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".meta.json.tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path+".meta.json")
|
|
}
|
|
|
|
// ReadMeta 读取会话元数据;不存在时返回 nil(新会话)。
|
|
func (s *Store) ReadMeta() map[string]string {
|
|
raw, err := os.ReadFile(s.path + ".meta.json")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var meta map[string]string
|
|
if err := json.Unmarshal(raw, &meta); err != nil {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|
|
|
|
const maxCompactAttempts = 3
|
|
|
|
// Compact appends a compaction entry. The file keeps every earlier record.
|
|
// Load hides the summarized prefix behind the summary plus the last
|
|
// keepLastN non-system messages. Returns false when there is nothing to fold.
|
|
// A session change during summary is retried from the new transcript.
|
|
// Three consecutive summary failures or conflicts trip the breaker and
|
|
// leave the file unchanged.
|
|
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
|
|
var last error
|
|
for attempt := 0; attempt < maxCompactAttempts; attempt++ {
|
|
done, err, retry := s.compactOnce(keepLastN, summarize)
|
|
if !retry {
|
|
return done, err
|
|
}
|
|
last = err
|
|
}
|
|
if last == nil {
|
|
last = fmt.Errorf("compaction conflict: session changed during summary")
|
|
}
|
|
return false, fmt.Errorf("compaction circuit open: %w", last)
|
|
}
|
|
|
|
func (s *Store) compactOnce(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error, bool) {
|
|
s.mu.Lock()
|
|
entries, err := s.readEntriesLocked()
|
|
if err != nil {
|
|
s.mu.Unlock()
|
|
return false, err, false
|
|
}
|
|
leaf := s.leafLocked(entries)
|
|
generation := len(entries)
|
|
s.mu.Unlock()
|
|
path := pathTo(entries, leaf)
|
|
var messages []llm.Message
|
|
var msgEntries []Entry
|
|
for _, e := range path {
|
|
if e.Type == TypeMessage && e.Message != nil {
|
|
messages = append(messages, *e.Message)
|
|
msgEntries = append(msgEntries, e)
|
|
}
|
|
}
|
|
if len(messages) <= keepLastN+1 {
|
|
return false, nil, false
|
|
}
|
|
rest := messages
|
|
restEntries := msgEntries
|
|
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
|
|
rest = rest[1:]
|
|
restEntries = restEntries[1:]
|
|
}
|
|
tailStart := len(rest) - keepLastN
|
|
if tailStart <= 0 {
|
|
return false, nil, false
|
|
}
|
|
tailStart = alignToolTail(rest, tailStart)
|
|
head := rest[:tailStart]
|
|
summary, err := summarize(head)
|
|
if err != nil {
|
|
return false, err, true
|
|
}
|
|
var tailIDs []string
|
|
for _, e := range restEntries[tailStart:] {
|
|
tailIDs = append(tailIDs, e.ID)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now, err := s.readEntriesLocked()
|
|
if err != nil {
|
|
return false, err, false
|
|
}
|
|
if len(now) != generation {
|
|
return false, fmt.Errorf("compaction conflict: session changed during summary"), true
|
|
}
|
|
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
|
|
return err == nil, err, false
|
|
}
|
|
|
|
// alignToolTail 让保留尾巴从一条非 tool 消息开始,避免把 tool_calls 和结果拆开。
|
|
func alignToolTail(messages []llm.Message, tailStart int) int {
|
|
for tailStart > 0 && messages[tailStart].Role == llm.RoleTool {
|
|
tailStart--
|
|
}
|
|
return tailStart
|
|
}
|