- 每轮 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) 全部完成并推库。**
129 lines
4.4 KiB
Go
129 lines
4.4 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// injectMemory retrieves the persona's relevant long-term memories and prepends
|
|
// them as a leading system message, so the model can ground its answer in recalled
|
|
// context (design §6 / §2). It is re-run every round via assembleContext so the
|
|
// retrieval reflects the latest conversation rather than a first-turn snapshot. It
|
|
// returns the input messages unchanged when no retriever/persona is configured,
|
|
// retrieval fails, or nothing relevant is found.
|
|
func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message {
|
|
if l.retriever == nil || l.personaID == "" {
|
|
return messages
|
|
}
|
|
block, err := l.retriever.Inject(ctx, l.personaID, queryFromMessages(messages), 0)
|
|
if err != nil {
|
|
l.log.Warn("memory inject", "error", err)
|
|
return messages
|
|
}
|
|
if block == "" {
|
|
return messages
|
|
}
|
|
out := make([]model.Message, 0, len(messages)+1)
|
|
out = append(out, model.TextMessage(model.RoleSystem, block))
|
|
out = append(out, messages...)
|
|
return out
|
|
}
|
|
|
|
// assembleContext builds the model-visible message list for one round (design
|
|
// §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it
|
|
// re-injects the persona's relevant long-term memory — retrieval is re-run every
|
|
// round from the current conversation, not cached from the first turn, so it
|
|
// supports cross-round refresh — folds in the per-session working-memory summary
|
|
// when present, and applies the optional TransformContext hook last so a caller
|
|
// can project/filter what the model actually sees. It returns the input unchanged
|
|
// when nothing is configured.
|
|
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
|
|
var leading []model.Message
|
|
if l.working != nil {
|
|
if s := l.working.Summarize(); s != "" {
|
|
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
|
|
}
|
|
}
|
|
leading = append(leading, l.injectMemory(ctx, messages)...)
|
|
|
|
out := leading
|
|
if l.transformContext != nil {
|
|
out = l.transformContext(ctx, leading, round)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// compactIfNeeded compacts the conversation when its estimated context exceeds
|
|
// the configured MaxContextTokens. It is a no-op when compaction is disabled
|
|
// (non-positive MaxContextTokens), the compactor is nil, the estimate is within
|
|
// budget, or the compactor returns an error — a compaction failure is logged and
|
|
// the conversation is kept as-is rather than failing the run.
|
|
func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []model.Message {
|
|
if l.maxContextTokens <= 0 || l.compactor == nil {
|
|
return messages
|
|
}
|
|
compacted, err := l.compactor.Compact(ctx, messages, l.maxContextTokens)
|
|
if err != nil {
|
|
l.log.Warn("context compact", "error", err, "tokens", l.maxContextTokens)
|
|
return messages
|
|
}
|
|
if compacted == nil {
|
|
return messages
|
|
}
|
|
return compacted
|
|
}
|
|
|
|
// extractMemory runs the extraction hook (design §6) over the current
|
|
// conversation and persists any derived items. Errors are logged, never fatal, so
|
|
// a bad extraction cannot abort the run.
|
|
func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) {
|
|
if l.extractor == nil || l.memory == nil {
|
|
return
|
|
}
|
|
items, err := l.extractor.Extract(ctx, messages)
|
|
if err != nil {
|
|
l.log.Warn("memory extract", "step", step, "error", err)
|
|
return
|
|
}
|
|
for _, it := range items {
|
|
if it.PersonaID == "" && l.personaID != "" {
|
|
it.PersonaID = l.personaID
|
|
}
|
|
if _, err := l.memory.Add(ctx, it); err != nil {
|
|
l.log.Warn("memory store", "step", step, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// queryFromMessages derives a retrieval query from the conversation: the last
|
|
// user turn's text, falling back to the last message's text, then "".
|
|
func queryFromMessages(messages []model.Message) string {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].Role == model.RoleUser {
|
|
return messageText(messages[i])
|
|
}
|
|
}
|
|
if n := len(messages); n > 0 {
|
|
return messageText(messages[n-1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// messageText returns the concatenated plain-text payload of a message's text
|
|
// parts, which is the shared view used to build a retrieval query.
|
|
func messageText(m model.Message) string {
|
|
var b strings.Builder
|
|
for _, part := range m.Content {
|
|
if part.Type != model.ContentTypeText {
|
|
continue
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(part.Text)
|
|
}
|
|
return b.String()
|
|
}
|