package main import ( "context" "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/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件锁定切片 G2 / G2b 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死): // // - 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken)(与附件注入预算 // **同一套换算**;G2 按 1 字符 ≈ 1 token 兑,于是 2 份满额注入的附件就顶到上限, // G2b 统一到注入侧那个常量); // - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落); // - 关系不变量「单回合全部附件的注入预算合计 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**; // - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束; // - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。 // // 多附件的回归与「N 份」不变量的用例在 wire_guardrail_multidoc_test.go。 // // 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文 // 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。 // largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug), // 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径, // 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。 const largeDocChars = 400000 // TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动): // 输入上限 = max(下限, 推导预算 token 数 × CharsPerToken) —— 小预算落回下限, // 大窗口随预算抬高(换算与附件注入预算同一个常量,否则多附件回合会被拦死)。 func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) { t.Parallel() cases := []struct { name string tokens int want int }{ {"1M 窗口推导出的预算(699050 token)⇒ 按 CharsPerToken 上抬", 699050, 2796200}, {"预算正好等于下限 ÷ CharsPerToken ⇒ 下限", guardrailMinInputChars / loop.CharsPerToken, guardrailMinInputChars}, {"小预算(600 token)⇒ 回落下限", 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, maxChatDocumentsPerTurn); 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, maxChatDocumentsPerTurn); 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, maxChatDocumentsPerTurn); got != 2796200 { t.Fatalf("非法覆盖后的回落 = %d, want 派生值 2796200", 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(2796200) { t.Fatalf("WARN 没带原始取值与回落值: %v", rec) } } } if !found { t.Fatal("没有找到非法覆盖的 WARN") } } // TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量 // 「单回合全部附件的注入预算合计 ≤ 输入护栏上限」:对 1M 窗口的推导值成立 // (maxDocuments 份 × 单份注入),且人为把上限压到之下 ⇒ **记一条 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, maxChatDocumentsPerTurn) if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit { t.Fatalf("关系不变量被破:%d 份注入合计 %d > 输入上限 %d", maxChatDocumentsPerTurn, total, limit) } if limit != 2796200 { t.Fatalf("1M 窗口下的输入上限 = %d, want 2796200(= 推导预算 token 数 × %d)", limit, loop.CharsPerToken) } if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 { t.Fatalf("不变量成立却记了 %d 条 WARN", n) } // 人为破坏:把上限压到「全部附件注入合计」之下 ⇒ 必须有一条 WARN 明说后果, // 且带上 N 份 / 单份 / 合计三项取值(否则没人看得出这是多附件问题)。 log, buf = capturingLog() if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn); 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 !strings.Contains(msg, "multi-attachment") { t.Fatalf("WARN 文案看不出是多附件问题: %q", msg) } if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) || rec["guardrail_max_input_chars"] != float64(4096) || rec["max_documents"] != float64(maxChatDocumentsPerTurn) || rec["document_inject_total_chars"] != float64(budget.DocumentInjectChars*maxChatDocumentsPerTurn) { t.Fatalf("WARN 没写清 N 份与两端取值: %v", rec) } if hint, _ := rec["hint"].(string); hint == "" { t.Fatalf("WARN 没给出处置提示: %v", rec) } } } if !found { t.Fatal("没有找到关系不变量被破的 WARN") } } // largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。 type attachmentTurn struct { // result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。 result *loop.Result // modelCalls 是模型被调用的次数(= 捕获到的请求数)。 modelCalls int // assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。 assembledChars int // perDocInject 是本回合如实记录的**每份**附件注入字符数 // (Result.Documents[i].Chars;夹具里各份等长)。 perDocInject int // documents 是 Result.Documents 的条数(本回合真的注入了几份)。 documents int // g2Limit 是 G2 时代的上限(推导预算 token 数按 1 字符 ≈ 1 token 兑成字符)。 // 它让回归用例能自证「装配结果确实落在 G2/G2b 的缺口上」:装配总量超过了它。 g2Limit int // inputLimit 是本回合实际生效的输入上限(a.guardrailInputLimit 的产物), // 供用例实证「装配总量落在上限之内」。 inputLimit int } // runAttachmentTurn 跑一个回合:1M 窗口的推导预算(单份注入 349525 字符)+ docs 份 // largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时 // 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。 // // 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的 // loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。 // 唯一被单独钉住的是「窗口 = 1M」。 func runAttachmentTurn(t *testing.T, docs, inputLimitOverride int) attachmentTurn { t.Helper() if docs < 1 { t.Fatalf("docs = %d, want ≥ 1(夹具至少要一份附件)", docs) } 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) refs := make([]model.Document, 0, docs) for i := 0; i < docs; i++ { name := attachmentName(i) if err := os.WriteFile(filepath.Join(uploads, name), []byte(body), 0o600); err != nil { t.Fatalf("写上传件: %v", err) } refs = append(refs, model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))}) } 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) {}, }) res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{documentTurnMessage("看看这些附件", refs)}) if err != nil { t.Fatalf("Run: %v", err) } turn := attachmentTurn{ result: res, modelCalls: len(adapter.reqs), documents: len(res.Documents), g2Limit: budget.Budget.Tokens, inputLimit: a.guardrailInputLimit(budget), } if len(res.Documents) > 0 { turn.perDocInject = 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 } // attachmentName 造第 i 份附件的服务端存储名(32 位十六进制 + .txt,与 // wire_document_budget_test.go 的 docE2EName 同一形态)。解析器按名取件, // 因此多份附件必须各有一个名字。 func attachmentName(i int) string { return fmt.Sprintf("%032x.txt", i+1) } // documentTurnMessage 造一条携带 N 份文档引用的用户消息:model 包只提供单份的 // 构造器(DocumentMessage),多份按同一形状续部件 —— 这正是「一个回合最多 // maxChatDocumentsPerTurn 份附件」的落库形状。 func documentTurnMessage(text string, refs []model.Document) model.Message { msg := model.DocumentMessage(model.RoleUser, text, refs[0]) for _, ref := range refs[1:] { msg.Content = append(msg.Content, model.Content{ Type: model.ContentTypeDocument, DocumentName: ref.Name, DocumentMIME: ref.MIME, DocumentURL: ref.URL, DocumentSize: ref.Size, }) } return msg } // 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 := runAttachmentTurn(t, 1, 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.perDocInject != 349525 { t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)", turn.perDocInject) } 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 := runAttachmentTurn(t, 1, 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.perDocInject != 349525 { t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.perDocInject) } } // TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化 // **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「N 份注入 ≤ 上限」 // 不可能靠两边同源凑出来 —— 两端确实各自有出处。 func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) { t.Parallel() log, _ := capturingLog() if got := deriveGuardrailInputLimit(log, 0, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 { t.Fatalf("注入预算为 0 时的输入上限 = %d, want 2796200", got) } if got := deriveGuardrailInputLimit(log, 0, 699050, 349525, maxChatDocumentsPerTurn); got != 2796200 { t.Fatalf("注入预算 349525 时的输入上限 = %d, want 2796200(不受注入预算影响)", got) } }