package main import ( "bytes" "context" "encoding/json" "log/slog" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3): // // - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导 // budget = min(窗口 × 比例, 窗口 − 输出保留); // - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭); // - 窗口未声明时**恰好一条** WARN; // - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条 // 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。 // loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/ // compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。 const loopCompactSummaryMarker = "[前文摘要 · history summary]" // compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。 type compactProbeAdapter struct{ reqs []model.Request } func (a *compactProbeAdapter) Name() string { return "compact-probe" } func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) { return model.Response{Content: "好"}, nil } func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { a.reqs = append(a.reqs, req) if on != nil { on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"}) } return model.Response{Content: "好"}, nil } // longConversation 造一段「比小预算长得多、单看每条都很正常」的对话: // 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。 func longConversation() []model.Message { nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"} filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17) msgs := make([]model.Message, 0, 2*len(nums)) for _, n := range nums { msgs = append(msgs, model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler), model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler), ) } return msgs } // runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致: // MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化 // compactor),返回模型实际看到的请求。 func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request { t.Helper() adapter := &compactProbeAdapter{} ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{ Agent: ag, MaxContextTokens: budget, OnStream: func(model.StreamEvent) {}, }) env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"} ctx := loop.WithSessionEnv(context.Background(), env) if _, err := l.Run(ctx, "web", msgs); err != nil { t.Fatalf("Run: %v", err) } if len(adapter.reqs) == 0 { t.Fatal("没有捕获到任何模型请求") } return adapter.reqs[len(adapter.reqs)-1] } // summaryMessages 返回请求里所有压缩摘要消息的下标。 func summaryMessages(req model.Request) []int { var idx []int for i, m := range req.Messages { if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) { idx = append(idx, i) } } return idx } // loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。 func loopText(m model.Message) string { var b strings.Builder for _, c := range m.Content { if c.Type == model.ContentTypeText { b.WriteString(c.Text) } } return b.String() } // capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN // (「窗口未声明恰好一条」),文本子串数不出来。 func capturingLog() (*slog.Logger, *bytes.Buffer) { buf := &bytes.Buffer{} return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf } // logRecords 把缓冲里的 JSON 记录逐条解出来。 func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any { t.Helper() out := []map[string]any{} for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { if strings.TrimSpace(line) == "" { continue } rec := map[string]any{} if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line) } out = append(out, rec) } return out } // countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。 func countRecords(records []map[string]any, level, substr string) int { n := 0 for _, rec := range records { msg, _ := rec["msg"].(string) if level != "" && rec["level"] != level { continue } if strings.Contains(msg, substr) { n++ } } return n } // envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时 // LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。 func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM { t.Helper() return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{ Provider: "openai", BaseURL: "https://api.example/v1", Model: "test-model", ContextWindow: window, }, testLog()) } // modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级 // contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。 func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM { t.Helper() path := filepath.Join(t.TempDir(), "models.json") raw, err := json.Marshal([]map[string]any{{ "id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1", "apiKey": "sk-test", "model": "test-model", "contextWindow": window, "maxTokens": maxTokens, }}) if err != nil { t.Fatalf("marshal models.json: %v", err) } if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatalf("write models.json: %v", err) } return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog()) } // TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自 // **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。 func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) { t.Parallel() envPath := filepath.Join(t.TempDir(), "absent.env") log, buf := capturingLog() a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)} got := a.contextBudget(envPath) if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived { t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget) } // 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。 if got.DocumentInjectChars <= 64*1024 { t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars) } if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 { t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n) } // 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。 a.llm = modelsWindowStack(t, 1048576, 384*1024) got = a.contextBudget(envPath) if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 { t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget) } } // TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」: // 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。 func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) { t.Parallel() envPath := filepath.Join(t.TempDir(), "absent.env") log, buf := capturingLog() a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)} got := a.contextBudget(envPath) if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared { t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false", got.Budget, config.DefaultModelContextWindow) } if got.Budget.Tokens != 87381 { t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens) } records := logRecords(t, buf) if n := countRecords(records, "WARN", "not declared"); n != 1 { t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records) } // WARN 必须点出两个声明入口,否则主人不知道该改哪儿。 warn := "" for _, rec := range records { if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") { warn = msg if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" { t.Fatalf("WARN 没点出声明入口: %v", rec) } } } if warn == "" { t.Fatal("没有找到窗口未声明的 WARN") } } // TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、 // 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。 func TestContextBudget_OverrideAndOff(t *testing.T) { t.Parallel() envPath := filepath.Join(t.TempDir(), "absent.env") log, buf := capturingLog() a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)} a.cfg.Context.MaxTokens = 600 if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride { t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget) } if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 { t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n) } for _, override := range []int{0, -1} { a.cfg.Context.MaxTokens = override a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开) got := a.contextBudget(envPath) if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff { t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget) } } } // TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带 // 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。 func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, "test.env") if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil { t.Fatalf("写 .env: %v", err) } log, buf := capturingLog() a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)} got := a.contextBudget(envPath) if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 { t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget) } records := logRecords(t, buf) if n := countRecords(records, "WARN", "ratio invalid"); n != 1 { t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records) } // 合法比例真的生效:1/2 × 1M = 524288。 if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil { t.Fatalf("写 .env: %v", err) } if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 { t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens) } } // TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先: // DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。 func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)} a.cfg.Context.DocumentMaxInjectChars = 4096 got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")) if got.DocumentInjectChars != 4096 { t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars) } if got.Budget.Tokens != 699050 { t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens) } } // TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与 // 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。 func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) { t.Parallel() log, buf := capturingLog() a := &app{log: log, cfg: &config.Config{}} a.cfg.Context.MaxTokens = config.MinContextOutputReserve a.cfg.Context.MaxTokensSet = true if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve { t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve) } if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 { t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n) } } // TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①: // 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。 func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}} a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量 msgs := longConversation() req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs) summaries := summaryMessages(req) if len(summaries) != 1 { t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages)) } if summaries[0] != 1 { t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0]) } if len(req.Messages) >= len(msgs)+1 { t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs)) } // 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。 all := "" for _, m := range req.Messages { all += loopText(m) } if !strings.Contains(all, "第十二件事") { t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息") } } // TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0 // (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求, // 且没有任何摘要消息。 func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}} a.cfg.Context.MaxTokens = 0 a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导) msgs := longConversation() req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs) if len(req.Messages) != len(msgs)+1 { t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1) } if summaries := summaryMessages(req); len(summaries) != 0 { t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries) } } // TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。 // // 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、 // 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」 // 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、 // 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。 func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) { t.Parallel() src, err := os.ReadFile("wire_runtime.go") if err != nil { t.Fatalf("read wire_runtime.go: %v", err) } for _, want := range []string{ "a.buildGuardrailPipeline()", "a.contextBudget(envPath)", "MaxContextTokens: ctxBudget.Budget.Tokens", "DocumentMaxInjectChars: ctxBudget.DocumentInjectChars", } { if !strings.Contains(string(src), want) { t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want) } } } // TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算 // 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动, // 而不是别的什么副作用。 func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) { t.Parallel() msgs := longConversation() req := runWithBudget(t, 1<<30, msgs) if len(req.Messages) != len(msgs)+1 { t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1) } if summaries := summaryMessages(req); len(summaries) != 0 { t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries) } }