Files
Cyrene_v2/internal/capability/skills/example_skill_e2e_test.go
T
AskaEth 4a04ad43fe feat(skills): 示例技能 + 端到端验收(V3)
- configs/skills/daily-brief/skill.yaml:真能跑的工作流(4 步依赖图 + 2 条 guardrail + allowed-tools)
- example_skill_e2e_test.go:真 Loader + 真 registry + 真 loop 三轮断言 —— 第1轮只有清单、
  第2轮命中展开正文且工具面只剩 allowed(B 工具不可见)、第3轮正文保留而工具面恢复、BodyRenders==1
- 3 个变异自检(triggers 清空/关掉收缩/正文漏进清单)
⚠️ 生产未验:当前后端启动早于技能落盘 ⇒ 需一次 restart 才会出现 skills registered;
  隔离实例(临时端口/临时 DATA_DIR)已 GREEN,含面板链四条真实回执
2026-10-06 20:25:14 +08:00

359 lines
13 KiB
Go
Raw 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 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/<id>/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
}