9bb5d143f7
executus CI / test (pull_request) Successful in 3m30s
Real issues from the PR review: - security: readTree now skips symlinks (a pack with SKILL.md -> /etc/passwd or scripts/x -> ../../.ssh/id_rsa could read host files); covers file and dir symlinks, incl. within a git subpath - security: GitSource rejects url/ref beginning with '-' (git arg injection) and clones with '--' separator; --filter=blob:none (blobless partial clone) instead of full-history clone - correctness: Subscribe no longer swallows a non-ErrNotFound store error from GetByName (would create a duplicate subscription); handles *GitSource as well as GitSource in the URL/subpath extraction - correctness: pinTo no longer renames a subscription, so Apply can't silently collide two subscriptions when an upstream pack changes its name - validation: isKebab rejects leading/trailing/consecutive hyphens; BOM- prefixed SKILL.md now parses (matches the doc comment) - robustness: Catalog/Activate/renderPackBody/Stage guard nil/malformed packs - test cleanup: Syncer.Store field renamed Cache (collided with the Store interface); test NewID returns distinct ids - tests: symlink-skip, BOM, strict-kebab, nil-pack-safety Deferred (advisory perf, documented): PackCache stores raw trees so activation re-parses; CheckAll is serial. Both fine at expected scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
4.8 KiB
Go
141 lines
4.8 KiB
Go
package skillpack
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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 {
|
|
sorted := make([]*Pack, 0, len(packs))
|
|
for _, p := range packs {
|
|
if p != nil && p.Manifest != nil {
|
|
sorted = append(sorted, p)
|
|
}
|
|
}
|
|
if len(sorted) == 0 {
|
|
return ""
|
|
}
|
|
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 {
|
|
byName := make(map[string]*Pack, len(packs))
|
|
for _, p := range packs {
|
|
if p != nil && p.Manifest != nil {
|
|
byName[p.Manifest.Name] = p
|
|
}
|
|
}
|
|
if len(byName) == 0 {
|
|
return nil
|
|
}
|
|
|
|
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 {
|
|
if p == nil || p.Manifest == nil {
|
|
return "Error: invalid skill pack."
|
|
}
|
|
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) {
|
|
if p == nil || p.Manifest == nil {
|
|
return "", errors.New("skillpack: Stage requires a non-nil pack")
|
|
}
|
|
dir := baseDir + "/" + p.Manifest.Name
|
|
if err := p.Tree.WriteTo(dir); err != nil {
|
|
return "", err
|
|
}
|
|
return dir, nil
|
|
}
|