github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/skills.go (about) 1 package agenttoolset 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "log/slog" 9 "os" 10 "path/filepath" 11 "strings" 12 13 anthropic "github.com/anthropics/anthropic-sdk-go" 14 "github.com/anthropics/anthropic-sdk-go/option" 15 ) 16 17 // SetupSkillsFromSession downloads the session agent's skills into 18 // {e.Workdir}/skills/<name>/. It reads the resolved agent off session, and for 19 // each skill fetches the files via client.Beta.Skills.Versions.Download and 20 // extracts the archive (a zip or gzip/bzip2/plain tar archive) under a 21 // directory named after the skill. Archive members and skill names that would 22 // escape the workspace are refused; a failure on one skill is logged and does 23 // not block the others. Call this before starting the dispatcher (e.g. right 24 // after the workdir is ready). 25 // 26 // Pass the session. A session's resources cannot change while it runs, so the 27 // caller fetches it once and shares that snapshot with the memory-store 28 // download — the two can then never disagree about the attached resources. 29 // Callers holding only an id have the deprecated [AgentToolContext.SetupSkills]. 30 // 31 // opts are applied to every request this makes (each skill version 32 // list/get/download). Self-hosted-environment callers must pass the 33 // environment key here — the skill endpoints are environment-scoped, and 34 // without it the requests fall back to the client's default credentials and 35 // fail. option.WithAuthToken alone only ADDS an Authorization header; the 36 // parent client's WithAPIKey middleware still emits X-Api-Key on every 37 // request, so both creds would land on the wire and the server rejects the 38 // dual auth. Pair the bearer with an explicit X-Api-Key delete: 39 // 40 // opts := []option.RequestOption{ 41 // option.WithHeaderDel("X-Api-Key"), 42 // option.WithAuthToken(environmentKey), 43 // } 44 // session, err := client.Beta.Sessions.Get(ctx, sessionID, anthropic.BetaSessionGetParams{}, opts...) 45 // // ... 46 // env.SetupSkillsFromSession(ctx, client, session, opts...) 47 func (e *AgentToolContext) SetupSkillsFromSession(ctx context.Context, client anthropic.Client, session *anthropic.BetaManagedAgentsSession, opts ...option.RequestOption) error { 48 log := slog.Default().With(slog.String("component", "tool-env"), slog.String("session_id", session.ID)) 49 skillsRoot, err := filepath.Abs(filepath.Join(e.Workdir, "skills")) 50 if err != nil { 51 return fmt.Errorf("resolve skills dir: %w", err) 52 } 53 for _, skill := range session.Agent.Skills { 54 if err := e.downloadSkill(ctx, client, skillsRoot, skill.SkillID, skill.Version, log, opts...); err != nil { 55 log.Warn("failed to download skill", slog.String("skill_id", skill.SkillID), slog.Any("error", err)) 56 } 57 } 58 return nil 59 } 60 61 // SetupSkills is [AgentToolContext.SetupSkillsFromSession] for a caller that holds 62 // only the session's id: it fetches the session — opts authorize that lookup 63 // as well — and delegates. 64 // 65 // Deprecated: prefer [AgentToolContext.SetupSkillsFromSession]. This entry point costs an 66 // extra session fetch on every call; a caller that uses it 67 // and then downloads the session's memory stores fetches the session twice, 68 // and two fetches can disagree about the attached resources. It remains 69 // supported for callers written before the session-taking form existed. 70 func (e *AgentToolContext) SetupSkills(ctx context.Context, client anthropic.Client, sessionID string, opts ...option.RequestOption) error { 71 slog.Default().With(slog.String("component", "tool-env"), slog.String("session_id", sessionID)). 72 Warn("SetupSkills(sessionID) is deprecated and costs an extra session fetch; " + 73 "fetch the session once and pass it to SetupSkillsFromSession instead") 74 session, err := client.Beta.Sessions.Get(ctx, sessionID, anthropic.BetaSessionGetParams{}, opts...) 75 if err != nil { 76 return fmt.Errorf("retrieve session %s: %w", sessionID, err) 77 } 78 return e.SetupSkillsFromSession(ctx, client, session, opts...) 79 } 80 81 // Cleanup removes the per-skill directories 82 // [AgentToolContext.SetupSkillsFromSession] downloaded under {Workdir}/skills 83 // — exactly those, and nothing else. The skills directory itself, and 84 // anything else the caller placed in it, survive: the directory lives inside 85 // the caller's workdir and its other contents are the caller's, not ours to 86 // delete. (A caller directory whose name collides with a downloaded skill is 87 // already replaced at download time, so there is nothing of the caller's left 88 // for Cleanup to spare.) The EnvironmentWorker calls this when a work item is done so one 89 // session's skills do not leak into the next item served by the same worker. 90 func (e *AgentToolContext) Cleanup() error { 91 var errs []error 92 for _, dir := range e.downloadedSkillDirs { 93 if err := os.RemoveAll(dir); err != nil { 94 errs = append(errs, err) 95 } 96 } 97 e.downloadedSkillDirs = nil 98 return errors.Join(errs...) 99 } 100 101 func (e *AgentToolContext) downloadSkill(ctx context.Context, client anthropic.Client, skillsRoot, skillID, skillVersion string, log *slog.Logger, opts ...option.RequestOption) error { 102 version, err := client.Beta.Skills.Versions.Get(ctx, skillVersion, anthropic.BetaSkillVersionGetParams{SkillID: skillID}, opts...) 103 if err != nil { 104 return fmt.Errorf("retrieve skill version: %w", err) 105 } 106 // The directory is the skill's name, reduced to a single safe path 107 // component so a hostile name can't escape skillsRoot. 108 dirname := filepath.Base(strings.TrimSpace(version.Name)) 109 if dirname == "" || dirname == "." || dirname == ".." || strings.ContainsAny(dirname, `/\`) { 110 dirname = skillID 111 } 112 dest := filepath.Join(skillsRoot, dirname) 113 if dest != skillsRoot && !strings.HasPrefix(dest, skillsRoot+string(os.PathSeparator)) { 114 return fmt.Errorf("skill name %q escapes the skills dir", version.Name) 115 } 116 // skillVersion may be the alias "latest", which only the retrieve 117 // endpoint resolves; download by the concrete id it returned. 118 resp, err := client.Beta.Skills.Versions.Download(ctx, version.ID, anthropic.BetaSkillVersionDownloadParams{SkillID: skillID}, opts...) 119 if err != nil { 120 return fmt.Errorf("download skill: %w", err) 121 } 122 defer resp.Body.Close() 123 124 // Stream the archive to a temp file rather than buffering it whole in 125 // memory: a skill bundle can be large, and the zip extractor needs random 126 // access over the file anyway. 127 tmp, err := os.CreateTemp("", "skill-archive-*") 128 if err != nil { 129 return fmt.Errorf("create temp file for skill archive: %w", err) 130 } 131 tmpPath := tmp.Name() 132 defer os.Remove(tmpPath) 133 if _, err := io.Copy(tmp, resp.Body); err != nil { 134 tmp.Close() 135 return fmt.Errorf("stream skill archive to disk: %w", err) 136 } 137 if err := tmp.Close(); err != nil { 138 return fmt.Errorf("flush skill archive: %w", err) 139 } 140 141 if err := os.RemoveAll(dest); err != nil { 142 return fmt.Errorf("clear %s: %w", dest, err) 143 } 144 // Recorded before the extract, like a successful one: even a 145 // half-extracted directory is this call's to remove, not the caller's. 146 e.downloadedSkillDirs = append(e.downloadedSkillDirs, dest) 147 if err := extractSkillArchive(tmpPath, dest); err != nil { 148 return fmt.Errorf("extract skill: %w", err) 149 } 150 log.Info("downloaded skill", 151 slog.String("skill_id", skillID), 152 slog.String("version", version.ID), 153 slog.String("dest", dest)) 154 return nil 155 }