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

280 lines
11 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 实现 M5 的可复用工作流能力:一个 Skill 是比 Tool 更结构化、
// 且可持久化的「高阶工作流」——一组带提示词的步骤(含依赖关系),配以基础
// 提示词、引用与约束(DevDocs/design/09 §0/§3/§6)。
//
// 包内五件套:
//
// - Skill:实现 pkg/plugins/sdk.Skill(SkillSpec) 的具象内存模型。
// - Store:把技能定义持久化进 SQLite 的 skills 表。
// - Loader:从 YAML 加载定义(单流 / 单文件 / configs/skills 目录),并做
// 加载期校验(依赖缺失、依赖环、allowed/disallowed 冲突等,见 Validate)。
// - Runner:把技能展开成可执行工作流(Expand),并**真执行**(Run/RunSkill):
// 步骤拓扑调度 + 无依赖步骤并行(上限 2,硬上限 3)+ guardrail 强制点。
// - Register:把技能作为 KindSkill 能力发布进全局能力注册表
// (internal/capability/registry)。
//
// 能力收缩(design/09 §6.4-3)见 surface.go:技能轮次可用工具 = allowed-tools
// ∩ 安全闸 ∩ 全局注册表 − disallowed-tools,**任何情况下不因技能放宽**。
package skills
import (
"errors"
"fmt"
"io"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本包的错误哨兵。调用方一律用 errors.Is 分类,绝不靠字符串匹配。
var (
// ErrInvalidSkill 表示技能为 nil 或名字为空。
ErrInvalidSkill = errors.New("skills: invalid skill")
// ErrNotFound 由 Store 在目标技能不存在时返回。
ErrNotFound = errors.New("skills: skill not found")
// ErrNilRegistrar 由 Register/NewRunner 在未提供注册表时返回。
ErrNilRegistrar = errors.New("skills: nil registrar")
// ErrNilDB 由 NewStore 在未提供 *sql.DB 时返回。
ErrNilDB = errors.New("skills: nil db")
// ErrStepDuplicate 表示步骤名重复(依赖图会因此歧义,故拒绝)。
ErrStepDuplicate = errors.New("skills: duplicate step name")
// ErrStepDependsMissing 表示 steps[].depends 指向了不存在的步骤。
ErrStepDependsMissing = errors.New("skills: step depends on unknown step")
// ErrStepCycle 表示步骤依赖成环(加载期就拒,运行期绝不挂死)。
ErrStepCycle = errors.New("skills: step dependency cycle")
// ErrToolConflict 表示同一工具同时出现在 allowed-tools 与 disallowed-tools
// (自相矛盾的声明,拒绝而不是猜哪边赢)。
ErrToolConflict = errors.New("skills: tool both allowed and disallowed")
// ErrGuardrailDuplicate 表示 guardrail id 重复。
ErrGuardrailDuplicate = errors.New("skills: duplicate guardrail id")
// ErrNilGate 表示执行技能时没给安全闸 —— 缺闸即拒绝(只收不放,fail-closed)。
ErrNilGate = errors.New("skills: nil tool gate")
// ErrNoExecutor 表示执行技能时没给步骤执行器。
ErrNoExecutor = errors.New("skills: nil step executor")
// ErrStepFailed 表示某一步执行失败(**不静默跳过**,错误里带步骤名)。
ErrStepFailed = errors.New("skills: step failed")
// ErrGuardrailViolation 表示 enforced guardrail 被违反,技能已中止;
// 错误里如实带上「哪条 guardrail、哪一步」。
ErrGuardrailViolation = errors.New("skills: guardrail violated")
// ErrGuardrailCheck 表示 guardrail 校验器本身失败(与「违规」区分开,
// 绝不把校验失败当成通过)。
ErrGuardrailCheck = errors.New("skills: guardrail check failed")
)
// Skill 是技能的可持久化具象形态(对应 pkg/plugins/sdk.Skill):一组带依赖的
// 有序步骤 + 基础提示词 + 引用 + 约束,外加 design/09 §6.2 定稿的新增字段
// (版本、调用三态、触发词/路径、工具上限、上下文模式、模型覆盖、元数据)。
//
// Skill 构造后不再变化;所有 getter 都返回切片/映射的副本,因此 *Skill 可以按
// sdk.Skill 的契约要求并发使用。
type Skill struct {
name string
description string
prompt string
steps []sdk.Step
refs []sdk.Ref
guardrails []sdk.Guardrail
spec Spec
}
// 编译期断言:*Skill 同时满足基础 sdk.Skill 与完整的 sdk.SkillSpec。
var (
_ sdk.Skill = (*Skill)(nil)
_ sdk.SkillSpec = (*Skill)(nil)
)
// Spec 是 design/09 §6.2 定稿的新增字段集合。零值即「全部取缺省」:
// version=1、invocation=both、context=inline,其余为空。
//
// 它同时是 Store 里 spec_json 列的 JSON 形态,因此字段名带 json tag。
type Spec struct {
// Version 是定义版本(形状迁移用),非正数取缺省 1。
Version int `json:"version,omitempty"`
// WhenToUse 是「何时使用」补充说明,与 Description 一起进清单并计入预算。
WhenToUse string `json:"whenToUse,omitempty"`
// Invocation 是调用三态;空取缺省 both。
Invocation sdk.Invocation `json:"invocation,omitempty"`
// Triggers 是触发词。
Triggers []string `json:"triggers,omitempty"`
// Paths 是 glob;仅当工作文件命中时自动激活。
Paths []string `json:"paths,omitempty"`
// AllowedTools 是能力**上限**(不是免审批名单),见 design/09 §6.4-3。
AllowedTools []string `json:"allowedTools,omitempty"`
// DisallowedTools 从上限里剔除。
DisallowedTools []string `json:"disallowedTools,omitempty"`
// Context 是上下文模式(YAML 键 `context`);空取缺省 inline。
Context sdk.ContextMode `json:"context,omitempty"`
// Agent 是 context=fork 时使用的子代理类型。
Agent string `json:"agent,omitempty"`
// Model 是可选模型覆盖;per-role 路由优先于它(design/04 §3)。
Model string `json:"model,omitempty"`
// Effort 是可选思考档位覆盖。
Effort string `json:"effort,omitempty"`
// Metadata 是自由 KV,宿主不解释。
Metadata map[string]string `json:"metadata,omitempty"`
}
// Option 定制 Skill 的构造。目前只有 WithSpec 一条 —— 新增字段整体走 Spec,
// 避免 11 个单字段 option 把构造面撑爆。
type Option func(*Skill)
// WithSpec 写入 design/09 §6.2 的新增字段(切片/映射会被复制)。
func WithSpec(spec Spec) Option {
return func(s *Skill) { s.spec = cloneSpec(spec) }
}
// New 构造一个 Skill。名字为空时返回 ErrInvalidSkill。步骤、引用、约束与 Spec
// 都会被复制,因此调用方之后改动入参不会影响已构造的技能。
func New(name, description, prompt string, steps []sdk.Step, refs []sdk.Ref, guardrails []sdk.Guardrail, opts ...Option) (*Skill, error) {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("skills: new: %w", ErrInvalidSkill)
}
s := &Skill{
name: name,
description: description,
prompt: prompt,
steps: cloneSteps(steps),
refs: cloneRefs(refs),
guardrails: cloneGuardrails(guardrails),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
s.spec = normalizeSpec(s.spec)
return s, nil
}
// Name 返回技能的唯一标识。
func (s *Skill) Name() string { return s.name }
// Description 说明技能做什么;模型据此挑选技能。
func (s *Skill) Description() string { return s.description }
// Prompt 返回技能的基础系统提示词。
func (s *Skill) Prompt() string { return s.prompt }
// Steps 返回有序工作流步骤的副本。
func (s *Skill) Steps() []sdk.Step { return cloneSteps(s.steps) }
// Refs 返回技能可引用素材的副本。
func (s *Skill) Refs() []sdk.Ref { return cloneRefs(s.refs) }
// Guardrails 返回技能约束的副本。
func (s *Skill) Guardrails() []sdk.Guardrail { return cloneGuardrails(s.guardrails) }
// Spec 返回新增字段集合的副本(含归一化后的缺省值)。
func (s *Skill) Spec() Spec { return cloneSpec(s.spec) }
// Version 返回定义版本(缺省 1)。
func (s *Skill) Version() int { return s.spec.Version }
// WhenToUse 返回「何时使用」补充说明。
func (s *Skill) WhenToUse() string { return s.spec.WhenToUse }
// Invocation 返回调用三态(缺省 both)。
func (s *Skill) Invocation() sdk.Invocation { return s.spec.Invocation }
// Triggers 返回触发词副本。
func (s *Skill) Triggers() []string { return append([]string(nil), s.spec.Triggers...) }
// Paths 返回 glob 副本。
func (s *Skill) Paths() []string { return append([]string(nil), s.spec.Paths...) }
// AllowedTools 返回能力上限副本(空表示不限,仍受安全闸约束)。
func (s *Skill) AllowedTools() []string { return append([]string(nil), s.spec.AllowedTools...) }
// DisallowedTools 返回剔除名单副本。
func (s *Skill) DisallowedTools() []string { return append([]string(nil), s.spec.DisallowedTools...) }
// ContextMode 返回上下文模式(缺省 inline)。
func (s *Skill) ContextMode() sdk.ContextMode { return s.spec.Context }
// Agent 返回 context=fork 时使用的子代理类型。
func (s *Skill) Agent() string { return s.spec.Agent }
// Model 返回可选模型覆盖。
func (s *Skill) Model() string { return s.spec.Model }
// Effort 返回可选思考档位覆盖。
func (s *Skill) Effort() string { return s.spec.Effort }
// Metadata 返回自由 KV 的副本。
func (s *Skill) Metadata() map[string]string { return cloneMetadata(s.spec.Metadata) }
// normalizeSpec 补上文档规定的缺省值,并把 nil 切片/映射留成 nil(空 == 未声明)。
func normalizeSpec(spec Spec) Spec {
if spec.Version <= 0 {
spec.Version = sdk.DefaultSkillVersion
}
spec.Invocation = spec.Invocation.OrDefault()
spec.Context = spec.Context.OrDefault()
return spec
}
func cloneSpec(spec Spec) Spec {
out := spec
out.Triggers = append([]string(nil), spec.Triggers...)
out.Paths = append([]string(nil), spec.Paths...)
out.AllowedTools = append([]string(nil), spec.AllowedTools...)
out.DisallowedTools = append([]string(nil), spec.DisallowedTools...)
out.Metadata = cloneMetadata(spec.Metadata)
return out
}
func cloneMetadata(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func cloneSteps(in []sdk.Step) []sdk.Step {
if len(in) == 0 {
return nil
}
out := make([]sdk.Step, len(in))
for i, st := range in {
out[i] = sdk.Step{
Name: st.Name,
Prompt: st.Prompt,
Depends: append([]string(nil), st.Depends...),
}
}
return out
}
func cloneRefs(in []sdk.Ref) []sdk.Ref {
if len(in) == 0 {
return nil
}
out := make([]sdk.Ref, len(in))
copy(out, in)
return out
}
func cloneGuardrails(in []sdk.Guardrail) []sdk.Guardrail {
if len(in) == 0 {
return nil
}
out := make([]sdk.Guardrail, len(in))
copy(out, in)
return out
}
// sourcedLog 返回带来源标签(source=capability/skills)的日志器;l 为 nil 时
// 换成丢弃输出的日志器,让叶子调用方可以安全传 nil。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "capability/skills")
}