- 每轮 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) 全部完成并推库。**
146 lines
5.1 KiB
Go
146 lines
5.1 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// TestAssembleContext_ReRetrievesEachRound proves memory is re-retrieved every
|
|
// round rather than cached from the first turn: an empty store yields no memory
|
|
// block, then after seeding the store a later round picks it up.
|
|
func TestAssembleContext_ReRetrievesEachRound(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
ret := memory.NewRetriever(store)
|
|
l := New(Options{Retriever: ret, PersonaID: "persona-a"})
|
|
|
|
msgs := []model.Message{model.TextMessage(model.RoleUser, "do the thing")}
|
|
|
|
// Round 1: the store is empty, so no memory is injected.
|
|
if view := l.assembleContext(context.Background(), msgs, 1); len(view) != 1 {
|
|
t.Fatalf("round 1 view = %d messages, want 1 (not memory)", len(view))
|
|
}
|
|
|
|
// Seed a memory as if it was learned between rounds.
|
|
if _, err := store.Add(context.Background(), memory.MemoryItem{
|
|
PersonaID: "persona-a", Content: "她喜欢冰美式",
|
|
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
|
}); err != nil {
|
|
t.Fatalf("seed memory: %v", err)
|
|
}
|
|
|
|
// Round 2: the retrieval is re-run and the memory now appears.
|
|
view := l.assembleContext(context.Background(), msgs, 2)
|
|
if len(view) != 2 {
|
|
t.Fatalf("round 2 view = %d messages, want 2 (memory injected)", len(view))
|
|
}
|
|
if view[0].Role != model.RoleSystem || !strings.Contains(view[0].Content[0].Text, "冰美式") {
|
|
t.Fatalf("round 2 memory not injected: %+v", view[0])
|
|
}
|
|
}
|
|
|
|
// TestCompactIfNeeded_OverBudget_Compacts drives a multi-turn conversation over a
|
|
// small budget and verifies the loop compacts it into a leading summary.
|
|
func TestCompactIfNeeded_OverBudget_Compacts(t *testing.T) {
|
|
t.Parallel()
|
|
l := New(Options{MaxContextTokens: 1000})
|
|
big := buildTurns(6, 600, false)
|
|
if estimateContextTokens(big) <= 1000 {
|
|
t.Fatalf("test precondition: want > 1000 tokens, got %d", estimateContextTokens(big))
|
|
}
|
|
got := l.compactIfNeeded(context.Background(), big)
|
|
if len(got) >= len(big) {
|
|
t.Fatalf("compactIfNeeded did not shrink: got %d >= %d", len(got), len(big))
|
|
}
|
|
if got[0].Role != model.RoleSystem || !strings.HasPrefix(messageText(got[0]), summaryMarker) {
|
|
t.Fatalf("expected a leading compaction summary, got %+v", got[0])
|
|
}
|
|
}
|
|
|
|
// TestCompactIfNeeded_Disabled_NoOp keeps the pre-existing behavior when
|
|
// MaxContextTokens is left at its zero value.
|
|
func TestCompactIfNeeded_Disabled_NoOp(t *testing.T) {
|
|
t.Parallel()
|
|
l := New(Options{}) // MaxContextTokens == 0 → disabled
|
|
big := buildTurns(6, 300, false)
|
|
got := l.compactIfNeeded(context.Background(), big)
|
|
if len(got) != len(big) {
|
|
t.Fatalf("disabled compaction should be a no-op: got %d, want %d", len(got), len(big))
|
|
}
|
|
}
|
|
|
|
// TestRun_TransformContext_InvokedEachRound verifies the TransformContext hook is
|
|
// called once per model step (per round), bridging per-round assemble into the
|
|
// Run path.
|
|
func TestRun_TransformContext_InvokedEachRound(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
|
a := newTestAgent(t, ad, td)
|
|
|
|
var rounds []int
|
|
var sizes []int
|
|
l := New(Options{
|
|
Agent: a,
|
|
TransformContext: func(_ context.Context, msgs []model.Message, round int) []model.Message {
|
|
rounds = append(rounds, round)
|
|
sizes = append(sizes, len(msgs))
|
|
return msgs
|
|
},
|
|
MaxRounds: 8,
|
|
})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(rounds) != 2 {
|
|
t.Fatalf("TransformContext called %d times, want 2 (one per round)", len(rounds))
|
|
}
|
|
if rounds[0] != 1 || rounds[1] != 2 {
|
|
t.Fatalf("rounds passed to TransformContext = %v, want [1 2]", rounds)
|
|
}
|
|
// Round 1 has just the user message; round 2 has the fed-back tool result.
|
|
if sizes[0] != 1 || sizes[1] != 2 {
|
|
t.Fatalf("sizes = %v, want [1 2]", sizes)
|
|
}
|
|
}
|
|
|
|
// TestRun_ContextOverflow_CompactsHistory passes a long multi-turn history and a
|
|
// small budget into Run; the first model step should see a compacted conversation
|
|
// carrying a leading summary.
|
|
func TestRun_ContextOverflow_CompactsHistory(t *testing.T) {
|
|
t.Parallel()
|
|
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final answer"}}}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, MaxContextTokens: 600, MaxRounds: 8})
|
|
|
|
history := buildTurns(8, 200, false)
|
|
if estimateContextTokens(history) <= 600 {
|
|
t.Fatalf("test precondition: want > 600 tokens, got %d", estimateContextTokens(history))
|
|
}
|
|
if _, err := l.Run(context.Background(), "sess", history); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(ad.reqs) == 0 {
|
|
t.Fatal("no request captured")
|
|
}
|
|
found := false
|
|
for _, m := range ad.reqs[0].Messages {
|
|
if m.Role == model.RoleSystem && strings.HasPrefix(messageText(m), summaryMarker) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected a compaction summary in the first request, got %+v", ad.reqs[0].Messages)
|
|
}
|
|
}
|