- 真 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 份各撞注入预算的大附件仍可能被拦。
378 lines
14 KiB
Go
378 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"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/persona/loader"
|
||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||
"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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 本文件锁定 P0-10 的**收窄**(design/11 §0 红线):
|
||
//
|
||
// - owner.yaml 的 identityFile(主人身份说明书)是**人格知识**,现在按
|
||
// **会话**注入:只有主人车道拿得到,群聊与陌生人私聊的上下文里一个字
|
||
// 都没有(因此也不会随请求发给模型供应商);
|
||
// - 它不再进静态 system 指令(那会让它跟着角色进入每个会话);
|
||
// - 它绝不参与主人判定 —— 判主只看 actor 元数据 + 白名单。
|
||
|
||
// identityFixture 是写进临时身份说明书的一段正文,含一个「陌生人 id」,
|
||
// 用来证明「文件里写了什么」与「谁被认成主人」毫无关系。
|
||
const identityFixture = "# 身份说明书 · 我的「人」\n\n" +
|
||
"- 称呼:你叫他「亲爱的」。\n" +
|
||
"- 陌生账号 kook:stranger-999 与本文件无关。\n" +
|
||
"- 本说明书只描述人设/关系,不授权任何权限。"
|
||
|
||
// identityProbeAdapter 记下每次请求,使测试能读回模型实际看到的上下文。
|
||
type identityProbeAdapter struct{ reqs []model.Request }
|
||
|
||
func (a *identityProbeAdapter) Name() string { return "identity-probe" }
|
||
|
||
func (a *identityProbeAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||
a.reqs = append(a.reqs, req)
|
||
return model.Response{Content: "好呀♪"}, nil
|
||
}
|
||
|
||
func (a *identityProbeAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||
a.reqs = append(a.reqs, req)
|
||
return model.Response{Content: "好呀♪"}, nil
|
||
}
|
||
|
||
// identityApp 装配一个最小组合根切片:真实默认角色档案 + 给定身份说明书的
|
||
// owner 档案。identityFile 为空串表示「没配」。
|
||
func identityApp(t *testing.T, identityFile string) (*app, *loader.Persona) {
|
||
t.Helper()
|
||
return identityAppLogged(t, identityFile, testLog())
|
||
}
|
||
|
||
// identityAppLogged 同上,但用调用方给的 logger(用于断言日志不静默)。
|
||
func identityAppLogged(t *testing.T, identityFile string, log *slog.Logger) (*app, *loader.Persona) {
|
||
t.Helper()
|
||
|
||
p, err := loader.Load(filepath.Join("..", "..", "configs", "personas", "cyrene.yaml"))
|
||
if err != nil {
|
||
t.Fatalf("loader.Load(cyrene.yaml): %v", err)
|
||
}
|
||
reg := personareg.New()
|
||
if err := reg.RegisterPersona(p); err != nil {
|
||
t.Fatalf("RegisterPersona: %v", err)
|
||
}
|
||
if err := reg.RegisterCharacter(&personareg.Character{
|
||
ID: p.ID, Name: p.Name, Default: true, PersonaID: p.ID,
|
||
}); err != nil {
|
||
t.Fatalf("RegisterCharacter: %v", err)
|
||
}
|
||
|
||
owner := &user.OwnerProfile{
|
||
ID: "owner",
|
||
DisplayName: "叶酱",
|
||
DefaultPersona: p.ID,
|
||
Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "owner-1"}},
|
||
IdentityFile: identityFile,
|
||
}
|
||
a := &app{log: log, personas: reg, owner: owner, cfg: &config.Config{}}
|
||
return a, p
|
||
}
|
||
|
||
// writeIdentity 把 identityFixture 写进临时文件并返回路径。
|
||
func writeIdentity(t *testing.T) string {
|
||
t.Helper()
|
||
path := filepath.Join(t.TempDir(), "owner_identity.md")
|
||
if err := os.WriteFile(path, []byte(identityFixture), 0o600); err != nil {
|
||
t.Fatalf("write identity file: %v", err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// absentIdentityEnvFile 返回一个不存在的 .env 路径,使测试不受仓库 .env 影响。
|
||
func absentIdentityEnvFile(t *testing.T) string {
|
||
t.Helper()
|
||
return filepath.Join(t.TempDir(), "absent.env")
|
||
}
|
||
|
||
// unsetInjectionEnv 真正清掉注入范围开关(不是设成空串 —— 空串会遮住 .env
|
||
// 里的取值),并在测试结束还原。
|
||
func unsetInjectionEnv(t *testing.T) {
|
||
t.Helper()
|
||
key := config.OwnerIdentityInjectionEnv
|
||
prev, had := os.LookupEnv(key)
|
||
if err := os.Unsetenv(key); err != nil {
|
||
t.Fatalf("os.Unsetenv(%s): %v", key, err)
|
||
}
|
||
t.Cleanup(func() {
|
||
if had {
|
||
_ = os.Setenv(key, prev)
|
||
return
|
||
}
|
||
_ = os.Unsetenv(key)
|
||
})
|
||
}
|
||
|
||
// envOwnerTurn / envGroupTurn / envStrangerTurn 与生产路径构造的取值一致
|
||
// (Web/语音/主人私聊 = OwnerEnv,群聊 / 陌生人 = 受限环境)。
|
||
func envOwnerTurn() loop.SessionEnv { return loop.OwnerEnv() }
|
||
|
||
func envGroupTurn() loop.SessionEnv {
|
||
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
|
||
}
|
||
|
||
func envStrangerTurn() loop.SessionEnv {
|
||
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
|
||
}
|
||
|
||
// runIdentityTurn 用**生产解析器**(a.personaKnowledgeFor)驱动一次真实循环,
|
||
// 返回模型实际收到的消息列表。
|
||
func runIdentityTurn(t *testing.T, resolve func(loop.SessionEnv) string, env loop.SessionEnv) []model.Message {
|
||
t.Helper()
|
||
|
||
adapter := &identityProbeAdapter{}
|
||
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, PersonaKnowledgeFor: resolve, MaxRounds: 8})
|
||
if _, err := l.Run(loop.WithSessionEnv(context.Background(), env), "sess",
|
||
[]model.Message{model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?")}); err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
if len(adapter.reqs) == 0 {
|
||
t.Fatal("适配器没有收到任何请求")
|
||
}
|
||
return adapter.reqs[0].Messages
|
||
}
|
||
|
||
// identityMessagesContain 报告消息里是否有任一携带给定文本。
|
||
func identityMessagesContain(messages []model.Message, want string) bool {
|
||
for _, m := range messages {
|
||
for _, part := range m.Content {
|
||
if strings.Contains(part.Text, want) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge 是本收窄的
|
||
// 反证断言:静态 system 指令里**不再**有身份说明书 —— 默认 agent 与角色池
|
||
// 里每个 agent 的指令都逐字节等于人格指令本身。静态注入正是泄漏面
|
||
// (它会跟着角色进入群聊与陌生人私聊),因此它必须消失。
|
||
func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
a, p := identityApp(t, writeIdentity(t))
|
||
base := instructionsFromPersona(p)
|
||
|
||
if got := a.defaultInstructions(); got != base {
|
||
t.Fatalf("默认静态指令被身份说明书污染:\n%q\nwant\n%q", got, base)
|
||
}
|
||
if pooled := a.personaInstructions(p); pooled != base {
|
||
t.Fatalf("角色池静态指令被身份说明书污染:\n%q\nwant\n%q", pooled, base)
|
||
}
|
||
if strings.Contains(a.defaultInstructions(), identityFixture) {
|
||
t.Fatal("静态指令里仍有身份说明书正文 —— 它会被带进每一个会话")
|
||
}
|
||
|
||
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
|
||
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
|
||
if ag.Instructions() != base {
|
||
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentityKnowledge_MissingFileIsFailSoft 是真断言(既有回归):文件
|
||
// 缺失、路径指向目录、未配置都只 WARN,读出来是空串 —— 此时连主人会话也
|
||
// 什么都不注入,输出与「接线前」逐字节一致,绝不报错、绝不阻断对话。
|
||
func TestOwnerIdentityKnowledge_MissingFileIsFailSoft(t *testing.T) {
|
||
t.Parallel()
|
||
unsetInjectionEnv(t)
|
||
|
||
missing := filepath.Join(t.TempDir(), "nope.md")
|
||
for _, tc := range []struct {
|
||
name string
|
||
path string
|
||
}{
|
||
{name: "文件不存在", path: missing},
|
||
{name: "路径是目录", path: t.TempDir()},
|
||
{name: "没配 identityFile", path: ""},
|
||
} {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
a, p := identityApp(t, tc.path)
|
||
if got, want := a.defaultInstructions(), instructionsFromPersona(p); got != want {
|
||
t.Fatalf("身份说明书不可用时静态指令变了:\n%q\nwant\n%q", got, want)
|
||
}
|
||
if a.ownerIdentityKnowledge() != "" {
|
||
t.Fatalf("ownerIdentityKnowledge() = %q,want 空串", a.ownerIdentityKnowledge())
|
||
}
|
||
if block := identityKnowledgeBlock(a.ownerIdentityKnowledge()); block != "" {
|
||
t.Fatalf("没有正文时不该渲染出任何块:%q", block)
|
||
}
|
||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||
if got := resolve(envOwnerTurn()); got != "" {
|
||
t.Fatalf("没有正文时主人会话也被注入了:%q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentityInjection_ScopeIsPerSession 是本任务的验收核心:
|
||
// 默认档(owner_only)下主人会话拿到整块身份说明书,群聊与陌生人私聊拿到
|
||
// 空串 —— 那段知识不进它们的上下文,也就不随请求发给模型供应商。
|
||
func TestOwnerIdentityInjection_ScopeIsPerSession(t *testing.T) {
|
||
t.Parallel()
|
||
unsetInjectionEnv(t)
|
||
|
||
a, _ := identityApp(t, writeIdentity(t))
|
||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||
|
||
owner := resolve(envOwnerTurn())
|
||
if owner == "" || !strings.Contains(owner, identityFixture) {
|
||
t.Fatalf("主人会话没有拿到身份说明书:%q", owner)
|
||
}
|
||
if !strings.HasPrefix(owner, identityKnowledgeHeader) {
|
||
t.Fatalf("主人会话的知识块没有段头:%q", owner)
|
||
}
|
||
for _, tc := range []struct {
|
||
name string
|
||
env loop.SessionEnv
|
||
}{
|
||
{name: "群聊会话", env: envGroupTurn()},
|
||
{name: "陌生人私聊会话", env: envStrangerTurn()},
|
||
} {
|
||
if got := resolve(tc.env); got != "" {
|
||
t.Fatalf("%s 拿到了身份说明书(红线泄漏):%q", tc.name, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentityInjection_AlwaysAndOff 证明开关真的生效:
|
||
// always 档下群聊也有(明确开启的红线例外),off 档下连主人会话也没有。
|
||
func TestOwnerIdentityInjection_AlwaysAndOff(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
mode string
|
||
env loop.SessionEnv
|
||
wantContent bool
|
||
}{
|
||
{name: "always-群聊", mode: config.OwnerIdentityAlways, env: envGroupTurn(), wantContent: true},
|
||
{name: "always-陌生人", mode: config.OwnerIdentityAlways, env: envStrangerTurn(), wantContent: true},
|
||
{name: "off-主人", mode: config.OwnerIdentityOff, env: envOwnerTurn(), wantContent: false},
|
||
{name: "off-群聊", mode: config.OwnerIdentityOff, env: envGroupTurn(), wantContent: false},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
unsetInjectionEnv(t)
|
||
t.Setenv(config.OwnerIdentityInjectionEnv, tc.mode)
|
||
|
||
a, _ := identityApp(t, writeIdentity(t))
|
||
got := a.personaKnowledgeFor(absentIdentityEnvFile(t))(tc.env)
|
||
|
||
if tc.wantContent != strings.Contains(got, identityFixture) {
|
||
t.Fatalf("%s:解析结果 %q,want 含正文 = %v", tc.name, got, tc.wantContent)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentityInjection_IllegalValueIsNotSilent 钉住「非法取值不静默」:
|
||
// 回退到最安全的 owner_only,且日志里带上原始取值与回退档。
|
||
func TestOwnerIdentityInjection_IllegalValueIsNotSilent(t *testing.T) {
|
||
unsetInjectionEnv(t)
|
||
t.Setenv(config.OwnerIdentityInjectionEnv, "everyone")
|
||
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||
a, _ := identityAppLogged(t, writeIdentity(t), log)
|
||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||
|
||
if got := resolve(envGroupTurn()); got != "" {
|
||
t.Fatalf("非法取值下群聊被注入了:%q", got)
|
||
}
|
||
if got := resolve(envOwnerTurn()); !strings.Contains(got, identityFixture) {
|
||
t.Fatalf("非法取值应回退到 owner_only(主人会话仍应有正文):%q", got)
|
||
}
|
||
logged := buf.String()
|
||
for _, want := range []string{config.OwnerIdentityInjectionEnv, "everyone", config.OwnerIdentityOwnerOnly, "WARN"} {
|
||
if !strings.Contains(logged, want) {
|
||
t.Fatalf("日志里没有 %q(非法取值不得静默):\n%s", want, logged)
|
||
}
|
||
}
|
||
if strings.Contains(logged, identityFixture) {
|
||
t.Fatal("日志里出现了身份说明书正文(不得把正文打进日志)")
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentityInjection_EndToEnd 是端到端真断言:真正发给模型的上下文
|
||
// 里,主人会话含身份正文(作为 agent 指令之后的第 2 条 system),群聊与
|
||
// 陌生人私聊的每条消息都不含它。
|
||
func TestOwnerIdentityInjection_EndToEnd(t *testing.T) {
|
||
t.Parallel()
|
||
unsetInjectionEnv(t)
|
||
|
||
a, _ := identityApp(t, writeIdentity(t))
|
||
resolve := a.personaKnowledgeFor(absentIdentityEnvFile(t))
|
||
|
||
ownerMsgs := runIdentityTurn(t, resolve, envOwnerTurn())
|
||
if !identityMessagesContain(ownerMsgs, identityFixture) {
|
||
t.Fatalf("主人会话的模型上下文里没有身份说明书:%+v", ownerMsgs)
|
||
}
|
||
if len(ownerMsgs) < 2 || !strings.Contains(ownerMsgs[1].Content[0].Text, identityKnowledgeHeader) {
|
||
t.Fatalf("身份知识块的位置不对(want 紧接 agent 指令之后):%+v", ownerMsgs)
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
env loop.SessionEnv
|
||
}{
|
||
{name: "群聊会话", env: envGroupTurn()},
|
||
{name: "陌生人私聊会话", env: envStrangerTurn()},
|
||
} {
|
||
msgs := runIdentityTurn(t, resolve, tc.env)
|
||
if identityMessagesContain(msgs, identityFixture) {
|
||
t.Fatalf("%s 的模型上下文里出现了身份说明书:%+v", tc.name, msgs)
|
||
}
|
||
if identityMessagesContain(msgs, identityKnowledgeHeader) {
|
||
t.Fatalf("%s 的模型上下文里出现了身份说明书段头:%+v", tc.name, msgs)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOwnerIdentity_NeverChangesOwnerJudgement 是设计红线(design/11 §0)的真
|
||
// 断言:说明书正文会作为人格知识进入主人上下文,但主人判定只认 actor 元数据
|
||
// + 白名单 —— 连文件里点名写到的那个陌生账号也照样不是主人。
|
||
func TestOwnerIdentity_NeverChangesOwnerJudgement(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
a, _ := identityApp(t, writeIdentity(t))
|
||
|
||
// 正文确实会被注入(只是限定在主人会话),且里面写着「kook:stranger-999」。
|
||
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
|
||
if !strings.Contains(block, "stranger-999") {
|
||
t.Fatalf("测试前提不成立:说明书正文没被读出来")
|
||
}
|
||
if !a.owner.IsOwner("kook", "owner-1") {
|
||
t.Fatal("白名单里的账号未被认成主人")
|
||
}
|
||
if a.owner.IsOwner("kook", "stranger-999") {
|
||
t.Fatal("身份说明书里出现过的账号被判成了主人 —— 身份不能被文本左右")
|
||
}
|
||
if a.owner.IsOwner("kook", "") {
|
||
t.Fatal("空账号被判成了主人")
|
||
}
|
||
}
|