- 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 待同步
260 lines
10 KiB
Go
260 lines
10 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件锁定 P0-10 的**收窄**:主人身份说明书(人格知识)按会话注入,
|
||
// 默认只在主人车道出现 —— 群聊与陌生人私聊的模型可见上下文里
|
||
// 完全没有它(design/11 §0 红线:不入主会话上下文、不泄露主人敏感信息)。
|
||
//
|
||
// 分工:本文件证明**机制**(loop 每轮按 SessionEnv 调用解析器,并把返回块
|
||
// 放进模型可见上下文);**策略**(owner_only 的判定与开关三档)由
|
||
// cmd/cyrene/persona_identity.go + 其测试拥有。
|
||
|
||
const (
|
||
// knowledgeFixture 是测试用的人格知识正文(真实装配里它是 owner.yaml
|
||
// identityFile 的正文)。
|
||
knowledgeFixture = "【主人身份说明书】\n- 称呼:你叫他「亲爱的」。"
|
||
// knowledgeProbe 是正文里唯一的指纹串,用来在上下文中判定「在不在」。
|
||
knowledgeProbe = "你叫他「亲爱的」"
|
||
)
|
||
|
||
// envOwner 是主人私聊环境;envGroup / envStranger 是受限环境
|
||
// (与网关/channelbridge 生产路径构造的取值一致)。
|
||
func envOwner() SessionEnv { return OwnerEnv() }
|
||
|
||
func envGroup() SessionEnv {
|
||
return SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
|
||
}
|
||
|
||
func envStranger() SessionEnv {
|
||
return SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
|
||
}
|
||
|
||
// knowledgeIn 报告消息列表里是否有任一消息带着知识指纹。
|
||
func knowledgeIn(messages []model.Message) bool {
|
||
for _, m := range messages {
|
||
if strings.Contains(messageText(m), knowledgeProbe) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// gateKnowledge 是 cmd/cyrene 那条生产策略的等价物:只在主人车道返回正文。
|
||
func gateKnowledge(env SessionEnv) string {
|
||
if env.ActorEnv != restrict.ActorOwner {
|
||
return ""
|
||
}
|
||
return knowledgeFixture
|
||
}
|
||
|
||
// TestPersonaKnowledge_OwnerSessionInjects 是断言①:主人会话的上下文里有
|
||
// 身份正文,且它排在所有既有前导块之前(即紧接 agent 指令之后)。
|
||
func TestPersonaKnowledge_OwnerSessionInjects(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{PersonaKnowledgeFor: gateKnowledge})
|
||
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
|
||
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
|
||
|
||
if len(view) != 2 {
|
||
t.Fatalf("主人会话 view = %d 条消息, want 2(知识块 + 用户消息)", len(view))
|
||
}
|
||
if view[0].Role != model.RoleSystem || !strings.Contains(messageText(view[0]), knowledgeProbe) {
|
||
t.Fatalf("主人会话首条不是身份知识块: %+v", view[0])
|
||
}
|
||
if messageText(view[1]) != "在吗" {
|
||
t.Fatalf("用户消息被顺序打乱: %+v", view[1])
|
||
}
|
||
}
|
||
|
||
// TestPersonaKnowledge_RestrictedSessionsDoNotInject 是本任务的验收核心:
|
||
// 群聊与陌生人私聊的上下文里**一个字都没有**这段知识 —— 因此它也不会
|
||
// 随请求发给模型供应商。
|
||
func TestPersonaKnowledge_RestrictedSessionsDoNotInject(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{PersonaKnowledgeFor: gateKnowledge})
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
env SessionEnv
|
||
}{
|
||
{name: "群聊会话", env: envGroup()},
|
||
{name: "陌生人私聊会话", env: envStranger()},
|
||
} {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
|
||
view := l.assembleContext(WithSessionEnv(context.Background(), tc.env), msgs, 1)
|
||
|
||
if len(view) != 1 {
|
||
t.Fatalf("%s view = %d 条消息, want 1(不得注入任何块)", tc.name, len(view))
|
||
}
|
||
if knowledgeIn(view) {
|
||
t.Fatalf("%s 的上下文里出现了主人身份说明书: %+v", tc.name, view)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestPersonaKnowledge_AlwaysReachesGroupChat 是断言③(开关真的生效):
|
||
// 解析器无差别返回正文(生产里对应 OWNER_IDENTITY_INJECTION=always)时,
|
||
// 群聊上下文里就**有**这段知识 —— 这同时反证上面那条断言不是恒真的
|
||
// (它命中与否确实由解析器的返回决定,而不是钩子从未生效)。
|
||
func TestPersonaKnowledge_AlwaysReachesGroupChat(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{PersonaKnowledgeFor: func(SessionEnv) string { return knowledgeFixture }})
|
||
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
|
||
view := l.assembleContext(WithSessionEnv(context.Background(), envGroup()), msgs, 1)
|
||
|
||
if !knowledgeIn(view) {
|
||
t.Fatalf("always 档下群聊上下文里没有身份正文(开关没生效): %+v", view)
|
||
}
|
||
}
|
||
|
||
// TestPersonaKnowledge_OffSkipsOwnerSession 是断言④:解析器恒返回空串
|
||
// (生产里对应 OWNER_IDENTITY_INJECTION=off)时,连主人会话也没有它,
|
||
// 且上下文与「没接线」逐字节一致。
|
||
func TestPersonaKnowledge_OffSkipsOwnerSession(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{PersonaKnowledgeFor: func(SessionEnv) string { return "" }})
|
||
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
|
||
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
|
||
|
||
if len(view) != 1 || knowledgeIn(view) {
|
||
t.Fatalf("off 档下主人会话仍被注入: %+v", view)
|
||
}
|
||
}
|
||
|
||
// TestPersonaKnowledge_NotWired_IsByteIdentical 钉住向后兼容:未接线该钩子
|
||
// (nil)时每轮上下文与接线前逐字节一致,任何会话都不出现该块。
|
||
func TestPersonaKnowledge_NotWired_IsByteIdentical(t *testing.T) {
|
||
t.Parallel()
|
||
l := New(Options{})
|
||
|
||
msgs := []model.Message{model.TextMessage(model.RoleUser, "在吗")}
|
||
view := l.assembleContext(WithSessionEnv(context.Background(), envOwner()), msgs, 1)
|
||
|
||
if len(view) != 1 || messageText(view[0]) != "在吗" {
|
||
t.Fatalf("未接线时上下文被改动: %+v", view)
|
||
}
|
||
}
|
||
|
||
// TestRun_PersonaKnowledge_StaysOutOfTheConversation 是同级红线断言:人格知识
|
||
// 块只是**每轮模型可见视图**的一部分,绝不进入对话本身 —— 因此记忆提取器
|
||
// (以及它写下的持久化面、checkpoint 里的对话)都看不到它。若将来有人把它
|
||
// 注入到 `messages` 而不是 `leading`,这条会红:那等于把主人身份知识交给
|
||
// 提取器当素材,可能被写成记忆条目(design/11 §0 不泄露主人敏感信息)。
|
||
func TestRun_PersonaKnowledge_StaysOutOfTheConversation(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
capture := &knowledgeCaptureExtractor{}
|
||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好呀♪"}}}}
|
||
l := New(Options{
|
||
Agent: newTestAgent(t, ad, nil),
|
||
PersonaKnowledgeFor: gateKnowledge,
|
||
Memory: newTestMemoryStore(t),
|
||
WorkingMemory: memory.NewWorkingMemory(0),
|
||
Extractor: capture,
|
||
MaxRounds: 8,
|
||
})
|
||
|
||
ctx := WithSessionEnv(context.Background(), envOwner())
|
||
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
// 前提:视图里确实有它(否则这条断言没有意义)。
|
||
if len(ad.reqs) == 0 || !knowledgeIn(ad.reqs[0].Messages) {
|
||
t.Fatalf("测试前提不成立:模型可见视图里没有知识块")
|
||
}
|
||
if len(capture.saw) == 0 {
|
||
t.Fatal("测试前提不成立:提取器没有被调用")
|
||
}
|
||
if knowledgeIn(capture.saw) {
|
||
t.Fatalf("人格知识块进了对话/提取面(会被持久化或写成记忆):%+v", capture.saw)
|
||
}
|
||
}
|
||
|
||
// knowledgeCaptureExtractor 记下提取器实际看到的对话。
|
||
type knowledgeCaptureExtractor struct{ saw []model.Message }
|
||
|
||
func (e *knowledgeCaptureExtractor) Extract(_ context.Context, conv []model.Message) ([]memory.MemoryItem, error) {
|
||
e.saw = append([]model.Message(nil), conv...)
|
||
return nil, nil
|
||
}
|
||
|
||
// TestRun_PersonaKnowledgePerSession 是端到端真断言(两条路径都测):真正
|
||
// 发给模型的消息里,主人会话第 2 条就是身份知识块(第 1 条是 agent 指令),
|
||
// 群聊会话则**没有任何一条**带它。流式(OnStream)与非流式(agent.RunStep)
|
||
// 共用 assembleContext,因此这里逐路径各断言一次,避免「只有一条路径收窄」。
|
||
func TestRun_PersonaKnowledgePerSession(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
stream bool
|
||
env SessionEnv
|
||
wantHave bool
|
||
}{
|
||
{name: "主人会话-非流式", stream: false, env: envOwner(), wantHave: true},
|
||
{name: "主人会话-流式", stream: true, env: envOwner(), wantHave: true},
|
||
{name: "群聊会话-非流式", stream: false, env: envGroup(), wantHave: false},
|
||
{name: "群聊会话-流式", stream: true, env: envGroup(), wantHave: false},
|
||
{name: "陌生人私聊-非流式", stream: false, env: envStranger(), wantHave: false},
|
||
{name: "陌生人私聊-流式", stream: true, env: envStranger(), wantHave: false},
|
||
} {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "ok"}}}}
|
||
opts := Options{
|
||
Agent: newTestAgent(t, ad, nil),
|
||
PersonaKnowledgeFor: gateKnowledge,
|
||
MaxRounds: 8,
|
||
}
|
||
if tc.stream {
|
||
opts.OnStream = func(model.StreamEvent) {}
|
||
}
|
||
l := New(opts)
|
||
|
||
ctx := WithSessionEnv(context.Background(), tc.env)
|
||
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
|
||
t.Fatalf("Run: %v", err)
|
||
}
|
||
reqs := ad.reqs
|
||
if tc.stream {
|
||
reqs = ad.streamReqs
|
||
}
|
||
if len(reqs) == 0 {
|
||
t.Fatal("适配器没有收到任何请求")
|
||
}
|
||
sent := reqs[0].Messages
|
||
if got := knowledgeIn(sent); got != tc.wantHave {
|
||
t.Fatalf("发给模型的消息里含身份正文 = %v, want %v: %+v", got, tc.wantHave, sent)
|
||
}
|
||
if !tc.wantHave {
|
||
return
|
||
}
|
||
// 注入位置:第 1 条是指令,第 2 条是知识块(与它过去作为指令尾段
|
||
// 的相对位置一致 —— 紧跟在人格提示词之后)。
|
||
if len(sent) < 2 || sent[0].Role != model.RoleSystem || sent[1].Role != model.RoleSystem {
|
||
t.Fatalf("注入位置不对(want 指令 + 知识块两条 system): %+v", sent)
|
||
}
|
||
if !strings.Contains(messageText(sent[1]), knowledgeProbe) {
|
||
t.Fatalf("第 2 条 system 不是身份知识块: %+v", sent[1])
|
||
}
|
||
})
|
||
}
|
||
}
|