Files
Agent-Native/runtime/session/compact.go
T
weichen 7710be2db4 Add session compaction, metadata, and crash-safe transcript loading
Why: long multi-turn executions grow the JSONL transcript until they
hit provider context limits (parity-matrix #11), and a crashed append
left a partial line that made the whole session unloadable
(parity-matrix #12 resume semantics).

Evidence: go vet ./... clean; go test ./... 13 packages ok. Session
cases: crash-residue tail skipped while mid-file corruption still
errors, Stats line/byte counts, meta sidecar roundtrip (missing meta
nil), Compact rewriting system+summary+keepLastN atomically with no
tmp residue, no-op on short transcripts, summarizer error leaving the
file untouched. Adapter case: threshold-triggered compaction runs
before the loop and the summary marker reaches both the transcript
and the model's context.

Impact: session gains Stats/SaveMeta/ReadMeta/Compact; native.Options
gains Compact *CompactConfig (default summarizer is one tool-less
stream call; compaction failure logs and continues - optimization,
never a silent failure); session meta sidecar records model/scope/
exec id per execution.

Refs: docs/parity-matrix.md #11/#12 (Batch3 scope).
2026-10-01 05:51:46 +00:00

139 lines
3.2 KiB
Go

package session
import (
"bufio"
"encoding/json"
"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
}
// Compact 把转录改写为「系统消息 + 摘要消息 + 最近 keepLastN 条」。
// 摘要由 summarize 生成(由适配层注入,通常是一次无工具模型调用);
// 改写原子落盘(临时文件 + rename)。
// 返回是否发生了压缩(消息数不足/已是紧凑形态时返回 false 且不动文件)。
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
messages, err := s.Load()
if err != nil {
return false, err
}
if len(messages) <= keepLastN+1 { // 系统消息 + 尾部,无可压缩头部
return false, nil
}
var system *llm.Message
rest := messages
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
copyOf := rest[0]
system = &copyOf
rest = rest[1:]
}
tailStart := len(rest) - keepLastN
if tailStart <= 0 {
return false, nil
}
head := rest[:tailStart]
tail := rest[tailStart:]
summary, err := summarize(head)
if err != nil {
return false, err
}
summaryMsg := llm.Message{
Role: llm.RoleUser,
Content: "[conversation summary]\n" + summary + "\n[recent messages follow]",
}
var rebuilt []llm.Message
if system != nil {
rebuilt = append(rebuilt, *system)
}
rebuilt = append(rebuilt, summaryMsg)
rebuilt = append(rebuilt, tail...)
s.mu.Lock()
defer s.mu.Unlock()
tmp := s.path + ".compact.tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
w := bufio.NewWriter(f)
for _, m := range rebuilt {
raw, err := json.Marshal(m)
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return false, err
}
w.Write(raw)
w.WriteByte('\n')
}
if err := w.Flush(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return false, err
}
_ = f.Close()
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return false, err
}
return true, nil
}