From da3a386daf6009e22b042d381cfc92e4ca367c03 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 14:30:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(cyrene,config,loop):=20=E6=8A=A4=E6=A0=8F/?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9/=E8=BA=AB=E4=BB=BD=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E8=90=BD=E5=9C=B0=EF=BC=88P0-5/P0-10/P0-11?= =?UTF-8?q?=EF=BC=89+=20=E8=BA=AB=E4=BB=BD=E6=B3=A8=E5=85=A5=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E5=88=B0=E4=B8=BB=E4=BA=BA=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 待同步 --- .env.example | 13 + cmd/cyrene/persona_agents.go | 12 +- cmd/cyrene/persona_identity.go | 141 +++++++ cmd/cyrene/persona_identity_test.go | 377 ++++++++++++++++++ cmd/cyrene/wire_context.go | 16 + cmd/cyrene/wire_context_test.go | 231 +++++++++++ cmd/cyrene/wire_guardrail.go | 62 +++ cmd/cyrene/wire_guardrail_test.go | 299 ++++++++++++++ cmd/cyrene/wire_runtime.go | 61 +-- internal/config/config.go | 2 + internal/config/context.go | 23 ++ internal/config/guardrail.go | 16 + internal/config/owner_identity.go | 66 +++ internal/config/owner_identity_test.go | 125 ++++++ internal/runtime/loop/loop.go | 106 ++--- internal/runtime/loop/memory.go | 16 +- internal/runtime/loop/options.go | 14 + .../runtime/loop/persona_knowledge_test.go | 259 ++++++++++++ 18 files changed, 1758 insertions(+), 81 deletions(-) create mode 100644 cmd/cyrene/persona_identity.go create mode 100644 cmd/cyrene/persona_identity_test.go create mode 100644 cmd/cyrene/wire_context.go create mode 100644 cmd/cyrene/wire_context_test.go create mode 100644 cmd/cyrene/wire_guardrail.go create mode 100644 cmd/cyrene/wire_guardrail_test.go create mode 100644 internal/config/context.go create mode 100644 internal/config/guardrail.go create mode 100644 internal/config/owner_identity.go create mode 100644 internal/config/owner_identity_test.go create mode 100644 internal/runtime/loop/persona_knowledge_test.go diff --git a/.env.example b/.env.example index c62b8cc..662a9da 100644 --- a/.env.example +++ b/.env.example @@ -165,6 +165,19 @@ CAPACITY_L3_CACHE_TTL=5m # 可配置);模型派发时可用 subagent_spawn 的 timeout_seconds 参数按调用覆盖。 SUBAGENT_TIMEOUT_SEC=600 +# ---- 护栏与上下文预算(design/04 §1.3 / §5) ---- +# 上下文溢出压缩预算(token,离线估算值)。默认启用:会话估算上下文超过它时, +# 更早的轮次被摘要成一条前导摘要消息,近期尾部(含尾部工具结果)原样保留。 +# 默认 32768 = loop.DefaultMaxContextTokens:默认模型(gpt-4o)窗口 128K, +# 它只占四分之一,给系统提示词/工具 schema/本轮输出留足余量,且远高于日常对话 +# 体量(正常聊天几乎不触发压缩)。设 0(或负数)= 关闭压缩,行为与接线前一致。 +CONTEXT_MAX_TOKENS=32768 +# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏 +# (项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key / +# bearer token / 密码 / 私钥)为阻断档、请求与响应各有长度上限,这两项是代码 +# 内定的安全默认值,不对外开放配置(见 cmd/cyrene/wire_guardrail.go)。 +# GUARDRAIL_KEYWORDS= + # ---- 推送出口(D1,主动推送的两级投递)---- # 主动消息在 WS/IM 投递之外,异步镜像到「主人自建统一推送服务」(主通道)。 # 该服务不可达/未配时,日志注明「个推(Getui)回退待接入」——当前无 Getui SDK,不伪造投递。 diff --git a/cmd/cyrene/persona_agents.go b/cmd/cyrene/persona_agents.go index c0e9141..cfaa1ce 100644 --- a/cmd/cyrene/persona_agents.go +++ b/cmd/cyrene/persona_agents.go @@ -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 { diff --git a/cmd/cyrene/persona_identity.go b/cmd/cyrene/persona_identity.go new file mode 100644 index 0000000..b7d29c8 --- /dev/null +++ b/cmd/cyrene/persona_identity.go @@ -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 + } +} diff --git a/cmd/cyrene/persona_identity_test.go b/cmd/cyrene/persona_identity_test.go new file mode 100644 index 0000000..34e854f --- /dev/null +++ b/cmd/cyrene/persona_identity_test.go @@ -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("空账号被判成了主人") + } +} diff --git a/cmd/cyrene/wire_context.go b/cmd/cyrene/wire_context.go new file mode 100644 index 0000000..d72a3c5 --- /dev/null +++ b/cmd/cyrene/wire_context.go @@ -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 +} diff --git a/cmd/cyrene/wire_context_test.go b/cmd/cyrene/wire_context_test.go new file mode 100644 index 0000000..20b6a03 --- /dev/null +++ b/cmd/cyrene/wire_context_test.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_guardrail.go b/cmd/cyrene/wire_guardrail.go new file mode 100644 index 0000000..68a5929 --- /dev/null +++ b/cmd/cyrene/wire_guardrail.go @@ -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 +} diff --git a/cmd/cyrene/wire_guardrail_test.go b/cmd/cyrene/wire_guardrail_test.go new file mode 100644 index 0000000..9e332c2 --- /dev/null +++ b/cmd/cyrene/wire_guardrail_test.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 6a5d479..901cf30 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index 2962d03..1039cfe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,8 @@ type Config struct { MCP MCPConfig Capacity CapacityConfig Channels ChannelsConfig + Context ContextConfig + Guardrail GuardrailConfig } // ServerConfig holds the single gateway listener settings. diff --git a/internal/config/context.go b/internal/config/context.go new file mode 100644 index 0000000..acd9d62 --- /dev/null +++ b/internal/config/context.go @@ -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"` +} diff --git a/internal/config/guardrail.go b/internal/config/guardrail.go new file mode 100644 index 0000000..93a42a7 --- /dev/null +++ b/internal/config/guardrail.go @@ -0,0 +1,16 @@ +package config + +// GuardrailConfig 承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。 +// +// 敏感信息阻断档与长度上限是代码内定的默认策略(见组合根的 +// buildGuardrailPipeline),不对外开放配置:它们是安全默认值,不该被 +// 一次误配置关掉。这里只放内容策略词表 —— 它取决于部署方的场景, +// 项目不内置任何词表。 +type GuardrailConfig struct { + // Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。 + // + // **为空时不挂关键词护栏**:项目绝不硬编一张词表 —— 凭空猜出来的词 + // 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过 + // 的词才会被拦(大小写不敏感的子串匹配)。 + Keywords []string `env:"GUARDRAIL_KEYWORDS"` +} diff --git a/internal/config/owner_identity.go b/internal/config/owner_identity.go new file mode 100644 index 0000000..b6be776 --- /dev/null +++ b/internal/config/owner_identity.go @@ -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) + } +} diff --git a/internal/config/owner_identity_test.go b/internal/config/owner_identity_test.go new file mode 100644 index 0000000..7da732c --- /dev/null +++ b/internal/config/owner_identity_test.go @@ -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) + } + } +} diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index f43e3ea..e1ec3e0 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -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), } } diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 8e3487d..12f1055 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -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)) + } + } // 每轮「当前时间」,使模型自然知道墙上时钟(而非首轮 // 快照)。以已配置的时区为条件,使测试与无时区的运行 // 保持其精确的前导消息数。它位于工作记忆 / diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index d52c6a8..e8a319a 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -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")。为空则完全关闭时间注入 // (向后兼容);加载失败的名字同样关闭它。 diff --git a/internal/runtime/loop/persona_knowledge_test.go b/internal/runtime/loop/persona_knowledge_test.go new file mode 100644 index 0000000..a64f624 --- /dev/null +++ b/internal/runtime/loop/persona_knowledge_test.go @@ -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]) + } + }) + } +}