Files
Cyrene_v2/cmd/cyrene/persona_identity.go
T
AskaEth da3a386daf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 待同步
2026-10-04 14:30:26 +08:00

142 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}