package main import ( "fmt" "os" "path/filepath" "strings" "testing" "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" ) // 本文件锁定切片 G2b:把输入护栏上限从「1 份附件」对齐到「**N 份**附件」。 // // G2(51c1672)修的是「单份大附件被拦死」,残留缺口是**多附件**: // // - 护栏量的是整段装配请求(字符数),而注入预算是**单份**的: // 1M 窗口下每份注入 349525 字符、上限 699050 字符 ⇒ **2 份**满额注入就顶到上限, // 再加系统提示词/历史即被拦成 guardrail_input,5 份(单回合上限)必被拦; // - 根因是两套换算:上限按 1 char ≈ 1 token 兑,注入按 loop.CharsPerToken(4)兑。 // // 修法:上限 = max(guardrailMinInputChars, 推导预算 token 数 × loop.CharsPerToken), // 关系不变量升级为「maxChatDocumentsPerTurn 份 × 单份注入 ≤ 输入上限」,破了就 WARN。 // TestGuardrailWiring_TwoFullAttachmentsPassInputGuard 是切片 G2b 对准缺口的回归: // **2 份**各自撞满注入预算的附件(+ 系统提示词/历史)**不得**再以 guardrail_input // 结束。G2 之后的上限是 699050 字符(预算 token 数按 1:1 兑),而 2 份满额注入恰好 // 就是 699050 字符 ⇒ 加上装配的固定开销必然超限,这正是主人实测的「多附件回合被拦死」。 func TestGuardrailWiring_TwoFullAttachmentsPassInputGuard(t *testing.T) { t.Parallel() turn := runAttachmentTurn(t, 2, 0) // ① 前提自证:这一回合真的带了 2 份附件,且每份都撞满注入预算(349525 字符)。 if turn.documents != 2 { t.Fatalf("本回合注入的附件份数 = %d, want 2", turn.documents) } if turn.perDocInject != 349525 { t.Fatalf("每份注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)", turn.perDocInject) } // ② bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。 if turn.result.StopReason == agent.StopReasonGuardrailInput { t.Fatalf("2 份大附件回合仍以 guardrail_input 结束(G2b 要修的就是它):%q", turn.result.Text) } if turn.modelCalls != 1 { t.Fatalf("2 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls) } // ③ 缺口自证:装配后的请求**超过**了 G2 时代的上限(= 推导预算 token 数), // 也就是「2 份满额注入就顶到上限」—— 上面那条断言因此不是碰巧成立。 if turn.assembledChars <= turn.g2Limit { t.Fatalf("装配后的请求只有 %d 字符(≤ G2 时代的上限 %d),复现不到多附件缺口", turn.assembledChars, turn.g2Limit) } // ④ 每份的注入事实如实回传(正文都比预算长 ⇒ 都该标 truncated + 续读游标)。 for i, ref := range turn.result.Documents { if ref.Chars != 349525 || !ref.Truncated { t.Fatalf("第 %d 份附件的注入记录 = %+v,want 349525 字符且 truncated", i+1, ref) } } } // TestGuardrailWiring_FiveFullAttachmentsPassInputGuard 是同一缺口的**上界**回归: // 5 份(单回合上限)满额注入的回合同样要抵达模型。只说「2 份能过」不够 —— // 本切片的不变量是「N 份全满也能过」,这里就按 N 跑一次真回合。 func TestGuardrailWiring_FiveFullAttachmentsPassInputGuard(t *testing.T) { t.Parallel() turn := runAttachmentTurn(t, maxChatDocumentsPerTurn, 0) if turn.documents != maxChatDocumentsPerTurn { t.Fatalf("本回合注入的附件份数 = %d, want %d", turn.documents, maxChatDocumentsPerTurn) } if turn.result.StopReason == agent.StopReasonGuardrailInput { t.Fatalf("%d 份大附件回合仍以 guardrail_input 结束:%q", maxChatDocumentsPerTurn, turn.result.Text) } if turn.modelCalls != 1 { t.Fatalf("%d 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", maxChatDocumentsPerTurn, turn.modelCalls) } // 装配总量必须落在生效上限之内 —— 这条既是「没被拦」的解释,也是不变量的实证。 if turn.assembledChars > turn.inputLimit { t.Fatalf("装配后的请求 %d 字符 > 生效输入上限 %d", turn.assembledChars, turn.inputLimit) } } // TestGuardrailInputLimit_CoversAllAttachmentsOfATurn 钉住**N 份**不变量本身: // 派生态下「单回合全部附件的注入预算合计 ≤ 输入上限」必须成立(且不记 WARN), // 而把上限压到合计之下必须有一条看得懂是**多附件**问题的 WARN。 // // maxChatDocumentsPerTurn = 5 的出处:单回合附件数上限的**真源**是 // documents.MaxPerMessage(网关侧以 `maxChatDocuments = documents.MaxPerMessage` // 引用它),design/24 §6.1、§9.4-3 的定稿值。本切片**不跨包引用** —— documents 正 // 被并行切片占用,而这里只需要一个数;因此取同值并在下面断言它仍是 5,值一旦漂移 // 本用例即红。待并行切片落定后统一为一个共享常量(届时这两处都改为引用它)。 func TestGuardrailInputLimit_CoversAllAttachmentsOfATurn(t *testing.T) { t.Parallel() if maxChatDocumentsPerTurn != 5 { t.Fatalf("单回合附件数上限 = %d, want 5(documents.MaxPerMessage 的定稿值)", maxChatDocumentsPerTurn) } cases := []struct { name string window int wantDoc int wantCap int }{ // 1M 窗口(声明了窗口的生产部署):预算 699050 token ⇒ 单份 349525、上限 2796200。 {name: "1M 窗口", window: 1048576, wantDoc: 349525, wantCap: 2796200}, // 未声明窗口的回落部署(131072):预算 87381 token ⇒ 单份落回 64K、上限 349524 // (= 87381 × 4 > 256 Ki)。旧的 1:1 兑法在这里只给 262144,5 份 64K 合计 327680 // 就会被拦 —— 缺口在回落部署上同样存在,本切片一并修掉。 {name: "未声明窗口的回落部署", window: 0, wantDoc: 64 * 1024, wantCap: 87381 * loop.CharsPerToken}, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, c.window, 0)} budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")) if budget.DocumentInjectChars != c.wantDoc { t.Fatalf("单份注入预算 = %d, want %d(推导链变了,本用例的前提需复核)", budget.DocumentInjectChars, c.wantDoc) } log, buf := capturingLog() limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn) if limit != c.wantCap { t.Fatalf("输入上限 = %d, want %d(= 推导预算 token 数 × %d)", limit, c.wantCap, loop.CharsPerToken) } if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit { t.Fatalf("N 份不变量被破:%d 份 × %d = %d > 上限 %d", maxChatDocumentsPerTurn, budget.DocumentInjectChars, total, limit) } if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 { t.Fatalf("不变量成立却记了 %d 条 WARN", n) } }) } } // TestEnvExample_GuardrailInputFormulaMatchesCode 钉住模板与实现同步(AGENTS §10 铁律): // 该键在 .env.example 里必须**逐字一致**,且注释里的算例必须与代码里的常量现算结果 // 相同 —— 常量或公式漂移时本用例红,照模板部署的新实例才不会漏配/误解口径。 func TestEnvExample_GuardrailInputFormulaMatchesCode(t *testing.T) { t.Parallel() raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) if err != nil { t.Fatalf("读 .env.example: %v", err) } template := string(raw) // 键名逐字一致(从常量拼出来,重命名时本用例即红)。 if key := "# " + config.GuardrailMaxInputCharsEnv + "="; !strings.Contains(template, key) { t.Fatalf(".env.example 里找不到 %q(键名必须与代码逐字一致)", key) } // 注释里的两个算例都由代码现算,模板与实现不许各说各话。 // 1M 窗口 ⇒ 上限 = 699050 × CharsPerToken;5 份满额注入合计 = 单份注入 × 5。 oneMegTokens := 699050 needles := []string{ fmt.Sprintf("%d", oneMegTokens*loop.CharsPerToken), fmt.Sprintf("%d", loop.DocumentInjectCharsFor(oneMegTokens)*maxChatDocumentsPerTurn), fmt.Sprintf("%d", guardrailMinInputChars), } for _, needle := range needles { if !strings.Contains(template, needle) { t.Fatalf(".env.example 的说明里缺少现算值 %q(模板没跟上代码口径)", needle) } } // 口径本身要写清是**多附件**问题,否则部署方照旧按「单份」理解。 for _, needle := range []string{"多附件", "CharsPerToken"} { if !strings.Contains(template, needle) { t.Fatalf(".env.example 的说明里缺少 %q(没写清多附件口径)", needle) } } }