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

+259 -26
View File
@@ -4,16 +4,15 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Workflow is a Skill expanded into its executable form: the base system prompt
// plus the ordered steps, references, and guardrails, ready to be handed to the
// loop/agent executor (M2). The Runner only assembles workflows; it does not
// execute commands.
// Workflow 是一个展开后的技能:基础系统提示词 + 有序步骤 + 引用 + 约束,
// 可以直接交给执行侧。Runner.Expand 只做展开(不改数据),真执行走 RunSkill。
type Workflow struct {
Name string
Description string
@@ -21,25 +20,139 @@ type Workflow struct {
Steps []sdk.Step
Refs []sdk.Ref
Guardrails []sdk.Guardrail
// Spec 是 design/09 §6.2 定稿的新增字段(含归一化缺省值)。
Spec Spec
}
// Runner expands skills into workflows. It is the M5 placeholder for the
// skill-execution wrapper: expansion (the real deliverable) is wired now, while
// Run awaits M12/loop integration.
// GuardrailFinalSubject 是收尾全量复核时传给 GuardrailChecker 的 subject:它表示
// 「本次校验的对象是**全部步骤的累计输出**」,而不是某一步。步骤名不可能取到这个
// 值(它以 $ 开头,且步骤名还会被 Validate 拒空)。
const GuardrailFinalSubject = "$final"
// StepRequest 是交给 StepExecutor 的一次步骤执行请求。每步 = 一次模型调用
// (design/09 §6.4-2),本结构把该步需要的全部信息一次给全,执行侧不必回读技能。
type StepRequest struct {
// Skill 是技能名。
Skill string
// Step 是当前步骤(含它自己的 prompt 与依赖声明)。
Step sdk.Step
// Prompt 是拼好的指令:技能基础提示词 + 本步提示词。
Prompt string
// Depends 是**本步依赖的**前序步骤输出(键为步骤名)。只有依赖它的步骤才拿得到,
// 且输出按 maxStepOutputChars 截断,避免上下文被单步输出撑爆。
Depends map[string]string
// Input 是调用方给这次技能执行的初始输入(可为空)。
Input string
// Tools 是**已收缩**的本步可用工具面(见 ResolveToolSurface);执行侧不得放宽。
Tools []string
// Refs 是技能声明的引用素材。
Refs []sdk.Ref
// Spec 是技能的完整新增字段(模型/思考档位/上下文模式/元数据等)。
Spec Spec
}
// StepResult 是一次步骤执行的结果。
type StepResult struct {
// Output 是该步的文本输出(给下游步骤与结果汇总用)。
Output string
}
// StepExecutor 真正执行技能的某一步(design/09 §6.4-2:每步 = 一次模型调用)。
//
// 实现**必须**尊重 ctx:父回合取消后应尽快返回(通常是 ctx.Err())。调度器靠
// 它在取消/中止时把在飞的步骤收干净,不留下无主的 goroutine。
type StepExecutor interface {
ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error)
}
// GuardrailChecker 判断一条 guardrail 是否被违反(design/09 §6.4-4 的强制点)。
//
// subject 说明这次校验的对象:逐步校验时是步骤名,收尾全量复核时是
// GuardrailFinalSubject。**校验本身失败**(无法判定)时返回 err,技能随之中止 ——
// 绝不把「判不了」当成「通过」。
type GuardrailChecker interface {
Check(ctx context.Context, g sdk.Guardrail, subject, output string) (violated bool, err error)
}
// GuardrailFunc 把普通函数适配成 GuardrailChecker。
type GuardrailFunc func(ctx context.Context, g sdk.Guardrail, subject, output string) (bool, error)
// Check 实现 GuardrailChecker;nil 函数一律报错(判不了 ≠ 通过)。
func (f GuardrailFunc) Check(ctx context.Context, g sdk.Guardrail, subject, output string) (bool, error) {
if f == nil {
return false, fmt.Errorf("skills: nil guardrail checker: %w", ErrGuardrailCheck)
}
return f(ctx, g, subject, output)
}
// RunOptions 是一次技能执行的入参与依赖(全部由调用方注入,Runner 不自己找)。
type RunOptions struct {
// Input 是这次执行的初始输入(用户请求)。
Input string
// Executor 是步骤执行器。为 nil ⇒ ErrNoExecutor。
Executor StepExecutor
// Gate 是当前安全闸(actor + ToolPolicy + 唤醒态)。为 nil ⇒ ErrNilGate。
Gate Gate
// Checker 是 guardrail 校验器。为 nil 时**跳过内容校验**并记一条 WARN:
// 结构约束(Validate)仍然生效,但「违规」判定无从谈起。
Checker GuardrailChecker
// ToolNames 是全局注册表里的工具名全集(通常是本轮 dispatcher 持有的工具)。
// 为 nil 时从 Runner 的注册表读 KindTool 能力的名字(需要注册表)。
ToolNames []string
// MaxParallel 是无依赖步骤的并行上限:非正取默认 2,超过硬上限 3 时夹到 3。
MaxParallel int
}
// StepOutcome 是一步的执行结果(按**声明顺序**汇报,因此输出确定)。
type StepOutcome struct {
// Name 是步骤名。
Name string
// Output 是该步的文本输出。
Output string
}
// Violation 是一条 guardrail 违规事实。enforced 违规会让执行中止,其余只 WARN。
type Violation struct {
// GuardrailID 是被违反的 guardrail id。
GuardrailID string
// Subject 是校验对象:步骤名,或 GuardrailFinalSubject。
Subject string
// Message 是 guardrail 自己声明的规则文本。
Message string
// Enforced 报告该条是否强制(为真即已中止技能)。
Enforced bool
}
// RunResult 是一次技能执行的如实汇报。
type RunResult struct {
// Skill 是技能名。
Skill string
// Tools 是本次执行收缩后实际可用的工具面(能力收缩的证据)。
Tools []string
// Steps 是**已完成**步骤的结果,按声明顺序排列。执行中止时只含已完成的部分
// (如实汇报,绝不用空结果冒充完成)。
Steps []StepOutcome
// Violations 是本次执行发现的所有 guardrail 违规(含 WARN 级)。
Violations []Violation
}
// Runner 展开并执行技能。它持有全局能力注册表,用于按名找技能、以及在未显式给出
// 工具全集时读出注册表里的工具面。
//
// Runner 本身无可变状态(注册表内部自带锁),因此并发安全。
type Runner struct {
reg *registry.Registry
log *slog.Logger
}
// NewRunner builds a Runner over the given registry, used by ExpandByName to
// look up registered skills. log may be nil.
// NewRunner 在 reg 之上构造 Runner;reg 供 ExpandByName / Run 按名查找技能用,
// 也可以为 nil(此时只能用 RunSkill 直接执行)。log 可以为 nil。
func NewRunner(reg *registry.Registry, log *slog.Logger) *Runner {
return &Runner{reg: reg, log: sourcedLog(log)}
}
// Expand converts s into a Workflow, copying the step, ref, and guardrail slices
// so the caller owns the result. It returns ErrInvalidSkill when s is nil or has
// a blank name.
// Expand 把 s 展开成 Workflow,并复制步骤/引用/约束切片(调用方拥有结果)。
// s 为 nil 或名字为空时返回 ErrInvalidSkill。
func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
if err := ctx.Err(); err != nil {
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", err)
@@ -47,6 +160,7 @@ func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
if s == nil || strings.TrimSpace(s.Name()) == "" {
return Workflow{}, fmt.Errorf("skills: runner: expand: %w", ErrInvalidSkill)
}
spec := sdk.SkillView(s)
wf := Workflow{
Name: s.Name(),
Description: s.Description(),
@@ -54,33 +168,152 @@ func (r *Runner) Expand(ctx context.Context, s sdk.Skill) (Workflow, error) {
Steps: cloneSteps(s.Steps()),
Refs: cloneRefs(s.Refs()),
Guardrails: cloneGuardrails(s.Guardrails()),
Spec: specOf(spec),
}
r.log.Info("skill expanded", "skill", wf.Name)
r.log.Info("skill expanded", "skill", wf.Name, "steps", len(wf.Steps))
return wf, nil
}
// ExpandByName looks up a skill in the registry by name and expands it. It
// returns ErrNilRegistrar when the runner has no registry, the registry's error
// (registry.ErrNotFound) when the name is absent, and ErrNotFound when the
// registered value is not a Skill.
// ExpandByName 按名从注册表找技能并展开。Runner 没有注册表时返回
// ErrNilRegistrar;名字不存在时返回注册表的 registry.ErrNotFound;注册表里该名字
// 不是技能时返回 ErrNotFound。
func (r *Runner) ExpandByName(ctx context.Context, name string) (Workflow, error) {
s, err := r.lookup(name)
if err != nil {
return Workflow{}, err
}
return r.Expand(ctx, s)
}
// Run 按名从注册表取技能并**真执行**(design/09 §6.4)。
//
// 错误分类:Runner 无注册表 ⇒ ErrNilRegistrar;技能不存在 ⇒ registry.ErrNotFound;
// 定义不合法 ⇒ Validate 的可分类错误;缺安全闸 ⇒ ErrNilGate;缺执行器 ⇒
// ErrNoExecutor;某步失败 ⇒ ErrStepFailed(带步骤名);enforced guardrail 违规 ⇒
// ErrGuardrailViolation(带 guardrail 与对象);父 ctx 取消 ⇒ ctx.Err()。
//
// 出错时返回的 RunResult 里只有**已完成**的步骤(诚实的部分结果)。
func (r *Runner) Run(ctx context.Context, name string, opts RunOptions) (RunResult, error) {
s, err := r.lookup(name)
if err != nil {
return RunResult{Skill: name}, err
}
return r.RunSkill(ctx, s, opts)
}
// RunSkill 直接执行一个技能(不经注册表按名查找)。
//
// 执行语义(design/09 §6.4):
//
// - 步骤按 depends 拓扑调度,无依赖的步骤可并行(默认 2 路,硬上限 3);
// - 每步的可用工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools;
// - 每步输出后校验 guardrail:enforced 违反即中止并如实报「哪条、哪一步」;
// 非 enforced 只 WARN 后继续;全部步骤完成后再做一次累计输出的全量复核;
// - 任一步失败即整体失败(不静默跳过);父 ctx 取消即整体取消。
func (r *Runner) RunSkill(ctx context.Context, s sdk.Skill, opts RunOptions) (RunResult, error) {
if s == nil || strings.TrimSpace(s.Name()) == "" {
return RunResult{}, fmt.Errorf("skills: runner: run: %w", ErrInvalidSkill)
}
name := s.Name()
result := RunResult{Skill: name}
if opts.Executor == nil {
return result, fmt.Errorf("skills: runner: run %q: %w", name, ErrNoExecutor)
}
if opts.Gate == nil {
// 缺安全闸 ⇒ 一律拒。**不能**因为没有闸就把工具全放出去(只收不放)。
return result, fmt.Errorf("skills: runner: run %q: %w", name, ErrNilGate)
}
if err := Validate(s); err != nil {
return result, fmt.Errorf("skills: runner: run %q: %w", name, err)
}
tools, err := r.toolSurface(ctx, s, opts)
if err != nil {
return result, err
}
result.Tools = tools
if opts.Checker == nil && len(s.Guardrails()) > 0 {
r.log.Warn("skill guardrails have no checker; content checks are skipped",
"skill", name, "guardrails", len(s.Guardrails()))
}
env := &runEnv{
skill: s,
spec: specOf(sdk.SkillView(s)),
tools: tools,
input: opts.Input,
exec: opts.Executor,
checker: opts.Checker,
parallel: clampParallel(opts.MaxParallel),
log: r.log,
}
return env.run(ctx, result)
}
// lookup 按名从注册表取技能。
func (r *Runner) lookup(name string) (sdk.Skill, error) {
if r.reg == nil {
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNilRegistrar)
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNilRegistrar)
}
cap, err := r.reg.Get(name)
if err != nil {
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, err)
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, err)
}
skill, ok := cap.Value.(sdk.Skill)
if !ok {
return Workflow{}, fmt.Errorf("skills: runner: expand %q: %w", name, ErrNotFound)
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNotFound)
}
return r.Expand(ctx, skill)
return skill, nil
}
// Run is a placeholder for executing a registered skill end to end. Execution
// lands in the loop/agent executor (M2) and the full wiring (M12); it always
// returns ErrRunPending.
func (r *Runner) Run(_ context.Context, _ string, _ any) (any, error) {
return nil, ErrRunPending
// toolSurface 求本次执行的工具面:allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools。
func (r *Runner) toolSurface(ctx context.Context, s sdk.Skill, opts RunOptions) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("skills: runner: run %q: %w", s.Name(), err)
}
spec := sdk.SkillView(s)
registered := opts.ToolNames
if registered == nil {
names, err := r.registeredToolNames()
if err != nil {
return nil, fmt.Errorf("skills: runner: run %q: %w", s.Name(), err)
}
registered = names
}
return ResolveToolSurface(registered, spec.AllowedTools(), spec.DisallowedTools(), opts.Gate), nil
}
// registeredToolNames 读出注册表里 KindTool 能力的名字(排序后返回,因此本次执行的
// 工具面与日志都是确定的)。注册表缺位时报 ErrNilRegistrar —— 工具面必须来自真实的
// 注册表,绝不凭空造一个空/全量集合。
func (r *Runner) registeredToolNames() ([]string, error) {
if r.reg == nil {
return nil, ErrNilRegistrar
}
caps := r.reg.List(registry.KindTool)
names := make([]string, 0, len(caps))
for _, c := range caps {
names = append(names, c.Name)
}
sort.Strings(names)
return names, nil
}
// specOf 把 sdk.SkillSpec 读成 Spec(同时补上归一化缺省值),使执行侧只认一种形状。
func specOf(spec sdk.SkillSpec) Spec {
if spec == nil {
return normalizeSpec(Spec{})
}
return normalizeSpec(Spec{
Version: spec.Version(),
WhenToUse: spec.WhenToUse(),
Invocation: spec.Invocation(),
Triggers: spec.Triggers(),
Paths: spec.Paths(),
AllowedTools: spec.AllowedTools(),
DisallowedTools: spec.DisallowedTools(),
Context: spec.ContextMode(),
Agent: spec.Agent(),
Model: spec.Model(),
Effort: spec.Effort(),
Metadata: spec.Metadata(),
})
}