feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
- 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:
1 parent
19f296a2ab
commit
da3a386daf
18 files changed
+1758
-81
No files matched your search
@@ -51,6 +51,8 @@ type Config struct {
|
||||
MCP MCPConfig
|
||||
Capacity CapacityConfig
|
||||
Channels ChannelsConfig
|
||||
Context ContextConfig
|
||||
Guardrail GuardrailConfig
|
||||
}
|
||||
|
||||
// ServerConfig holds the single gateway listener settings.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
// DefaultContextMaxTokens 是 CONTEXT_MAX_TOKENS 的默认值(32Ki token),与
|
||||
// loop.DefaultMaxContextTokens 取同一个数:项目早已把「上下文溢出压缩预算」
|
||||
// 定为 32K,默认值直接复用它,而不是另起一个数。两处的一致性由
|
||||
// cmd/cyrene/wire_context_test.go 锁定。
|
||||
const DefaultContextMaxTokens = 32768
|
||||
|
||||
// ContextConfig 承载运行时循环的上下文预算(design/04 §1.3 上下文溢出压缩)。
|
||||
// 它与 MCP / Capacity 一样是运行时调优量,由组合根消费。
|
||||
type ContextConfig struct {
|
||||
// MaxTokens 是触发溢出压缩的上下文预算,单位是循环离线估算的 token 数
|
||||
// (CONTEXT_MAX_TOKENS)。**0 或负值 = 关闭压缩**,行为与本条接线前完全
|
||||
// 一致;未配置时用 DefaultContextMaxTokens(即默认启用)。
|
||||
//
|
||||
// 取值依据:默认模型(.env.example 的 gpt-4o)上下文窗口为 128K token,
|
||||
// 32K 只有它的四分之一 —— 既给系统提示词 / 工具 schema / 本轮输出留出
|
||||
// 充足余量(不会把窗口塞到上游静默截断或报错),又比日常对话的实际体量
|
||||
// 大一个量级(正常聊天几乎不触发压缩,宁可不压也不频繁压)。真触发时
|
||||
// compactor 仍保留 50% 预算的近期尾部(含尾部工具结果)并只把更早的轮次
|
||||
// 摘要成一条前导消息,因此不会打断正在进行的对话(design/04 §1.3)。
|
||||
MaxTokens int `env:"CONTEXT_MAX_TOKENS" default:"32768"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package config
|
||||
|
||||
// GuardrailConfig 承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。
|
||||
//
|
||||
// 敏感信息阻断档与长度上限是代码内定的默认策略(见组合根的
|
||||
// buildGuardrailPipeline),不对外开放配置:它们是安全默认值,不该被
|
||||
// 一次误配置关掉。这里只放内容策略词表 —— 它取决于部署方的场景,
|
||||
// 项目不内置任何词表。
|
||||
type GuardrailConfig struct {
|
||||
// Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。
|
||||
//
|
||||
// **为空时不挂关键词护栏**:项目绝不硬编一张词表 —— 凭空猜出来的词
|
||||
// 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过
|
||||
// 的词才会被拦(大小写不敏感的子串匹配)。
|
||||
Keywords []string `env:"GUARDRAIL_KEYWORDS"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OwnerIdentityInjectionEnv 是「主人身份说明书注入范围」开关的环境变量名
|
||||
// (design/11 §0 红线;见 cmd/cyrene/persona_identity.go 的消费点)。
|
||||
const OwnerIdentityInjectionEnv = "OWNER_IDENTITY_INJECTION"
|
||||
|
||||
// 注入范围的三档取值。默认(也是唯一安全的一档)是 OwnerIdentityOwnerOnly:
|
||||
// 只有主人会话能拿到那段知识,群聊与陌生人私聊的上下文里根本不出现。
|
||||
const (
|
||||
// OwnerIdentityOwnerOnly 只在主人会话注入(默认)。
|
||||
OwnerIdentityOwnerOnly = "owner_only"
|
||||
// OwnerIdentityAlways 所有会话都注入,含群聊与陌生人私聊。
|
||||
// 它显式开启红线例外,只应在本地自测/排障时短暂使用。
|
||||
OwnerIdentityAlways = "always"
|
||||
// OwnerIdentityOff 完全关闭注入:连主人会话也没有这段知识。
|
||||
OwnerIdentityOff = "off"
|
||||
)
|
||||
|
||||
// LoadOwnerIdentityInjection 读取注入范围开关,优先级与其余配置完全一致:
|
||||
// 进程环境 > .env > 默认 owner_only。返回值已去空白(空串按默认处理),
|
||||
// 由调用方用 ParseOwnerIdentityInjection 判定合法性 —— 非法值必须被显式
|
||||
// 报告(记日志 + 回退默认),绝不静默生效或静默失效。
|
||||
//
|
||||
// 为什么需要这个读取函数,而不是给 Config 加一个字段:Config 的反射解码器
|
||||
// 只做标量赋值,承载不了「三档 + 非法值判定」的语义;而直接 os.Getenv 会
|
||||
// 让只写在 .env 里的声明被静默忽略(.env 由 parseDotEnv 读进独立 map,
|
||||
// 并不会写回进程环境)。这里复用同一套优先级与同一份 .env 解析,因此两种
|
||||
// 写法都真实生效。envPath 为空表示不看 .env(与 Load 的约定一致)。
|
||||
func LoadOwnerIdentityInjection(envPath string) (string, error) {
|
||||
fromEnv := map[string]string{}
|
||||
if envPath != "" {
|
||||
m, err := parseDotEnv(envPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("config: read %s: %w", envPath, err)
|
||||
}
|
||||
fromEnv = m
|
||||
}
|
||||
raw := strings.TrimSpace(lookupRaw(OwnerIdentityInjectionEnv, OwnerIdentityOwnerOnly, fromEnv))
|
||||
if raw == "" {
|
||||
raw = OwnerIdentityOwnerOnly
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ParseOwnerIdentityInjection 把开关取值判定成三档之一(大小写不敏感,
|
||||
// 允许首尾空白)。空串按默认 owner_only 处理;其余无法识别的取值返回错误,
|
||||
// 由调用方明确回退到 owner_only 并把非法值记进日志。
|
||||
func ParseOwnerIdentityInjection(raw string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", OwnerIdentityOwnerOnly:
|
||||
return OwnerIdentityOwnerOnly, nil
|
||||
case OwnerIdentityAlways:
|
||||
return OwnerIdentityAlways, nil
|
||||
case OwnerIdentityOff:
|
||||
return OwnerIdentityOff, nil
|
||||
default:
|
||||
return OwnerIdentityOwnerOnly, fmt.Errorf(
|
||||
"config: %s=%q is not one of %s|%s|%s",
|
||||
OwnerIdentityInjectionEnv, raw, OwnerIdentityOwnerOnly, OwnerIdentityAlways, OwnerIdentityOff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定 OWNER_IDENTITY_INJECTION 的读取与判定(design/11 §0 收窄后的
|
||||
// 开关):默认档是 owner_only,取值优先级与其余配置一致(进程环境 > .env >
|
||||
// 默认),非法取值**必须报错**,由调用方明确回退并记日志。
|
||||
//
|
||||
// 读环境/写 .env 的用例按本包既有约定**不并行**(unsetEnvForTest 会改动
|
||||
// 进程环境,与并行读取互相干扰)。
|
||||
|
||||
// writeDotEnv 把给定行写进一个临时 .env 并返回路径。
|
||||
func writeDotEnv(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestLoadOwnerIdentityInjection_DefaultsToOwnerOnly 钉住默认档:没配(进程
|
||||
// 环境与 .env 都没有)时是 owner_only,也就是最安全的一侧。
|
||||
func TestLoadOwnerIdentityInjection_DefaultsToOwnerOnly(t *testing.T) {
|
||||
unsetEnvForTest(t, OwnerIdentityInjectionEnv)
|
||||
|
||||
got, err := LoadOwnerIdentityInjection(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOwnerIdentityInjection: %v", err)
|
||||
}
|
||||
if got != OwnerIdentityOwnerOnly {
|
||||
t.Fatalf("默认档 = %q, want %q", got, OwnerIdentityOwnerOnly)
|
||||
}
|
||||
|
||||
mode, perr := ParseOwnerIdentityInjection(got)
|
||||
if perr != nil || mode != OwnerIdentityOwnerOnly {
|
||||
t.Fatalf("Parse(%q) = (%q, %v), want (%q, nil)", got, mode, perr, OwnerIdentityOwnerOnly)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOwnerIdentityInjection_DotEnvApplies 证明只写在 .env 里的声明真实
|
||||
// 生效 —— 这正是不能直接用 os.Getenv 的原因(.env 不写回进程环境,直接读
|
||||
// 会静默忽略用户写在 .env 里的开关)。
|
||||
func TestLoadOwnerIdentityInjection_DotEnvApplies(t *testing.T) {
|
||||
unsetEnvForTest(t, OwnerIdentityInjectionEnv)
|
||||
path := writeDotEnv(t, "# 注释行", OwnerIdentityInjectionEnv+"=always")
|
||||
|
||||
got, err := LoadOwnerIdentityInjection(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOwnerIdentityInjection: %v", err)
|
||||
}
|
||||
if got != OwnerIdentityAlways {
|
||||
t.Fatalf(".env 取值 = %q, want %q", got, OwnerIdentityAlways)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOwnerIdentityInjection_ProcessEnvWins 证明优先级:进程环境压过 .env
|
||||
// (与 internal/config 其余配置同一口径)。
|
||||
func TestLoadOwnerIdentityInjection_ProcessEnvWins(t *testing.T) {
|
||||
unsetEnvForTest(t, OwnerIdentityInjectionEnv)
|
||||
t.Setenv(OwnerIdentityInjectionEnv, OwnerIdentityOff)
|
||||
path := writeDotEnv(t, OwnerIdentityInjectionEnv+"=always")
|
||||
|
||||
got, err := LoadOwnerIdentityInjection(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOwnerIdentityInjection: %v", err)
|
||||
}
|
||||
if got != OwnerIdentityOff {
|
||||
t.Fatalf("进程环境取值 = %q, want %q(进程环境优先于 .env)", got, OwnerIdentityOff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOwnerIdentityInjection_ReadErrorIsExplicit 钉住「读不动就显式报错」:
|
||||
// 一个不可读的 .env(这里是目录路径)返回错误,绝不悄悄变成默认值 ——
|
||||
// 调用方据此记日志并明确回退。
|
||||
func TestLoadOwnerIdentityInjection_ReadErrorIsExplicit(t *testing.T) {
|
||||
unsetEnvForTest(t, OwnerIdentityInjectionEnv)
|
||||
|
||||
if _, err := LoadOwnerIdentityInjection(t.TempDir()); err == nil {
|
||||
t.Fatal("把目录当 .env 读本应报错,却返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOwnerIdentityInjection_ThreeModesAndIllegal 钉住三档取值与非法值:
|
||||
// 大小写/空白容忍,空串按默认,其余取值返回**带原始取值的错误**。
|
||||
func TestParseOwnerIdentityInjection_ThreeModesAndIllegal(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{raw: "", want: OwnerIdentityOwnerOnly},
|
||||
{raw: OwnerIdentityOwnerOnly, want: OwnerIdentityOwnerOnly},
|
||||
{raw: " OWNER_ONLY ", want: OwnerIdentityOwnerOnly},
|
||||
{raw: "Always", want: OwnerIdentityAlways},
|
||||
{raw: "off", want: OwnerIdentityOff},
|
||||
{raw: " OFF", want: OwnerIdentityOff},
|
||||
} {
|
||||
got, err := ParseOwnerIdentityInjection(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q) 报错: %v", tc.raw, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("Parse(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{"everyone", "true", "1", "owner-only", "允许"} {
|
||||
mode, err := ParseOwnerIdentityInjection(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("Parse(%q) 本应报错(非法取值不得静默生效),却返回 %q", raw, mode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), raw) {
|
||||
t.Fatalf("错误信息里没有原始取值 %q: %v", raw, err)
|
||||
}
|
||||
if mode != OwnerIdentityOwnerOnly {
|
||||
t.Fatalf("Parse(%q) 的回退档 = %q, want %q(回退方向必须是最安全一侧)", raw, mode, OwnerIdentityOwnerOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,23 +102,26 @@ type Loop struct {
|
||||
personaName func(personaID string) string
|
||||
personaKnobs func(personaID string) (LoopKnobs, bool)
|
||||
personaAwakening func(personaID string) capacity.Awakening
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
maxRounds int
|
||||
toolExec string
|
||||
toolParallelism int
|
||||
hooks ToolLoopHooks
|
||||
onStream func(model.StreamEvent)
|
||||
thinking *model.Thinking
|
||||
reasoningEffort string
|
||||
thinkingShow *bool
|
||||
onAudit func(AuditEvent)
|
||||
onTurnEnd TurnEndHook
|
||||
maxContextTokens int
|
||||
compactor Compactor
|
||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||
imageResolver ImageResolver
|
||||
log *slog.Logger
|
||||
// personaKnowledgeFor 是按会话解析「人格知识」前导块的解析器
|
||||
// (Options.PersonaKnowledgeFor);nil 时每轮都不注入任何块。
|
||||
personaKnowledgeFor func(SessionEnv) string
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
maxRounds int
|
||||
toolExec string
|
||||
toolParallelism int
|
||||
hooks ToolLoopHooks
|
||||
onStream func(model.StreamEvent)
|
||||
thinking *model.Thinking
|
||||
reasoningEffort string
|
||||
thinkingShow *bool
|
||||
onAudit func(AuditEvent)
|
||||
onTurnEnd TurnEndHook
|
||||
maxContextTokens int
|
||||
compactor Compactor
|
||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||
imageResolver ImageResolver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时
|
||||
@@ -145,40 +148,41 @@ func New(opts Options) *Loop {
|
||||
}
|
||||
timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log))
|
||||
return &Loop{
|
||||
agent: opts.Agent,
|
||||
agents: opts.Agents,
|
||||
tools: opts.Tools,
|
||||
capacity: opts.Capacity,
|
||||
onCapacity: opts.OnCapacity,
|
||||
memory: opts.Memory,
|
||||
retriever: opts.Retriever,
|
||||
working: opts.WorkingMemory,
|
||||
extractor: opts.Extractor,
|
||||
checkpoint: opts.Checkpoint,
|
||||
tracer: opts.Tracer,
|
||||
flow: opts.Flow,
|
||||
guards: opts.Guards,
|
||||
personaID: opts.PersonaID,
|
||||
personaName: opts.PersonaName,
|
||||
personaKnobs: opts.PersonaKnobs,
|
||||
personaAwakening: opts.PersonaAwakening,
|
||||
timeLocation: timeLocation,
|
||||
timeZoneName: timeZoneName,
|
||||
maxRounds: maxRounds,
|
||||
toolExec: toolExec,
|
||||
toolParallelism: toolParallelism,
|
||||
hooks: opts.Hooks,
|
||||
onStream: opts.OnStream,
|
||||
thinking: opts.Thinking,
|
||||
reasoningEffort: opts.ReasoningEffort,
|
||||
thinkingShow: opts.ThinkingShowToUser,
|
||||
onAudit: opts.OnAudit,
|
||||
onTurnEnd: opts.OnTurnEnd,
|
||||
maxContextTokens: opts.MaxContextTokens,
|
||||
compactor: compactor,
|
||||
transformContext: opts.TransformContext,
|
||||
imageResolver: opts.Vision,
|
||||
log: sourcedLog(opts.Log),
|
||||
agent: opts.Agent,
|
||||
agents: opts.Agents,
|
||||
tools: opts.Tools,
|
||||
capacity: opts.Capacity,
|
||||
onCapacity: opts.OnCapacity,
|
||||
memory: opts.Memory,
|
||||
retriever: opts.Retriever,
|
||||
working: opts.WorkingMemory,
|
||||
extractor: opts.Extractor,
|
||||
checkpoint: opts.Checkpoint,
|
||||
tracer: opts.Tracer,
|
||||
flow: opts.Flow,
|
||||
guards: opts.Guards,
|
||||
personaID: opts.PersonaID,
|
||||
personaName: opts.PersonaName,
|
||||
personaKnobs: opts.PersonaKnobs,
|
||||
personaAwakening: opts.PersonaAwakening,
|
||||
personaKnowledgeFor: opts.PersonaKnowledgeFor,
|
||||
timeLocation: timeLocation,
|
||||
timeZoneName: timeZoneName,
|
||||
maxRounds: maxRounds,
|
||||
toolExec: toolExec,
|
||||
toolParallelism: toolParallelism,
|
||||
hooks: opts.Hooks,
|
||||
onStream: opts.OnStream,
|
||||
thinking: opts.Thinking,
|
||||
reasoningEffort: opts.ReasoningEffort,
|
||||
thinkingShow: opts.ThinkingShowToUser,
|
||||
onAudit: opts.OnAudit,
|
||||
onTurnEnd: opts.OnTurnEnd,
|
||||
maxContextTokens: opts.MaxContextTokens,
|
||||
compactor: compactor,
|
||||
transformContext: opts.TransformContext,
|
||||
imageResolver: opts.Vision,
|
||||
log: sourcedLog(opts.Log),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +164,23 @@ func formatMemoryBlock(results []memory.Result, marker string) string {
|
||||
// 声明紧接其后(就在它所描述的对话之前),而发言人
|
||||
// 边界说明被插入对话本身。没有发言人标签的对话
|
||||
// 完全不渲染归属,因此每条单角色路径都保持多角色支持
|
||||
// 之前的提示词。
|
||||
// 之前的提示词。唯一的头部新增是「人格知识」块(本批 P0-10
|
||||
// 收窄,见下),它排在最前 —— 即紧接该轮 agent 的指令消息之后,
|
||||
// 与它过去作为指令尾段时的相对位置一致。
|
||||
//
|
||||
// 「人格知识」(主人身份说明书)**按会话**注入(Options.PersonaKnowledgeFor):
|
||||
// 解析器拿该轮的 SessionEnv 决定返回什么,群聊 / 陌生人会话返回空串即
|
||||
// 完全不出现(design/11 §0 红线:不入主会话上下文、不泄露主人敏感信息)。
|
||||
// 为什么不能像过去那样静态拼进 system 指令:指令在装配期定死,静态注入
|
||||
// 会让它进入该角色的每一个会话(含群聊与陌生人私聊)并随请求发给模型
|
||||
// 供应商。未接线该解析器时逐字节等于接线前。
|
||||
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
|
||||
var leading []model.Message
|
||||
if l.personaKnowledgeFor != nil {
|
||||
if block := l.personaKnowledgeFor(sessionEnvFrom(ctx)); block != "" {
|
||||
leading = append(leading, model.TextMessage(model.RoleSystem, block))
|
||||
}
|
||||
}
|
||||
// 每轮「当前时间」,使模型自然知道墙上时钟(而非首轮
|
||||
// 快照)。以已配置的时区为条件,使测试与无时区的运行
|
||||
// 保持其精确的前导消息数。它位于工作记忆 /
|
||||
|
||||
@@ -119,6 +119,20 @@ type Options struct {
|
||||
// 都由同一个输入落到正确角色的档位。为 nil 时每个 turn 都不传逐调用覆盖,
|
||||
// 即接线前的行为。
|
||||
PersonaAwakening func(personaID string) capacity.Awakening
|
||||
// PersonaKnowledgeFor 非 nil 时,每轮组装模型可见上下文时被调用一次,
|
||||
// 传入**该轮的会话环境**(SessionEnv),返回要作为前导系统块注入的
|
||||
// 「人格知识」正文(本接线:owner.yaml 的主人身份说明书)。
|
||||
//
|
||||
// 为什么必须按轮次 + 按会话解析,而不是静态写进 system 指令(agent 的
|
||||
// 指令在装配期定死):静态注入会让这段知识进入该角色的**每一个**会话,
|
||||
// 包括群聊与陌生人私聊,并随请求发给模型供应商 —— 那正是 design/11 §0
|
||||
// 红线禁止的(群聊 / 陌生人:不入主会话上下文、不泄露主人敏感信息)。
|
||||
// 放在这里,调用方能按会话环境决定注入与否;返回空串(非主人会话 /
|
||||
// 开关关闭 / 未配置)即**完全不出现**,模型可见上下文与接线前逐字节一致。
|
||||
//
|
||||
// 注入的块只是知识:它不参与任何身份或权限判定(那些只看渠道层 actor
|
||||
// 元数据 + 白名单),因此这里返回什么都不会改变谁能做什么。
|
||||
PersonaKnowledgeFor func(env SessionEnv) string
|
||||
// Timezone 是用于渲染每轮「当前时间」系统消息的 IANA
|
||||
// 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入
|
||||
// (向后兼容);加载失败的名字同样关闭它。
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user