- 每轮 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) 全部完成并推库。**
284 lines
9.0 KiB
Go
284 lines
9.0 KiB
Go
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])
|
||
}
|