package skills_test // 本文件是 V3 切片「技能端到端」的验收测试:它**不造替身技能**,而是把 // configs/skills/ 下那份真的示例技能(daily-brief)经真加载器读进来、经真注册表 // 发布、再让**真 loop** 跑真轮次,逐条钉住 design/09 §6.3 的渐进披露与 §6.4-3 的 // 工具面收缩(含 §6.6-2/§6.6-3 两条验收判据)。 // // 为什么要放进本包(而不是 loop 包):loop 不在本切片文件面内;Go 的外部测试包 // (skills_test)可以 import loop,而 loop import skills —— 不构成包级循环依赖。 // 于是「真轮次」的证据落在本切片的文件面之内,且不需要复制 loop 的测试脚手架。 import ( "context" "path/filepath" "runtime" "sort" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" "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/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // exampleSkillID 是随仓库进 git 的示例技能目录名(configs/skills//skill.yaml)。 const exampleSkillID = "daily-brief" // ownerToolsNotInSkill 是主人本来可用、但**没有**出现在示例技能 allowed-tools 里的 // 工具名:它们是「能力收缩只收不放」的判别工具(该轮次里必须不可见)。 var ownerToolsNotInSkill = []string{"calculator", "terminal"} // repoSkillsDir 用本文件的编译期路径反推仓库根的 configs/skills —— 不依赖测试进程 // 的当前工作目录(换 CI/换调用方式也不会指错)。 func repoSkillsDir(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller 不可用,无法定位仓库根") } root := filepath.Join(filepath.Dir(file), "..", "..", "..") return filepath.Join(root, "configs", "skills") } // textOf 拼出一条消息里的全部文本片段。 func textOf(m model.Message) string { var b strings.Builder for _, part := range m.Content { if part.Type == model.ContentTypeText { b.WriteString(part.Text) } } return b.String() } // scriptedAdapter 是循环测试用的最小 model.Adapter:记录每次请求,恒回一个终答。 type scriptedAdapter struct { reqs []model.Request } func (a *scriptedAdapter) Name() string { return "scripted" } func (a *scriptedAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { a.reqs = append(a.reqs, req) return model.Response{Content: "done"}, nil } func (a *scriptedAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { a.reqs = append(a.reqs, req) return model.Response{Content: "done"}, nil } // stubTool 是只报名字的注册表成员,用来观察「本轮工具面里有哪些名字」。 type stubTool struct{ name string } func (t stubTool) Name() string { return t.name } func (t stubTool) Description() string { return "stub " + t.name } func (t stubTool) Parameters() (map[string]any, error) { return nil, nil } func (t stubTool) Execute(context.Context, any) (any, error) { return "ok", nil } // loadExampleSkill 用真加载器读仓库里的示例技能目录,并从注册表视角取回它。 func loadExampleSkill(t *testing.T) *skills.Skill { t.Helper() loaded, err := skills.NewLoader(nil).LoadDirDetailed(repoSkillsDir(t)) if err != nil { t.Fatalf("加载 configs/skills 失败(示例技能必须是能载入的合法定义): %v", err) } for _, item := range loaded { if item.ID != exampleSkillID { continue } return item.Skill } t.Fatalf("configs/skills 下没有找到示例技能 %q", exampleSkillID) return nil } // skillSource 复刻组合根的做法:技能来源 = 能力注册表里的 KindSkill 条目(进程内, // 每轮实时读)。见 cmd/cyrene/wire_services.go 的 buildSkillFace。 func skillSource(reg *registry.Registry) loop.SkillSource { return func() []sdk.Skill { caps := reg.List(registry.KindSkill) out := make([]sdk.Skill, 0, len(caps)) for _, cap := range caps { if s, ok := cap.Value.(sdk.Skill); ok && s != nil { out = append(out, s) } } return out } } // toolNamesOf 返回一次模型请求里的工具名(排序后,便于断言)。 func toolNamesOf(t *testing.T, req model.Request) []string { t.Helper() names := make([]string, 0, len(req.Tools)) for _, spec := range req.Tools { names = append(names, spec.Name) } sort.Strings(names) return names } // messagesTextOf 拼出一次模型请求的全部消息文本。 func messagesTextOf(req model.Request) string { var b strings.Builder for _, m := range req.Messages { b.WriteString(textOf(m)) b.WriteString("\n") } return b.String() } // systemBlocksOf 返回一次模型请求里的全部 system 文本块。 func systemBlocksOf(req model.Request) []string { out := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { if m.Role == model.RoleSystem { out = append(out, textOf(m)) } } return out } // TestExampleSkill_清单与上限 钉住示例技能的定义事实:它真的声明了触发词与 // allowed-tools,且上限工具名与仓库里真实存在的工具同源(写错名字 = 采不到)。 func TestExampleSkill_清单与上限(t *testing.T) { t.Parallel() sk := loadExampleSkill(t) if sk.Name() != exampleSkillID { t.Fatalf("Name = %q, want %q", sk.Name(), exampleSkillID) } if strings.TrimSpace(sk.Description()) == "" || strings.TrimSpace(sk.Prompt()) == "" { t.Fatalf("示例技能必须有 description 与 prompt(不能是空壳)") } if len(sk.Steps()) < 2 { t.Fatalf("示例技能要有真实步骤图,got %d 步", len(sk.Steps())) } if len(sk.Guardrails()) == 0 { t.Fatalf("示例技能要带 guardrail") } if got := sk.Triggers(); len(got) == 0 { t.Fatalf("示例技能必须声明触发词,否则永远不会自动激活") } // 清单投影只给 name + description:正文(prompt 首句)不得出现在清单行里。 listing := skills.BuildListing([]sdk.Skill{sk}) if len(listing.Entries) != 1 { t.Fatalf("清单条目 = %d, want 1", len(listing.Entries)) } entry := listing.Entries[0] if entry.Description == "" { t.Fatalf("invocation=both 的清单行必须带说明") } if strings.Contains(entry.Description, "你是新闻简报编辑") { t.Fatalf("正文漏进了清单:%q", entry.Description) } if !entry.ModelInvocable || !entry.UserInvocable { t.Fatalf("invocation 应为 both:model=%v user=%v", entry.ModelInvocable, entry.UserInvocable) } } // TestExampleSkill_端到端渐进披露与工具面收缩 是本切片的端到端证据:真加载器 + // 真注册表 + 真 loop + 真轮次。 func TestExampleSkill_端到端渐进披露与工具面收缩(t *testing.T) { t.Parallel() sk := loadExampleSkill(t) reg := registry.New() if err := skills.Register(reg, sk); err != nil { t.Fatalf("Register: %v", err) } d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } registered := append(append([]string{}, sk.AllowedTools()...), ownerToolsNotInSkill...) for _, name := range registered { if err := d.Register(stubTool{name: name}); err != nil { t.Fatalf("Register tool %q: %v", name, err) } } ad := &scriptedAdapter{} a, err := agent.New("You are a helpful assistant.", d, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } face := loop.NewSkillFace(loop.SkillFaceOptions{Source: skillSource(reg)}) l := loop.New(loop.Options{Agent: a, Tools: d, MaxRounds: 4}) l.SetSkillFace(face) ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv()) const session = "s-daily-brief" run := func(turnText string) model.Request { t.Helper() if _, err := l.Run(ctx, session, []model.Message{model.TextMessage(model.RoleUser, turnText)}); err != nil { t.Fatalf("Run(%q): %v", turnText, err) } if len(ad.reqs) == 0 { t.Fatalf("Run(%q): 模型没有被调用", turnText) } return ad.reqs[len(ad.reqs)-1] } // ---- 第 1 轮:没有触发词 ⇒ 只投影清单,不展开正文,工具面原样 ---- first := run("今天天气不错") firstText := messagesTextOf(first) if !strings.Contains(firstText, "技能清单") || !strings.Contains(firstText, "- "+exampleSkillID+":") { t.Fatalf("第 1 轮应只有清单行:%q", firstText) } if strings.Contains(firstText, "技能正文:"+exampleSkillID) { t.Fatalf("第 1 轮没有命中却展开了正文:%q", firstText) } if got := toolNamesOf(t, first); !sameSet(got, registered) { t.Fatalf("第 1 轮工具面 = %v, want 全集 %v", got, registered) } // ---- 第 2 轮:触发词命中 ⇒ 正文展开 + 工具面收缩(B 不可见) ---- second := run("来一份每日简报") secondText := messagesTextOf(second) if !strings.Contains(secondText, "技能正文:"+exampleSkillID) { t.Fatalf("第 2 轮命中后应展开正文:%q", secondText) } if n := countSystemBlocks(systemBlocksOf(second), "技能正文:"+exampleSkillID); n != 1 { t.Fatalf("正文块 = %d, want 1(一个技能一条消息)", n) } wantNarrowed := append([]string{}, sk.AllowedTools()...) if got := toolNamesOf(t, second); !sameSet(got, wantNarrowed) { t.Fatalf("技能轮次工具面 = %v, want 仅 allowed-tools %v(B 必须不可见)", got, wantNarrowed) } for _, hidden := range ownerToolsNotInSkill { if contains(toolNamesOf(t, second), hidden) { t.Fatalf("技能轮次里 %q 仍可见(只收不放被破坏)", hidden) } } // ---- 第 3 轮:新回合没有触发词 ⇒ 正文跨回合保留,权限恢复,且不重复渲染 ---- third := run("谢谢") thirdText := messagesTextOf(third) if !strings.Contains(thirdText, "技能正文:"+exampleSkillID) { t.Fatalf("正文应跨回合保留:%q", thirdText) } if got := toolNamesOf(t, third); !sameSet(got, registered) { t.Fatalf("新回合工具面应恢复原样 = %v, want %v", got, registered) } stats := face.Stats() if stats.BodyRenders != 1 { t.Fatalf("正文渲染次数 = %d, want 1(同一份定义不重复渲染/不重复读盘)", stats.BodyRenders) } if stats.BodyCacheHits == 0 { t.Fatalf("正文缓存应当被命中,stats=%+v", stats) } if stats.Activations != 1 { t.Fatalf("激活次数 = %d, want 1(已激活的技能不重复计数)", stats.Activations) } } // TestExampleSkill_未命中不写正文 是上面那条的负向对照:说同一句无关文本三次, // 正文块一次都不该出现(避免「命中判定」被写成恒真)。 func TestExampleSkill_未命中不写正文(t *testing.T) { t.Parallel() sk := loadExampleSkill(t) reg := registry.New() if err := skills.Register(reg, sk); err != nil { t.Fatalf("Register: %v", err) } d, err := tool.New(reg) if err != nil { t.Fatalf("tool.New: %v", err) } if err := d.Register(stubTool{name: sk.AllowedTools()[0]}); err != nil { t.Fatalf("Register tool: %v", err) } ad := &scriptedAdapter{} a, err := agent.New("You are a helpful assistant.", d, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } face := loop.NewSkillFace(loop.SkillFaceOptions{Source: skillSource(reg)}) l := loop.New(loop.Options{Agent: a, Tools: d, MaxRounds: 4}) l.SetSkillFace(face) ctx := loop.WithSessionEnv(context.Background(), loop.OwnerEnv()) for i := 0; i < 3; i++ { if _, err := l.Run(ctx, "s-quiet", []model.Message{model.TextMessage(model.RoleUser, "随便聊聊")}); err != nil { t.Fatalf("Run: %v", err) } } if strings.Contains(messagesTextOf(ad.reqs[len(ad.reqs)-1]), "技能正文:"+exampleSkillID) { t.Fatalf("未命中却注入了正文") } if got := face.Stats().Activations; got != 0 { t.Fatalf("Activations = %d, want 0", got) } } // countSystemBlocks 数一数 system 块里含 marker 的块数。 func countSystemBlocks(blocks []string, marker string) int { n := 0 for _, b := range blocks { if strings.Contains(b, marker) { n++ } } return n } // contains 报告 names 里是否有 want。 func contains(names []string, want string) bool { for _, name := range names { if name == want { return true } } return false } // sameSet 比较两个名字集合(乱序、忽略重复)。 func sameSet(got, want []string) bool { g := append([]string(nil), got...) w := append([]string(nil), want...) sort.Strings(g) sort.Strings(w) if len(g) != len(w) { return false } for i := range g { if g[i] != w[i] { return false } } return true }