Files
2026-08-14 23:41:57 +08:00

375 lines
14 KiB
Go

// This file implements declarative skills (US-028, #45): a skill is a markdown
// file with a YAML frontmatter block (mirrors SKILL.md) that names a reusable
// capability. Loading a skill parses its metadata (name, description, optional
// tool allow-list and model) and its markdown body (the skill's system prompt),
// then materializes it as a sub-agent tool — so invoking a skill runs its body
// as the system prompt of a child agent loop, reusing the SubAgentTool
// abstraction rather than introducing a second execution path. Each skill's
// description is surfaced in the parent's capability list so the model can
// choose to delegate to it.
package runtime
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"gopkg.in/yaml.v3"
)
// SkillFrontmatter is the YAML metadata block at the head of a skill file.
type SkillFrontmatter struct {
// Name is the skill identifier; it becomes the spawnable tool name. When
// omitted it defaults to the file's base name (without extension).
Name string `yaml:"name"`
// Description tells the model what the skill does and when to use it. It is
// injected into the capability list, so it should be action-oriented.
Description string `yaml:"description"`
// AllowedTools optionally restricts the tools the skill's sub-agent may use,
// by tool name. Empty means "inherit the provided tool set as-is". Real
// Claude Code skills write this either as a YAML list or as a single
// scalar string (e.g. "Bash(foo:*), Read"), so it tolerates both forms.
AllowedTools stringList `yaml:"allowed-tools"`
// Model optionally pins the skill to a specific model; empty inherits.
Model string `yaml:"model"`
// DisableModelInvocation, when true, keeps the skill out of the system
// prompt's <available_skills> list so the model cannot auto-invoke it; it
// remains reachable only via its explicit "/name" slash command (mirrors pi's
// disable-model-invocation frontmatter key). Defaults to false.
DisableModelInvocation bool `yaml:"disable-model-invocation"`
}
// Agent Skills spec limits (mirrors pi/agentskills.io): a skill name is a short
// slug and a description is a single sentence, both bounded so they stay cheap
// to inject into the system prompt.
const (
maxSkillNameLength = 64
maxSkillDescriptionLength = 1024
)
// skillNamePattern matches a valid skill name per the Agent Skills spec:
// lowercase ASCII letters, digits, and hyphens only.
var skillNamePattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// validateSkillName reports why name violates the Agent Skills spec, or nil
// when it is valid: lowercase a-z/0-9/hyphen only, at most maxSkillNameLength
// characters, and no leading, trailing, or consecutive hyphens.
func validateSkillName(name string) error {
if len(name) > maxSkillNameLength {
return fmt.Errorf("name exceeds %d characters (%d)", maxSkillNameLength, len(name))
}
if !skillNamePattern.MatchString(name) {
return fmt.Errorf("name %q contains invalid characters (allowed: lowercase a-z, 0-9, hyphen)", name)
}
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf("name %q must not start or end with a hyphen", name)
}
if strings.Contains(name, "--") {
return fmt.Errorf("name %q must not contain consecutive hyphens", name)
}
return nil
}
// validateSkillDescription reports why description violates the spec, or nil
// when it is valid: non-empty and at most maxSkillDescriptionLength characters.
func validateSkillDescription(description string) error {
if strings.TrimSpace(description) == "" {
return errors.New("frontmatter missing required 'description'")
}
if len(description) > maxSkillDescriptionLength {
return fmt.Errorf("description exceeds %d characters (%d)", maxSkillDescriptionLength, len(description))
}
return nil
}
// stringList is a []string that unmarshals from either a YAML sequence
// (- a\n- b) or a single scalar. A scalar is split on commas so the common
// Claude Code form `allowed-tools: Bash(foo:*), Read` parses into two entries.
// This tolerance matters: a strict []string field rejects the scalar form and,
// because LoadSkillsDir aborts on the first parse error, one such skill would
// hide every other skill in the directory.
type stringList []string
// UnmarshalYAML accepts a scalar or a sequence node.
func (l *stringList) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return err
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
*l = out
return nil
case yaml.SequenceNode:
var ss []string
if err := node.Decode(&ss); err != nil {
return err
}
*l = ss
return nil
default:
// An empty/null node leaves the list nil (no restriction).
return nil
}
}
// Skill is a parsed skill file: its metadata plus the markdown body that serves
// as the sub-agent's system prompt.
type Skill struct {
Frontmatter SkillFrontmatter
// Body is the markdown after the frontmatter block — the skill's instructions,
// used as the child agent's system prompt.
Body string
// Path is the source file, retained for diagnostics.
Path string
}
// ParseSkill parses a skill's raw file content into a Skill. The file must open
// with a YAML frontmatter block delimited by lines containing only "---"; the
// remainder is the markdown body. A missing or malformed frontmatter block is
// an error, since name/description drive discovery.
func ParseSkill(path string, content []byte) (*Skill, error) {
fm, body, err := splitFrontmatter(content)
if err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
var meta SkillFrontmatter
if err := yaml.Unmarshal(fm, &meta); err != nil {
return nil, fmt.Errorf("skill %s: parse frontmatter: %w", path, err)
}
if meta.Name == "" {
base := filepath.Base(path)
meta.Name = strings.TrimSuffix(base, filepath.Ext(base))
}
if err := validateSkillName(meta.Name); err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
if err := validateSkillDescription(meta.Description); err != nil {
return nil, fmt.Errorf("skill %s: %w", path, err)
}
return &Skill{Frontmatter: meta, Body: strings.TrimSpace(string(body)), Path: path}, nil
}
// splitFrontmatter separates a leading "---"-delimited YAML block from the rest
// of the document. It returns the frontmatter bytes (without the fences) and
// the remaining body. It errors if the document does not open with a fence or
// the closing fence is missing.
func splitFrontmatter(content []byte) (frontmatter, body []byte, err error) {
text := string(content)
// Tolerate a UTF-8 BOM and leading blank lines before the opening fence.
text = strings.TrimPrefix(text, "\ufeff")
trimmed := strings.TrimLeft(text, "\r\n")
if !strings.HasPrefix(trimmed, "---") {
return nil, nil, fmt.Errorf("missing YAML frontmatter (file must start with '---')")
}
lines := strings.Split(trimmed, "\n")
// lines[0] is the opening fence. Find the closing fence.
var fmLines []string
closeIdx := -1
for i := 1; i < len(lines); i++ {
if strings.TrimRight(lines[i], "\r") == "---" {
closeIdx = i
break
}
fmLines = append(fmLines, lines[i])
}
if closeIdx == -1 {
return nil, nil, fmt.Errorf("unterminated YAML frontmatter (missing closing '---')")
}
bodyLines := lines[closeIdx+1:]
return []byte(strings.Join(fmLines, "\n")), []byte(strings.Join(bodyLines, "\n")), nil
}
// LoadSkillsDir loads every "*.md" skill file in dir (non-recursively) plus any
// "<name>/SKILL.md" nested layout (mirrors the SKILL.md convention). It returns the
// parsed skills sorted by name. A missing directory yields no skills and no
// error (skills are optional).
//
// A malformed skill file does NOT abort the load: the file is skipped and its
// error accumulated, so one bad skill cannot hide every other skill in the
// directory (a real ~/.agents/skills holds 100+ skills authored to varying
// conventions). The successfully parsed skills are always returned; the error,
// when non-nil, joins every skip reason for the caller to surface as a
// non-fatal warning.
func LoadSkillsDir(dir string) ([]*Skill, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read skills dir %s: %w", dir, err)
}
var skills []*Skill
var errs []error
for _, e := range entries {
var path string
switch {
case e.IsDir():
// Nested layout: <dir>/<name>/SKILL.md.
candidate := filepath.Join(dir, e.Name(), "SKILL.md")
if _, statErr := os.Stat(candidate); statErr != nil {
continue
}
path = candidate
case strings.EqualFold(filepath.Ext(e.Name()), ".md"):
path = filepath.Join(dir, e.Name())
default:
continue
}
content, readErr := os.ReadFile(path)
if readErr != nil {
errs = append(errs, fmt.Errorf("read skill %s: %w", path, readErr))
continue
}
skill, parseErr := ParseSkill(path, content)
if parseErr != nil {
errs = append(errs, parseErr)
continue
}
skills = append(skills, skill)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Frontmatter.Name < skills[j].Frontmatter.Name
})
return skills, errors.Join(errs...)
}
// SubAgentSpec turns a skill into a sub-agent spec: the skill body becomes the
// child's system prompt, the description is surfaced to the model, and the tool
// set is the provided tools filtered by AllowedTools (when set). newRunConfig
// builds each child run's configuration; it receives the resolved tool set so
// the caller can wire a matching registry.
func (s *Skill) SubAgentSpec(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) SubAgentSpec {
resolved := filterToolsByName(tools, s.Frontmatter.AllowedTools)
return SubAgentSpec{
Name: s.Frontmatter.Name,
Description: s.Frontmatter.Description,
SystemPrompt: s.Body,
Tools: resolved,
NewRunConfig: func() RunConfig { return newRunConfig(resolved) },
}
}
// SkillTool materializes a skill as an invocable sub-agent tool.
func (s *Skill) SkillTool(tools []agentcore.AgentTool, newRunConfig func(tools []agentcore.AgentTool) RunConfig) *SubAgentTool {
return NewSubAgentTool(s.SubAgentSpec(tools, newRunConfig))
}
// SlashCommand exposes the skill as a "/name" slash command (mirrors Claude Code's
// /skill-name invocation). Invoking it expands to the skill's instructions (its
// markdown body) as the prompt, with any arguments appended, so the skill runs
// in the current conversation. It is a prompt command (not an action): the
// expanded text is fed to the agent loop as the next user turn.
func (s *Skill) SlashCommand() SlashCommand {
body := s.Body
return SlashCommand{
Name: s.Frontmatter.Name,
Description: s.Frontmatter.Description,
Source: SourceUser,
Expand: func(args string) string {
if strings.Contains(body, "$ARGUMENTS") {
return strings.ReplaceAll(body, "$ARGUMENTS", args)
}
if strings.TrimSpace(args) == "" {
return body
}
return body + "\n\n" + args
},
}
}
// FormatSkillsForPrompt renders the visible skills as an <available_skills>
// XML block for injection into the system prompt (mirrors pi's
// formatSkillsForPrompt). It implements progressive disclosure: only each
// skill's name, description, and location (the absolute SKILL.md path) are
// listed, so the model can read the file on demand rather than carrying every
// skill body in context.
//
// Skills with DisableModelInvocation == true are excluded (they remain
// reachable only via their explicit "/name" slash command). When no visible
// skill remains, it returns the empty string so callers can append
// unconditionally without altering a skill-free prompt.
func FormatSkillsForPrompt(skills []*Skill) string {
visible := make([]*Skill, 0, len(skills))
for _, s := range skills {
if s != nil && !s.Frontmatter.DisableModelInvocation {
visible = append(visible, s)
}
}
if len(visible) == 0 {
return ""
}
lines := []string{
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
}
for _, s := range visible {
lines = append(lines,
" <skill>",
" <name>"+escapeXML(s.Frontmatter.Name)+"</name>",
" <description>"+escapeXML(s.Frontmatter.Description)+"</description>",
" <location>"+escapeXML(skillLocation(s.Path))+"</location>",
" </skill>",
)
}
lines = append(lines, "</available_skills>")
return strings.Join(lines, "\n")
}
// skillLocation returns the absolute path to a skill file so the model can load
// it with the read tool regardless of the working directory. It falls back to
// the original path if resolution fails.
func skillLocation(path string) string {
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
// escapeXML escapes the five XML special characters so a skill's name or
// description cannot break the surrounding markup.
func escapeXML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return r.Replace(s)
}
// filterToolsByName keeps only tools whose Name is in allow. An empty allow
// list means "no restriction" and returns the input unchanged.
func filterToolsByName(tools []agentcore.AgentTool, allow []string) []agentcore.AgentTool {
if len(allow) == 0 {
return tools
}
set := make(map[string]bool, len(allow))
for _, n := range allow {
set[n] = true
}
out := make([]agentcore.AgentTool, 0, len(tools))
for _, t := range tools {
if set[t.Name()] {
out = append(out, t)
}
}
return out
}