Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions charts/oz-agent-worker/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ data:
max_concurrent_tasks: {{ .Values.worker.maxConcurrentTasks }}
{{- if .Values.worker.idleOnComplete }}
idle_on_complete: {{ .Values.worker.idleOnComplete | quote }}
{{- end }}
{{- with .Values.worker.skillsDirs }}
skills_dirs:
{{ toYaml . | indent 6 }}
{{- end }}
backend:
kubernetes:
Expand Down
4 changes: 4 additions & 0 deletions charts/oz-agent-worker/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ worker:
cleanup: true
maxConcurrentTasks: 0
idleOnComplete: ""
# skillsDirs is a list of local filesystem directories containing skill folders.
# Each directory should contain subdirectories with a SKILL.md file.
# Skills discovered here appear in the webapp's agent selector.
skillsDirs: []
extraArgs: []
deploymentAnnotations: {}
podAnnotations: {}
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ type FileConfig struct {
// overrides are no longer needed.
IdleOnComplete *string `yaml:"idle_on_complete"`
Backend BackendConfig `yaml:"backend"`
// SkillsDirs is a list of local filesystem directories containing skill folders.
// Each directory should contain subdirectories with a SKILL.md file:
// <dir>/skill-name/SKILL.md
// Skills discovered here are reported to the server on connect so they appear
// in the webapp's agent/skill selector for users with access to this worker.
SkillsDirs []string `yaml:"skills_dirs"`
}

// BackendConfig contains the backend selection.
Expand Down
37 changes: 37 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,43 @@ worker_id: "test"
})
}

func TestLoadSkillsDirs(t *testing.T) {
t.Run("parses skills_dirs when set", func(t *testing.T) {
path := writeTestConfig(t, `
worker_id: "test"
skills_dirs:
- /opt/skills
- /home/user/my-skills
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.SkillsDirs) != 2 {
t.Fatalf("skills_dirs count = %d, want 2", len(cfg.SkillsDirs))
}
if cfg.SkillsDirs[0] != "/opt/skills" {
t.Errorf("skills_dirs[0] = %q, want %q", cfg.SkillsDirs[0], "/opt/skills")
}
if cfg.SkillsDirs[1] != "/home/user/my-skills" {
t.Errorf("skills_dirs[1] = %q, want %q", cfg.SkillsDirs[1], "/home/user/my-skills")
}
})

t.Run("skills_dirs is nil when not set", func(t *testing.T) {
path := writeTestConfig(t, `
worker_id: "test"
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.SkillsDirs != nil {
t.Errorf("expected skills_dirs to be nil, got %v", cfg.SkillsDirs)
}
})
}

func TestLoadValidKubernetesPodTemplateConfig(t *testing.T) {
path := writeTestConfig(t, `
worker_id: "k8s-worker"
Expand Down
17 changes: 17 additions & 0 deletions internal/types/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
MessageTypeTaskFailed MessageType = "task_failed"
MessageTypeTaskRejected MessageType = "task_rejected"
MessageTypeHeartbeat MessageType = "heartbeat"
MessageTypeWorkerSkills MessageType = "worker_skills"
)

// WebSocketMessage is the base structure for all WebSocket messages
Expand Down Expand Up @@ -65,6 +66,22 @@ type TaskDefinition struct {
Prompt string `json:"prompt"`
}

// WorkerSkill represents a single skill discovered from a local skills directory.
type WorkerSkill struct {
Name string `json:"name"`
Description string `json:"description"`
// Path is the absolute filesystem path to the SKILL.md file.
Path string `json:"path"`
// BasePrompt is the SKILL.md body content (everything after the frontmatter).
BasePrompt string `json:"base_prompt,omitempty"`
}

// WorkerSkillsMessage is sent from worker to server after connecting,
// reporting the skills available in the worker's configured skills_dirs.
type WorkerSkillsMessage struct {
Skills []WorkerSkill `json:"skills"`
}

// Harness defines a third-party harness to run a cloud agent with.
type Harness struct {
// Type is the name of the harness, e.g. "claude".
Expand Down
122 changes: 122 additions & 0 deletions internal/worker/skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package worker

import (
"context"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/warpdotdev/oz-agent-worker/internal/log"
"github.com/warpdotdev/oz-agent-worker/internal/types"
"gopkg.in/yaml.v3"
)

var skillFrontMatterRegex = regexp.MustCompile(`(?ms)\A\s*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?`)

type skillFrontMatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}

// scanSkillsDirs walks the configured skills directories and returns all valid skills found.
// Each directory should contain subdirectories with a SKILL.md file:
//
// <dir>/skill-name/SKILL.md
//
// Skills with missing or unparseable frontmatter are skipped with a warning.
func scanSkillsDirs(ctx context.Context, dirs []string) []types.WorkerSkill {
var skills []types.WorkerSkill
seen := make(map[string]struct{}) // deduplicate by absolute path

for _, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil {
log.Warnf(ctx, "Skipping skills dir %q: failed to resolve absolute path: %v", dir, err)
continue
}

entries, err := os.ReadDir(absDir)
if err != nil {
log.Warnf(ctx, "Skipping skills dir %q: %v", absDir, err)
continue
}

for _, entry := range entries {
if !entry.IsDir() {
continue
}

skillFile := filepath.Join(absDir, entry.Name(), "SKILL.md")
skill, err := parseSkillFile(skillFile)
if err != nil {
log.Warnf(ctx, "Skipping skill at %q: %v", skillFile, err)
continue
}

if _, exists := seen[skill.Path]; exists {
continue
}
seen[skill.Path] = struct{}{}
skills = append(skills, *skill)
}
}

return skills
}

// parseSkillFile reads a SKILL.md file and extracts the skill metadata from its YAML frontmatter.
func parseSkillFile(path string) (*types.WorkerSkill, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}

name, description, basePrompt, err := parseSkillMarkdown(string(data))
if err != nil {
return nil, err
}

absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}

return &types.WorkerSkill{
Name: name,
Description: description,
Path: absPath,
BasePrompt: basePrompt,
}, nil
}

// parseSkillMarkdown extracts the name, description, and base prompt from a SKILL.md file.
func parseSkillMarkdown(content string) (string, string, string, error) {
loc := skillFrontMatterRegex.FindStringSubmatchIndex(content)
if len(loc) < 4 {
return "", "", "", errMissingFrontMatter
}

frontMatterYaml := content[loc[2]:loc[3]]
var fm skillFrontMatter
if err := yaml.Unmarshal([]byte(frontMatterYaml), &fm); err != nil {
return "", "", "", err
}

name := strings.TrimSpace(fm.Name)
if name == "" {
return "", "", "", errMissingSkillName
}

basePrompt := strings.TrimSpace(content[loc[1]:])
return name, strings.TrimSpace(fm.Description), basePrompt, nil
}

type skillError string

func (e skillError) Error() string { return string(e) }

const (
errMissingFrontMatter skillError = "missing YAML front matter"
errMissingSkillName skillError = "missing skill name in front matter"
)
Loading
Loading