llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
179 lines
7.0 KiB
Go
179 lines
7.0 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||
)
|
||
|
||
// 请求过大自救。网关的 token 计数与我们的 bytes/3 估算必然有偏差,真撞墙时拿到的
|
||
// 往往是一句 400(param=messages)或一个空补全——按字面意思处理就是把错误丢回给用户,
|
||
// 而正确动作是先把上下文压下来,再重发这一次。
|
||
|
||
// sizeProvider 记录摘要调用与真实调用各发生了几次。
|
||
type sizeProvider struct {
|
||
mu sync.Mutex
|
||
real int
|
||
compact int
|
||
sizes []int // 每次真实调用发出去的消息字节数(断言重发用的是压缩后的视图)
|
||
failReal int // 前 N 次真实调用返回这个错误
|
||
err error
|
||
reply string
|
||
}
|
||
|
||
func (p *sizeProvider) Stream(_ context.Context, system string, msgs []llm.Message, _ []llm.ToolDef,
|
||
_ llm.StreamOptions, _ func(llm.StreamDelta)) (llm.Message, error) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if strings.HasPrefix(system, "You are a context compaction") {
|
||
p.compact++
|
||
return llm.Message{Role: llm.RoleAssistant, Content: "<summary>压缩后的交接</summary>"}, nil
|
||
}
|
||
p.real++
|
||
if b, err := json.Marshal(msgs); err == nil {
|
||
p.sizes = append(p.sizes, len(b))
|
||
}
|
||
if p.err != nil && p.real <= p.failReal {
|
||
return llm.Message{}, p.err
|
||
}
|
||
return llm.Message{Role: llm.RoleAssistant, Content: p.reply}, nil
|
||
}
|
||
|
||
const badRequest = "openai: HTTP 400: {\"error\":{\"message\":\"inference request is invalid\"," +
|
||
"\"type\":\"invalid_request_error\",\"param\":\"messages\",\"code\":\"400002\"}}"
|
||
|
||
// oversizedAgent 造一个"上下文已经不小、但还没到自动压缩阈值"的回合:
|
||
// 估算 ~7000 tokens,阈值 8500,窗口 10000(六成线 = 6000)。
|
||
// 这样常规压缩不会触发,只有"请求过大自救"这条路会去压。
|
||
func oversizedAgent(t *testing.T, p *sizeProvider) (*Agent, int) {
|
||
t.Helper()
|
||
a, sess := newTestAgent(t)
|
||
a.Provider = p
|
||
a.Opt.Stderr, a.Opt.Stdout = io.Discard, io.Discard
|
||
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 10000, 0.85, 10000
|
||
sess.Append(llm.Message{Role: llm.RoleUser, Content: "把这件事做完"})
|
||
sess.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("a", 21000)})
|
||
view, err := a.contextView(context.Background())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := estimateTokens(view, a.sysNow()); got >= 8500 || got < 6000 {
|
||
t.Fatalf("夹具的上下文占比不对:est=%d,want 6000..8499", got)
|
||
}
|
||
return a, len(view)
|
||
}
|
||
|
||
// TestOversizedRequestCompactsAndResends:400 之后压一次、重发一次,回合正常收尾。
|
||
func TestOversizedRequestCompactsAndResends(t *testing.T) {
|
||
p := &sizeProvider{failReal: 1, err: errors.New(badRequest), reply: "压完就能发出去了"}
|
||
a, _ := oversizedAgent(t, p)
|
||
if err := a.Run(context.Background(), ""); err != nil {
|
||
t.Fatalf("该自救成功而不是失败:%v", err)
|
||
}
|
||
if p.compact != 1 {
|
||
t.Errorf("摘要调用 %d 次,want 1", p.compact)
|
||
}
|
||
if p.real != 2 {
|
||
t.Errorf("真实调用 %d 次,want 2(失败一次 + 压缩后重发一次)", p.real)
|
||
}
|
||
last := a.Session.Messages[len(a.Session.Messages)-1]
|
||
if last.Content != "压完就能发出去了" {
|
||
t.Errorf("最终回复不对:%q", last.Content)
|
||
}
|
||
// 重发必须用压缩后的视图:端到端实测里漏了这一步,等于把同一个 1.4MB 的请求
|
||
// 原样再撞一次墙(第一次单测是绿的,因为假 provider 不看消息内容)。
|
||
if len(p.sizes) != 2 {
|
||
t.Fatalf("真实调用记录 %v,want 2 次", p.sizes)
|
||
}
|
||
if p.sizes[1] >= p.sizes[0] {
|
||
t.Errorf("重发的请求没变小:%d → %d(视图没重取)", p.sizes[0], p.sizes[1])
|
||
}
|
||
}
|
||
|
||
// TestOversizedRetryOnlyOncePerStep:压完还是 400 时不无限自救——一个 step 一次,
|
||
// 之后把原错误如实交出去。
|
||
func TestOversizedRetryOnlyOncePerStep(t *testing.T) {
|
||
p := &sizeProvider{failReal: 99, err: errors.New(badRequest)}
|
||
a, _ := oversizedAgent(t, p)
|
||
err := a.Run(context.Background(), "")
|
||
if err == nil {
|
||
t.Fatal("一直 400 时该失败")
|
||
}
|
||
if !strings.Contains(err.Error(), "HTTP 400") {
|
||
t.Errorf("错误没原样交出:%v", err)
|
||
}
|
||
if p.compact != 1 {
|
||
t.Errorf("摘要调用 %d 次,want 1(同一 step 不该反复自救)", p.compact)
|
||
}
|
||
if p.real != 2 {
|
||
t.Errorf("真实调用 %d 次,want 2", p.real)
|
||
}
|
||
}
|
||
|
||
// TestRequestLooksOversizedNeedsBigContext:判定必须带"上下文本来就大"这个前提。
|
||
// 小上下文下的 400 是请求本身写错了,空补全是网关抖动——去压缩只是白费一次调用。
|
||
func TestRequestLooksOversizedNeedsBigContext(t *testing.T) {
|
||
a, _ := newTestAgent(t)
|
||
a.Opt.Stderr = io.Discard
|
||
a.ContextWindow, a.CompactAt = 10000, 0.85
|
||
big := []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("a", 21000)}}
|
||
small := []llm.Message{{Role: llm.RoleUser, Content: "hi"}}
|
||
medium := []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("a", 12600)}}
|
||
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
view []llm.Message
|
||
want bool
|
||
}{
|
||
{"大上下文 + 400 param=messages", errors.New(badRequest), big, true},
|
||
{"大上下文 + 空补全", llm.ErrEmptyCompletion, big, true},
|
||
{"大上下文 + 坏流", llm.ErrTruncatedStream, big, true},
|
||
{"小上下文 + 400", errors.New(badRequest), small, false},
|
||
{"小上下文 + 空补全", llm.ErrEmptyCompletion, small, false},
|
||
// 把 context_window 按真实模型配大(1M 的模型本该如此)之后,"占窗口六成"
|
||
// 这条线高得够不着;400 是网关在点名说请求本身有问题,必须仍然能自救。
|
||
{"大窗口 + 400(六成线够不着)", errors.New(badRequest), big, true},
|
||
{"刚过地板线 + 400", errors.New(badRequest), medium, true},
|
||
{"大窗口 + 空补全(弱证据要占比)", llm.ErrEmptyCompletion, medium, false},
|
||
{"大上下文 + 权限错误", errors.New("permission denied"), big, false},
|
||
{"大上下文 + 超时", errors.New("context deadline exceeded"), big, false},
|
||
{"没配窗口", errors.New(badRequest), big, false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
switch c.name {
|
||
case "没配窗口":
|
||
a.ContextWindow = 0
|
||
case "大窗口 + 400(六成线够不着)", "大窗口 + 空补全(弱证据要占比)":
|
||
a.ContextWindow = 500000
|
||
default:
|
||
a.ContextWindow = 10000
|
||
}
|
||
if got := a.requestLooksOversized(c.err, c.view); got != c.want {
|
||
t.Errorf("requestLooksOversized = %v, want %v", got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestOversizedRetryClearsGaveUpFlag:上一 step 压缩失败留下的"本回合放弃"不该挡住
|
||
// 这次的自救——那时还没有"请求过大"这个新证据。
|
||
func TestOversizedRetryWorksAfterGaveUp(t *testing.T) {
|
||
p := &sizeProvider{failReal: 1, err: errors.New(badRequest), reply: "压完就能发出去了"}
|
||
a, _ := oversizedAgent(t, p)
|
||
a.compactGaveUp = true // 模拟本回合早前压缩失败过
|
||
if err := a.Run(context.Background(), ""); err != nil {
|
||
t.Fatalf("放弃标记不该挡住新证据下的自救:%v", err)
|
||
}
|
||
if p.compact != 1 {
|
||
t.Errorf("摘要调用 %d 次,want 1", p.compact)
|
||
}
|
||
}
|