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