feat(capability/skills): 可复用工作流技能(Skill 模型 + Store + YAML Loader + Runner 展开 + 注册)
This commit is contained in:
1 parent
d947ec41a9
commit
915aa8a100
10 files changed
+1235
No files matched your search
@@ -0,0 +1,236 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
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.
|
||||
const createSkillsTable = `
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
steps_json TEXT NOT NULL DEFAULT '[]',
|
||||
refs_json TEXT NOT NULL DEFAULT '[]',
|
||||
guardrails_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.
|
||||
const upsertSkillSQL = `
|
||||
INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_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,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
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`
|
||||
deleteSkillSQL = `DELETE FROM skills WHERE name = ?`
|
||||
)
|
||||
|
||||
// idByteLen is the size in bytes of the random skill id (128-bit).
|
||||
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.
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("skills: store: %w", ErrNilDB)
|
||||
}
|
||||
s := &Store{db: db, log: sourcedLog(log)}
|
||||
if _, err := db.ExecContext(ctx, createSkillsTable); err != nil {
|
||||
return nil, fmt.Errorf("skills: store: create skills table: %w", 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.
|
||||
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)
|
||||
}
|
||||
name := skill.Name()
|
||||
stepsJSON, err := marshalJSON(skill.Steps())
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: steps: %w", name, err)
|
||||
}
|
||||
refsJSON, err := marshalJSON(skill.Refs())
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: refs: %w", name, err)
|
||||
}
|
||||
guardrailsJSON, err := marshalJSON(skill.Guardrails())
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: guardrails: %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,
|
||||
); err != nil {
|
||||
return fmt.Errorf("skills: store: upsert %q: %w", name, err)
|
||||
}
|
||||
s.log.Info("skill upserted", "skill", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the skill registered under name, or ErrNotFound when absent.
|
||||
func (s *Store) Get(ctx context.Context, name string) (*Skill, error) {
|
||||
row := s.db.QueryRowContext(ctx, getSkillSQL, name)
|
||||
skill, err := scanSkill(row, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// List returns every stored skill, ordered by name.
|
||||
func (s *Store) List(ctx context.Context) ([]*Skill, error) {
|
||||
rows, err := s.db.QueryContext(ctx, listSkillsSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Skill, 0)
|
||||
for rows.Next() {
|
||||
skill, err := scanSkill(rows, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, skill)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("skills: store: list: iterate: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Delete removes the skill named name. It returns ErrNotFound when no such skill
|
||||
// exists.
|
||||
func (s *Store) Delete(ctx context.Context, name string) error {
|
||||
res, err := s.db.ExecContext(ctx, deleteSkillSQL, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: delete %q: %w", name, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("skills: store: delete %q: rows affected: %w", name, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("skills: store: delete %q: %w", name, ErrNotFound)
|
||||
}
|
||||
s.log.Info("skill deleted", "skill", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowScanner is the subset of *sql.Row and *sql.Rows that scanSkill needs.
|
||||
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.
|
||||
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 {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("skills: store: scan skill: %w", err)
|
||||
}
|
||||
steps, err := unmarshalSteps(stepsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: steps: %w", name, err)
|
||||
}
|
||||
refs, err := unmarshalRefs(refsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: refs: %w", name, err)
|
||||
}
|
||||
guardrails, err := unmarshalGuardrails(guardrailsJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err)
|
||||
}
|
||||
skill, err := New(name, desc, prompt, steps, refs, guardrails)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skills: store: skill %q: %w", name, err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func marshalJSON(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func unmarshalSteps(s string) ([]sdk.Step, error) {
|
||||
var out []sdk.Step
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func unmarshalRefs(s string) ([]sdk.Ref, error) {
|
||||
var out []sdk.Ref
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func unmarshalGuardrails(s string) ([]sdk.Guardrail, error) {
|
||||
var out []sdk.Guardrail
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newID returns a random 128-bit hex identifier for a skill row.
|
||||
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.
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
Reference in new issue
Block a user