feat(skillpack): SKILL.md-subscription battery
New additive, nil-safe battery for subscribing to skill packages in the Anthropic agent-skills format (SKILL.md manifest + bundled files): - Manifest/ParseManifest: SKILL.md frontmatter+body parse & validation (name/description required, allowed-tools passthrough, kebab/length limits) - Tree/Pack/LoadPack: self-contained file set, order-independent content digest (the pin identity + change signal), bundled-file listing, traversal- safe staging - Source (DirSource, GitSource): Fetch returns tree + resolved ref; git clones to temp, reads subpath into memory, cleans up (self-contained tree) - Subscription + Store + content-addressed PackCache, with Memory defaults - Syncer: Subscribe pins; Check records a PENDING update but never moves the pin; Apply is the only re-pin (supply-chain guard — upstream can't silently change what an agent runs) - Activate: resolved packs -> majordomo agent.Skill (catalog instructions + one skill_use tool) for progressive disclosure; Stage materializes files Third distinct 'skill' concept, deliberately separate from executus/skill (saved-agent noun) and majordomo/skill (eager capability bundle). Mort-side wiring (convars, .skillpack commands, Agent.SkillPacks, allowed-tools shim) is a later, separate step. Full unit + hermetic local-git tests; gofmt/vet clean; race-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
mdskill "gitea.stevedudenhoeffer.com/steve/majordomo/skill"
|
||||
)
|
||||
|
||||
// Resolve loads the pinned Pack for each enabled subscription from the cache. It
|
||||
// is how a host turns "this agent subscribes to these packs" into activatable
|
||||
// packs at run time without touching the network. A pinned digest missing from
|
||||
// the cache is an error (the host should have cached it at pin/apply time).
|
||||
// Disabled subscriptions are skipped.
|
||||
func Resolve(ctx context.Context, cache PackCache, subs []Subscription) ([]*Pack, error) {
|
||||
out := make([]*Pack, 0, len(subs))
|
||||
for i := range subs {
|
||||
s := &subs[i]
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
tree, err := cache.Get(ctx, s.PinnedDigest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillpack: resolving %q: %w", s.Name, err)
|
||||
}
|
||||
pack, err := LoadPack(tree)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillpack: loading %q: %w", s.Name, err)
|
||||
}
|
||||
out = append(out, pack)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Catalog renders the always-in-prompt block for a set of packs: one line per
|
||||
// pack (name + description) plus how to load one. This is the whole prompt cost
|
||||
// of a subscription — the bodies stay out until skill_use is called.
|
||||
func Catalog(packs []*Pack) string {
|
||||
if len(packs) == 0 {
|
||||
return ""
|
||||
}
|
||||
sorted := append([]*Pack(nil), packs...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Manifest.Name < sorted[j].Manifest.Name })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("You have access to skills — packaged instructions for specific tasks. ")
|
||||
b.WriteString("When a task matches one, call skill_use with its name to load its full instructions before proceeding.\n\n")
|
||||
b.WriteString("Available skills:\n")
|
||||
for _, p := range sorted {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", p.Manifest.Name, p.Manifest.Description)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
type skillUseArgs struct {
|
||||
Name string `json:"name" description:"the exact name of the skill to load, from the Available skills list"`
|
||||
}
|
||||
|
||||
// Activate turns a set of resolved packs into a majordomo agent.Skill: its
|
||||
// Instructions are the Catalog, and it contributes a single skill_use tool that
|
||||
// returns a named pack's full body (progressive disclosure). Attach the result
|
||||
// to an agent with agent.WithSkill. Returns nil when there are no packs, which
|
||||
// agent.WithSkill tolerates (a nil skill contributes nothing).
|
||||
//
|
||||
// stagedDir, if non-empty, is the directory a host has staged the packs' bundled
|
||||
// files into (see Stage); skill_use appends the concrete path so the model knows
|
||||
// where to read scripts/references with its file tools. Leave it empty when the
|
||||
// host has no staging.
|
||||
func Activate(packs []*Pack, stagedDir string) mdagent.Skill {
|
||||
if len(packs) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]*Pack, len(packs))
|
||||
for _, p := range packs {
|
||||
byName[p.Manifest.Name] = p
|
||||
}
|
||||
|
||||
tool := llm.DefineTool("skill_use",
|
||||
"Load the full instructions for a skill by name before doing a task it covers. Returns the skill's instructions and a list of any bundled files.",
|
||||
func(_ context.Context, args skillUseArgs) (any, error) {
|
||||
p, ok := byName[strings.TrimSpace(args.Name)]
|
||||
if !ok {
|
||||
return fmt.Sprintf("No skill named %q. Use one of the names from the Available skills list.", args.Name), nil
|
||||
}
|
||||
return renderPackBody(p, stagedDir), nil
|
||||
})
|
||||
|
||||
tb := llm.NewToolbox("skillpack", tool)
|
||||
return mdskill.New("skillpacks",
|
||||
mdskill.WithInstructions(Catalog(packs)),
|
||||
mdskill.WithToolbox(tb),
|
||||
)
|
||||
}
|
||||
|
||||
// renderPackBody is what skill_use returns: the pack's instructions plus a
|
||||
// pointer to its bundled files (with the staged path when known).
|
||||
func renderPackBody(p *Pack, stagedDir string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Skill: %s\n\n%s\n", p.Manifest.Name, p.Manifest.Body)
|
||||
if len(p.Bundled) > 0 {
|
||||
b.WriteString("\nBundled files")
|
||||
if stagedDir != "" {
|
||||
fmt.Fprintf(&b, " (under %s)", strings.TrimRight(stagedDir, "/")+"/"+p.Manifest.Name)
|
||||
}
|
||||
b.WriteString(":\n")
|
||||
for _, f := range p.Bundled {
|
||||
fmt.Fprintf(&b, "- %s\n", f)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// Stage materializes a pack's files under baseDir/<pack name>/ so a host can
|
||||
// mount them (read-only is the host's concern) into a sandbox the agent's file
|
||||
// tools can read. Returns the pack's staged directory.
|
||||
func Stage(p *Pack, baseDir string) (string, error) {
|
||||
dir := baseDir + "/" + p.Manifest.Name
|
||||
if err := p.Tree.WriteTo(dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
Reference in New Issue
Block a user