Files
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

320 lines
12 KiB
Go
Raw Permalink 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"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Workflow 是一个展开后的技能:基础系统提示词 + 有序步骤 + 引用 + 约束,
// 可以直接交给执行侧。Runner.Expand 只做展开(不改数据),真执行走 RunSkill。
type Workflow struct {
Name string
Description string
Prompt string
Steps []sdk.Step
Refs []sdk.Ref
Guardrails []sdk.Guardrail
// Spec 是 design/09 §6.2 定稿的新增字段(含归一化缺省值)。
Spec Spec
}
// 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 在 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 把 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)
}
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(),
Prompt: s.Prompt(),
Steps: cloneSteps(s.Steps()),
Refs: cloneRefs(s.Refs()),
Guardrails: cloneGuardrails(s.Guardrails()),
Spec: specOf(spec),
}
r.log.Info("skill expanded", "skill", wf.Name, "steps", len(wf.Steps))
return wf, nil
}
// 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 nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNilRegistrar)
}
cap, err := r.reg.Get(name)
if err != nil {
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, err)
}
skill, ok := cap.Value.(sdk.Skill)
if !ok {
return nil, fmt.Errorf("skills: runner: lookup %q: %w", name, ErrNotFound)
}
return skill, nil
}
// 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(),
})
}