package agent import ( "context" "errors" "io" "strings" "testing" "unicode/utf8" "git.gopher.ink/mirrors2/AutoZero/llm" ) // 摘要请求自身的预算。没有它,"压缩"这件事会在上下文最大的时候恰好失效: // 要摘要的那份历史有多大,摘要请求就有多大,于是它和真实调用一起被网关挡下—— // 用户看到的是"空回复/400",实际是上下文再也降不下来了。 func manyMsgs(n int) []llm.Message { out := make([]llm.Message, 0, n) for i := 0; i < n; i++ { role := llm.RoleUser if i%3 == 1 { role = llm.RoleAssistant } else if i%3 == 2 { role = llm.RoleTool } out = append(out, llm.Message{Role: role, Content: strings.Repeat("中", 40) + itoa(i)}) } return out } func itoa(i int) string { if i == 0 { return "0" } var b []byte for ; i > 0; i /= 10 { b = append([]byte{byte('0' + i%10)}, b...) } return string(b) } // TestSerializeForSummaryBudget:超预算时保头 + 保尾 + 中间留一行说明。 // 保留第一条是因为"用户最初要什么"正是交接文档最不能丢的一句; // 留一行说明而不是悄悄少说一段,否则摘要看起来像覆盖了全程。 func TestSerializeForSummaryBudget(t *testing.T) { msgs := manyMsgs(40) const budget = 4000 got := serializeForSummary(msgs, 8000, budget) if len(got) > budget { t.Errorf("输出 %d 字节,超出预算 %d", len(got), budget) } if !strings.Contains(got, "0\n") || !strings.Contains(got, msgs[0].Content) { t.Error("第一条(原始诉求)必须保留") } if !strings.Contains(got, msgs[len(msgs)-1].Content) { t.Error("最近一条必须保留") } if !strings.Contains(got, "因预算省略") { t.Error("被略过的中间段要留一行说明,不能静默") } if !utf8.ValidString(got) { t.Error("按字节裁剪切坏了中文") } // 预算为 0 = 不限(旧行为),未配置窗口的入口不该被换算改变。 full := serializeForSummary(msgs, 8000, 0) if strings.Contains(full, "因预算省略") || !strings.Contains(full, msgs[7].Content) { t.Error("totalBudget<=0 时应完整平铺") } } // TestAutocompactShrinksAfterSizeFailure:摘要请求太大被拒时,缩小预算再试一次。 // 这是单向门的自救路径:第一次发不出去,减半就该发得出去。 func TestAutocompactShrinksAfterSizeFailure(t *testing.T) { const limit = 3000 var sizes []int a, _ := newTestAgent(t) a.Opt.Stderr = io.Discard a.ContextWindow, a.CompactAt = 4000, 0.85 // 预算 = 阈值的一半 ≈ 5100 字节 a.Provider = &scriptedProvider{fn: func(_ int, _ string, msgs []llm.Message, _ []llm.ToolDef) (llm.Message, error) { n := len(msgs[0].Content) sizes = append(sizes, n) if n > limit { return llm.Message{}, errors.New("HTTP 400: request too large") } return llm.Message{Role: llm.RoleAssistant, Content: "ok"}, nil }} out, err := a.autocompact(context.Background(), manyMsgs(60)) if err != nil { t.Fatalf("缩小预算后该成功:%v(各次输入 %v)", err, sizes) } if len(sizes) < 2 { t.Fatalf("只试了一次(%v),没走缩小重试", sizes) } if sizes[0] <= sizes[1] { t.Errorf("第二次的输入该更小:%d → %d", sizes[0], sizes[1]) } if len(out) == 0 || !strings.Contains(out[0].Content, "ok") { t.Errorf("摘要没接上:%+v", out[0]) } } // TestAutocompactGivesUpOncePerTurn:全都失败时,本回合只撞一次——不记这个标记, // 一次 30 步的回合就是 30 次注定失败的摘要调用(慢、烧钱、且对用户完全静默)。 // 失败同时必须经 OnCompact 报出去(Err 非空),否则用户只会看到后面的网关错误。 func TestAutocompactGivesUpOncePerTurn(t *testing.T) { a, sess := newTestAgent(t) a.Opt.Stderr = io.Discard a.ContextWindow, a.CompactAt, a.MinCompactTokens = 1000, 0.5, 1 calls := 0 a.Provider = &scriptedProvider{fn: func(_ int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) { calls++ return llm.Message{}, errors.New("HTTP 400: request too large") }} var events []CompactionResult a.Opt.OnCompact = func(r CompactionResult) { events = append(events, r) } for i := 0; i < 6; i++ { sess.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("经", 200)}) if _, err := a.contextView(context.Background()); err != nil { t.Fatal(err) } } if calls != summaryRetries { t.Errorf("摘要调用 %d 次,want %d(本回合放弃后不该再撞)", calls, summaryRetries) } if !a.compactGaveUp { t.Error("compactGaveUp 没置上") } if len(events) != 1 || events[0].Err == "" { t.Fatalf("失败要报给 UI 一次,实际 %+v", events) } // 下一回合(Run 开头清零)该再给一次机会。 a.compactGaveUp = false if _, err := a.contextView(context.Background()); err != nil { t.Fatal(err) } if calls != summaryRetries*2 { t.Errorf("清零后应再试一轮:%d", calls) } }