- 加载器/校验:目录式 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 的分阶段)。
334 lines
12 KiB
Go
334 lines
12 KiB
Go
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]...)
|
||
}
|