- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
199 lines
7.4 KiB
Go
199 lines
7.4 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
)
|
||
|
||
// 本文件是切片 T1 §7.2 的**端到端**成对契约(S4 实测断裂的正面用例):
|
||
// 装配期注入预算与文档读侧共用同一套字符坐标系,因此清单行给的续读游标真的能
|
||
// 被 document-read 用起来。预算推导本身的用例在 wire_context_test.go。
|
||
|
||
// docE2EName 是合法存储名形态(32 位十六进制 + .txt)。
|
||
const docE2EName = "0123456789abcdef0123456789abcdef.txt"
|
||
|
||
// docBudgetFixture 是一份端到端夹具:隔离数据目录 + 已落盘的正文 + 生产解析器 +
|
||
// 生产 document-read 工具 + 一个能捕获模型请求的循环。
|
||
type docBudgetFixture struct {
|
||
cfg *config.Config
|
||
body string
|
||
ad *compactProbeAdapter
|
||
read sdk.Tool
|
||
cursor int
|
||
}
|
||
|
||
// newDocBudgetFixture 造夹具并跑**一次真实的回合**,返回模型看到的清单游标。
|
||
func newDocBudgetFixture(t *testing.T, injectBudget int) *docBudgetFixture {
|
||
t.Helper()
|
||
dataDir := t.TempDir()
|
||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||
t.Fatalf("建上传目录: %v", err)
|
||
}
|
||
fx := &docBudgetFixture{cfg: &config.Config{DataDir: dataDir}, body: strings.Repeat("abcdefghij", 20000)}
|
||
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(fx.body), 0o600); err != nil {
|
||
t.Fatalf("写上传件: %v", err)
|
||
}
|
||
|
||
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,
|
||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||
DocumentMaxInjectChars: injectBudget,
|
||
OnStream: func(model.StreamEvent) {},
|
||
})
|
||
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
|
||
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(fx.body))})
|
||
if _, err := l.Run(context.Background(), "sess-doc", []model.Message{msg}); err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
if len(adapter.reqs) == 0 {
|
||
t.Fatal("没有捕获到模型请求")
|
||
}
|
||
fx.ad = adapter
|
||
|
||
tools, err := iotools.New(fx.cfg)
|
||
if err != nil {
|
||
t.Fatalf("iotools.New: %v", err)
|
||
}
|
||
fx.read = documentReadToolByName(t, tools)
|
||
|
||
req := adapter.reqs[len(adapter.reqs)-1]
|
||
prompt := loopText(req.Messages[len(req.Messages)-1])
|
||
fx.cursor, _ = cursorFromManifest(t, prompt)
|
||
return fx
|
||
}
|
||
|
||
// TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget 是切片 T1 §7.2 的
|
||
// **端到端**成对契约(S4 实测断裂的正面用例):正文远大于注入预算时
|
||
//
|
||
// ①清单行带 truncated + 续读游标(游标 = 实际注入的字符数);
|
||
// ②把清单行里那个游标原样交给 `document-read`,拿到的是注入预算**之外**的正文。
|
||
//
|
||
// 三个角色都是生产实现:装配期是真实的 loop.resolveDocuments,sidecar 由真实的
|
||
// documents.Resolver 写出,读取走真实的 document-read 工具。唯一被单独钉住的是
|
||
// 「注入预算 = 1000 字符」,那正是本切片新增的可配置项。
|
||
func TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget(t *testing.T) {
|
||
t.Parallel()
|
||
const injectBudget = 1000
|
||
fx := newDocBudgetFixture(t, injectBudget)
|
||
|
||
if fx.cursor != injectBudget {
|
||
t.Fatalf("清单游标 = %d, want %d(= 实际注入的字符数)", fx.cursor, injectBudget)
|
||
}
|
||
prompt := loopText(fx.ad.reqs[len(fx.ad.reqs)-1].Messages[len(fx.ad.reqs[len(fx.ad.reqs)-1].Messages)-1])
|
||
_, injected := cursorFromManifest(t, prompt)
|
||
if injected != fx.body[:injectBudget] {
|
||
t.Fatalf("注入正文 = %d 字符,want 正文的前 %d 字符", len(injected), injectBudget)
|
||
}
|
||
|
||
// ② 照游标续读:拿到的是注入预算之外的正文(旧实现这里恒为空 —— 断裂所在)。
|
||
first := callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": fx.cursor})
|
||
if first["offset"] != float64(fx.cursor) {
|
||
t.Fatalf("续读 offset = %v, want %d", first["offset"], fx.cursor)
|
||
}
|
||
text, _ := first["text"].(string)
|
||
if text == "" {
|
||
t.Fatal("按清单游标续读读到了空正文 —— 清单给的承诺是假的")
|
||
}
|
||
if want := fx.body[fx.cursor : fx.cursor+len(text)]; text != want {
|
||
t.Fatalf("续读正文不是注入预算之外的那一段:\n got %q\nwant %q", text, want)
|
||
}
|
||
|
||
// 一路翻页到底:拼起来必须正好是注入预算之外的全文(两边同一坐标系)。
|
||
joined := text
|
||
offset := fx.cursor + len(text)
|
||
for i := 0; i < 100 && first["truncated"] == true; i++ {
|
||
first = callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": offset})
|
||
page, _ := first["text"].(string)
|
||
joined += page
|
||
offset += len(page)
|
||
}
|
||
if joined != fx.body[fx.cursor:] {
|
||
t.Fatalf("分页续读拼出 %d 字符,want %d(注入预算之外的全文)", len(joined), len(fx.body)-fx.cursor)
|
||
}
|
||
if total, _ := first["totalChars"].(float64); int(total) != len(fx.body) {
|
||
t.Fatalf("totalChars = %v, want 全文 %d 字符", first["totalChars"], len(fx.body))
|
||
}
|
||
}
|
||
|
||
// cursorFromManifest 从模型看到的上下文里取出清单行与续读游标(offset=N)。它刻意
|
||
// **解析文本**而不是读内部字段:模型能看到的就只有这一行,游标能不能被模型用起来,
|
||
// 正是这条契约要证明的事。返回游标与注入块(清单行 + 换行 + 注入正文)。
|
||
func cursorFromManifest(t *testing.T, prompt string) (int, string) {
|
||
t.Helper()
|
||
start := strings.Index(prompt, "[附件 ")
|
||
if start < 0 {
|
||
t.Fatalf("上下文里没有清单行:%q", prompt)
|
||
}
|
||
block := prompt[start:]
|
||
line, injected, ok := strings.Cut(block, "]\n")
|
||
if !ok {
|
||
t.Fatalf("清单行没有闭合或没有正文行:%q", block)
|
||
}
|
||
if !strings.Contains(line, "已截断") {
|
||
t.Fatalf("清单行没有如实标注截断:%q", line)
|
||
}
|
||
i := strings.Index(line, "offset=")
|
||
if i < 0 {
|
||
t.Fatalf("清单行没有续读游标:%q", line)
|
||
}
|
||
digits := line[i+len("offset="):]
|
||
end := 0
|
||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||
end++
|
||
}
|
||
n, err := strconv.Atoi(digits[:end])
|
||
if err != nil {
|
||
t.Fatalf("续读游标不是整数:%q", digits[:end])
|
||
}
|
||
return n, injected
|
||
}
|
||
|
||
// documentReadToolByName 在真实工具集里找出 document-read 工具。
|
||
func documentReadToolByName(t *testing.T, tools []sdk.Tool) sdk.Tool {
|
||
t.Helper()
|
||
for _, tl := range tools {
|
||
if tl.Name() == "document-read" {
|
||
return tl
|
||
}
|
||
}
|
||
t.Fatal("工具集里没有 document-read")
|
||
return nil
|
||
}
|
||
|
||
// callDocumentRead 执行一次 document-read 并把返回的 JSON 文本解回 map。
|
||
func callDocumentRead(t *testing.T, tl sdk.Tool, args map[string]any) map[string]any {
|
||
t.Helper()
|
||
raw, err := tl.Execute(context.Background(), args)
|
||
if err != nil {
|
||
t.Fatalf("document-read: %v", err)
|
||
}
|
||
text, ok := raw.(string)
|
||
if !ok {
|
||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||
}
|
||
out := map[string]any{}
|
||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||
}
|
||
return out
|
||
}
|