Files
Cyrene_v2/internal/capability/skills/steps_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

368 lines
14 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"
"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)
}
}
}