mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
feat(agent): bundle skill creator
This commit is contained in:
@@ -22,6 +22,54 @@ const (
|
||||
SkillsDirEnv = "OLLAMA_SKILLS"
|
||||
skillFilename = "SKILL.md"
|
||||
maxSkillBytes = 1 << 20
|
||||
|
||||
bundledSkillCreatorName = "skill-creator"
|
||||
bundledSkillCreatorContent = `---
|
||||
name: skill-creator
|
||||
description: Create or improve reusable skills. Use when the user wants a reusable skill, asks how to author SKILL.md, or needs help installing a skill.
|
||||
---
|
||||
|
||||
# Create a skill
|
||||
|
||||
Create a focused, reusable instruction package. Treat a skill as guidance for the model, not as a way to gain new permissions or bypass safety controls.
|
||||
|
||||
## Choose the location
|
||||
|
||||
Create user skills beside this one. The skill directory shown in the loaded skill context is this skill's location; its parent is the user skill root. This bundled skill normally lives at ~/.ollama/skills/skill-creator, so new user skills normally go at ~/.ollama/skills/<skill-name>/SKILL.md.
|
||||
|
||||
Use a project-local skill directory only when the user asks to keep the skill with that project. Do not overwrite an existing skill without the user's approval. New and changed skills are discovered when the agent starts, so tell the user to begin a new agent session afterward.
|
||||
|
||||
## Follow the required shape
|
||||
|
||||
Use the directory name as the skill name. Use lowercase letters, numbers, and single hyphens only. Keep the name short and no longer than 64 characters.
|
||||
|
||||
Every skill needs a SKILL.md with YAML frontmatter followed by Markdown instructions:
|
||||
|
||||
~~~md
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes from completed changes. Use when the user asks for a changelog, release notes, or GitHub release copy.
|
||||
---
|
||||
|
||||
# Draft release notes
|
||||
|
||||
Write the workflow here.
|
||||
~~~
|
||||
|
||||
Require a non-empty description that says both what the skill does and when to use it. Keep the body procedural and concise. Put detailed schemas, long examples, and variant-specific guidance in references/ only when the skill needs them.
|
||||
|
||||
Use scripts/ for repeatable or fragile operations that benefit from deterministic execution. Use assets/ for files that belong in generated output. Do not add README files, changelogs, or setup notes that do not help the model perform the task.
|
||||
|
||||
## Create safely
|
||||
|
||||
1. Identify the repeated task, expected inputs, and useful output.
|
||||
2. Choose the smallest name and description that reliably trigger the skill.
|
||||
3. Create the folder and SKILL.md; add resources only when they remove real repeated work.
|
||||
4. Re-read the completed file and verify its frontmatter, directory-name match, and relative resource paths.
|
||||
5. Tell the user where it was created and that a new agent session will discover it.
|
||||
|
||||
Skills provide instructions only. They do not grant filesystem, network, shell, or approval privileges, and they do not make a tool available. Use only the tools that are actually available, follow their normal approval rules, and ask before actions that need user authorization.
|
||||
`
|
||||
)
|
||||
|
||||
var skillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
@@ -51,11 +99,13 @@ type Skill struct {
|
||||
}
|
||||
|
||||
func (s Skill) Content() string {
|
||||
dir := filepath.Dir(s.Path)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "<skill name=%q>\n%s\n", s.Name, strings.TrimSpace(s.Instructions))
|
||||
if s.Path != "" {
|
||||
dir := filepath.Dir(s.Path)
|
||||
fmt.Fprintf(&b, "Skill directory: %s\n", dir)
|
||||
b.WriteString("Relative paths in this skill are relative to the skill directory.\n")
|
||||
}
|
||||
if resources := s.resources(); len(resources) > 0 {
|
||||
b.WriteString("<skill_resources>\n")
|
||||
for _, r := range resources {
|
||||
@@ -70,6 +120,9 @@ func (s Skill) Content() string {
|
||||
// resources lists bundled files one level deep under scripts/, references/,
|
||||
// and assets/ without reading them, so the model can load them on demand.
|
||||
func (s Skill) resources() []string {
|
||||
if s.Path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.Path)
|
||||
var resources []string
|
||||
for _, sub := range []string{"scripts", "references", "assets"} {
|
||||
@@ -159,6 +212,14 @@ func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
|
||||
return nil, err
|
||||
}
|
||||
catalog := &SkillCatalog{skills: make(map[string]Skill)}
|
||||
bundled, err := bundledSkillCreator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.skills[bundled.Name] = bundled
|
||||
if err := installBundledSkillCreator(); err != nil {
|
||||
catalog.diagnostics = append(catalog.diagnostics, err)
|
||||
}
|
||||
for _, root := range roots {
|
||||
sub, err := DiscoverSkills(root.path)
|
||||
if err != nil {
|
||||
@@ -176,6 +237,36 @@ func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func bundledSkillCreator() (Skill, error) {
|
||||
skill, err := parseSkillContent("", bundledSkillCreatorName, bundledSkillCreatorContent)
|
||||
if err != nil {
|
||||
return Skill{}, fmt.Errorf("load bundled %s skill: %w", bundledSkillCreatorName, err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func installBundledSkillCreator() error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve bundled skill directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundled skill directory: %w", err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err == nil && string(contents) == bundledSkillCreatorContent {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("read bundled skill: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(bundledSkillCreatorContent), 0o644); err != nil {
|
||||
return fmt.Errorf("write bundled skill: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type skillRoot struct {
|
||||
path string
|
||||
}
|
||||
@@ -284,7 +375,11 @@ func parseSkill(path, directoryName string) (Skill, error) {
|
||||
if err != nil {
|
||||
return Skill{}, fmt.Errorf("read skill %q: %w", directoryName, err)
|
||||
}
|
||||
instructions := strings.TrimSpace(string(data))
|
||||
return parseSkillContent(path, directoryName, string(data))
|
||||
}
|
||||
|
||||
func parseSkillContent(path, directoryName, input string) (Skill, error) {
|
||||
instructions := strings.TrimSpace(input)
|
||||
if instructions == "" {
|
||||
return Skill{}, fmt.Errorf("skill %q: %s is empty", directoryName, skillFilename)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("valid skill was hidden by bad root: %v", err)
|
||||
}
|
||||
if _, err := catalog.Load(bundledSkillCreatorName); err != nil {
|
||||
t.Fatalf("bundled skill was hidden by bad root: %v", err)
|
||||
}
|
||||
var foundDiagnostic bool
|
||||
for _, diagnostic := range catalog.Diagnostics() {
|
||||
if strings.Contains(diagnostic.Error(), badRoot) {
|
||||
@@ -130,6 +133,51 @@ func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsInstallsBundledSkillCreator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(SkillsDirEnv, dir)
|
||||
|
||||
catalog, err := LoadDefaultSkills("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load(bundledSkillCreatorName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != bundledSkillCreatorContent {
|
||||
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||
}
|
||||
if skill.Path != path {
|
||||
t.Fatalf("skill path = %q, want %q", skill.Path, path)
|
||||
}
|
||||
if !strings.Contains(skill.Content(), "Skill directory: "+filepath.Dir(path)) {
|
||||
t.Fatalf("skill content does not identify its directory: %q", skill.Content())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsUpdatesExistingSkillCreator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(SkillsDirEnv, dir)
|
||||
writeCatalogSkill(t, dir, bundledSkillCreatorName, "custom instructions")
|
||||
|
||||
if _, err := LoadDefaultSkills(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(dir, bundledSkillCreatorName, skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != bundledSkillCreatorContent {
|
||||
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
|
||||
@@ -153,6 +201,14 @@ func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
|
||||
if got, err := SkillsDir(); err != nil || got != filepath.Join(xdg, "ollama", "skills") {
|
||||
t.Fatalf("SkillsDir xdg = %q, want %q, %v", got, filepath.Join(xdg, "ollama", "skills"), err)
|
||||
}
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
home := filepath.Join(base, "home")
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
if got, err := SkillsDir(); err != nil || got != filepath.Join(home, ".ollama", "skills") {
|
||||
t.Fatalf("SkillsDir default = %q, want %q, %v", got, filepath.Join(home, ".ollama", "skills"), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
|
||||
|
||||
@@ -1107,10 +1107,14 @@ func (m chatModel) completions() []chatCompletion {
|
||||
}
|
||||
|
||||
func (m chatModel) slashCompletions() []chatCompletion {
|
||||
input := strings.TrimSpace(string(m.input))
|
||||
rawInput := string(m.input)
|
||||
input := strings.TrimSpace(rawInput)
|
||||
if !strings.HasPrefix(input, "/") {
|
||||
return nil
|
||||
}
|
||||
if m.skillSlashPromptStarted(rawInput) {
|
||||
return nil
|
||||
}
|
||||
|
||||
commands := matchingSlashCommands(input)
|
||||
completions := make([]chatCompletion, 0, len(commands))
|
||||
@@ -1150,6 +1154,16 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
||||
return completions
|
||||
}
|
||||
|
||||
func (m chatModel) skillSlashPromptStarted(input string) bool {
|
||||
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
end := strings.IndexFunc(input, unicode.IsSpace)
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
_, _, ok := m.skillSlashInvocation(input[:end])
|
||||
return ok
|
||||
}
|
||||
|
||||
func matchingSlashCommands(input string) []chatSlashCommand {
|
||||
prefix := strings.ToLower(strings.TrimSpace(input))
|
||||
if prefix == "" {
|
||||
|
||||
@@ -636,6 +636,18 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
m := chatModel{opts: Options{Skills: catalog}, input: []rune(input)}
|
||||
if lines := m.completionLines(80); len(lines) != 0 {
|
||||
t.Fatalf("completion lines = %#v, want none", lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
m := &chatModel{opts: Options{Skills: catalog}}
|
||||
|
||||
@@ -1084,9 +1084,9 @@ func toolActionPhrase(action string, count int) string {
|
||||
return "Fetched a URL"
|
||||
case "skill":
|
||||
if plural {
|
||||
return fmt.Sprintf("Ran %d skills", count)
|
||||
return fmt.Sprintf("Loaded %d skills", count)
|
||||
}
|
||||
return "Ran a skill"
|
||||
return "Loaded a skill"
|
||||
default:
|
||||
if plural {
|
||||
return fmt.Sprintf("Used %d tools", count)
|
||||
|
||||
@@ -1049,6 +1049,15 @@ func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolActionPhraseLoadsSkills(t *testing.T) {
|
||||
if got := toolActionPhrase("skill", 1); got != "Loaded a skill" {
|
||||
t.Fatalf("single skill action = %q", got)
|
||||
}
|
||||
if got := toolActionPhrase("skill", 2); got != "Loaded 2 skills" {
|
||||
t.Fatalf("multiple skill action = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("command", "pwd")
|
||||
|
||||
Reference in New Issue
Block a user