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 }