diff --git a/cmd/cyrene/wire_capacity.go b/cmd/cyrene/wire_capacity.go index beb7614..0056540 100644 --- a/cmd/cyrene/wire_capacity.go +++ b/cmd/cyrene/wire_capacity.go @@ -27,9 +27,14 @@ import ( // (CAPACITY_L3_CACHE_TTL, default 5m) is parsed here because the config loader // only carries scalars. // -// The persona-level awakening override (裁决 14.4.3) is filled here from the -// current main persona's YAML block (see defaultAwakening); a persona that -// declares no block contributes the zero value, i.e. no override. +// The persona-level awakening override (裁决 14.4.3) enters the decision layer in +// two places. This function fills the *global* fallback (Config.Awakening) from the +// current main persona's YAML block (see defaultAwakening): it is what a persona +// declaring no block inherits. The per-turn value is resolved by awakeningFor, +// which the loop calls for every turn's own role through +// loop.Options.PersonaAwakening — without it, a non-default character's +// `awakening` block would be parsed, validated and then silently ignored +// (forbidden by design/01 §2.1). func (a *app) buildCapacity() *capacity.Decider { if !a.cfg.Capacity.Enabled { return nil @@ -96,21 +101,48 @@ func parseCapacityToolSets(raw string) (map[capacity.State][]string, error) { } // defaultAwakening 取当前主角色(`configs/users/owner.yaml` 的 `defaultPersona`, -// 未配置时回落 `cyrene`)档案里的 `awakening` 块,映射为 capacity 的人格档位覆盖 -// (design/14 §3,裁决 14.4.3)。下列情形一律返回零值 = **无覆盖**,由决策层走 -// 全局默认,绝不在此处凭空补档位: -// - 尚未装配人格注册表(降级启动 / 单测构造的 app); -// - 该角色档案里没写 `awakening` 块(缺席就是没有); -// - 档位名不是规范态名(loader 已在载入期拒绝非法值;这里按无覆盖处理并 -// 告警——未知态若被塞进决策层,会落进「不受限工具集」分支)。 +// 未配置时回落 `cyrene`)档案里的 `awakening` 块。它填的是决策层的**全局兜底** +// (capacity.Config.Awakening);每个 turn 真正生效的档位由 awakeningFor 按该 +// turn 的角色解析(裁决 14.4.3 的后半)。两者共用同一处映射,因此「全局兜底」 +// 与「按角色取值」的口径绝不会分叉。 // -// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。 +// 未装配人格注册表(降级启动 / 单测构造的 app)时返回零值 —— 注册表缺席时连 +// 默认角色都问不出来,此时不补任何档位。 func (a *app) defaultAwakening() capacity.Awakening { if a.personas == nil { return capacity.Awakening{} } - p, ok := a.defaultPersona() - if !ok || p == nil || p.Awakening == nil { + return a.awakeningFor(a.defaultPersonaID()) +} + +// awakeningFor 取**该角色**档案里的 `awakening` 块,映射为决策层的人格档位覆盖 +// (design/14 §3,裁决 14.4.3)。它是 loop.Options.PersonaAwakening 的实现:循环 +// 每个 turn 按该 turn 的角色 id 调它,因此绑定非默认角色的会话用**自己**的档位 +// 判定能力面,而不是只吃默认角色的全局兜底。 +// +// 下列情形一律返回零值 = **无覆盖**,由决策层走全局默认(Config.Awakening + +// 全局阈值),绝不在此处凭空补档位: +// - 尚未装配人格注册表(降级启动 / 单测构造的 app); +// - 角色 id 为空或该角色不在注册表里(未知/已删除人格); +// - 该角色档案里没写 `awakening` 块(缺席就是没有)。 +// +// 档位是倾向/呈现,不是能力边界——它只影响本轮暴露的工具面与唤醒阈值。 +func (a *app) awakeningFor(personaID string) capacity.Awakening { + if a.personas == nil || personaID == "" { + return capacity.Awakening{} + } + p, ok := a.personas.Persona(personaID) + if !ok || p == nil { + return capacity.Awakening{} + } + return a.awakeningOf(p) +} + +// awakeningOf 把一份人格档案的 `awakening` 块映射为决策层的档位覆盖。映射不了 +// 的档位名按「无该档位」处理(awakeningStateOf 会告警),因此非法值绝不会被塞进 +// 决策层(未知态会落进「不受限工具集」分支)。 +func (a *app) awakeningOf(p *loader.Persona) capacity.Awakening { + if p == nil || p.Awakening == nil { return capacity.Awakening{} } aw := capacity.Awakening{ diff --git a/cmd/cyrene/wire_capacity_role_test.go b/cmd/cyrene/wire_capacity_role_test.go new file mode 100644 index 0000000..fb7803f --- /dev/null +++ b/cmd/cyrene/wire_capacity_role_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "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/capacity" + "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/runtime/tool" +) + +// 本文件端到端锁定裁决 14.4.3 的后半:**每个 turn 用该 turn 所属角色的唤醒档位**。 +// 只用默认角色的档位填 Config.Awakening(全局兜底)是不够的 —— 绑定非默认角色的 +// 会话会「档位被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效配置)。 +// 因此这里的断言落在**模型实际看到的工具面**上:链路是 persona YAML → loader → +// 注册表 → app.awakeningFor → loop.Options.PersonaAwakening → capacity.Input.Awakening +// → Decide → 广告给模型的 schema。 + +// 加在两个角色身上的工具名取自 capacity 的内建档位集:terminal 在静息态就可见, +// web-search 只在唤醒/全能态出现,因此「被广告的工具集」直接暴露判定结果。 +const ( + quietToolName = "terminal" + awakeToolName = "web-search" +) + +// roleProbeTool 是只为广告工具名存在的 sdk.Tool 替身:本文件断言的是 +// 「哪些工具被广告给模型」,工具本身从不执行。 +type roleProbeTool string + +func (t roleProbeTool) Name() string { return string(t) } +func (t roleProbeTool) Description() string { return "role probe tool " + string(t) } +func (t roleProbeTool) Parameters() (map[string]any, error) { + return nil, nil +} +func (t roleProbeTool) Execute(context.Context, any) (any, error) { return nil, nil } + +// roleProbeAdapter 每次都记下流式请求,使测试能读回该 turn 广告给模型的工具面。 +type roleProbeAdapter struct{ streamReqs []model.Request } + +func (a *roleProbeAdapter) Name() string { return "role-probe" } + +func (a *roleProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) { + return model.Response{Content: "好的"}, nil +} + +func (a *roleProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { + a.streamReqs = append(a.streamReqs, req) + if on != nil { + on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好的"}) + } + return model.Response{Content: "好的"}, nil +} + +// twoRoleApp 注册两个角色,两者都来自真实 YAML(走与生产相同的载入期解析与校验): +// `cyrene` 是默认角色且档案里**没有** `awakening` 块,`tasky` 带调用方给定的块。 +// 默认角色不声明档位,正是「不声明者继承全局兜底(零覆盖)」的现场。 +func twoRoleApp(t *testing.T, taskyAwakening string) *app { + t.Helper() + + dir := t.TempDir() + reg := personareg.New() + for _, spec := range []struct { + name string + body string + isDefault bool + }{ + {"cyrene", awakeningPersonaBody, true}, + {"tasky", awakeningPersonaBody + taskyAwakening, false}, + } { + path := filepath.Join(dir, spec.name+".yaml") + if err := os.WriteFile(path, []byte("id: "+spec.name+"\n"+spec.body), 0o600); err != nil { + t.Fatalf("write %s yaml: %v", spec.name, err) + } + p, err := loader.Load(path) + if err != nil { + t.Fatalf("loader.Load(%s): %v", spec.name, err) + } + if err := reg.RegisterPersona(p); err != nil { + t.Fatalf("RegisterPersona(%s): %v", spec.name, err) + } + if err := reg.RegisterCharacter(&personareg.Character{ + ID: p.ID, Name: p.Name, Default: spec.isDefault, PersonaID: p.ID, + }); err != nil { + t.Fatalf("RegisterCharacter(%s): %v", spec.name, err) + } + } + a := &app{log: testLog(), personas: reg} + a.cfg = &config.Config{} + a.cfg.Capacity.Enabled = true + return a +} + +// roleProbeLoop 按生产接线的形状组装带 capacity 的循环:逐 turn 的档位经 +// `resolve` 注入(生产传的正是 `a.awakeningFor`),`resolve` 为 nil 时是 +// 「接线前」的对照循环。 +func roleProbeLoop(t *testing.T, dec *capacity.Decider, ad model.Adapter, resolve func(string) capacity.Awakening) *loop.Loop { + t.Helper() + + caps := registry.New() + d, err := tool.New(caps) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + for _, name := range []string{quietToolName, awakeToolName} { + if err := d.Register(roleProbeTool(name)); err != nil { + t.Fatalf("register %s: %v", name, err) + } + } + pool := make(map[string]*agent.Agent, 2) + for _, id := range []string{"cyrene", "tasky"} { + ag, err := agent.New("你是"+id+"。", d, nil, agent.WithModel(ad, nil)) + if err != nil { + t.Fatalf("agent.New(%s): %v", id, err) + } + pool[id] = ag + } + return loop.New(loop.Options{ + Agent: pool["cyrene"], + Agents: pool, + Tools: d, + Capacity: dec, + PersonaID: "cyrene", + PersonaAwakening: resolve, + OnStream: func(model.StreamEvent) {}, + }) +} + +// roleTurnTools 以 personaID 的会话身份驱动一个 turn,并返回模型实际看到的工具名。 +// 多个 texts 按时间顺序入列,最后一个即本轮的用户消息(与判定器看到的窗口一致)。 +func roleTurnTools(t *testing.T, l *loop.Loop, ad *roleProbeAdapter, personaID string, texts ...string) map[string]bool { + t.Helper() + + msgs := make([]model.Message, 0, len(texts)) + for _, txt := range texts { + msgs = append(msgs, model.TextMessage(model.RoleUser, txt)) + } + env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID} + ctx := loop.WithSessionEnv(context.Background(), env) + if _, err := l.Run(ctx, "web:"+personaID, msgs); err != nil { + t.Fatalf("Run(%s): %v", personaID, err) + } + if len(ad.streamReqs) == 0 { + t.Fatalf("no streaming request captured for %s", personaID) + } + req := ad.streamReqs[len(ad.streamReqs)-1] + out := make(map[string]bool, len(req.Tools)) + for _, s := range req.Tools { + out[s.Name] = true + } + return out +} + +// TestCapacityAwakening_PerRoleAppliesToItsOwnTurns 是裁决 14.4.3 后半的端到端断言: +// 同一个**意图各层都拿不准**的输入("这个")下,声明了档位的角色与没声明的角色 +// 得到不同的工具面,而没声明的角色与「完全没接线」的对照循环逐字一致。 +func TestCapacityAwakening_PerRoleAppliesToItsOwnTurns(t *testing.T) { + t.Parallel() + + a := twoRoleApp(t, "awakening:\n overrides:\n force_state: 唤醒\n") + + // 档位确实按角色取出:非默认角色读自己的块,默认角色缺席即没有。 + if aw := a.awakeningFor("tasky"); aw.ForceState != capacity.StateAwake { + t.Fatalf("awakeningFor(tasky) = %+v, want force_state awake(取自己的档案)", aw) + } + if aw := a.awakeningFor("cyrene"); !aw.IsZero() { + t.Fatalf("awakeningFor(cyrene) = %+v, want zero(档案里没写该块)", aw) + } + + dec := a.buildCapacity() + const ambiguous = "这个" + + wired := &roleProbeAdapter{} + withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor) + baseline := &roleProbeAdapter{} + // 对照循环就是接线前的形状:判定器照旧,只是没有人按 turn 传档位。 + before := roleProbeLoop(t, dec, baseline, nil) + + declaring := roleTurnTools(t, withResolver, wired, "tasky", ambiguous) + undeclaring := roleTurnTools(t, withResolver, wired, "cyrene", ambiguous) + // 对照循环跑**同一个**角色与同一个输入。 + undeclaringBefore := roleTurnTools(t, before, baseline, "cyrene", ambiguous) + + if !declaring[awakeToolName] || !declaring[quietToolName] { + t.Fatalf("声明档位的角色工具面 = %v, want terminal+web-search(force_state 唤醒)", declaring) + } + if undeclaring[awakeToolName] { + t.Fatalf("未声明档位的角色工具面 = %v, want 静息(web-search 不得被广告)", undeclaring) + } + if len(declaring) == len(undeclaring) { + t.Fatalf("同一模糊输入下两个角色的工具面相同(%v)—— 档位没有按 turn 生效", declaring) + } + if !sameToolSet(undeclaring, undeclaringBefore) { + t.Fatalf("未声明档位者接线上后行为变了:%v vs 接线前 %v", undeclaring, undeclaringBefore) + } +} + +// TestCapacityAwakening_ThresholdRoleDiffersFromGlobal 同一落点的第二个判据:档位 +// 也可以只抬高阈值(min_confidence)。默认角色(零覆盖)在话题延续的模糊回合会因 +// L2 趋势猜到「办事」而唤醒,非默认角色把自己的阈值抬到 0.95 后同一回合回到静息。 +func TestCapacityAwakening_ThresholdRoleDiffersFromGlobal(t *testing.T) { + t.Parallel() + + a := twoRoleApp(t, "awakening:\n overrides:\n min_confidence: 0.95\n") + dec := a.buildCapacity() + // L2 趋势(置信 0.66 量级):默认角色零覆盖 → 唤醒;抬高阈值者 → 静息。 + trend := []string{"帮我查一下资料", "继续"} + + wired := &roleProbeAdapter{} + withResolver := roleProbeLoop(t, dec, wired, a.awakeningFor) + + // 对照:完全没接线的循环在同一序列上唤醒(证明差异来自该角色的档位)。 + baseline := &roleProbeAdapter{} + before := roleProbeLoop(t, dec, baseline, nil) + + loose := roleTurnTools(t, before, baseline, "cyrene", trend...) + strict := roleTurnTools(t, withResolver, wired, "tasky", trend...) + if !loose[awakeToolName] { + t.Fatalf("对照循环工具面 = %v, want 唤醒(web-search 被广告)", loose) + } + if strict[awakeToolName] { + t.Fatalf("抬高阈值的角色工具面 = %v, want 静息(0.95 阈值压回)", strict) + } +} + +// sameToolSet 比较两个工具名集合是否逐项相同(忽略注册顺序)。 +func sameToolSet(a, b map[string]bool) bool { + if len(a) != len(b) { + return false + } + for name := range a { + if !b[name] { + return false + } + } + return true +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 035adce..0f5f221 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -251,6 +251,7 @@ func (a *app) wireRuntime() error { PersonaID: personaID, PersonaName: a.personaName, PersonaKnobs: a.personaKnobs, + PersonaAwakening: a.awakeningFor, Timezone: a.cfg.Timezone, MaxRounds: defaultMaxRounds, WorkingMemory: memory.NewWorkingMemory(0), diff --git a/internal/runtime/loop/capacity_awakening_test.go b/internal/runtime/loop/capacity_awakening_test.go new file mode 100644 index 0000000..1d207bc --- /dev/null +++ b/internal/runtime/loop/capacity_awakening_test.go @@ -0,0 +1,85 @@ +package loop + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件锁定裁决 14.4.3 后半在**循环**这一层的契约:唤醒档位按该 turn 的角色 +// 解析(Options.PersonaAwakening),而不是绑定在 Loop 构造期;未注入解析器或 +// 角色未知时一律零覆盖、回落到判定器的全局兜底 —— 即接线前的行为。 + +// lastStreamTools 返回最新一次流式请求广告给模型的工具名集合。 +func lastStreamTools(t *testing.T, ad *mockAdapter) map[string]bool { + t.Helper() + if len(ad.streamReqs) == 0 { + t.Fatal("adapter recorded no streaming request") + } + return toolSet(ad.streamReqs[len(ad.streamReqs)-1].Tools) +} + +// TestLoop_CapacityAwakeningFollowsTheTurnRole 断言同一句「意图各层都拿不准」的 +// 输入下("这个"),声明了档位的角色唤醒、没声明的角色静息,而**未知角色**与 +// **未注入解析器**两种退化都保持判定器的既有行为(静息,且不广告 web-search)。 +func TestLoop_CapacityAwakeningFollowsTheTurnRole(t *testing.T) { + t.Parallel() + + td := capacityToolSet(t) + ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}} + a := newTestAgent(t, ad, td) + dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}) + awakened := map[string]capacity.Awakening{"Y": {ForceState: capacity.StateAwake}} + newLoop := func(resolve func(string) capacity.Awakening) *Loop { + return New(Options{ + Agent: a, + PersonaID: "X", + Capacity: dec, + OnStream: func(model.StreamEvent) {}, + PersonaAwakening: resolve, + }) + } + const ambiguous = "这个" + + resolved := newLoop(func(id string) capacity.Awakening { return awakened[id] }) + runPersonaTurn(t, resolved, roleEnv("X"), "web:x1", ambiguous) + if got := lastStreamTools(t, ad); got["web-search"] { + t.Fatalf("无档位的角色 X 的工具面 = %v, want 静息(不得广告 web-search)", got) + } + runPersonaTurn(t, resolved, roleEnv("Y"), "web:y1", ambiguous) + if got := lastStreamTools(t, ad); !got["web-search"] { + t.Fatalf("声明 force_state=awake 的角色 Y 的工具面 = %v, want 唤醒", got) + } + // 未知角色(解析器不认识它)不得让该 turn 失败,也不得泄漏别人的档位。 + runPersonaTurn(t, resolved, roleEnv("nobody"), "web:z1", ambiguous) + if got := lastStreamTools(t, ad); got["web-search"] { + t.Fatalf("未知角色的工具面 = %v, want 静息(按无覆盖处理)", got) + } + + // 未注入解析器 = 接线前的形状:所有角色都走全局兜底。 + before := newLoop(nil) + runPersonaTurn(t, before, roleEnv("Y"), "web:y2", ambiguous) + if got := lastStreamTools(t, ad); got["web-search"] { + t.Fatalf("未注入解析器时角色 Y 的工具面 = %v, want 与接线前一致(静息)", got) + } +} + +// TestLoop_AwakeningForDegradesQuietly 覆盖解析器的两个降级口:未注入解析器、 +// 以及该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空)时, +// awakeningFor 一律返回零覆盖而不是 panic 或猜一个角色。 +func TestLoop_AwakeningForDegradesQuietly(t *testing.T) { + t.Parallel() + + plain := WithSessionEnv(context.Background(), OwnerEnv()) + if got := New(Options{}).awakeningFor(plain); !got.IsZero() { + t.Fatalf("未注入解析器的循环返回了 %+v, want 零覆盖", got) + } + l := New(Options{PersonaAwakening: func(string) capacity.Awakening { + return capacity.Awakening{ForceState: capacity.StateAwake} + }}) + if got := l.awakeningFor(plain); !got.IsZero() { + t.Fatalf("没有角色的 turn 返回了 %+v, want 零覆盖(不猜角色)", got) + } +} diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 1609678..f43e3ea 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -101,6 +101,7 @@ type Loop struct { personaID string personaName func(personaID string) string personaKnobs func(personaID string) (LoopKnobs, bool) + personaAwakening func(personaID string) capacity.Awakening timeLocation *time.Location timeZoneName string maxRounds int @@ -160,6 +161,7 @@ func New(opts Options) *Loop { personaID: opts.PersonaID, personaName: opts.PersonaName, personaKnobs: opts.PersonaKnobs, + personaAwakening: opts.PersonaAwakening, timeLocation: timeLocation, timeZoneName: timeZoneName, maxRounds: maxRounds, diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index 04a8acb..d52c6a8 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -109,6 +109,16 @@ type Options struct { // 输入落到正确角色旋钮的原因。为 nil 时每个 turn 都保持配置的 // 默认值,即多角色之前的行为。 PersonaKnobs func(personaID string) (LoopKnobs, bool) + // PersonaAwakening 非 nil 时,为该 turn 的角色解析唤醒档位 + // (design/14 §3,裁决 14.4.3),使绑定到角色 Y 的会话按 Y 档案里的 + // `awakening` 块判定能力面 —— 而不是只吃默认角色的全局兜底 + // (Capacity 的 Config.Awakening)。没有它,别的角色写了档位也会 + // 「被解析、被校验、却静默不生效」(design/01 §2.1 禁止静默无效的配置)。 + // 返回零值 = 该角色无覆盖,回落到 Config.Awakening 与全局阈值(档案里 + // 没写该块就是没有)。与 PersonaKnobs 同样经角色 id 解析,因此所有路径 + // 都由同一个输入落到正确角色的档位。为 nil 时每个 turn 都不传逐调用覆盖, + // 即接线前的行为。 + PersonaAwakening func(personaID string) capacity.Awakening // Timezone 是用于渲染每轮「当前时间」系统消息的 IANA // 时区(如 "Asia/Shanghai")。为空则完全关闭时间注入 // (向后兼容);加载失败的名字同样关闭它。 diff --git a/internal/runtime/loop/persona.go b/internal/runtime/loop/persona.go index 8539027..9d7c5fb 100644 --- a/internal/runtime/loop/persona.go +++ b/internal/runtime/loop/persona.go @@ -4,6 +4,7 @@ import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -13,11 +14,11 @@ import ( // 记忆命名空间、情绪机与推理旋钮 —— 改为从该 turn 的 // SessionEnv 解析。 // -// 解析刻意做成 ctx 上的三个小函数,而不是 Loop 上的 +// 解析刻意做成 ctx 上的几个小函数,而不是 Loop 上的 // 字段:Loop 由所有并发会话共享,把解析出的角色存在 // 它上面会产生数据竞争,而 ctx 本就携带按 turn 的 env。 // 所有角色作用域读取都必须走这些辅助函数,使提示词、记忆、 -// 情绪与思考永远不会对「谁在回答」产生分歧。 +// 情绪、思考与唤醒档位永远不会对「谁在回答」产生分歧。 // LoopKnobs 是注入循环构建的每个请求的按角色模型/推理 // 旋钮。它镜像三个构造期选项 @@ -36,8 +37,8 @@ type LoopKnobs struct { // personaFor 返回当前 turn 发言所作为的角色(design/22 §2):调用方 // 解析出角色时取会话环境的显式角色,否则取循环 -// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆 -// 与情绪钩子都读它,因此一个 turn 绝不会混用两个角色。 +// 配置的角色(Options.PersonaID,全局默认)。提示词/记忆、 +// 情绪钩子与唤醒档位都读它,因此一个 turn 绝不会混用两个角色。 func (l *Loop) personaFor(ctx context.Context) string { if pid := sessionEnvFrom(ctx).PersonaID; pid != "" { return pid @@ -84,3 +85,27 @@ func (l *Loop) knobsFor(ctx context.Context) LoopKnobs { } return knobs } + +// awakeningFor 解析当前 turn 的唤醒档位(design/14 §3,裁决 14.4.3):该 turn +// 所属角色在档案里声明的 `awakening` 块,映射成决策层的人格覆盖。档位随 turn +// 解析而不是在 Loop 构造期绑定,理由与提示词/记忆/旋钮相同 —— 一个进程同时 +// 承载多个角色,而 Loop 由所有并发会话共享(存在 Loop 上会产生数据竞争)。 +// 解析器由组合根注入(Options.PersonaAwakening,按角色 id 查那份 YAML), +// 与 knobsFor 一样按角色 id 解析,因此所有路径(网关、channelbridge、 +// backthink、子代理)都从同一个输入落到正确角色的档位。 +// +// 下列情形一律返回零值 = **无覆盖**,由判定器回落到 Config.Awakening(全局 +// 兜底)与全局阈值,绝不在此处凭空补档位: +// - 未注入解析器(单角色接线、或未接线的调用方)——逐字节等于接线前的行为; +// - 该 turn 没有角色(SessionEnv.PersonaID 与 Options.PersonaID 都为空); +// - 该角色档案里没写 `awakening` 块(缺席就是没有)。 +func (l *Loop) awakeningFor(ctx context.Context) capacity.Awakening { + if l.personaAwakening == nil { + return capacity.Awakening{} + } + pid := l.personaFor(ctx) + if pid == "" { + return capacity.Awakening{} + } + return l.personaAwakening(pid) +} diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 81b150e..0f6a18e 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -98,6 +98,10 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode dec := l.capacity.Decide(ctx, capacity.Input{ Messages: capacityTexts(messages), CurrentState: acc.state, + // 该 turn 所属角色的档位(裁决 14.4.3 的后半):没有它,绑定非默认 + // 角色的会话只能吃到默认角色的全局兜底,别的角色写在档案里的 + // `awakening` 会被解析、被校验、却静默不生效。 + Awakening: l.awakeningFor(ctx), AllToolNames: names, Recall: l.capacityRecall(ctx, messages), }) diff --git a/internal/services/gateway/admin_owner_profile.go b/internal/services/gateway/admin_owner_profile.go new file mode 100644 index 0000000..6671ee5 --- /dev/null +++ b/internal/services/gateway/admin_owner_profile.go @@ -0,0 +1,137 @@ +package gateway + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/user" +) + +// 本文件拥有主人档案的读写(裁决 10.4.5),从 admin_write_personas_users.go 拆出 +// (AGENTS §10 文件 ≤500 行)。 + +// --- 主人档案(裁决 10.4.5)-------------------------------------------------- +// +// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据 +// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写) +// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。 + +// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML +// 一一对应,面板可原样往返回填。 +type ownerProfileResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + DefaultPersona string `json:"defaultPersona,omitempty"` + IdentityFile string `json:"identityFile,omitempty"` + Notes string `json:"notes,omitempty"` + Accounts []ownerAccountRequest `json:"accounts"` +} + +// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。 +type ownerAccountRequest struct { + Platform string `json:"platform"` + AccountID string `json:"accountId"` +} + +// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。 +func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse { + out := ownerProfileResponse{ + ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona, + IdentityFile: p.IdentityFile, Notes: p.Notes, + Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)), + } + for _, a := range p.Accounts { + out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID}) + } + return out +} + +// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白 +// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。 +func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile { + p := &user.OwnerProfile{ + ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName), + DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile), + Notes: strings.TrimSpace(r.Notes), + Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)), + } + for _, a := range r.Accounts { + p.Accounts = append(p.Accounts, user.OwnerAccount{ + Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID), + }) + } + return p +} + +// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过 +// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。 +func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) { + p, err := user.LoadOwnerProfile(ownerProfilePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{})) + return + } + // 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案: + // 那会让「白名单是空的」和「档案读不出来」看起来一模一样。 + s.log.Error("owner profile read", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err)) + return + } + writeJSON(w, http.StatusOK, ownerProfileFrom(p)) +} + +// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使 +// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、 +// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。 +func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) { + var req ownerProfileResponse + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) + return + } + p := req.toOwnerProfile() + if err := p.Validate(); err != nil { + s.auditClaims(r, "owner.profile.update", "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err)) + return + } + if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil { + s.auditClaims(r, "owner.profile.update", "error") + s.log.Error("owner profile persist", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile")) + return + } + s.hotSwapOwnerProfile(p) + s.auditClaims(r, "owner.profile.update", "ok") + writeJSON(w, http.StatusOK, ownerProfileFrom(p)) +} + +// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其 +// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来 +// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。 +type liveOwnerProfile interface { + // LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。 + LiveOwnerProfile() *user.OwnerProfile +} + +// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条 +// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。 +func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) { + holder, ok := s.ownerRegistrar.(liveOwnerProfile) + if !ok { + s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply") + return + } + live := holder.LiveOwnerProfile() + if live == nil { + s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply") + return + } + live.Replace(p) + s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts)) +} diff --git a/internal/services/gateway/admin_write_persona_blocks_test.go b/internal/services/gateway/admin_write_persona_blocks_test.go new file mode 100644 index 0000000..217ee28 --- /dev/null +++ b/internal/services/gateway/admin_write_persona_blocks_test.go @@ -0,0 +1,178 @@ +package gateway + +import ( + "net/http" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" +) + +// 本文件锁定人格写路径的**非破坏性更新**语义:面板 PUT 是整块替换,而请求体只 +// 带它认识的字段,因此「请求体里不出现的角色级块」必须保留已存值。曾经只有 +// `thinking` 做了这件事,`awakening`(裁决 14.4.3 刚接入的档位)、`visual`、 +// `triggerWords`、`emotion`(初始态/衰减/转移表)与 `affection`(好感度曲线)都会被 +// 一次面板保存静默抹掉 —— 配置从档案里消失(design/01 §2.1 禁止静默无效的配置), +// 随仓库发布的 configs/personas/cyrene.yaml 正是受害者(它五个块都写了)。 + +// personaWithReadOnlyBlocks 造一个「手工写过 YAML」的角色:这一整类只读块都配齐。 +func personaWithReadOnlyBlocks() *loader.Persona { + return &loader.Persona{ + ID: "xilian", Name: "昔涟", CharacterRef: "xilian", + Identity: loader.Identity{Role: "r", SystemPrompt: "你是昔涟。"}, + Awakening: &loader.AwakeningParams{ + DefaultState: loader.AwakeningStateQuiet, + Overrides: &loader.AwakeningOverrides{MinConfidence: 0.7, ForceState: loader.AwakeningStateAwake}, + }, + Visual: loader.PersonaVisual{ + Avatar: "/images/Cyrene_Avatar/x.png", + ChatBackground: loader.ChatBackground{Landscape: "l.png", Portrait: "p.png"}, + }, + TriggerWords: []string{"昔涟", "Cyrene"}, + Emotion: loader.EmotionParams{ + InitialState: emotion.Happy, + DecayRate: 0.05, + Transitions: map[emotion.EmotionState][]emotion.EmotionState{emotion.Happy: {emotion.Calm}}, + }, + Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10}, + } +} + +// registerStoredPersona 把该角色连同一个同名角色登记进注册表(写路径要求两者都在)。 +func registerStoredPersona(t *testing.T, p *loader.Persona) *personareg.Registry { + t.Helper() + + 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, PersonaID: p.ID}); err != nil { + t.Fatalf("RegisterCharacter: %v", err) + } + return reg +} + +// TestPersonaUpdate_PreservesReadOnlyBlocks 是那次数据丢失的回归锁:面板 PUT 一次 +// (请求体只带 name/role/systemPrompt),五个只读块必须**仍在** —— 在内存注册表里、 +// 也在落盘 YAML 里(能重新载入出同一份配置)。 +func TestPersonaUpdate_PreservesReadOnlyBlocks(t *testing.T) { + t.Chdir(t.TempDir()) + reg := registerStoredPersona(t, personaWithReadOnlyBlocks()) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{ + "name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。", + }, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + + // 内存注册表:一个块都不能少(更新会据此重建情绪机与好感度曲线)。 + p, ok := reg.Persona("xilian") + if !ok { + t.Fatal("persona disappeared from the registry") + } + assertReadOnlyBlocks(t, "registry", p) + + // 落盘 YAML:面板保存的是真档案,重新载入必须得到同样的块。 + reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml")) + if err != nil { + t.Fatalf("reload the saved persona: %v", err) + } + assertReadOnlyBlocks(t, "saved yaml", reloaded) +} + +// TestPersonaUpdate_KeepsMemoryNamespaceFromRequest 守住那次修复的边界: +// `emotion.memoryNamespace` **不是**只读块(面板确实编辑它),因此保留其它情绪参数 +// 不得把请求体里的新命名空间一并丢掉。 +func TestPersonaUpdate_KeepsMemoryNamespaceFromRequest(t *testing.T) { + t.Chdir(t.TempDir()) + stored := personaWithReadOnlyBlocks() + stored.Emotion.MemoryNamespace = "persona.old" + reg := registerStoredPersona(t, stored) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/personas/xilian", map[string]any{ + "name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。", "memoryNamespace": "persona.new", + }, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + p, _ := reg.Persona("xilian") + if p.Emotion.MemoryNamespace != "persona.new" { + t.Fatalf("memoryNamespace = %q, want the request's value (面板确实编辑它)", p.Emotion.MemoryNamespace) + } + if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 { + t.Fatalf("emotion params = %+v, want the stored initialState/decayRate", p.Emotion) + } +} + +// assertReadOnlyBlocks 断言这一整类只读块逐项还在(含嵌套值)。 +func assertReadOnlyBlocks(t *testing.T, where string, p *loader.Persona) { + t.Helper() + + if p.Awakening == nil { + t.Fatalf("%s: awakening block was wiped by the update (档位数据丢失)", where) + } + if p.Awakening.DefaultState != loader.AwakeningStateQuiet { + t.Errorf("%s: awakening.default_state = %q, want quiet", where, p.Awakening.DefaultState) + } + if ov := p.Awakening.Overrides; ov == nil || ov.MinConfidence != 0.7 || ov.ForceState != loader.AwakeningStateAwake { + t.Errorf("%s: awakening.overrides = %+v, want 0.7/awake", where, p.Awakening.Overrides) + } + if p.Visual.Avatar != "/images/Cyrene_Avatar/x.png" || + p.Visual.ChatBackground.Landscape != "l.png" || p.Visual.ChatBackground.Portrait != "p.png" { + t.Errorf("%s: visual = %+v, want the stored assets (头像/背景数据丢失)", where, p.Visual) + } + if len(p.TriggerWords) != 2 || p.TriggerWords[0] != "昔涟" || p.TriggerWords[1] != "Cyrene" { + t.Errorf("%s: triggerWords = %v, want [昔涟 Cyrene] (角色提醒词数据丢失)", where, p.TriggerWords) + } + if p.Emotion.InitialState != emotion.Happy || p.Emotion.DecayRate != 0.05 || len(p.Emotion.Transitions) != 1 { + t.Errorf("%s: emotion = %+v, want the stored initialState/decayRate/transitions (情绪机参数被重置)", where, p.Emotion) + } + if p.Affection.Delta != 10 || p.Affection.Max != 700 || p.Affection.Initial != 50 { + t.Errorf("%s: affection = %+v, want 50/0/700/10 (好感度曲线被重置)", where, p.Affection) + } +} + +// TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate 直接锁两个编辑助手:非 nil 的 +// 已存角色一律保留;没有已存角色(创建路径不会走到这里,但助手必须不 panic)得到 +// 零值。保留的切片还要**复制**,否则改一处会把注册表里那份也改掉。 +func TestApplyPersonaReadOnlyBlocks_PreserveAndIsolate(t *testing.T) { + t.Parallel() + + stored := personaWithReadOnlyBlocks() + if got := applyAwakeningEdit(stored); got != stored.Awakening { + t.Fatalf("applyAwakeningEdit(stored) = %+v, want the stored block", got) + } + + // 请求体里的命名空间在保留其它情绪参数时不被覆盖。 + p := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.new"}} + applyPersonaReadOnlyBlocks(p, stored) + if p.Emotion.MemoryNamespace != "persona.new" { + t.Fatalf("memoryNamespace = %q, want the request's value", p.Emotion.MemoryNamespace) + } + if p.Visual.Avatar != stored.Visual.Avatar || p.Emotion.InitialState != emotion.Happy || + p.Affection.Delta != 10 || len(p.TriggerWords) != 2 { + t.Fatalf("read-only blocks were not preserved: %+v", p) + } + p.TriggerWords[0] = "改了" + if stored.TriggerWords[0] != "昔涟" { + t.Fatal("applyPersonaReadOnlyBlocks aliased the registry's slice") + } + + // 创建路径(没有已存人格):助手必须什么都不做,而不是 panic。 + created := &loader.Persona{Emotion: loader.EmotionParams{MemoryNamespace: "persona.cyrene"}} + applyPersonaReadOnlyBlocks(created, nil) + if created.Awakening != nil || created.Visual != (loader.PersonaVisual{}) || created.Affection != (loader.AffectionParams{}) { + t.Fatalf("a nil stored persona must leave the request's persona alone: %+v", created) + } + if applyAwakeningEdit(nil) != nil { + t.Fatal("applyAwakeningEdit(nil) must yield nil") + } +} diff --git a/internal/services/gateway/admin_write_personas_users.go b/internal/services/gateway/admin_write_personas_users.go index 3cb8db2..8b6c046 100644 --- a/internal/services/gateway/admin_write_personas_users.go +++ b/internal/services/gateway/admin_write_personas_users.go @@ -2,7 +2,6 @@ package gateway import ( "encoding/json" - "errors" "fmt" "net/http" "os" @@ -15,7 +14,6 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "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/user" ) // 人格到身份与 API key 的管理写操作(仅 owner,带审计)。人格 @@ -152,11 +150,17 @@ func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid thinking config: %v", err)) return } + // 面板的写路径是**整块替换**,而请求体只带它认识的字段。请求体里不出现的 + // 角色级块一律保留已存值,否则「面板改一次角色」就会抹掉手工写进 YAML 的 + // 配置(数据丢失): + existing := existingPersona(reg, id) // 不认识可选 `thinking` 块的客户端(旧面板、脚本)会省略它; // 应保留已存的那个,而不是静默清掉手工编辑的 // 配置。发送该对象则替换它,全字段为默认值 // 时清除它。 - p.Thinking = applyThinkingEdit(req.Thinking, existingPersona(reg, id)) + p.Thinking = applyThinkingEdit(req.Thinking, existing) + p.Awakening = applyAwakeningEdit(existing) + applyPersonaReadOnlyBlocks(p, existing) if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.update", "error") s.log.Error("persona persist", "id", id, "err", err) @@ -238,128 +242,6 @@ func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true}) } -// --- 主人档案(裁决 10.4.5)-------------------------------------------------- -// -// 档案是 design/11 §0 的身份红线:渠道层用它的 accounts 白名单比对 actor 元数据 -// 认出主人,所以读写都只给 owner。落盘 configs/users/owner.yaml(0600 原子写) -// 后热替换活档案 —— 面板的「保存」因此是真保存,不再是只改本地 state 的假成功。 - -// ownerProfileResponse 是主人档案的线上形式,字段与 user.OwnerProfile 的 YAML -// 一一对应,面板可原样往返回填。 -type ownerProfileResponse struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - DefaultPersona string `json:"defaultPersona,omitempty"` - IdentityFile string `json:"identityFile,omitempty"` - Notes string `json:"notes,omitempty"` - Accounts []ownerAccountRequest `json:"accounts"` -} - -// ownerAccountRequest 是一条渠道账号白名单项(平台 + 该平台的账号 id)。 -type ownerAccountRequest struct { - Platform string `json:"platform"` - AccountID string `json:"accountId"` -} - -// ownerProfileFrom 把档案映射为线上形式,accounts 一律是空数组而不是 null。 -func ownerProfileFrom(p *user.OwnerProfile) ownerProfileResponse { - out := ownerProfileResponse{ - ID: p.ID, DisplayName: p.DisplayName, DefaultPersona: p.DefaultPersona, - IdentityFile: p.IdentityFile, Notes: p.Notes, - Accounts: make([]ownerAccountRequest, 0, len(p.Accounts)), - } - for _, a := range p.Accounts { - out.Accounts = append(out.Accounts, ownerAccountRequest{Platform: a.Platform, AccountID: a.AccountID}) - } - return out -} - -// toOwnerProfile 把线上档案映射回 user.OwnerProfile,并去掉各字段首尾空白 -// (面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)。 -func (r ownerProfileResponse) toOwnerProfile() *user.OwnerProfile { - p := &user.OwnerProfile{ - ID: strings.TrimSpace(r.ID), DisplayName: strings.TrimSpace(r.DisplayName), - DefaultPersona: strings.TrimSpace(r.DefaultPersona), IdentityFile: strings.TrimSpace(r.IdentityFile), - Notes: strings.TrimSpace(r.Notes), - Accounts: make([]user.OwnerAccount, 0, len(r.Accounts)), - } - for _, a := range r.Accounts { - p.Accounts = append(p.Accounts, user.OwnerAccount{ - Platform: strings.TrimSpace(a.Platform), AccountID: strings.TrimSpace(a.AccountID), - }) - } - return p -} - -// adminGetOwnerProfile 返回落盘的主人档案。文件不存在(全新安装,还没人配过 -// 主人)时返回一份空白档案而不是 404 —— 面板据此渲染一张空表单让主人首次填写。 -func (s *Server) adminGetOwnerProfile(w http.ResponseWriter, r *http.Request) { - p, err := user.LoadOwnerProfile(ownerProfilePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - writeJSON(w, http.StatusOK, ownerProfileFrom(&user.OwnerProfile{})) - return - } - // 坏档案(IO 失败或必填字段缺失)如实报告,不返回一份看似正常的空档案: - // 那会让「白名单是空的」和「档案读不出来」看起来一模一样。 - s.log.Error("owner profile read", "err", err) - writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_unreadable", "failed to read owner profile: %v", err)) - return - } - writeJSON(w, http.StatusOK, ownerProfileFrom(p)) -} - -// adminPutOwnerProfile 校验并落盘主人档案(0600 原子写),随后热替换活档案,使 -// 新白名单**立即**参与渠道层的 actor 判定,不必重启(design/11 §9「可编辑、 -// 热重载」)。校验失败或落盘失败都以明确的错误作答,绝不假装保存成功。 -func (s *Server) adminPutOwnerProfile(w http.ResponseWriter, r *http.Request) { - var req ownerProfileResponse - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) - return - } - p := req.toOwnerProfile() - if err := p.Validate(); err != nil { - s.auditClaims(r, "owner.profile.update", "error") - writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid owner profile: %v", err)) - return - } - if err := user.SaveOwnerProfile(ownerProfilePath, p); err != nil { - s.auditClaims(r, "owner.profile.update", "error") - s.log.Error("owner profile persist", "err", err) - writeError(w, apperr.Wrap(apperr.ErrInternal, "owner_profile_persist_failed", "failed to persist owner profile")) - return - } - s.hotSwapOwnerProfile(p) - s.auditClaims(r, "owner.profile.update", "ok") - writeJSON(w, http.StatusOK, ownerProfileFrom(p)) -} - -// liveOwnerProfile 是 OwnerRegistrar 的**可选**扩展:组合根额外实现它时(其 -// ownerRegistrar 持有启动时加载的活 *user.OwnerProfile,也就是渠道 bridge 用来 -// 解析 actor 的同一个指针),admin 写路径就能原地热替换那份档案。 -type liveOwnerProfile interface { - // LiveOwnerProfile 返回活的档案;启动时未能加载档案时为 nil。 - LiveOwnerProfile() *user.OwnerProfile -} - -// hotSwapOwnerProfile 把刚落盘的档案原地装进活档案。拿不到活档案时只记一条 -// WARN 说明「已落盘、需重启生效」,绝不假装热重载成功。 -func (s *Server) hotSwapOwnerProfile(p *user.OwnerProfile) { - holder, ok := s.ownerRegistrar.(liveOwnerProfile) - if !ok { - s.log.Warn("owner profile saved; live profile not exposed by composition root, restart to apply") - return - } - live := holder.LiveOwnerProfile() - if live == nil { - s.log.Warn("owner profile saved; no live profile was loaded at boot, restart to apply") - return - } - live.Replace(p) - s.log.Info("owner profile hot-reloaded", "accounts", len(p.Accounts)) -} - // personaFromRequest 从 upsert 请求构建 loader.Persona。characterRef 省略时 // 回落到 id(它只是资源引用,有人格 id 就够);identity.systemPrompt 则 // **不回落到任何派生串** —— 缺失由调用方显式报 400(裁决 08.4.2),这样写进 @@ -420,6 +302,41 @@ func applyThinkingEdit(req *personaThinkingRequest, existing *loader.Persona) lo return loader.ThinkingParams{} } +// applyAwakeningEdit 返回更新应持久化的 `awakening` 块(design/14 §3,裁决 +// 14.4.3):请求体不携带它就保留已存档位。面板目前**没有**该块的编辑控件 +// (档位只能写进 `configs/personas/*.yaml`),因此像 applyThinkingEdit 的 +// 「省略即保留」那样,更新路径必须留住它 —— 否则面板保存一次角色,刚手工写好 +// 的唤醒档位就被抹成 nil,配置被静默丢失(design/01 §2.1 禁止静默无效的配置)。 +// 面板将来支持编辑它时,在这里加请求体分支(自带块则替换、缺省仍保留), +// 调用点不动。 +func applyAwakeningEdit(existing *loader.Persona) *loader.AwakeningParams { + if existing == nil { + return nil + } + return existing.Awakening +} + +// applyPersonaReadOnlyBlocks 保留请求体**根本不携带**的角色级块:视觉资产 +// (visual:头像/聊天背景)、角色提醒词(triggerWords,design/18 §2 触发②)、情绪机 +// 参数(emotion 的 initialState/decayRate/transitions)与好感度曲线(affection)。 +// 它们都只能由人格 YAML 手工声明(面板不提供编辑控件,请求体里也没有对应字段), +// 而写路径是**整块替换** —— 不显式保留,一次「改个角色名」的保存就会把它们从 +// 档案与注册表里一并抹掉(数据丢失,design/01 §2.1)。 +// +// emotion.memoryNamespace 刻意不在此列:面板确实编辑它(`memoryNamespace`), +// 由请求体决定。 +func applyPersonaReadOnlyBlocks(p, existing *loader.Persona) { + if p == nil || existing == nil { + return + } + p.Visual = existing.Visual + p.TriggerWords = append([]string(nil), existing.TriggerWords...) + p.Emotion.InitialState = existing.Emotion.InitialState + p.Emotion.DecayRate = existing.Emotion.DecayRate + p.Emotion.Transitions = existing.Emotion.Transitions + p.Affection = existing.Affection +} + // characterFromRequest 构建绑定到该人格的 registry.Character。 func characterFromRequest(req personaUpsertRequest) *personareg.Character { modes := append([]string(nil), req.Modes...)