29598df814
executus CI / test (pull_request) Successful in 2m19s
Replace Activate's stagedDir string with a BundleStager callback invoked lazily inside skill_use: when the model loads a pack with bundled files, the host stages them (mort: into run-scoped file storage) and the returned note is appended to the body so the model knows how to reach them. A nil stager (or a stager error) degrades gracefully to just listing the file names. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
5.6 KiB
Go
157 lines
5.6 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"`
|
|
}
|
|
|
|
// BundleStager makes a pack's bundled files available to the current run and
|
|
// returns a short note the model can act on (e.g. where the files are and how to
|
|
// reference them). It is called LAZILY, inside the skill_use tool, so a pack's
|
|
// files are staged only when the model actually loads that pack — not for every
|
|
// subscribed pack on every run. A host implements it over its own file plumbing
|
|
// (mort saves the files to run-scoped storage and returns their file_ids). nil =
|
|
// no staging: skill_use just lists the bundled file names.
|
|
type BundleStager func(ctx context.Context, p *Pack) (string, error)
|
|
|
|
// 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).
|
|
//
|
|
// stager, if non-nil, is invoked when skill_use loads a pack with bundled files;
|
|
// its returned note is appended to the body so the model knows how to reach the
|
|
// staged scripts/references. A stager error degrades gracefully (the
|
|
// instructions still return, with a note that the files are unavailable).
|
|
func Activate(packs []*Pack, stager BundleStager) 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, if it has bundled files, how to access them.",
|
|
func(ctx 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
|
|
}
|
|
body := renderPackBody(p)
|
|
if stager != nil && len(p.Bundled) > 0 {
|
|
note, err := stager(ctx, p)
|
|
switch {
|
|
case err != nil:
|
|
body += "\n\n(bundled files could not be staged: " + err.Error() + ")"
|
|
case note != "":
|
|
body += "\n\n" + note
|
|
}
|
|
}
|
|
return body, nil
|
|
})
|
|
|
|
tb := llm.NewToolbox("skillpack", tool)
|
|
return mdskill.New("skillpacks",
|
|
mdskill.WithInstructions(Catalog(packs)),
|
|
mdskill.WithToolbox(tb),
|
|
)
|
|
}
|
|
|
|
// renderPackBody is the base skill_use payload: the pack's instructions plus, if
|
|
// it has any, a list of its bundled file names. A stager (see Activate) appends
|
|
// the concrete access note.
|
|
func renderPackBody(p *Pack) 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:\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
|
|
}
|