fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 真 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 份各撞注入预算的大附件仍可能被拦。
This commit is contained in:
AskaEth committed 2026-10-04 19:15:12 +08:00
1 parent d37d4a899b
commit 51c1672355
10 files changed
+577 -48

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
package main
import (
"context"
"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/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
//
// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
//
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
const largeDocChars = 400000
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokens int
want int
}{
{"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050},
{"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars},
{"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars},
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want {
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
}
})
}
}
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
t.Parallel()
log, buf := capturingLog()
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 {
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
}
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
}
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 {
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
found = true
if rec["env"] != config.GuardrailMaxInputCharsEnv {
t.Fatalf("WARN 没点出配置键: %v", rec)
}
if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) {
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到非法覆盖的 WARN")
}
}
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比
// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
t.Parallel()
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if budget.DocumentInjectChars != 349525 {
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
budget.DocumentInjectChars)
}
log, buf := capturingLog()
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars)
if budget.DocumentInjectChars > limit {
t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit)
}
if limit != 699050 {
t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit)
}
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
}
// 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 {
t.Fatalf("收紧后的上限 = %d, want 4096", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
found = true
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
rec["guardrail_max_input_chars"] != float64(4096) {
t.Fatalf("WARN 没写清两端取值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到关系不变量被破的 WARN")
}
}
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
type largeAttachmentTurn struct {
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
result *loop.Result
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
modelCalls int
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
assembledChars int
// injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。
injectChars int
}
// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+
// 一份 largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
//
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
// 唯一被单独钉住的是「窗口 = 1M」。
func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmentTurn {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
body := strings.Repeat("abcdefghij", largeDocChars/10)
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(body), 0o600); err != nil {
t.Fatalf("写上传件: %v", err)
}
cfg := &config.Config{DataDir: dataDir}
cfg.Guardrail.MaxInputChars = inputLimitOverride
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
guards := a.buildGuardrailPipeline(budget)
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
DocumentMaxInjectChars: budget.DocumentInjectChars,
OnStream: func(model.StreamEvent) {},
})
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(body))})
res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{msg})
if err != nil {
t.Fatalf("Run: %v", err)
}
turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)}
if len(res.Documents) > 0 {
turn.injectChars = res.Documents[0].Chars
}
if len(adapter.reqs) > 0 {
req := adapter.reqs[len(adapter.reqs)-1]
for _, m := range req.Messages {
turn.assembledChars += len(loopText(m))
}
}
return turn
}
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
//
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
// 抵达模型(adapter 被调用一次)并以正常回答结束。
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 0)
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
}
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.injectChars)
}
if turn.assembledChars <= guardrailMinInputChars {
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
turn.assembledChars, guardrailMinInputChars)
}
if !turn.result.Documents[0].Truncated {
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
}
}
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
//
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 1000)
if turn.result.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
}
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
}
if turn.modelCalls != 0 {
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
}
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars)
}
}
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, 699050, 0); got != 699050 {
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 699050", got)
}
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525); got != 699050 {
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got)
}
}