Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
206
internal/skill/skill.go
Normal file
206
internal/skill/skill.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user