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:
1 parent
29181245ce
commit
f75e7a9c9b
17 files changed
+3067
-164
No files matched your search
@@ -15,18 +15,18 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// Store persists skill definitions in SQLite (the skills table). It is safe for
|
||||
// concurrent use — the underlying *sql.DB serializes writes — and every query is
|
||||
// parameterized, so values are never interpolated into SQL.
|
||||
// Store 把技能定义持久化进 SQLite 的 skills 表。它并发安全(底层 *sql.DB 串行化
|
||||
// 写),且所有查询都参数化 —— 值永远不拼进 SQL。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// createSkillsTable creates the skills schema. id is an opaque primary key;
|
||||
// name is the stable, unique skill identifier used for Upsert/Get/Delete
|
||||
// lookups. The desc column is double-quoted because DESC is a SQL keyword in
|
||||
// ordering context.
|
||||
// createSkillsTable 创建 skills 表结构。id 是不透明主键;name 是稳定且唯一的技能
|
||||
// 标识(Upsert/Get/Delete 都按它查)。desc 列加双引号是因为 DESC 在排序语境里是
|
||||
// SQL 关键字。spec_json 承载 design/09 §6.2 的新增字段(版本/调用三态/工具上限/
|
||||
// 上下文模式/元数据等)——**必须落盘**:丢掉 allowed-tools 等于把能力收缩放宽,
|
||||
// 那是安全红线,不允许「读回来就变成不限」。
|
||||
const createSkillsTable = `
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -36,37 +36,44 @@ CREATE TABLE IF NOT EXISTS skills (
|
||||
steps_json TEXT NOT NULL DEFAULT '[]',
|
||||
refs_json TEXT NOT NULL DEFAULT '[]',
|
||||
guardrails_json TEXT NOT NULL DEFAULT '[]',
|
||||
spec_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name);
|
||||
`
|
||||
|
||||
// upsertSkillSQL inserts a skill, updating the existing row when the name
|
||||
// already exists; created_at is preserved across an update.
|
||||
// upsertSkillSQL 插入一条技能;name 已存在时更新该行,created_at 保持不变。
|
||||
const upsertSkillSQL = `
|
||||
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
"desc" = excluded."desc",
|
||||
prompt = excluded.prompt,
|
||||
steps_json = excluded.steps_json,
|
||||
refs_json = excluded.refs_json,
|
||||
guardrails_json = excluded.guardrails_json,
|
||||
spec_json = excluded.spec_json,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
// skillColumns 是读取技能行时的列清单(禁止 SELECT *)。
|
||||
const skillColumns = `name, "desc", prompt, steps_json, refs_json, guardrails_json, spec_json`
|
||||
|
||||
const (
|
||||
getSkillSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills WHERE name = ?`
|
||||
listSkillsSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills ORDER BY name`
|
||||
getSkillSQL = `SELECT ` + skillColumns + ` FROM skills WHERE name = ?`
|
||||
listSkillsSQL = `SELECT ` + skillColumns + ` FROM skills ORDER BY name`
|
||||
deleteSkillSQL = `DELETE FROM skills WHERE name = ?`
|
||||
)
|
||||
|
||||
// idByteLen is the size in bytes of the random skill id (128-bit).
|
||||
// idByteLen 是技能行随机 id 的字节数(128 位)。
|
||||
const idByteLen = 16
|
||||
|
||||
// NewStore builds a Store, creating the skills table if needed. It returns
|
||||
// ErrNilDB when db is nil. ctx is used for the schema creation statement.
|
||||
// specColumnName 是 spec_json 列名;ensureSkillSpecColumn 用它做幂等补列。
|
||||
const specColumnName = "spec_json"
|
||||
|
||||
// NewStore 构造 Store,并在需要时建表/补列。db 为 nil 时返回 ErrNilDB。
|
||||
// ctx 用于建表语句。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("skills: store: %w", ErrNilDB)
|
||||
@@ -75,13 +82,45 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if _, err := db.ExecContext(ctx, createSkillsTable); err != nil {
|
||||
return nil, fmt.Errorf("skills: store: create skills table: %w", err)
|
||||
}
|
||||
if err := s.ensureSpecColumn(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.log.Info("skills table ready")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Upsert stores skill, inserting a new row or updating the existing row that
|
||||
// shares its name. It returns ErrInvalidSkill when skill is nil or has a blank
|
||||
// name.
|
||||
// ensureSpecColumn 给既有的 skills 表补上 spec_json 列(SQLite 支持 ADD COLUMN 但
|
||||
// 不支持 IF NOT EXISTS,因此先读 pragma 再决定)。CREATE TABLE IF NOT EXISTS 对旧库
|
||||
// 不会改结构,缺这一列就会让写入报错,故必须幂等补齐。
|
||||
func (s *Store) ensureSpecColumn(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name FROM pragma_table_info('skills')`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: read skills columns: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return fmt.Errorf("skills: store: scan skills column: %w", err)
|
||||
}
|
||||
if name == specColumnName {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("skills: store: read skills columns: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`ALTER TABLE skills ADD COLUMN spec_json TEXT NOT NULL DEFAULT '{}'`); err != nil {
|
||||
return fmt.Errorf("skills: store: add column %s: %w", specColumnName, err)
|
||||
}
|
||||
s.log.Info("skills table migrated", "column", specColumnName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert 写入技能:插入新行,或更新同名的既有行(created_at 保持)。skill 为 nil
|
||||
// 或名字为空时返回 ErrInvalidSkill。实现了 sdk.SkillSpec 的技能会连 Spec 一起落盘;
|
||||
// 只实现基础 sdk.Skill 的技能按各字段的缺省值落盘。
|
||||
func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
if skill == nil || strings.TrimSpace(skill.Name()) == "" {
|
||||
return fmt.Errorf("skills: store: upsert: %w", ErrInvalidSkill)
|
||||
@@ -99,10 +138,14 @@ func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: guardrails: %w", name, err)
|
||||
}
|
||||
specJSON, err := marshalJSON(specOf(sdk.SkillView(skill)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: spec: %w", name, err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := s.db.ExecContext(ctx, upsertSkillSQL,
|
||||
newID(), name, skill.Description(), skill.Prompt(),
|
||||
stepsJSON, refsJSON, guardrailsJSON, now, now,
|
||||
stepsJSON, refsJSON, guardrailsJSON, specJSON, now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: %w", name, err)
|
||||
}
|
||||
@@ -110,7 +153,7 @@ func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the skill registered under name, or ErrNotFound when absent.
|
||||
// Get 返回名为 name 的技能;不存在时返回 ErrNotFound。
|
||||
func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
|
||||
row := s.db.QueryRowContext(ctx, getSkillSQL, name)
|
||||
skill, err := scanSkill(row, name)
|
||||
@@ -120,7 +163,7 @@ func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// List returns every stored skill, ordered by name.
|
||||
// List 返回全部已存技能,按名字升序。
|
||||
func (s *Store) List(ctx context.Context) ([]*Skill, error) {
|
||||
rows, err := s.db.QueryContext(ctx, listSkillsSQL)
|
||||
if err != nil {
|
||||
@@ -142,8 +185,7 @@ func (s *Store) List(ctx context.Context) ([]*Skill, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes the skill named name. It returns ErrNotFound when no such skill
|
||||
// exists.
|
||||
// Delete 删除名为 name 的技能;不存在时返回 ErrNotFound。
|
||||
func (s *Store) Delete(ctx context.Context, name string) error {
|
||||
res, err := s.db.ExecContext(ctx, deleteSkillSQL, name)
|
||||
if err != nil {
|
||||
@@ -160,14 +202,13 @@ func (s *Store) Delete(ctx context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowScanner is the subset of *sql.Row and *sql.Rows that scanSkill needs.
|
||||
// rowScanner 是 scanSkill 需要的 *sql.Row / *sql.Rows 子集。
|
||||
type rowScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
// scanSkill reads one skills row and builds a *Skill. key is the lookup name
|
||||
// used only in error messages.
|
||||
// scanSkill 读一行 skills 记录并构造 *Skill。key 只用于错误文案里的查找名。
|
||||
func scanSkill(sc rowScanner, key string) (*Skill, error) {
|
||||
var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON string
|
||||
if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON); err != nil {
|
||||
var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON, specJSON string
|
||||
if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON, &specJSON); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound)
|
||||
}
|
||||
@@ -185,7 +226,11 @@ func scanSkill(sc rowScanner, key string) (*Skill, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err)
|
||||
}
|
||||
skill, err := New(name, desc, prompt, steps, refs, guardrails)
|
||||
spec, err := unmarshalSpec(specJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: spec: %w", name, err)
|
||||
}
|
||||
skill, err := New(name, desc, prompt, steps, refs, guardrails, WithSpec(spec))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: %w", name, err)
|
||||
}
|
||||
@@ -224,12 +269,24 @@ func unmarshalGuardrails(s string) ([]sdk.Guardrail, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newID returns a random 128-bit hex identifier for a skill row.
|
||||
// unmarshalSpec 解析 spec_json;空串按空定义对待(旧行默认 '{}'),非法 JSON 报错
|
||||
// 而不是静默丢掉「工具上限」这类安全相关字段。
|
||||
func unmarshalSpec(s string) (Spec, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return Spec{}, nil
|
||||
}
|
||||
var out Spec
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newID 返回技能行的随机 128 位十六进制标识。
|
||||
func newID() string {
|
||||
var b [idByteLen]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand is effectively infallible; fall back to a nanosecond
|
||||
// timestamp so the store never blocks on a missing identifier.
|
||||
// crypto/rand 实际上不会失败;退化成纳秒时间戳,别让存储因为拿不到 id 卡住。
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
|
||||
Reference in new issue
Block a user