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 的分阶段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:35 +08:00
1 parent 29181245ce
commit f75e7a9c9b
17 files changed
+3067 -164

No files matched your search

+367
View File
@@ -0,0 +1,367 @@
package skills
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件专测步骤调度与 guardrail 强制点(design/09 §6.4):并行上限、拓扑顺序、
// 中止语义、取消传播、收尾全量复核。
// barrierExecutor 让每一步执行前先登记,再停住等测试放行 —— 用来**观测**两路是否
// 真的同时在飞(串行实现会让第二个登记永远不来,测试超时变红)。
type barrierExecutor struct {
entered chan string
release chan struct{}
}
func (e *barrierExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
select {
case e.entered <- req.Step.Name:
case <-ctx.Done():
return StepResult{}, ctx.Err()
}
select {
case <-e.release:
return StepResult{Output: req.Step.Name}, nil
case <-ctx.Done():
return StepResult{}, ctx.Err()
}
}
// ctxWaitExecutor 一直等到 ctx 取消才返回,用于验证取消传播。
type ctxWaitExecutor struct {
started chan string
}
func (e *ctxWaitExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) {
select {
case e.started <- req.Step.Name:
default:
}
<-ctx.Done()
return StepResult{}, ctx.Err()
}
// TestRunner_RunSkill_无依赖步骤并行 覆盖 design/09 §6.4-1:无依赖的步骤可并行。
func TestRunner_RunSkill_无依赖步骤并行(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "parallel", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p"},
})
exec := &barrierExecutor{entered: make(chan string, 2), release: make(chan struct{})}
result := make(chan RunResult, 1)
errs := make(chan error, 1)
go func() {
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
result <- res
errs <- err
}()
waitEntered(t, exec.entered, 2)
close(exec.release)
if err := <-errs; err != nil {
t.Fatalf("RunSkill: %v", err)
}
res := <-result
if len(res.Steps) != 2 {
t.Fatalf("Steps = %+v, want 2 条", res.Steps)
}
}
// TestRunner_RunSkill_并行上限夹到硬顶 断言 maxStepParallel=3 是真的咬得住的:
// 4 个无依赖步骤 + 申请 100 路 ⇒ 同时最多 3 路在飞,第 4 个不会被提前放出去。
func TestRunner_RunSkill_并行上限夹到硬顶(t *testing.T) {
t.Parallel()
steps := []sdk.Step{
{Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"},
{Name: "c", Prompt: "p"}, {Name: "d", Prompt: "p"},
}
skill := mustSkill(t, "wide", steps)
exec := &barrierExecutor{entered: make(chan string, 4), release: make(chan struct{})}
errs := make(chan error, 1)
go func() {
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, MaxParallel: 100})
errs <- err
}()
waitEntered(t, exec.entered, maxStepParallel)
select {
case got := <-exec.entered:
t.Fatalf("第 %q 步在 3 路已满时仍被放出,并行上限没咬住", got)
case <-time.After(200 * time.Millisecond):
}
close(exec.release)
if err := <-errs; err != nil {
t.Fatalf("RunSkill: %v", err)
}
}
// TestRunner_RunSkill_依赖未就绪不执行 覆盖拓扑语义:两条链 a→b、c→d,
// 每个后继都必须在自己前序完成之后才开始。
func TestRunner_RunSkill_依赖未就绪不执行(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "chain", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p", Depends: []string{"a"}},
{Name: "c", Prompt: "p"},
{Name: "d", Prompt: "p", Depends: []string{"c"}},
})
exec := &orderGuardExecutor{after: map[string]string{"b": "a", "d": "c"}}
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
t.Fatalf("RunSkill: %v", err)
}
if exec.violation != "" {
t.Fatalf("依赖未就绪就执行了:%s", exec.violation)
}
}
// orderGuardExecutor 记录完成过的步骤,并断言某步开始时它的前序已完成。
type orderGuardExecutor struct {
mu sync.Mutex
done map[string]bool
after map[string]string
violation string
}
func (e *orderGuardExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.done == nil {
e.done = make(map[string]bool)
}
if want := e.after[req.Step.Name]; want != "" && !e.done[want] {
e.violation = req.Step.Name + " 在 " + want + " 之前就开始"
}
e.done[req.Step.Name] = true
return StepResult{Output: req.Step.Name}, nil
}
// TestRunner_RunSkill_Enforced护栏违规中止 覆盖 design/09 §6.4-4:enforced 违反
// ⇒ 中止,且错误里如实带「哪条 guardrail、哪一步」,后续步骤不再执行。
func TestRunner_RunSkill_Enforced护栏违规中止(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "guarded",
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}},
[]sdk.Guardrail{{ID: "no-pii", Message: "不许输出个人信息", Enforced: true}})
exec := &recordingExecutor{}
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, g sdk.Guardrail, subject, _ string) (bool, error) {
return g.ID == "no-pii" && subject == "one", nil
}),
})
if !errors.Is(err, ErrGuardrailViolation) {
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
}
if !strings.Contains(err.Error(), "no-pii") || !strings.Contains(err.Error(), "one") {
t.Fatalf("错误没如实带出哪条 guardrail / 哪一步:%v", err)
}
if got := strings.Join(exec.order(), ","); got != "one" {
t.Fatalf("执行顺序 = %q, want 只有 one(中止后 two 不得执行)", got)
}
}
// TestRunner_RunSkill_非Enforced护栏只告警 覆盖「false ⇒ WARN 后继续」。
func TestRunner_RunSkill_非Enforced护栏只告警(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "warned",
[]sdk.Step{{Name: "one", Prompt: "p"}},
[]sdk.Guardrail{{ID: "tone", Message: "语气要软", Enforced: false}})
exec := &recordingExecutor{}
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec,
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
return subject == "one", nil
}),
})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if len(res.Steps) != 1 {
t.Fatalf("Steps = %+v, want 1 条(WARN 不阻断)", res.Steps)
}
if len(res.Violations) == 0 || res.Violations[0].GuardrailID != "tone" || res.Violations[0].Enforced {
t.Fatalf("Violations = %+v, want tone/非 enforced 一条", res.Violations)
}
}
// TestRunner_RunSkill_护栏校验器失败即中止 断言「判不了」不等于「通过」。
func TestRunner_RunSkill_护栏校验器失败即中止(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "badcheck",
[]sdk.Step{{Name: "one", Prompt: "p"}},
[]sdk.Guardrail{{ID: "g", Message: "m", Enforced: false}})
boom := errors.New("checker down")
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: &recordingExecutor{},
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(context.Context, sdk.Guardrail, string, string) (bool, error) { return false, boom }),
})
if !errors.Is(err, ErrGuardrailCheck) || !errors.Is(err, boom) {
t.Fatalf("err = %v, want 同时命中 ErrGuardrailCheck 与原始错误", err)
}
}
// TestRunner_RunSkill_步骤失败不静默跳过 覆盖 design/09 §6.4-5:任一步失败 ⇒
// 技能失败,错误带步骤名,后继步骤不再执行,结果只含已完成的部分。
func TestRunner_RunSkill_步骤失败不静默跳过(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "failing", []sdk.Step{
{Name: "one", Prompt: "p"},
{Name: "two", Prompt: "p", Depends: []string{"one"}},
})
boom := errors.New("model exploded")
exec := &recordingExecutor{errs: map[string]error{"one": boom}}
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"},
})
if !errors.Is(err, ErrStepFailed) || !errors.Is(err, boom) {
t.Fatalf("err = %v, want 同时命中 ErrStepFailed 与原始错误", err)
}
if !strings.Contains(err.Error(), "one") {
t.Fatalf("错误没带出失败的步骤名:%v", err)
}
if got := strings.Join(exec.order(), ","); got != "one" {
t.Fatalf("执行顺序 = %q, want 只有 one", got)
}
if len(res.Steps) != 0 {
t.Fatalf("Steps = %+v, want 空(失败步不算完成)", res.Steps)
}
}
// TestRunner_RunSkill_取消传播 覆盖 design/09 §6.4-5:父 ctx 取消 ⇒ 技能随之取消。
func TestRunner_RunSkill_取消传播(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "cancelled", []sdk.Step{
{Name: "a", Prompt: "p"},
{Name: "b", Prompt: "p"},
})
exec := &ctxWaitExecutor{started: make(chan string, 2)}
ctx, cancel := context.WithCancel(context.Background())
errs := make(chan error, 1)
go func() {
_, err := NewRunner(registry.New(), testLogger()).RunSkill(ctx, skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
errs <- err
}()
waitEntered(t, exec.started, 1)
cancel()
select {
case err := <-errs:
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("取消后 RunSkill 没收敛(疑似遗留 goroutine 或死等)")
}
}
// TestRunner_RunSkill_收尾全量复核 覆盖 design/09 §6.4-4 的「再全量复核一次」:
// 逐步校验都通过,但累计输出违反 enforced guardrail ⇒ 仍然中止。
func TestRunner_RunSkill_收尾全量复核(t *testing.T) {
t.Parallel()
skill := mustRunSkill(t, "final-check",
[]sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p"}},
[]sdk.Guardrail{{ID: "budget", Message: "总长受限", Enforced: true}})
_, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{
Executor: &recordingExecutor{outputs: map[string]string{"one": "a", "two": "b"}},
Gate: NewNameSet([]string{"t"}),
ToolNames: []string{"t"},
Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) {
return subject == GuardrailFinalSubject, nil
}),
})
if !errors.Is(err, ErrGuardrailViolation) {
t.Fatalf("err = %v, want ErrGuardrailViolation", err)
}
if !strings.Contains(err.Error(), GuardrailFinalSubject) {
t.Fatalf("错误没说明违规发生在全量复核阶段:%v", err)
}
}
// TestRunner_RunSkill_无步骤直接成功 断言空工作流不报错、也不造出假步骤。
func TestRunner_RunSkill_无步骤直接成功(t *testing.T) {
t.Parallel()
res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), mustSkill(t, "empty", nil),
RunOptions{Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}})
if err != nil {
t.Fatalf("RunSkill: %v", err)
}
if len(res.Steps) != 0 {
t.Fatalf("Steps = %+v, want 空", res.Steps)
}
}
// TestRunner_RunSkill_下游输出有界 断言传给下游的步骤输出受 maxStepOutputChars 约束,
// 且截断有显式标记(绝不静默)。
func TestRunner_RunSkill_下游输出有界(t *testing.T) {
t.Parallel()
skill := mustSkill(t, "bounded", []sdk.Step{
{Name: "one", Prompt: "p"},
{Name: "two", Prompt: "p", Depends: []string{"one"}},
})
exec := &recordingExecutor{outputs: map[string]string{"one": strings.Repeat("x", maxStepOutputChars+50)}}
if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill,
RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil {
t.Fatalf("RunSkill: %v", err)
}
got := exec.depsOf("two")["one"]
if len([]rune(got)) != maxStepOutputChars+len([]rune(stepTruncatedMarker)) {
t.Fatalf("传给下游的输出长度 = %d,want %d", len([]rune(got)), maxStepOutputChars+len([]rune(stepTruncatedMarker)))
}
if !strings.Contains(got, "truncated") {
t.Fatalf("截断没有显式标记:%q", got[len(got)-20:])
}
}
func TestClampParallel(t *testing.T) {
t.Parallel()
cases := map[int]int{
0: defaultStepParallel, -3: defaultStepParallel, 1: 1, 2: 2, 3: maxStepParallel, 100: maxStepParallel,
}
for in, want := range cases {
if got := clampParallel(in); got != want {
t.Fatalf("clampParallel(%d) = %d, want %d", in, got, want)
}
}
}
// mustRunSkill 构造一个带 guardrail 的技能;guardrail 的存在使 Validate 之外的分支
// 也被覆盖(重复 id、冲突工具在 validate_test.go 里测)。
func mustRunSkill(t *testing.T, name string, steps []sdk.Step, guardrails []sdk.Guardrail) *Skill {
t.Helper()
skill, err := New(name, "desc "+name, "base", steps, nil, guardrails)
if err != nil {
t.Fatalf("New(%s): %v", name, err)
}
return skill
}
// waitEntered 从 ch 收 n 个登记;超时即测试失败(说明并行没发生 / 步骤没被执行)。
func waitEntered(t *testing.T, ch <-chan string, n int) {
t.Helper()
for i := 0; i < n; i++ {
select {
case <-ch:
case <-time.After(3 * time.Second):
t.Fatalf("只等到 %d/%d 个步骤进入执行(疑似串行或未调度)", i, n)
}
}
}