Files
Cyrene_v2/cmd/cyrene/wire_context_test.go
T
AskaEth 51c1672355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00

427 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.contextBudget(envPath),
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 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(ctxBudget)",
"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)
}
}