feat(loop): Top5#5 上下文溢出压缩 + 每轮 assemble(对齐 OpenClaw context-engine)——Top5 全完成
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 每轮 assemble(transformContext 等价):run.go 每 step 前 assembleContext——依次 Inject(每轮
  重新 Retriever.Inject 不缓存首轮)、WorkingMemory 前置 Summarize、可插拔 TransformContext 投影/
  过滤;生成的 view 交 step,msgs 保持纯对话供工具结果追加,记忆不重复注入、不写回 checkpoint。
- Compactor:结构分支感知压缩——estimateContextTokens 启发式超 MaxContextTokens 且消息≥3 才动;
  findKeepStart 按 user 消息切整轮从后往前累加(至少保留最后一整轮→尾随工具结果绝不切片/不跨轮);
  旧轮 user/assistant 对压成一条 RoleSystem 摘要置顶,既有摘要前缀识别合并防叠;capSummary 上限
  4000 rune;ctx 即 abortSignal(取消返回 ctx.Err,压缩失败仅记日志不中断 run)。
- 选项:Options 增 MaxContextTokens/Compactor/TransformContext,均 nil-tolerant(默认=现有行为,
  零值不压缩);DefaultMaxContextTokens=32768。Compactor 接口预留注入 LLM 摘要。

go build/vet/test -count=1 全绿 + windows/darwin 交叉编译。12 断言覆盖尾随工具结果保留/不跨轮切割/
摘要上限/摘要合并/ctx 取消/每轮重检索/TransformContext 逐轮/超限压缩/默认关闭。-race 受宿主
TSan VMA 限制无法运行(与代码无关)。

