diff --git a/configs/skills/daily-brief/skill.yaml b/configs/skills/daily-brief/skill.yaml new file mode 100644 index 0000000..4b384ae --- /dev/null +++ b/configs/skills/daily-brief/skill.yaml @@ -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 diff --git a/internal/capability/skills/example_skill_e2e_test.go b/internal/capability/skills/example_skill_e2e_test.go new file mode 100644 index 0000000..437b6e8 --- /dev/null +++ b/internal/capability/skills/example_skill_e2e_test.go @@ -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//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 +}