- 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 待同步
142 lines
6.0 KiB
Go
142 lines
6.0 KiB
Go
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
|
||
}
|
||
}
|