Files
Cyrene_v2/cmd/cyrene/wire_capacity_role_test.go
AskaEth 7afef8d037
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop,gateway,persona): awakening 逐 turn 按角色生效 + 修「面板保存角色抹掉 5 个块」的数据丢失(裁决 14.4.3)
- 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 仍写不出这五个块
2026-10-04 13:07:52 +08:00

246 lines
9.8 KiB
Go
Raw Permalink Blame History

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