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)
+ }
+ })
+ }
+}