diff --git a/adk/middlewares/skill/local.go b/adk/middlewares/skill/local.go new file mode 100644 index 00000000..9fd48242 --- /dev/null +++ b/adk/middlewares/skill/local.go @@ -0,0 +1,199 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package skill + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const skillFileName = "SKILL.md" + +// LocalBackend is a Backend implementation that reads skills from the local filesystem. +// Skills are stored in subdirectories of baseDir, each containing a SKILL.md file. +type LocalBackend struct { + // baseDir is the root directory containing skill subdirectories. + baseDir string +} + +// LocalBackendConfig is the configuration for creating a LocalBackend. +type LocalBackendConfig struct { + // BaseDir is the root directory containing skill subdirectories. + // Each subdirectory should contain a SKILL.md file with frontmatter and content. + BaseDir string +} + +// NewLocalBackend creates a new LocalBackend with the given configuration. +func NewLocalBackend(config *LocalBackendConfig) (*LocalBackend, error) { + if config == nil { + return nil, fmt.Errorf("config is required") + } + if config.BaseDir == "" { + return nil, fmt.Errorf("baseDir is required") + } + + // Verify the directory exists + info, err := os.Stat(config.BaseDir) + if err != nil { + return nil, fmt.Errorf("failed to stat baseDir: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("baseDir is not a directory: %s", config.BaseDir) + } + + return &LocalBackend{ + baseDir: config.BaseDir, + }, nil +} + +// skillFrontmatter represents the YAML frontmatter in a SKILL.md file. +type skillFrontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + License *string `yaml:"license"` + Compatibility *string `yaml:"compatibility"` + Metadata map[string]any `yaml:"metadata"` + AllowedTools []string `yaml:"allowed-tools"` +} + +// List returns all skills from the local filesystem. +// It scans subdirectories of baseDir for SKILL.md files and parses them as skills. +func (b *LocalBackend) List(ctx context.Context) ([]Skill, error) { + var skills []Skill + + entries, err := os.ReadDir(b.baseDir) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillDir := filepath.Join(b.baseDir, entry.Name()) + skillPath := filepath.Join(skillDir, skillFileName) + + // Check if SKILL.md exists in this directory + if _, err := os.Stat(skillPath); os.IsNotExist(err) { + continue + } + + skill, err := b.loadSkillFromFile(skillPath) + if err != nil { + return nil, fmt.Errorf("failed to load skill from %s: %w", skillPath, err) + } + + skills = append(skills, skill) + } + + return skills, nil +} + +// Get returns a skill by name from the local filesystem. +// It searches subdirectories for a SKILL.md file with matching name. +func (b *LocalBackend) Get(ctx context.Context, name string) (Skill, error) { + skills, err := b.List(ctx) + if err != nil { + return Skill{}, fmt.Errorf("failed to list skills: %w", err) + } + + for _, skill := range skills { + if skill.Name == name { + return skill, nil + } + } + + return Skill{}, fmt.Errorf("skill not found: %s", name) +} + +// loadSkillFromFile loads a skill from a SKILL.md file. +// The file format is: +// +// --- +// name: skill-name +// description: skill description +// --- +// Content goes here... +func (b *LocalBackend) loadSkillFromFile(path string) (Skill, error) { + data, err := os.ReadFile(path) + if err != nil { + return Skill{}, fmt.Errorf("failed to read file: %w", err) + } + + frontmatter, content, err := parseFrontmatter(string(data)) + if err != nil { + return Skill{}, fmt.Errorf("failed to parse frontmatter: %w", err) + } + + var fm skillFrontmatter + if err = yaml.Unmarshal([]byte(frontmatter), &fm); err != nil { + return Skill{}, fmt.Errorf("failed to unmarshal frontmatter: %w", err) + } + + // Get the absolute path of the directory containing SKILL.md + absDir, err := filepath.Abs(filepath.Dir(path)) + if err != nil { + return Skill{}, fmt.Errorf("failed to get absolute path: %w", err) + } + + return Skill{ + Name: fm.Name, + Description: fm.Description, + License: fm.License, + Compatibility: fm.Compatibility, + Metadata: fm.Metadata, + AllowedTools: fm.AllowedTools, + Content: strings.TrimSpace(content), + BaseDirectory: absDir, + }, nil +} + +// parseFrontmatter parses a markdown file with YAML frontmatter. +// Returns the frontmatter content (without ---), the remaining content, and any error. +func parseFrontmatter(data string) (frontmatter string, content string, err error) { + const delimiter = "---" + + data = strings.TrimSpace(data) + + // Must start with --- + if !strings.HasPrefix(data, delimiter) { + return "", "", fmt.Errorf("file does not start with frontmatter delimiter") + } + + // Find the closing --- + rest := data[len(delimiter):] + endIdx := strings.Index(rest, "\n"+delimiter) + if endIdx == -1 { + return "", "", fmt.Errorf("frontmatter closing delimiter not found") + } + + frontmatter = strings.TrimSpace(rest[:endIdx]) + content = rest[endIdx+len("\n"+delimiter):] + + // Remove the newline after the closing --- + if strings.HasPrefix(content, "\n") { + content = content[1:] + } + + return frontmatter, content, nil +} diff --git a/adk/middlewares/skill/local_test.go b/adk/middlewares/skill/local_test.go new file mode 100644 index 00000000..5a136665 --- /dev/null +++ b/adk/middlewares/skill/local_test.go @@ -0,0 +1,468 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package skill + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewLocalBackend(t *testing.T) { + t.Run("nil config returns error", func(t *testing.T) { + backend, err := NewLocalBackend(nil) + assert.Nil(t, backend) + assert.Error(t, err) + assert.Contains(t, err.Error(), "config is required") + }) + + t.Run("empty baseDir returns error", func(t *testing.T) { + backend, err := NewLocalBackend(&LocalBackendConfig{ + BaseDir: "", + }) + assert.Nil(t, backend) + assert.Error(t, err) + assert.Contains(t, err.Error(), "baseDir is required") + }) + + t.Run("non-existent baseDir returns error", func(t *testing.T) { + backend, err := NewLocalBackend(&LocalBackendConfig{ + BaseDir: "/path/that/does/not/exist", + }) + assert.Nil(t, backend) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to stat baseDir") + }) + + t.Run("baseDir is a file returns error", func(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "skill-test-*") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + backend, err := NewLocalBackend(&LocalBackendConfig{ + BaseDir: tmpFile.Name(), + }) + assert.Nil(t, backend) + assert.Error(t, err) + assert.Contains(t, err.Error(), "baseDir is not a directory") + }) + + t.Run("valid baseDir succeeds", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + backend, err := NewLocalBackend(&LocalBackendConfig{ + BaseDir: tmpDir, + }) + assert.NoError(t, err) + assert.NotNil(t, backend) + assert.Equal(t, tmpDir, backend.baseDir) + }) +} + +func TestLocalBackend_List(t *testing.T) { + ctx := context.Background() + + t.Run("empty directory returns empty list", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.NoError(t, err) + assert.Empty(t, skills) + }) + + t.Run("directory with no SKILL.md files returns empty list", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a subdirectory without SKILL.md + subDir := filepath.Join(tmpDir, "subdir") + require.NoError(t, os.Mkdir(subDir, 0755)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.NoError(t, err) + assert.Empty(t, skills) + }) + + t.Run("files in root directory are ignored", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a SKILL.md in root (should be ignored, only subdirs are scanned) + skillFile := filepath.Join(tmpDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`--- +name: root-skill +description: Root skill +--- +Content`), 0644)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.NoError(t, err) + assert.Empty(t, skills) + }) + + t.Run("valid skill directory returns skill", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a skill directory with SKILL.md + skillDir := filepath.Join(tmpDir, "my-skill") + require.NoError(t, os.Mkdir(skillDir, 0755)) + skillFile := filepath.Join(skillDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`--- +name: pdf-processing +description: Extract text and tables from PDF files, fill forms, merge documents. +license: Apache-2.0 +metadata: + author: example-org + version: "1.0" +--- +This is the skill content.`), 0644)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.NoError(t, err) + require.Len(t, skills, 1) + assert.Equal(t, "pdf-processing", skills[0].Name) + assert.Equal(t, "Extract text and tables from PDF files, fill forms, merge documents.", skills[0].Description) + assert.Equal(t, "Apache-2.0", *skills[0].License) + assert.Equal(t, map[string]any{"author": "example-org", "version": "1.0"}, skills[0].Metadata) + assert.Equal(t, "This is the skill content.", skills[0].Content) + assert.Equal(t, skillDir, skills[0].BaseDirectory) + }) + + t.Run("multiple skill directories returns all skills", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create first skill + skill1Dir := filepath.Join(tmpDir, "skill-1") + require.NoError(t, os.Mkdir(skill1Dir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`--- +name: skill-1 +description: First skill +--- +Content 1`), 0644)) + + // Create second skill + skill2Dir := filepath.Join(tmpDir, "skill-2") + require.NoError(t, os.Mkdir(skill2Dir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`--- +name: skill-2 +description: Second skill +--- +Content 2`), 0644)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.NoError(t, err) + assert.Len(t, skills, 2) + + // Check both skills exist (order may vary due to filesystem) + names := []string{skills[0].Name, skills[1].Name} + assert.Contains(t, names, "skill-1") + assert.Contains(t, names, "skill-2") + }) + + t.Run("invalid SKILL.md returns error", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a skill directory with invalid SKILL.md (no frontmatter) + skillDir := filepath.Join(tmpDir, "invalid-skill") + require.NoError(t, os.Mkdir(skillDir, 0755)) + skillFile := filepath.Join(skillDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`No frontmatter here`), 0644)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skills, err := backend.List(ctx) + assert.Error(t, err) + assert.Nil(t, skills) + assert.Contains(t, err.Error(), "failed to load skill") + }) +} + +func TestLocalBackend_Get(t *testing.T) { + ctx := context.Background() + + t.Run("skill not found returns error", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skill, err := backend.Get(ctx, "non-existent") + assert.Error(t, err) + assert.Empty(t, skill) + assert.Contains(t, err.Error(), "skill not found") + }) + + t.Run("existing skill is returned", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create a skill directory + skillDir := filepath.Join(tmpDir, "test-skill") + require.NoError(t, os.Mkdir(skillDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(`--- +name: test-skill +description: Test skill description +--- +Test content here.`), 0644)) + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skill, err := backend.Get(ctx, "test-skill") + assert.NoError(t, err) + assert.Equal(t, "test-skill", skill.Name) + assert.Equal(t, "Test skill description", skill.Description) + assert.Equal(t, "Test content here.", skill.Content) + }) + + t.Run("get specific skill from multiple", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create multiple skills + for _, name := range []string{"alpha", "beta", "gamma"} { + skillDir := filepath.Join(tmpDir, name) + require.NoError(t, os.Mkdir(skillDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(`--- +name: `+name+` +description: Skill `+name+` +--- +Content for `+name), 0644)) + } + + backend, err := NewLocalBackend(&LocalBackendConfig{BaseDir: tmpDir}) + require.NoError(t, err) + + skill, err := backend.Get(ctx, "beta") + assert.NoError(t, err) + assert.Equal(t, "beta", skill.Name) + assert.Equal(t, "Skill beta", skill.Description) + assert.Equal(t, "Content for beta", skill.Content) + }) +} + +func TestParseFrontmatter(t *testing.T) { + t.Run("valid frontmatter", func(t *testing.T) { + data := `--- +name: test +description: test description +--- +This is the content.` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Equal(t, "name: test\ndescription: test description", fm) + assert.Equal(t, "This is the content.", content) + }) + + t.Run("frontmatter with multiline content", func(t *testing.T) { + data := `--- +name: test +--- +Line 1 +Line 2 +Line 3` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Equal(t, "name: test", fm) + assert.Equal(t, "Line 1\nLine 2\nLine 3", content) + }) + + t.Run("frontmatter with leading/trailing whitespace", func(t *testing.T) { + data := ` +--- +name: test +--- +Content ` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Equal(t, "name: test", fm) + // Note: parseFrontmatter trims trailing whitespace from input data + assert.Equal(t, "Content", content) + }) + + t.Run("missing opening delimiter returns error", func(t *testing.T) { + data := `name: test +--- +Content` + + fm, content, err := parseFrontmatter(data) + assert.Error(t, err) + assert.Empty(t, fm) + assert.Empty(t, content) + assert.Contains(t, err.Error(), "does not start with frontmatter delimiter") + }) + + t.Run("missing closing delimiter returns error", func(t *testing.T) { + data := `--- +name: test +Content without closing` + + fm, content, err := parseFrontmatter(data) + assert.Error(t, err) + assert.Empty(t, fm) + assert.Empty(t, content) + assert.Contains(t, err.Error(), "closing delimiter not found") + }) + + t.Run("empty frontmatter", func(t *testing.T) { + data := `--- +--- +Content only` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Empty(t, fm) + assert.Equal(t, "Content only", content) + }) + + t.Run("empty content", func(t *testing.T) { + data := `--- +name: test +---` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Equal(t, "name: test", fm) + assert.Empty(t, content) + }) + + t.Run("content with --- inside", func(t *testing.T) { + data := `--- +name: test +--- +Content with --- in the middle` + + fm, content, err := parseFrontmatter(data) + assert.NoError(t, err) + assert.Equal(t, "name: test", fm) + assert.Equal(t, "Content with --- in the middle", content) + }) +} + +func TestLoadSkillFromFile(t *testing.T) { + t.Run("valid skill file", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + skillFile := filepath.Join(tmpDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`--- +name: file-skill +description: Skill from file +--- +File skill content.`), 0644)) + + backend := &LocalBackend{baseDir: tmpDir} + skill, err := backend.loadSkillFromFile(skillFile) + assert.NoError(t, err) + assert.Equal(t, "file-skill", skill.Name) + assert.Equal(t, "Skill from file", skill.Description) + assert.Equal(t, "File skill content.", skill.Content) + + // Verify BaseDirectory is set correctly + absDir, _ := filepath.Abs(tmpDir) + assert.Equal(t, absDir, skill.BaseDirectory) + }) + + t.Run("non-existent file returns error", func(t *testing.T) { + backend := &LocalBackend{baseDir: "/tmp"} + skill, err := backend.loadSkillFromFile("/path/to/nonexistent/SKILL.md") + assert.Error(t, err) + assert.Empty(t, skill) + assert.Contains(t, err.Error(), "failed to read file") + }) + + t.Run("invalid yaml in frontmatter returns error", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + skillFile := filepath.Join(tmpDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`--- +name: [invalid yaml +--- +Content`), 0644)) + + backend := &LocalBackend{baseDir: tmpDir} + skill, err := backend.loadSkillFromFile(skillFile) + assert.Error(t, err) + assert.Empty(t, skill) + assert.Contains(t, err.Error(), "failed to unmarshal frontmatter") + }) + + t.Run("content with extra whitespace is trimmed", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "skill-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + skillFile := filepath.Join(tmpDir, "SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte(`--- +name: trimmed-skill +description: desc +--- + + Content with whitespace + +`), 0644)) + + backend := &LocalBackend{baseDir: tmpDir} + skill, err := backend.loadSkillFromFile(skillFile) + assert.NoError(t, err) + assert.Equal(t, "Content with whitespace", skill.Content) + }) +} diff --git a/adk/middlewares/skill/prompt.go b/adk/middlewares/skill/prompt.go new file mode 100644 index 00000000..81e707cc --- /dev/null +++ b/adk/middlewares/skill/prompt.go @@ -0,0 +1,60 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package skill + +const ( + skillToolDescriptionBase = `Execute a skill within the main conversation + + +When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. + +How to invoke: +- Use this tool with the skill name only (no arguments) +- Examples: + - ` + "`" + `skill: "pdf"` + "`" + ` - invoke the pdf skill + - ` + "`" + `skill: "xlsx"` + "`" + ` - invoke the xlsx skill + - ` + "`" + `skill: "ms-office-suite:pdf"` + "`" + ` - invoke using fully qualified name + +Important: +- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action +- NEVER just announce or mention a skill in your text response without actually calling this tool +- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task +- Only use skills listed in below +- Do not invoke a skill that is already running +- Do not use this tool for built-in CLI commands (like /help, /clear, etc.) + + +` + skillToolDescriptionTemplate = ` + +{{- range .Skills }} + + +{{ .Name }} + + +{{ .Description }} + + +{{- end }} + +` + skillToolResult = "Launching skill: %s\n" + skillUserContent = `Base directory for this skill: %s + +%s` +) diff --git a/adk/middlewares/skill/skill.go b/adk/middlewares/skill/skill.go new file mode 100644 index 00000000..5267114f --- /dev/null +++ b/adk/middlewares/skill/skill.go @@ -0,0 +1,202 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package skill provides the skill middleware, types, and a local filesystem backend. +package skill + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "text/template" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +type Skill struct { + Name string + Description string + License *string + Compatibility *string + Metadata map[string]any + AllowedTools []string + + Content string + + BaseDirectory string +} + +type Backend interface { + List(ctx context.Context) ([]Skill, error) + Get(ctx context.Context, name string) (Skill, error) +} + +type Config struct { + Backend Backend +} + +// New creates a new skill middleware. +// It provides a tool for the agent to use skills. +func New(ctx context.Context, config *Config) (adk.AgentMiddleware, error) { + if config == nil { + return adk.AgentMiddleware{}, fmt.Errorf("config is required") + } + if config.Backend == nil { + return adk.AgentMiddleware{}, fmt.Errorf("backend is required") + } + return adk.AgentMiddleware{ + AdditionalTools: []tool.BaseTool{&skillTool{b: config.Backend}}, + }, nil +} + +type skillTool struct { + b Backend +} + +type descriptionTemplateHelper struct { + Skills []Skill +} + +func (s *skillTool) Info(ctx context.Context) (*schema.ToolInfo, error) { + skills, err := s.b.List(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list skills: %w", err) + } + + err = validateSkillMetadata(skills) + if err != nil { + return nil, err + } + err = validateSkillContent(skills) + if err != nil { + return nil, err + } + + desc, err := renderToolDescription(skills) + if err != nil { + return nil, fmt.Errorf("failed to render skill tool description: %w", err) + } + + return &schema.ToolInfo{ + Name: "skill", + Desc: skillToolDescriptionBase + desc, + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "skill": { + Type: schema.String, + Desc: "The skill name (no arguments). E.g., \"pdf\" or \"xlsx\"", + Required: true, + }, + }), + }, nil +} + +type inputArguments struct { + Skill string `json:"skill"` +} + +func (s *skillTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { + args := &inputArguments{} + err := json.Unmarshal([]byte(argumentsInJSON), args) + if err != nil { + return "", fmt.Errorf("failed to unmarshal arguments: %w", err) + } + skill, err := s.b.Get(ctx, args.Skill) + if err != nil { + return "", fmt.Errorf("failed to get skill: %w", err) + } + + err = validateSkillName(skill.Name) + if err != nil { + return "", err + } + err = validateSkillContent([]Skill{skill}) + if err != nil { + return "", err + } + + return fmt.Sprintf(skillToolResult, skill.Name) + fmt.Sprintf(skillUserContent, skill.BaseDirectory, skill.Content), nil +} + +func renderToolDescription(skills []Skill) (string, error) { + tpl, err := template.New("skills").Parse(skillToolDescriptionTemplate) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = tpl.Execute(&buf, descriptionTemplateHelper{Skills: skills}) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +const ( + maxSkillNameLength = 64 + maxSkillDescriptionLength = 1024 + maxSkillContentLength = 10 * 1024 * 1024 +) + +func validateSkillMetadata(skills []Skill) error { + for _, skill := range skills { + err := validateSkillName(skill.Name) + if err != nil { + return err + } + + if len(skill.Description) == 0 { + return fmt.Errorf("skill %s must have a non-empty description", skill.Name) + } + if len(skill.Description) > maxSkillDescriptionLength { + return fmt.Errorf("skill %s description must not exceed %d characters", skill.Name, maxSkillDescriptionLength) + } + } + return nil +} + +var ( + skillNameRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) +) + +func validateSkillName(name string) error { + if len(name) > maxSkillNameLength { + return fmt.Errorf("skill name must not exceed %d characters", maxSkillNameLength) + } + + if len(name) == 0 { + return fmt.Errorf("skill name cannot be empty") + } + + if !skillNameRegex.MatchString(name) { + return fmt.Errorf("skill name must contain only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen: %s", name) + } + + return nil +} + +func validateSkillContent(skills []Skill) error { + for _, skill := range skills { + if len(skill.Content)+len(skill.BaseDirectory) > maxSkillContentLength { + return fmt.Errorf("skill content must not exceed %d characters", maxSkillContentLength) + } + } + return nil +} diff --git a/adk/middlewares/skill/skill_test.go b/adk/middlewares/skill/skill_test.go new file mode 100644 index 00000000..8627fc70 --- /dev/null +++ b/adk/middlewares/skill/skill_test.go @@ -0,0 +1,232 @@ +/* + * Copyright 2025 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package skill + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cloudwego/eino/components/tool" +) + +type inMemoryBackend struct { + m []Skill +} + +func (i *inMemoryBackend) List(ctx context.Context) ([]Skill, error) { + return i.m, nil +} + +func (i *inMemoryBackend) Get(ctx context.Context, name string) (Skill, error) { + for _, skill := range i.m { + if skill.Name == name { + return skill, nil + } + } + return Skill{}, errors.New("skill not found") +} + +func TestTool(t *testing.T) { + backend := &inMemoryBackend{m: []Skill{ + { + Name: "name1", + Description: "desc1", + Content: "content1", + BaseDirectory: "basedir1", + }, + { + Name: "name2", + Description: "desc2", + Content: "content2", + BaseDirectory: "basedir2", + }, + }} + + ctx := context.Background() + m, err := New(ctx, &Config{Backend: backend}) + assert.NoError(t, err) + assert.Len(t, m.AdditionalTools, 1) + + to := m.AdditionalTools[0].(tool.InvokableTool) + + info, err := to.Info(ctx) + assert.NoError(t, err) + assert.Equal(t, "skill", info.Name) + desc := strings.TrimPrefix(info.Desc, skillToolDescriptionBase) + assert.Equal(t, ` + + + +name1 + + +desc1 + + + + +name2 + + +desc2 + + + +`, desc) + + result, err := to.InvokableRun(ctx, `{"skill": "name1"}`) + assert.NoError(t, err) + assert.Equal(t, `Launching skill: name1 +Base directory for this skill: basedir1 + +content1`, result) +} + +func TestValidateSkillName(t *testing.T) { + tests := []struct { + name string + skillName string + wantErr bool + errContains string + }{ + { + name: "valid name", + skillName: "valid-skill-name-123", + wantErr: false, + }, + { + name: "empty name", + skillName: "", + wantErr: true, + errContains: "skill must have a name", // This check is inside validateSkillMetadata, but validateSkillName check length > 0 too? In code I added check in validateSkillName + }, + { + name: "name too long", + skillName: strings.Repeat("a", 65), + wantErr: true, + errContains: "must not exceed 64 characters", + }, + { + name: "uppercase letters", + skillName: "SkillName", + wantErr: true, + errContains: "lowercase letters", + }, + { + name: "starts with hyphen", + skillName: "-skill", + wantErr: true, + errContains: "must not start or end with a hyphen", + }, + { + name: "ends with hyphen", + skillName: "skill-", + wantErr: true, + errContains: "must not start or end with a hyphen", + }, + { + name: "invalid char", + skillName: "skill_name", + wantErr: true, + errContains: "lowercase letters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSkillName(tt.skillName) + if tt.name == "empty name" { + // validateSkillName checks empty too now + if err == nil { + t.Errorf("validateSkillName() error = nil, wantErr %v", tt.wantErr) + } + } else { + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + } + }) + } +} + +func TestValidateSkillMetadata(t *testing.T) { + longDesc := strings.Repeat("a", 1025) + + tests := []struct { + name string + skill Skill + wantErr bool + errContains string + }{ + { + name: "valid skill", + skill: Skill{ + Name: "valid-skill", + Description: "Valid description", + }, + wantErr: false, + }, + { + name: "missing name", + skill: Skill{ + Description: "Valid description", + }, + wantErr: true, + errContains: "skill name cannot be empty", + }, + { + name: "missing description", + skill: Skill{ + Name: "valid-skill", + }, + wantErr: true, + errContains: "non-empty description", + }, + { + name: "description too long", + skill: Skill{ + Name: "valid-skill", + Description: longDesc, + }, + wantErr: true, + errContains: "description must not exceed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSkillMetadata([]Skill{tt.skill}) + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +}