feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 待同步
This commit is contained in:
AskaEth committed 2026-10-04 14:30:26 +08:00
1 parent 19f296a2ab
commit da3a386daf
18 files changed
+1758 -81

No files matched your search

+8 -4
View File
@@ -34,9 +34,13 @@ func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatche
// buildPersonaAgents returns the role → agent pool: every registered character's
// persona gets an agent carrying that persona's system instruction
// (identityInstructionFor + the personality/values/speechStyle/catchphrase/
// background sections, design/01 §2.2). It returns an empty map when no persona is
// registered, so the loop keeps its single default agent.
// (instructionsFromPersona, the very same assembly point as the default agent —
// see personaInstructions), design/01 §2.2. It returns an empty map when no
// persona is registered, so the loop keeps its single default agent.
//
// 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入
// (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与
// 陌生人私聊(design/11 §0 红线)。
//
// Only personas reachable through a registered character are pooled: a character
// is the selectable unit (1 character ↔ 1 persona), so a persona with no character
@@ -58,7 +62,7 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc
// 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels /
// personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。
stack := llm.ForPersona(c.PersonaID)
ag, err := agent.New(instructionsFromPersona(p), dispatcher, guards,
ag, err := agent.New(a.personaInstructions(p), dispatcher, guards,
agent.WithModel(stack, stack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if err != nil {
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"io"
"log/slog"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// identityKnowledgeHeader 是主人身份说明书注入上下文时的段头:
// 与【性格】【价值观】等并列,使模型能把它读成「人格知识」,而不是一条指令。
const identityKnowledgeHeader = "【主人身份说明书】"
// ownerIdentityKnowledge 读取 owner.yaml 的 identityFile 指向的正文
// (configs/users/owner_identity.md),供按会话注入。
//
// 它是**人格知识**,不是权限来源:该文件只描述「你在陪伴谁、你们是什么关系」,
// 身份识别始终只看渠道层 actor 元数据 + 白名单(design/11 §0),文件内容
// 既不参与 IsOwner,也不改变任何授权判定。文件里自己也把这条边界写在开头。
//
// fail-soft:未配置、文件缺失或读不动(含路径指向目录)都返回空串 —— 只记一条
// WARN,绝不报错、绝不阻断启动或对话。这是「宁可少一段知识,也不能因为一个
// 可选文件起步失败」的取舍。
func (a *app) ownerIdentityKnowledge() string {
if a.owner == nil {
return ""
}
path := strings.TrimSpace(a.owner.IdentityFile)
if path == "" {
return ""
}
raw, err := os.ReadFile(path)
if err != nil {
if a.log != nil {
logger.WithSource(a.log, "cmd/cyrene").Warn(
"owner identity file skipped", "path", path, "error", err)
}
return ""
}
return strings.TrimSpace(string(raw))
}
// identityKnowledgeBlock 把身份说明书正文渲染成一个可独立注入的系统块;
// knowledge 为空(未配置 / 读不出)时返回空串 = 不注入任何块。
func identityKnowledgeBlock(knowledge string) string {
if strings.TrimSpace(knowledge) == "" {
return ""
}
return identityKnowledgeHeader + "\n" + knowledge
}
// personaInstructions 装配某个角色 agent 的**静态** system 指令:人格指令
// (instructionsFromPersona,design/01 §2.2)。默认 agent 与角色池里的每个
// agent 都走这里,因此两个装配点不会漂移。
//
// 主人身份说明书**刻意不在这里**:静态指令在装配期定死,会让那段知识进入该
// 角色的每一个会话(含群聊与陌生人私聊)并随请求发给模型供应商。它现在按
// 会话注入 —— 见 personaKnowledgeFor 与 loop.Options.PersonaKnowledgeFor。
func (a *app) personaInstructions(p *loader.Persona) string {
if p == nil {
return ""
}
return instructionsFromPersona(p)
}
// personaKnowledgeFor 返回循环的按会话「人格知识」解析器
// (loop.Options.PersonaKnowledgeFor):该 turn 的会话在注入范围内时返回主人
// 身份说明书整块(段头 + 正文),否则返回空串 —— 空串意味着那段知识
// **完全不进**该会话的上下文,也就绝不随请求发给模型供应商。
//
// 默认档 owner_only:只有渠道层 actor 元数据判定的主人车道拿得到它。群聊与
// 陌生人私聊(design/11 §0 隔离红线)返回空串。开关见 OWNER_IDENTITY_INJECTION。
//
// 正文在装配期读一次并捕获进闭包(不是每轮读盘):它只在启动时变一次,
// 每轮重读一个启动期就不变的可选文件纯属浪费。
func (a *app) personaKnowledgeFor(envFilePath string) func(loop.SessionEnv) string {
log := sourcedWiringLog(a.log)
mode := ownerIdentityInjectionMode(log, envFilePath)
block := identityKnowledgeBlock(a.ownerIdentityKnowledge())
log.Info("owner identity injection",
"mode", mode, "env", config.OwnerIdentityInjectionEnv, "knowledge", block != "")
return func(env loop.SessionEnv) string {
if !injectsIdentityKnowledge(mode, env) {
return ""
}
return block
}
}
// sourcedWiringLog 返回带来源标记 "cmd/cyrene" 的 logger;nil 基础 logger
// 换成丢弃 logger(与 loop 包的 sourcedLog 同一约定),使这里的日志调用
// 永远不会因为「没接 logger」而空指针。
func sourcedWiringLog(base *slog.Logger) *slog.Logger {
if base == nil {
base = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(base, "cmd/cyrene")
}
// ownerIdentityInjectionMode 解析注入范围开关,任何读不出/不认识的情形都
// **明确回退**到默认的 owner_only 并记一条带原始取值的 WARN —— 回退方向永远
// 是最安全的一侧,且绝不静默(非法取值不会被当成默认值悄悄放行)。
func ownerIdentityInjectionMode(log *slog.Logger, envFilePath string) string {
raw, err := config.LoadOwnerIdentityInjection(envFilePath)
if err != nil {
log.Warn("owner identity injection: cannot read config, falling back",
"env", config.OwnerIdentityInjectionEnv,
"fallback", config.OwnerIdentityOwnerOnly, "error", err)
return config.OwnerIdentityOwnerOnly
}
mode, perr := config.ParseOwnerIdentityInjection(raw)
if perr != nil {
log.Warn("owner identity injection: unknown value, falling back",
"env", config.OwnerIdentityInjectionEnv, "value", raw,
"fallback", config.OwnerIdentityOwnerOnly, "error", perr)
return config.OwnerIdentityOwnerOnly
}
return mode
}
// injectsIdentityKnowledge 报告该会话是否在注入范围内。
//
// owner_only 的判定读**强类型的渠道层 actor 元数据**(SessionEnv.ActorEnv),
// 与 memory / 工具准入门用的是同一个车道 —— 绝不从提示词文本或会话 id 猜。
// 注意反向也成立:身份说明书的内容永远不参与这个判定,也不参与任何授权。
func injectsIdentityKnowledge(mode string, env loop.SessionEnv) bool {
switch mode {
case config.OwnerIdentityAlways:
return true
case config.OwnerIdentityOff:
return false
default: // owner_only(默认,也是未知取值的回退档)
return env.ActorEnv == restrict.ActorOwner
}
}
+377
View File
@@ -0,0 +1,377 @@
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()) {
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("空账号被判成了主人")
}
}
+16
View File
@@ -0,0 +1,16 @@
package main
import "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
// contextBudget 返回循环的上下文溢出压缩预算(CONTEXT_MAX_TOKENS,
// design/04 §1.3)。**非正 = 关闭压缩**,行为与本条接线前逐字节一致;
// 未配置时由 config 层给出 DefaultContextMaxTokens(即默认启用)。
//
// 取值只从配置来,组合根不做二次猜测:阈值语义(含「0 关掉」)由配置层
// 一处定义,组合根只负责把它交给 loop.Options.MaxContextTokens。
func (a *app) contextBudget() int {
if a.cfg == nil {
return config.DefaultContextMaxTokens
}
return a.cfg.Context.MaxTokens
}
+231
View File
@@ -0,0 +1,231 @@
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)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
// 组合根的默认护栏策略(design/04 §5)。本条修的就是「护栏是空壳」:
// 此前 `guardrail.New()` 不带任何 Option,管线里一个检查都没有,
// 输入/输出两侧的 guardrail span 每次都只是走过场。
//
// 三条默认策略与它们的依据:
//
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
// 因此默认按阻断处理。
// 2. 长度上限 = 只拦病态载荷,不误伤正常对话(两个上限的取值依据见下方常量)。
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
const (
// guardrailMaxInputChars 是请求侧的字符上限。这个检查跑在**组装完成的
// 请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),不是单条用户
// 消息,因此上限必须按「整个上下文」来定:默认压缩预算 32K token
// 折算成字符约 65K(中文)~131K(拉丁),取 262144(256 Ki)字符约为
// 它的 2~4 倍 —— 正常对话(哪怕很长)永远碰不到,只有把几百 KB 的
// 整块文本直接灌进来这种病态输入才会被拦下。
guardrailMaxInputChars = 262144
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
// 所以上限必须留足余量。
guardrailMaxOutputChars = 65536
)
// buildGuardrailPipeline 组装循环与各 agent 共用的护栏管线(design/04 §5)。
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
// 断言这个函数的产物,而不必启动整个运行时。
//
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
func (a *app) buildGuardrailPipeline() *guardrail.Pipeline {
opts := []guardrail.Option{
// ① 敏感信息外泄必须拦:阻断档。
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
guardrail.WithLengthLimits(guardrailMaxInputChars, guardrailMaxOutputChars),
}
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
if keywords := a.guardrailKeywords(); len(keywords) > 0 {
opts = append(opts, guardrail.WithKeywords(keywords...))
}
return guardrail.New(opts...)
}
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
// 未配置时返回 nil,管线里就不含关键词检查。
func (a *app) guardrailKeywords() []string {
if a.cfg == nil {
return nil
}
return a.cfg.Guardrail.Keywords
}
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"context"
"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/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 P0-5:组合根的护栏管线**不是空壳**。断言分两层 ——
// 管线本身(正常对话过 / 敏感与超长被拦 / 词表只在配置提供时挂),
// 以及端到端(同一个循环:正常消息真被模型回答,敏感消息在模型调用**之前**
// 就被拦下,适配器一次都没被调用)。
// guardProbeAdapter 只回答固定文本并数调用次数,使「护栏是否在模型调用前拦下」
// 变成可断言的事实。
type guardProbeAdapter struct {
reply string
calls int
}
func (a *guardProbeAdapter) Name() string { return "guard-probe" }
func (a *guardProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
a.calls++
return model.Response{Content: a.reply}, nil
}
func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: a.reply})
}
return a.Chat(ctx, req, model.Options{})
}
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
func guardrailApp(t *testing.T, keywords []string) *app {
t.Helper()
if keywords == nil {
return &app{log: testLog()}
}
cfg := &config.Config{}
cfg.Guardrail.Keywords = keywords
return &app{log: testLog(), cfg: cfg}
}
// checkInput / checkOutput 是测试侧的薄封装:把一段文本包成请求或响应再判一次。
func checkInput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
req := model.Request{Messages: []model.Message{model.TextMessage(model.RoleUser, text)}}
v, err := p.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput(%q): %v", truncate(text, 40), err)
}
return v
}
func checkOutput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
resp := model.Response{Content: text}
v, err := p.CheckOutput(context.Background(), &resp)
if err != nil {
t.Fatalf("CheckOutput: %v", err)
}
return v
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// TestGuardrailPipeline_NormalConversationPasses 是真断言①:正常对话照常通过。
// 系统提示词 + 日常用户消息 + 一段几千字的正常长回复都必须放行 —— 护栏上线
// 不能以「误伤日常聊天」为代价。
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
model.TextMessage(model.RoleUser, "今天有点累,陪我聊会儿天好不好?"),
model.TextMessage(model.RoleAssistant, "好呀,人家陪着你♪ 今天遇到什么啦?"),
model.TextMessage(model.RoleUser, "就是工作有点多。"),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("正常对话被输入护栏拦下:%+v", v)
}
// 一段普通的长回复(约 3000 字)也不该被长度上限碰到。
long := strings.Repeat("今天也要好好吃饭、早点睡觉哦,人家会一直陪着你的♪", 100)
if v := checkOutput(t, p, long); !v.Allow {
t.Fatalf("正常长回复被输出护栏拦下:%+v", v)
}
}
// TestGuardrailPipeline_BlocksSensitiveInfo 是真断言②:敏感信息真被拦(阻断档,
// 不是标记档),且判决里不回流被命中的秘密本身(guardrail 包的既有契约)。
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
cases := []struct {
name string
text string
secret string
}{
{name: "api key 参数", text: "api_key = " + secret, secret: secret},
{name: "私钥头", text: "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...", secret: "MIIEow"},
{name: "bearer token", text: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9", secret: "eyJhbGciOiJIUzI1NiJ9"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := checkOutput(t, p, tc.text)
if v.Allow {
t.Fatalf("敏感内容未被拦下:%q → %+v", tc.text, v)
}
if !strings.Contains(v.Reason, "sensitive_information_blocked") {
t.Fatalf("拦截原因 = %q,want 含 sensitive_information_blocked", v.Reason)
}
if strings.Contains(v.Reason, tc.secret) {
t.Fatalf("判决里回流了被命中的秘密:%q", v.Reason)
}
})
}
// 请求侧同样拦(用户把密钥贴进来,不该先消耗一次模型调用)。
if v := checkInput(t, p, "帮我看看 password: hunter2 对不对"); v.Allow {
t.Fatalf("请求侧敏感内容未被拦下:%+v", v)
}
}
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
t.Fatalf("恰好到上限的输出被拦:%+v", v)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars+1)); v.Allow {
t.Fatal("超出输出上限一个字符却放行")
} else if !strings.Contains(v.Reason, "output_length_exceeded") {
t.Fatalf("超长判决 = %q,want output_length_exceeded", v.Reason)
}
// 输入侧检查跑在**组装完成的请求**上,因此上限按整个上下文定:
// 10 万字符(约 5 万 token 的上下文)仍属正常,必须放行。
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, strings.Repeat("设定。", 1000)),
model.TextMessage(model.RoleUser, strings.Repeat("闲聊内容。", 10000)),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("10 万字符的正常上下文被输入上限拦下:%+v", v)
}
// 超过上限则被拦。
over := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMaxInputChars+1)),
}}
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
t.Fatal("超出输入上限的载荷却放行")
} else if !strings.Contains(v.Reason, "input_length_exceeded") {
t.Fatalf("超长输入判决 = %q,want input_length_exceeded", v.Reason)
}
}
// TestGuardrailPipeline_KeywordsOnlyWhenConfigured 锁定策略③:项目不硬编词表。
// 同一段文本,没配置时放行,配置了该词才被拦。
func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
t.Parallel()
text := "这段文本里出现了违禁词。"
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(), text); !v.Allow {
t.Fatalf("未配置词表却拦下了文本:%+v", v)
}
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline()
v := checkInput(t, configured, text)
if v.Allow {
t.Fatal("配置了词表却没拦下命中文本")
}
if !strings.Contains(v.Reason, "content_policy_blocked") {
t.Fatalf("关键词判决 = %q,want content_policy_blocked", v.Reason)
}
// 词表只按声明的词命中,不牵连别的正常文本。
if v := checkInput(t, configured, "这句很正常。"); !v.Allow {
t.Fatalf("词表误伤了正常文本:%+v", v)
}
}
// TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes 是反证(变异):空管线
// (P0-5 的原始形态 `guardrail.New()`)对同一段敏感文本一律放行 —— 证明上面
// 的断言不是恒真的,敏感拦截确实来自组合根挂上的检查。
func TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes(t *testing.T) {
t.Parallel()
empty := guardrail.New()
if v := checkOutput(t, empty, "api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"); !v.Allow {
t.Fatalf("空管线本应全部放行(这正是 P0-5 的空壳),却拦下了:%+v", v)
}
if v := checkOutput(t, empty, strings.Repeat("a", guardrailMaxOutputChars+1)); !v.Allow {
t.Fatalf("空管线本应不做长度检查,却拦下了:%+v", v)
}
}
// TestGuardrailPipeline_RealPromptPasses 是一道防误伤的现实检查:生产里真正被
// 检查的输入侧载荷是**组装完成的请求**(真实人格提示词 + 主人身份说明书 + 用户
// 消息)。若这几段里恰好命中某条敏感模式,每一轮对话都会被永久拦死 ——
// 因此这里拿仓库里真实的两份文件走一遍。
func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
t.Parallel()
a, p := identityApp(t, filepath.Join("..", "..", "configs", "users", "owner_identity.md"))
// 身份说明书自 P0-10 收窄后不再进静态指令,而是按会话注入系统上下文
// (persona_identity.go 的 personaKnowledgeFor)。护栏看到的真实载荷仍是
// 「人格提示词 + 身份说明书 + 用户消息」,故这里把它拼回同一段文本。
instructions := a.personaInstructions(p) +
"\n\n" + identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(instructions, "你叫他「亲爱的」") {
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
}
pipeline := a.buildGuardrailPipeline()
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, instructions),
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
}}
v, err := pipeline.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput: %v", err)
}
if !v.Allow {
t.Fatalf("真实人格提示词 + 身份说明书被护栏拦下(会造成每轮都被拦死):%+v", v)
}
}
// TestGuardrailWiring_EndToEnd 是端到端真断言:同一个循环里,
// - 正常消息被模型正常回答(适配器被调用一次);
// - 敏感消息在模型调用**之前**就被拦下(适配器一次都没被调用),
// 且该回合的 StopReason 是 guardrail_input。
func TestGuardrailWiring_EndToEnd(t *testing.T) {
t.Parallel()
guards := guardrailApp(t, nil).buildGuardrailPipeline()
adapter := &guardProbeAdapter{reply: "我在呢♪"}
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,
OnStream: func(model.StreamEvent) {},
})
normal, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "晚上好呀"),
})
if err != nil {
t.Fatalf("Run(正常消息): %v", err)
}
if normal.Text != "我在呢♪" {
t.Fatalf("正常消息的回答 = %q,want 模型回复(护栏不该改动它)", normal.Text)
}
if adapter.calls != 1 {
t.Fatalf("正常消息的模型调用次数 = %d,want 1", adapter.calls)
}
blocked, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "我的 api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"),
})
if err != nil {
t.Fatalf("Run(敏感消息): %v", err)
}
if adapter.calls != 1 {
t.Fatalf("敏感消息触发了模型调用(calls=%d)—— 输入护栏没有前置拦下", adapter.calls)
}
if !strings.Contains(blocked.Text, "sensitive_information_blocked") {
t.Fatalf("敏感消息的回答 = %q,want 含拦截原因", blocked.Text)
}
if blocked.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("敏感消息的 StopReason = %q,want %q", blocked.StopReason, agent.StopReasonGuardrailInput)
}
}
+36 -25
View File
@@ -15,7 +15,6 @@ import (
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/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
@@ -148,7 +147,9 @@ func (a *app) wireRuntime() error {
}
a.wireVision()
guards := guardrail.New()
// 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 +
// 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。
guards := a.buildGuardrailPipeline()
instructions := a.defaultInstructions()
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
@@ -240,26 +241,34 @@ func (a *app) wireRuntime() error {
approvalGate := a.buildApprovalGate(approver)
a.loop = loop.New(loop.Options{
Agent: defaultAgent,
Agents: agents,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(),
Checkpoint: checkpoint,
Tracer: tracer,
Flow: flowPub,
Guards: guards,
PersonaID: personaID,
PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs,
PersonaAwakening: a.awakeningFor,
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
Agent: defaultAgent,
Agents: agents,
Tools: a.dispatcher,
Capacity: capDecider,
OnCapacity: a.onCapacity,
OnAudit: a.onToolAudit,
OnTurnEnd: a.onTurnEnd,
Memory: store,
Retriever: a.retriever,
Extractor: a.buildMemoryExtractor(),
Checkpoint: checkpoint,
Tracer: tracer,
Flow: flowPub,
Guards: guards,
PersonaID: personaID,
PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs,
PersonaAwakening: a.awakeningFor,
// 主人身份说明书按**会话**注入(design/11 §0 红线):只有主人车道的
// 上下文里有它,群聊 / 陌生人私聊的返回空串 —— 那段知识绝不进它们的
// 上下文,也绝不随之发给模型供应商。开关与判定见 persona_identity.go。
PersonaKnowledgeFor: a.personaKnowledgeFor(envPath),
Timezone: a.cfg.Timezone,
MaxRounds: defaultMaxRounds,
// 上下文溢出压缩:预算取自 CONTEXT_MAX_TOKENS(非正 = 关闭,
// 与接线前一致),默认启用(见 wire_context.go)。Compactor 不注入,
// 由 loop 选用内置的结构化 compactor(离线、不调模型)。
MaxContextTokens: a.contextBudget(),
WorkingMemory: memory.NewWorkingMemory(0),
OnStream: a.streamChunk,
Vision: visionResolver,
@@ -429,11 +438,13 @@ func (a *app) defaultPersonaID() string {
// defaultInstructions returns the default persona's system prompt followed by the
// personality/values/speechStyle/catchphrase/background sections (design/01
// §2.2), falling back to its role text or a minimal instruction when neither is
// set.
// §2.2), falling back to its role text or a minimal instruction when neither is set.
//
// 主人身份说明书**不在这里**(P0-10 收窄后按会话注入,见 personaKnowledgeFor):
// 静态拼进指令会让它进入该角色的每一个会话,含群聊与陌生人私聊。
func (a *app) defaultInstructions() string {
if p, ok := a.defaultPersona(); ok {
return instructionsFromPersona(p)
return a.personaInstructions(p)
}
return fallbackInstruction
}