**至此 Top5(1~5) 全部完成并推库。**
This commit is contained in:
Aska committed 2026-09-06 18:08:55 +08:00
1 parent 35cb2d3942
commit 648292d035
7 files changed
+709 -31

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
package loop
import (
"context"
"fmt"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Compaction tuning constants. These bound the compactor so it stays
// deterministic and offline: the summary is capped, the recent tail is a share
// of the budget, and very short conversations are left alone even when they
// exceed the budget (design §1.3 上下文溢出压缩).
const (
// DefaultMaxContextTokens is the context budget a caller can assign to
// Options.MaxContextTokens to enable overflow compaction (design §1.3).
DefaultMaxContextTokens = 32768
// keepRecentFraction is the share of the budget reserved for the recent tail
// during compaction; the remainder is left for the summary message and prompt.
keepRecentFraction = 0.5
// minKeepTokens is the floor for the recent-tail budget so a tiny
// MaxContextTokens does not force every turn into the summary.
minKeepTokens = 128
// summaryMarker tags a compaction summary system message so the compactor can
// recognize an existing summary prefix and preserve (merge into) it rather
// than re-summarize it on a later compaction.
summaryMarker = "[前文摘要 · history summary]"
// maxSummaryChars caps the compaction summary so repeated compactions cannot
// inflate the summary without bound (design §1.3 摘要容量上限).
maxSummaryChars = 4000
// minCompactMessages is the smallest conversation the compactor will touch; a
// shorter one is left alone even when it is over budget.
minCompactMessages = 3
// messageOverheadTokens is the per-message token overhead charged for the role
// and separator tokens the provider adds around each message.
messageOverheadTokens = 4
// imageTokens is the rough token charge for a single image part.
imageTokens = 1000
// charsPerToken is the character-to-token divisor used by the offline estimate.
charsPerToken = 4
// summarySampleChars bounds the per-message text sampled into a naive summary.
summarySampleChars = 120
)
// Compactor compacts an over-budget conversation by summarizing older turns into
// a single leading summary message while preserving the recent tail — including
// the trailing tool results — unmodified (design §1.3, aligned with OpenClaw's
// context-engine). It is injected through Options.Compactor so a caller can swap
// in an LLM-backed summarizer; the loop ships a structural default that never
// calls a model.
type Compactor interface {
// Compact returns messages with older turns compressed into a leading summary
// when the estimated tokens exceed budget. It returns the input unchanged when
// the messages already fit within budget or there is nothing safe to
// summarize. ctx is the abort signal: a canceled context stops compaction and
// returns a wrapped context.Canceled so the caller can bail out.
Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error)
}
// defaultCompactor is the built-in structural compactor. It is stateless and
// safe for concurrent use.
type defaultCompactor struct{}
// Compact implements Compactor.
func (defaultCompactor) Compact(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: compact: %w", err)
}
return compactMessages(ctx, messages, budget)
}
// compactMessages is the shared compaction implementation. It never mutates the
// input slices; a compacted result is always freshly allocated.
func compactMessages(ctx context.Context, messages []model.Message, budget int) ([]model.Message, error) {
if budget <= 0 || len(messages) < minCompactMessages {
return messages, nil
}
if estimateContextTokens(messages) <= budget {
return messages, nil
}
prefixEnd := summaryPrefixEnd(messages)
keepTokens := max(int(float64(budget)*keepRecentFraction), minKeepTokens)
keepStart := findKeepStart(messages, prefixEnd, keepTokens)
if keepStart <= prefixEnd {
// Everything is being kept; there is no safe prefix to summarize.
return messages, nil
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: compact: %w", err)
}
existing := ""
if prefixEnd > 0 {
existing = strings.TrimPrefix(messageText(messages[0]), summaryMarker+"\n\n")
}
fresh := buildSummary(messages[prefixEnd:keepStart])
if fresh == "" && existing == "" {
return messages, nil
}
full := fresh
if existing != "" {
full = existing + "\n\n" + fresh
}
summary := model.TextMessage(model.RoleSystem, summaryMarker+"\n\n"+capSummary(full, maxSummaryChars))
out := make([]model.Message, 0, len(messages)-(keepStart-prefixEnd)+1)
out = append(out, summary)
out = append(out, messages[keepStart:]...)
return out, nil
}
// summaryPrefixEnd returns the count of leading compaction-summary messages,
// which are never re-summarized. It is 0 (no summary) or 1 (a single summary
// prefix), since compaction always emits exactly one leading summary.
func summaryPrefixEnd(messages []model.Message) int {
if len(messages) == 0 || messages[0].Role != model.RoleSystem {
return 0
}
if strings.HasPrefix(messageText(messages[0]), summaryMarker) {
return 1
}
return 0
}
// findKeepStart returns the index of the first message to keep given that the
// recent tail should fit within keepTokens. It always keeps whole turns: a turn
// starts at a user message and runs to the next user message. It scores turns
// from the back, so the most recent turns survive and a trailing tool result is
// never sliced apart. When no user turn exists (unusual), it keeps nothing extra
// and returns from, so nothing is summarized.
func findKeepStart(messages []model.Message, from, keepTokens int) int {
n := len(messages)
turns := make([]int, 0, 8)
for i := from; i < n; i++ {
if messages[i].Role == model.RoleUser {
turns = append(turns, i)
}
}
if len(turns) == 0 {
return from
}
acc := 0
keptStart := turns[len(turns)-1]
for i := len(turns) - 1; i >= 0; i-- {
to := n
if i+1 < len(turns) {
to = turns[i+1]
}
if i == len(turns)-1 || acc+estimateContextTokens(messages[turns[i]:to]) <= keepTokens {
acc += estimateContextTokens(messages[turns[i]:to])
keptStart = turns[i]
} else {
break
}
}
return keptStart
}
// estimateContextTokens returns a conservative token estimate for messages. It is
// a heuristic (chars ÷ charsPerToken with a per-message and per-image overhead)
// so it stays dependency-free and offline, but it is monotonic enough to drive
// the compact decision.
func estimateContextTokens(messages []model.Message) int {
total := 0
for _, m := range messages {
total += messageOverheadTokens
for _, part := range m.Content {
switch part.Type {
case model.ContentTypeText:
total += estimateStringTokens(part.Text)
case model.ContentTypeImage:
total += imageTokens
}
}
}
return total
}
// estimateStringTokens is the character-level token estimate for one text string,
// weighting wide (CJK) runes double and dividing by charsPerToken. An empty string
// costs nothing; a non-empty one costs at least one token.
func estimateStringTokens(s string) int {
if s == "" {
return 0
}
runes := utf8.RuneCountInString(s)
wide := 0
for _, r := range s {
if r >= 0x2E80 {
wide++
}
}
t := (runes + wide) / charsPerToken
if t < 1 {
t = 1
}
return t
}
// buildSummary renders a compact structural summary of the given messages: a role
// distribution plus the most recent user and assistant text. It is the default
// (offline, no-model) summarizer; a caller that wants a semantic digest injects
// its own Compactor instead.
func buildSummary(messages []model.Message) string {
if len(messages) == 0 {
return ""
}
counts := map[model.Role]int{}
lastUser, lastAssistant := "", ""
for _, m := range messages {
counts[m.Role]++
text := messageText(m)
if text == "" {
continue
}
switch m.Role {
case model.RoleUser:
lastUser = text
case model.RoleAssistant:
lastAssistant = text
}
}
var b strings.Builder
fmt.Fprintf(&b, "已压缩 %d 条历史消息(", len(messages))
first := true
for _, role := range []model.Role{model.RoleSystem, model.RoleUser, model.RoleAssistant, model.RoleTool} {
if c := counts[role]; c > 0 {
if !first {
b.WriteString(",")
}
first = false
fmt.Fprintf(&b, "%s %d", role, c)
}
}
b.WriteString(")")
if lastUser != "" {
fmt.Fprintf(&b, "。最近用户: %s", truncateRunes(lastUser, summarySampleChars))
}
if lastAssistant != "" {
fmt.Fprintf(&b, "。最近助手: %s", truncateRunes(lastAssistant, summarySampleChars))
}
return b.String()
}
// capSummary truncates s to at most max runes, appending a "..." marker when it
// had to cut. It is rune-safe so it never slices a multi-byte rune in half.
func capSummary(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max < 3 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
// truncateRunes shortens s to at most n runes, Unicode-safe.
func truncateRunes(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}