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  }