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,含面板链四条真实回执
This commit is contained in:
1 parent
745365c7df
commit
4a04ad43fe
2 files changed
+409
No files matched your search
@@ -0,0 +1,51 @@
|
||||
# 示例技能:每日简报(design/09-插件生态.md §6.2 定稿字段的完整示范)
|
||||
#
|
||||
# 载体:目录式人写技能 configs/skills/daily-brief/skill.yaml(design/09 §6.1),
|
||||
# 进 git、启动时由 cmd/cyrene 的 wireSkills 读入并注册成 KindSkill 能力。
|
||||
#
|
||||
# 渐进披露(design/09 §6.3):清单里只有 name + description(+when-to-use);
|
||||
# 触发词或工作文件命中时,正文(prompt / 步骤 / 约束)才作为一条消息注入上下文,
|
||||
# 之后跨回合保留且不重复读盘。allowed-tools 是**能力上限**(不是免审批名单):
|
||||
# 该技能轮次里,主人本来能用的其它工具会暂时不可见,轮次结束即恢复。
|
||||
name: daily-brief
|
||||
description: 把「今天」发生的要闻整理成一份带出处的中文简报:先取时间口径,再检索素材,然后成稿,最后逐条核对来源。
|
||||
prompt: |
|
||||
你是新闻简报编辑。目标:输出一份**短、准、有出处**的今日简报(中文)。
|
||||
规则:
|
||||
- 只使用检索 / 抓取到的材料,逐条给出出处(链接或来源名)。
|
||||
- 没有查到的信息一律标注「未找到」,绝不补全、绝不推测、绝不凭训练数据编日期。
|
||||
- 时间口径以 datetime 工具给出的当前时间为准。
|
||||
version: 1
|
||||
when-to-use: 用户要求「今日简报 / 每日简报 / 今天有什么要闻」时。
|
||||
invocation: both
|
||||
triggers: [每日简报, 今日简报, 今日要闻]
|
||||
allowed-tools: [datetime, web-search, web-fetch, knowledge]
|
||||
context: inline
|
||||
metadata:
|
||||
category: workflow
|
||||
language: zh
|
||||
example: "true"
|
||||
steps:
|
||||
- name: 取时间口径
|
||||
prompt: 调用 datetime 工具拿到当前本地日期与星期,作为本次简报的时间口径;不要凭训练数据猜日期。
|
||||
- name: 收集素材
|
||||
prompt: >-
|
||||
用 web-search 检索当日要闻(科技 / 行业 / 与主人相关领域各一到三条),
|
||||
必要时用 web-fetch 打开原文核对标题与发布时间;把每条素材连同链接与时间记下来。
|
||||
- name: 成稿
|
||||
prompt: >-
|
||||
把素材写成简报:分「今日要闻」与「值得关注」两段,每条不超过两行,末尾附出处。
|
||||
整体控制在 400 字以内。
|
||||
depends: [收集素材]
|
||||
- name: 核来源
|
||||
prompt: >-
|
||||
逐条复核出处是否真的支撑该条结论;支撑不了就删掉该条,
|
||||
并在末尾如实写明「以下条目因无法核实已删除:…」。
|
||||
depends: [成稿]
|
||||
guardrails:
|
||||
- id: 不许编造
|
||||
message: 任何没有检索到出处的结论必须删除或标注「未找到」;编造即违规。
|
||||
enforced: true
|
||||
- id: 控制篇幅
|
||||
message: 简报正文控制在 400 字以内,超出时先删次要条目。
|
||||
enforced: false
|
||||
@@ -0,0 +1,358 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user