- per-role 注入补齐:Options.PersonaAwakening(personaID) → Loop.personaAwakening → l.awakeningFor(ctx) → capacity.Input.Awakening(toolSchemas 是全仓唯一构造该 Input 处) · 按 turn 解析(不绑 Loop 构造期,避免并发会话数据竞争),与既有 personaFor/knobsFor 同构 · 未知角色/空角色/档案无该块 ⇒ 零值 ⇒ 回落 Config.Awakening(不声明者行为与今天构造性一致) - **数据丢失修复**:persona PUT 是整块替换,此前只保留 thinking ⇒ 保存一次会同时抹掉 awakening / visual(头像·背景) / triggerWords(群聊提醒词) / emotion(情绪机参数) / affection(好感度曲线)。 新增 applyAwakeningEdit + applyPersonaReadOnlyBlocks 逐一保留(cyrene.yaml 五块全写,属必现故障) - 文件守 500 行:主人档案段原样搬移到 admin_owner_profile.go(零逻辑变更) - 复核:build/vet 绿 + 全树 `go test ./... -count=1` 0 FAIL;端到端=真 YAML→loader→buildCapacity→真 loop 流式请求→断言工具面(tasky 声明 force_state 唤醒 → 含 web-search;cyrene 不声明 → 与无解析器对照逐字相同); **8 条变异反证**先红后还原(含逐块删除各自的保留逻辑) - 残留缺口(登记):wire_runtime.go:254 那一行本身无用例覆盖;loader 非 strict(未知 YAML 键静默忽略, 需主人裁决是否开 KnownFields);POST /admin/personas 仍写不出这五个块
246 lines
9.8 KiB
Go
246 lines
9.8 KiB
Go
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
|
||
}
|