Files
executus/skillpack/activation.go
T
steve bf0b67f9af
executus CI / test (pull_request) Successful in 1m6s
Adversarial Review (Gadfly) / review (pull_request) Successful in 15m50s
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>
2026-07-04 16:46:37 -04:00

127 lines
4.5 KiB
Go

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
}