first commit
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
// 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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user