Files
backend/internal/skill/skill.go

207 lines
7.1 KiB
Go

// Package skill handles the markdown representation of skills: parsing,
// loading, writing, and the structure shared with the agent runtime.
//
// A skill file is markdown with a YAML frontmatter block delimited by "---".
// The frontmatter carries machine-readable metadata (tools, permissions,
// endpoint mappings); the document body is the natural-language system
// prompt fed to the LLM. This dual form lets operators read and edit a skill
// like a normal doc while the runtime parses a strict contract from it.
package skill
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Skill is the in-memory representation of a skill markdown file.
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Version int `yaml:"version" json:"version"`
AppSlug string `yaml:"app_slug" json:"app_slug"`
ModelConfig string `yaml:"model_config" json:"model_config,omitempty"`
Permissions Permissions `yaml:"permissions" json:"permissions"`
Tools []Tool `yaml:"tools" json:"tools"`
// SystemPrompt is the markdown body (everything after frontmatter). It
// is NOT stored in frontmatter; Write places it after the delimiter.
SystemPrompt string `yaml:"-" json:"system_prompt"`
}
// Permissions governs what the agent may do with this skill's tools.
type Permissions struct {
// DefaultMode is the blanket policy: "readonly" (default) restricts
// tools to safe methods; "confirm" requires end-user confirmation for
// any tool not explicitly allowed.
DefaultMode string `yaml:"default_mode" json:"default_mode"`
// RequireConfirmation lists tool names that always need end-user
// confirmation before execution, regardless of HTTP method.
RequireConfirmation []string `yaml:"require_confirmation" json:"require_confirmation"`
// AllowWrite lists tool names explicitly permitted to use mutating HTTP
// methods without per-call confirmation. Empty by default.
AllowWrite []string `yaml:"allow_write" json:"allow_write"`
}
// Tool is one callable capability exposed to the LLM. Parameters is a JSON
// Schema describing the arguments; Endpoint maps the tool to a concrete HTTP
// call against the target site.
type Tool struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Parameters jsonObject `yaml:"parameters" json:"parameters"` // JSON Schema object
Endpoint Endpoint `yaml:"endpoint" json:"endpoint"`
}
// Endpoint maps a tool call to a concrete HTTP request. Path/Query/Header
// values may use {{param}} placeholders resolved from the tool arguments at
// runtime. Body is a JSON template (also {{param}}-substituted) for writes.
type Endpoint struct {
Method string `yaml:"method" json:"method"`
URL string `yaml:"url" json:"url"`
Path map[string]string `yaml:"path" json:"path,omitempty"`
Query map[string]string `yaml:"query" json:"query,omitempty"`
Header map[string]string `yaml:"header" json:"header,omitempty"`
Body string `yaml:"body" json:"body,omitempty"` // JSON template
}
// MakeParameters wraps a JSON Schema byte slice into the Parameters type used
// by Tool. Exported so callers in other packages (and tests) can build a Tool
// without referencing the unexported jsonObject type directly. Returns the
// same underlying bytes; the conversion is type-level.
func MakeParameters(jsonSchema []byte) jsonObject {
return jsonObject(jsonSchema)
}
const frontMatterDelim = "---"
// Load reads and parses a skill markdown file from path.
func Load(path string) (*Skill, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read skill %s: %w", path, err)
}
return Parse(raw)
}
// Parse decodes a skill markdown document from raw bytes.
func Parse(raw []byte) (*Skill, error) {
s := &Skill{}
// Defaults: skills are read-only and require confirmation for anything
// mutating unless the operator explicitly opts in.
s.Permissions.DefaultMode = "readonly"
body, err := splitFrontMatter(raw)
if err != nil {
return nil, err
}
if body.front != nil {
if err := yaml.Unmarshal(body.front, s); err != nil {
return nil, fmt.Errorf("parse frontmatter yaml: %w", err)
}
}
if s.Permissions.DefaultMode == "" {
s.Permissions.DefaultMode = "readonly"
}
if s.Version == 0 {
s.Version = 1
}
s.SystemPrompt = strings.TrimSpace(body.body)
return s, nil
}
type parts struct {
front []byte
body string
}
// splitFrontMatter separates a leading "---\n...\n---\n" block from the
// remainder of the document. If no frontmatter is present the whole input is
// returned as the body.
func splitFrontMatter(raw []byte) (*parts, error) {
text := string(raw)
// Trim a leading UTF-8 BOM if present so editors that add one don't
// break frontmatter detection.
text = strings.TrimPrefix(text, "\uFEFF")
text = strings.TrimPrefix(text, "\n")
if !strings.HasPrefix(text, frontMatterDelim+"\n") && text != frontMatterDelim {
return &parts{body: strings.TrimSpace(text)}, nil
}
// Skip the opening delimiter line.
rest := text[len(frontMatterDelim):]
rest = strings.TrimPrefix(rest, "\n")
// Find the closing delimiter on its own line.
endIdx := indexClosingDelim(rest)
if endIdx < 0 {
return nil, fmt.Errorf("frontmatter not terminated by a closing %q line", frontMatterDelim)
}
front := []byte(rest[:endIdx])
// Move past the closing delimiter and its trailing newline.
tail := rest[endIdx+len(frontMatterDelim):]
tail = strings.TrimPrefix(tail, "\r")
tail = strings.TrimPrefix(tail, "\n")
return &parts{front: front, body: strings.TrimSpace(tail)}, nil
}
// indexClosingDelim returns the byte offset of a line equal to "---" within
// rest, or -1 if none. A line is matched when it starts with "---" followed
// by end-of-line.
func indexClosingDelim(rest string) int {
lines := strings.SplitN(rest, "\n", -1)
off := 0
for _, ln := range lines {
if strings.TrimRight(ln, "\r") == frontMatterDelim {
return off
}
// +1 for the '\n' that Split removed.
off += len(ln) + 1
}
return -1
}
// Write serialises a skill to a markdown file at path, creating parent dirs.
func Write(path string, s *Skill) error {
raw, err := Marshal(s)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir for skill: %w", err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
return fmt.Errorf("write skill %s: %w", path, err)
}
return nil
}
// Marshal renders a skill as markdown: frontmatter block + system prompt body.
func Marshal(s *Skill) ([]byte, error) {
if s == nil {
return nil, fmt.Errorf("nil skill")
}
if s.Permissions.DefaultMode == "" {
s.Permissions.DefaultMode = "readonly"
}
if s.Version == 0 {
s.Version = 1
}
front, err := yaml.Marshal(s)
if err != nil {
return nil, fmt.Errorf("marshal frontmatter: %w", err)
}
var b strings.Builder
b.WriteString(frontMatterDelim)
b.WriteString("\n")
b.Write(front)
b.WriteString(frontMatterDelim)
b.WriteString("\n\n")
b.WriteString(strings.TrimSpace(s.SystemPrompt))
b.WriteString("\n")
return []byte(b.String()), nil
}