- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
· 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
· 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
· **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
· 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
`internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
232 lines
9.1 KiB
Go
232 lines
9.1 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"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"
|
||
)
|
||
|
||
// 本文件锁定 P0-11:上下文溢出压缩在生产里**真的启用**,且阈值来自
|
||
// CONTEXT_MAX_TOKENS。断言落在「模型实际看到的请求消息」上:
|
||
// 预算小时长对话被压成「一条前导摘要 + 近期尾部」,摘要消息确实进了 prompt;
|
||
// 预算为 0 时行为与接线前逐条一致(一条消息都不少)。
|
||
|
||
// 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 取自 a.contextBudget(),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()
|
||
}
|
||
|
||
// TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault 锁定取值链:
|
||
// 配置层默认 == loop.DefaultMaxContextTokens(两个常量不许漂移),
|
||
// 组合根读的就是配置值(含「0 = 关闭」这条语义)。
|
||
func TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault(t *testing.T) {
|
||
// 环境里若已有 CONTEXT_MAX_TOKENS,配置层会优先用它,默认值断言就不成立;
|
||
// 用 t.Setenv 钉空串也不行(空串会被解析成 0),因此这里显式清掉再还原。
|
||
if prev, had := os.LookupEnv("CONTEXT_MAX_TOKENS"); had {
|
||
_ = os.Unsetenv("CONTEXT_MAX_TOKENS")
|
||
t.Cleanup(func() { _ = os.Setenv("CONTEXT_MAX_TOKENS", prev) })
|
||
}
|
||
t.Setenv("JWT_SECRET", strings.Repeat("k", 40))
|
||
|
||
cfg, err := config.Load(filepath.Join(t.TempDir(), "absent.env"))
|
||
if err != nil {
|
||
t.Fatalf("config.Load: %v", err)
|
||
}
|
||
if cfg.Context.MaxTokens != config.DefaultContextMaxTokens {
|
||
t.Fatalf("CONTEXT_MAX_TOKENS 默认值 = %d,want %d", cfg.Context.MaxTokens, config.DefaultContextMaxTokens)
|
||
}
|
||
if config.DefaultContextMaxTokens != loop.DefaultMaxContextTokens {
|
||
t.Fatalf("默认值与 loop 的常量漂移:%d vs %d",
|
||
config.DefaultContextMaxTokens, loop.DefaultMaxContextTokens)
|
||
}
|
||
if got := (&app{cfg: cfg}).contextBudget(); got != loop.DefaultMaxContextTokens {
|
||
t.Fatalf("contextBudget() = %d,want %d", got, loop.DefaultMaxContextTokens)
|
||
}
|
||
|
||
// 显式取值原样透传;0 = 关闭。
|
||
cfg.Context.MaxTokens = 4096
|
||
if got := (&app{cfg: cfg}).contextBudget(); got != 4096 {
|
||
t.Fatalf("contextBudget() = %d,want 4096", got)
|
||
}
|
||
cfg.Context.MaxTokens = 0
|
||
if got := (&app{cfg: cfg}).contextBudget(); got != 0 {
|
||
t.Fatalf("contextBudget() = %d,want 0(关闭)", got)
|
||
}
|
||
}
|
||
|
||
// 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(), 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{}} // Context.MaxTokens 零值 = 关闭
|
||
msgs := longConversation()
|
||
|
||
req := runWithBudget(t, a.contextBudget(), 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)无法在单元测试里实例化:它要开库、迁移、
|
||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此 P0-5 / P0-11 的
|
||
// 「组合根确实走了这两条缝」只能在源码层钉住:wire_runtime.go 必须调用
|
||
// a.buildGuardrailPipeline(),并且把 MaxContextTokens 取自 a.contextBudget()。
|
||
// 有人把这两行改回空管线 / 不设预算时,这里立刻红。
|
||
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()", "a.contextBudget()"} {
|
||
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)
|
||
}
|
||
}
|