- 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,含面板链四条真实回执
359 lines
13 KiB
Go
359 lines
13 KiB
Go
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
|
||
}
|