Files
Cyrene_v2/internal/capability/skills/runner_test.go
T
AskaEth f75e7a9c9b feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)
- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
2026-10-06 09:03:35 +08:00

334 lines
12 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
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
func TestRunner_Expand_正常(t *testing.T) {
t.Parallel()
r := NewRunner(registry.New(), testLogger())
wf, err := r.Expand(context.Background(), makeSkill(t, "review"))
if err != nil {
t.Fatalf("Expand: %v", err)
}
if wf.Name != "review" {
t.Fatalf("Name = %q, want review", wf.Name)
}
if wf.Description != "desc review" {
t.Fatalf("Description = %q, want desc review", wf.Description)
}
if wf.Prompt != "prompt review" {
t.Fatalf("Prompt = %q, want prompt review", wf.Prompt)
}
if len(wf.Steps) != 1 || wf.Steps[0].Name != "s1" {
t.Fatalf("Steps = %+v, want one step s1", wf.Steps)
}
if len(wf.Refs) != 1 || wf.Refs[0].Name != "r1" {
t.Fatalf("Refs = %+v, want one ref r1", wf.Refs)
}
if len(wf.Guardrails) != 1 || wf.Guardrails[0].ID != "g1" {
t.Fatalf("Guardrails = %+v, want one guardrail g1", wf.Guardrails)
}
if wf.Spec.Version != sdk.DefaultSkillVersion || wf.Spec.Invocation != sdk.InvocationBoth || wf.Spec.Context != sdk.ContextInline {
t.Fatalf("Spec 缺省值 = %+v, want version=1/invocation=both/context=inline", wf.Spec)
}
}
func TestRunner_Expand_Nil_异常(t *testing.T) {
t.Parallel()
r := NewRunner(registry.New(), testLogger())
if _, err := r.Expand(context.Background(), nil); !errors.Is(err, ErrInvalidSkill) {
t.Fatalf("Expand(nil) err = %v, want ErrInvalidSkill", err)
}
}
func TestRunner_Expand_BlankName_异常(t *testing.T) {
t.Parallel()
r := NewRunner(registry.New(), testLogger())
if _, err := r.Expand(context.Background(), mockSkill{name: " "}); !errors.Is(err, ErrInvalidSkill) {
t.Fatalf("Expand(blank) err = %v, want ErrInvalidSkill", err)
}
}
func TestRunner_Expand_ContextCanceled_异常(t *testing.T) {
t.Parallel()
r := NewRunner(registry.New(), testLogger())
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := r.Expand(ctx, makeSkill(t, "review")); !errors.Is(err, context.Canceled) {
t.Fatalf("Expand(canceled) err = %v, want context.Canceled", err)
}
}
func TestRunner_ExpandByName_正常(t *testing.T) {
t.Parallel()
reg := registry.New()
if err := Register(reg, makeSkill(t, "review")); err != nil {
t.Fatalf("Register: %v", err)
}
r := NewRunner(reg, testLogger())
wf, err := r.ExpandByName(context.Background(), "review")
if err != nil {
t.Fatalf("ExpandByName: %v", err)
}
if wf.Name != "review" || len(wf.Steps) != 1 {
t.Fatalf("Workflow = %+v, want review with one step", wf)
}
}
func TestRunner_ExpandByName_NotSkill_异常(t *testing.T) {
t.Parallel()
reg := registry.New()
// 注册一个 Value 不是 Skill 的能力(直接用裸 API)。
if err := reg.Register(registry.Capability{Name: "notaskill", Kind: registry.KindTool, Value: "x"}); err != nil {
t.Fatalf("register tool-cap: %v", err)
}
r := NewRunner(reg, testLogger())
if _, err := r.ExpandByName(context.Background(), "notaskill"); !errors.Is(err, ErrNotFound) {
t.Fatalf("ExpandByName(non-skill) err = %v, want ErrNotFound", err)
}
}
func TestRunner_ExpandByName_NotFound_异常(t *testing.T) {
t.Parallel()
r := NewRunner(registry.New(), testLogger())
if _, err := r.ExpandByName(context.Background(), "missing"); !errors.Is(err, registry.ErrNotFound) {
t.Fatalf("ExpandByName(missing) err = %v, want registry.ErrNotFound", err)
}
}
func TestRunner_ExpandByName_NilRegistrar_异常(t *testing.T) {
t.Parallel()
r := NewRunner(nil, testLogger())
if _, err := r.ExpandByName(context.Background(), "x"); !errors.Is(err, ErrNilRegistrar) {
t.Fatalf("ExpandByName(nil reg) err = %v, want ErrNilRegistrar", err)
}
}
// ---- Run / RunSkill:真执行 ----
// TestRunner_RunSkill_线性依赖按序执行 覆盖最基本的执行路径:一步依赖另一步,
// 前序输出按步骤名传给下游,结果按声明序汇报。
func TestRunner_RunSkill_线性依赖按序执行(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "brief",
[]sdk.Step{
{Name: "collect", Prompt: "收集"},
{Name: "summarize", Prompt: "汇总", Depends: []string{"collect"}},
},
WithSpec(Spec{AllowedTools: []string{"search", "note"}}))
exec := &recordingExecutor{outputs: map[string]string{"collect": "原料", "summarize": "结论"}}
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"search", "note", "write"}),
ToolNames: []string{"search", "note", "write"},
})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if got := strings.Join(exec.order(), ","); got != "collect,summarize" {
t.Fatalf("执行顺序 = %q, want collect,summarize", got)
}
if got := exec.depsOf("summarize")["collect"]; got != "原料" {
t.Fatalf("summarize 拿到的 collect 输出 = %q, want 原料", got)
}
if got := strings.Join(res.Tools, ","); got != "search,note" {
t.Fatalf("Tools = %q, want search,note(write 被 allowed-tools 收掉)", got)
}
if len(res.Steps) != 2 || res.Steps[0].Name != "collect" || res.Steps[1].Output != "结论" {
t.Fatalf("Steps = %+v, want collect/summarize 两条", res.Steps)
}
}
// TestRunner_RunSkill_每步工具面一致且已收缩 断言 executor 看到的 Tools 就是
// 收缩后的结果(执行侧不可能拿到更宽的面)。
func TestRunner_RunSkill_每步工具面一致且已收缩(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}},
WithSpec(Spec{AllowedTools: []string{"a"}, DisallowedTools: []string{"a2"}}))
exec := &recordingExecutor{}
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"a", "b"}),
ToolNames: []string{"a", "a2", "b"},
})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if got := strings.Join(exec.toolsOf("one"), ","); got != "a" {
t.Fatalf("步骤工具面 = %q, want a", got)
}
}
func TestRunner_RunSkill_校验失败_异常(t *testing.T) {
t.Parallel()
// 依赖成环 ⇒ 执行前就被 Validate 拦下(运行期绝不挂死)。
skill := mustSkill(t, "cycle", []sdk.Step{
{Name: "a", Prompt: "p", Depends: []string{"b"}},
{Name: "b", Prompt: "p", Depends: []string{"a"}},
})
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: &recordingExecutor{}, Gate: NewNameSet(nil), ToolNames: []string{"x"},
})
if !errors.Is(err, ErrStepCycle) {
t.Fatalf("RunSkill(cycle) err = %v, want ErrStepCycle", err)
}
}
func TestRunner_RunSkill_缺执行器_异常(t *testing.T) {
t.Parallel()
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{
Gate: NewNameSet(nil),
})
if !errors.Is(err, ErrNoExecutor) {
t.Fatalf("RunSkill 无 executor err = %v, want ErrNoExecutor", err)
}
}
func TestRunner_RunSkill_缺安全闸_异常(t *testing.T) {
t.Parallel()
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), makeSkill(t, "s"), RunOptions{
Executor: &recordingExecutor{},
})
if !errors.Is(err, ErrNilGate) {
t.Fatalf("RunSkill 无 gate err = %v, want ErrNilGate", err)
}
}
func TestRunner_RunSkill_Nil_异常(t *testing.T) {
t.Parallel()
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), nil, RunOptions{
Executor: &recordingExecutor{}, Gate: NewNameSet(nil),
})
if !errors.Is(err, ErrInvalidSkill) {
t.Fatalf("RunSkill(nil) err = %v, want ErrInvalidSkill", err)
}
}
// TestRunner_Run_按注册表找技能 覆盖 Run 的按名查找路径(含找不到的两种情形)。
func TestRunner_Run_按注册表找技能(t *testing.T) {
t.Parallel()
reg := registry.New()
skill := mustSkill(t, "brief", []sdk.Step{{Name: "one", Prompt: "p"}})
if err := Register(reg, skill); err != nil {
t.Fatalf("Register: %v", err)
}
r := NewRunner(reg, testLogger())
res, err := r.Run(context.Background(), "brief", RunOptions{
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Skill != "brief" || len(res.Steps) != 1 {
t.Fatalf("RunResult = %+v, want brief with one step", res)
}
if _, err := r.Run(context.Background(), "missing", RunOptions{
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
}); !errors.Is(err, registry.ErrNotFound) {
t.Fatalf("Run(missing) err = %v, want registry.ErrNotFound", err)
}
if _, err := NewRunner(nil, testLogger()).Run(context.Background(), "brief", RunOptions{
Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}),
}); !errors.Is(err, ErrNilRegistrar) {
t.Fatalf("Run(nil reg) err = %v, want ErrNilRegistrar", err)
}
}
// TestRunner_RunSkill_工具面取自注册表 断言 ToolNames 缺省时从注册表 KindTool 读,
// 且技能声明的工具名必须真实存在于注册表 —— 不存在的名字不会凭空出现。
func TestRunner_RunSkill_工具面取自注册表(t *testing.T) {
t.Parallel()
reg := registry.New()
for _, name := range []string{"alpha", "beta"} {
sdkTool := stubTool{name: name}
if err := reg.RegisterTool(sdkTool); err != nil {
t.Fatalf("RegisterTool(%s): %v", name, err)
}
}
skill := mustSkill(t, "s", []sdk.Step{{Name: "one", Prompt: "p"}},
WithSpec(Spec{AllowedTools: []string{"alpha", "ghost"}}))
exec := &recordingExecutor{}
res, err := NewRunner(reg, testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec, Gate: NewNameSet([]string{"alpha", "beta"}),
})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if got := strings.Join(res.Tools, ","); got != "alpha" {
t.Fatalf("Tools = %q, want alpha(beta 被技能收掉,ghost 不在注册表里)", got)
}
}
// stubTool 是注册表测试用的最小 sdk.Tool。
type stubTool struct{ name string }
func (s stubTool) Name() string { return s.name }
func (s stubTool) Description() string { return "stub " + s.name }
func (s stubTool) Parameters() (map[string]any, error) { return map[string]any{"type": "object"}, nil }
func (s stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
// mustSkill 构造一个技能,失败即 fatal。
func mustSkill(t *testing.T, name string, steps []sdk.Step, opts ...Option) *Skill {
t.Helper()
skill, err := New(name, "desc "+name, "base prompt", steps, nil, nil, opts...)
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}
// recordingExecutor 记录每一步的执行顺序与入参,并可按步骤名定制输出/错误。
type recordingExecutor struct {
mu sync.Mutex
steps []string
deps map[string]map[string]string
tools map[string][]string
outputs map[string]string
errs map[string]error
}
func (e *recordingExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.deps == nil {
e.deps = make(map[string]map[string]string)
}
if e.tools == nil {
e.tools = make(map[string][]string)
}
e.steps = append(e.steps, req.Step.Name)
e.deps[req.Step.Name] = req.Depends
e.tools[req.Step.Name] = append([]string(nil), req.Tools...)
if err := e.errs[req.Step.Name]; err != nil {
return StepResult{}, err
}
return StepResult{Output: e.outputs[req.Step.Name]}, nil
}
func (e *recordingExecutor) order() []string {
e.mu.Lock()
defer e.mu.Unlock()
return append([]string(nil), e.steps...)
}
func (e *recordingExecutor) depsOf(step string) map[string]string {
e.mu.Lock()
defer e.mu.Unlock()
return e.deps[step]
}
func (e *recordingExecutor) toolsOf(step string) []string {
e.mu.Lock()
defer e.mu.Unlock()
return append([]string(nil), e.tools[step]...)
}