mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Feature: Implement Skill Discovery - With Clawhub Integration and Caching (#332)
* Add Find Skills and Install Skills * Improvements * fix file name * Update pkg/skills/clawhub_registry.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix * Comments addressed * Resolve comments * fix tests * fixes * Comments resolved * Update pkg/skills/search_cache_repro_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * minor fix * fix test * fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// InstallSkillTool allows the LLM agent to install skills from registries.
|
||||
// It shares the same RegistryManager that FindSkillsTool uses,
|
||||
// so all registries configured in config are available for installation.
|
||||
type InstallSkillTool struct {
|
||||
registryMgr *skills.RegistryManager
|
||||
workspace string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewInstallSkillTool creates a new InstallSkillTool.
|
||||
// registryMgr is the shared registry manager (same instance as FindSkillsTool).
|
||||
// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
|
||||
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
|
||||
return &InstallSkillTool{
|
||||
registryMgr: registryMgr,
|
||||
workspace: workspace,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *InstallSkillTool) Name() string {
|
||||
return "install_skill"
|
||||
}
|
||||
|
||||
func (t *InstallSkillTool) Description() string {
|
||||
return "Install a skill from a registry by slug. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
|
||||
}
|
||||
|
||||
func (t *InstallSkillTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"slug": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
|
||||
},
|
||||
"version": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Specific version to install (optional, defaults to latest)",
|
||||
},
|
||||
"registry": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Registry to install from (required, e.g., 'clawhub')",
|
||||
},
|
||||
"force": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "Force reinstall if skill already exists (default false)",
|
||||
},
|
||||
},
|
||||
"required": []string{"slug", "registry"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
// Install lock to prevent concurrent directory operations.
|
||||
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// Validate slug
|
||||
slug, _ := args["slug"].(string)
|
||||
if err := utils.ValidateSkillIdentifier(slug); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
|
||||
}
|
||||
|
||||
// Validate registry
|
||||
registryName, _ := args["registry"].(string)
|
||||
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
|
||||
}
|
||||
|
||||
version, _ := args["version"].(string)
|
||||
force, _ := args["force"].(bool)
|
||||
|
||||
// Check if already installed.
|
||||
skillsDir := filepath.Join(t.workspace, "skills")
|
||||
targetDir := filepath.Join(skillsDir, slug)
|
||||
|
||||
if !force {
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
return ErrorResult(fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir))
|
||||
}
|
||||
} else {
|
||||
// Force: remove existing if present.
|
||||
os.RemoveAll(targetDir)
|
||||
}
|
||||
|
||||
// Resolve which registry to use.
|
||||
registry := t.registryMgr.GetRegistry(registryName)
|
||||
if registry == nil {
|
||||
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
|
||||
}
|
||||
|
||||
// Ensure skills directory exists.
|
||||
if err := os.MkdirAll(skillsDir, 0755); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
|
||||
}
|
||||
|
||||
// Download and install (handles metadata, version resolution, extraction).
|
||||
result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
|
||||
if err != nil {
|
||||
// Clean up partial install.
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
logger.ErrorCF("tool", "Failed to remove partial install",
|
||||
map[string]interface{}{
|
||||
"tool": "install_skill",
|
||||
"target_dir": targetDir,
|
||||
"error": rmErr.Error(),
|
||||
})
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
|
||||
}
|
||||
|
||||
// Moderation: block malware.
|
||||
if result.IsMalwareBlocked {
|
||||
rmErr := os.RemoveAll(targetDir)
|
||||
if rmErr != nil {
|
||||
logger.ErrorCF("tool", "Failed to remove partial install",
|
||||
map[string]interface{}{
|
||||
"tool": "install_skill",
|
||||
"target_dir": targetDir,
|
||||
"error": rmErr.Error(),
|
||||
})
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
||||
}
|
||||
|
||||
// Write origin metadata.
|
||||
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
|
||||
logger.ErrorCF("tool", "Failed to write origin metadata",
|
||||
map[string]interface{}{
|
||||
"tool": "install_skill",
|
||||
"error": err.Error(),
|
||||
"target": targetDir,
|
||||
"registry": registry.Name(),
|
||||
"slug": slug,
|
||||
"version": result.Version,
|
||||
})
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Build result with moderation warning if suspicious.
|
||||
var output string
|
||||
if result.IsSuspicious {
|
||||
output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
|
||||
}
|
||||
output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
|
||||
slug, result.Version, registry.Name(), targetDir)
|
||||
|
||||
if result.Summary != "" {
|
||||
output += fmt.Sprintf("Description: %s\n", result.Summary)
|
||||
}
|
||||
output += "\nThe skill is now available and can be loaded in the current session."
|
||||
|
||||
return SilentResult(output)
|
||||
}
|
||||
|
||||
// originMeta tracks which registry a skill was installed from.
|
||||
type originMeta struct {
|
||||
Version int `json:"version"`
|
||||
Registry string `json:"registry"`
|
||||
Slug string `json:"slug"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
InstalledAt int64 `json:"installed_at"`
|
||||
}
|
||||
|
||||
func writeOriginMeta(targetDir, registryName, slug, version string) error {
|
||||
meta := originMeta{
|
||||
Version: 1,
|
||||
Registry: registryName,
|
||||
Slug: slug,
|
||||
InstalledVersion: version,
|
||||
InstalledAt: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstallSkillToolName(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
assert.Equal(t, "install_skill", tool.Name())
|
||||
}
|
||||
|
||||
func TestInstallSkillToolMissingSlug(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
|
||||
}
|
||||
|
||||
func TestInstallSkillToolEmptySlug(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"slug": " ",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string")
|
||||
}
|
||||
|
||||
func TestInstallSkillToolUnsafeSlug(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
|
||||
cases := []string{
|
||||
"../etc/passwd",
|
||||
"path/traversal",
|
||||
"path\\traversal",
|
||||
}
|
||||
|
||||
for _, slug := range cases {
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"slug": slug,
|
||||
})
|
||||
assert.True(t, result.IsError, "slug %q should be rejected", slug)
|
||||
assert.Contains(t, result.ForLLM, "invalid slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSkillToolAlreadyExists(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "existing-skill")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0755))
|
||||
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"slug": "existing-skill",
|
||||
"registry": "clawhub",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "already installed")
|
||||
}
|
||||
|
||||
func TestInstallSkillToolRegistryNotFound(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"slug": "some-skill",
|
||||
"registry": "nonexistent",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "registry")
|
||||
assert.Contains(t, result.ForLLM, "not found")
|
||||
}
|
||||
|
||||
func TestInstallSkillToolParameters(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
params := tool.Parameters()
|
||||
|
||||
props, ok := params["properties"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, props, "slug")
|
||||
assert.Contains(t, props, "version")
|
||||
assert.Contains(t, props, "registry")
|
||||
assert.Contains(t, props, "force")
|
||||
|
||||
required, ok := params["required"].([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, required, "slug")
|
||||
assert.Contains(t, required, "registry")
|
||||
}
|
||||
|
||||
func TestInstallSkillToolMissingRegistry(t *testing.T) {
|
||||
tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir())
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"slug": "some-skill",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "invalid registry")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
|
||||
type FindSkillsTool struct {
|
||||
registryMgr *skills.RegistryManager
|
||||
cache *skills.SearchCache
|
||||
}
|
||||
|
||||
// NewFindSkillsTool creates a new FindSkillsTool.
|
||||
// registryMgr is the shared registry manager (built from config in createToolRegistry).
|
||||
// cache is the search cache for deduplicating similar queries.
|
||||
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
|
||||
return &FindSkillsTool{
|
||||
registryMgr: registryMgr,
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FindSkillsTool) Name() string {
|
||||
return "find_skills"
|
||||
}
|
||||
|
||||
func (t *FindSkillsTool) Description() string {
|
||||
return "Search for installable skills from skill registries. Returns skill slugs, descriptions, versions, and relevance scores. Use this to discover skills before installing them with install_skill."
|
||||
}
|
||||
|
||||
func (t *FindSkillsTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
|
||||
},
|
||||
"limit": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (1-20, default 5)",
|
||||
"minimum": 1.0,
|
||||
"maximum": 20.0,
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
query, ok := args["query"].(string)
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if !ok || query == "" {
|
||||
return ErrorResult("query is required and must be a non-empty string")
|
||||
}
|
||||
|
||||
limit := 5
|
||||
if l, ok := args["limit"].(float64); ok {
|
||||
li := int(l)
|
||||
if li >= 1 && li <= 20 {
|
||||
limit = li
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache first.
|
||||
if t.cache != nil {
|
||||
if cached, hit := t.cache.Get(query); hit {
|
||||
return SilentResult(formatSearchResults(query, cached, true))
|
||||
}
|
||||
}
|
||||
|
||||
// Search all registries.
|
||||
results, err := t.registryMgr.SearchAll(ctx, query, limit)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("skill search failed: %v", err))
|
||||
}
|
||||
|
||||
// Cache the results.
|
||||
if t.cache != nil && len(results) > 0 {
|
||||
t.cache.Put(query, results)
|
||||
}
|
||||
|
||||
return SilentResult(formatSearchResults(query, results, false))
|
||||
}
|
||||
|
||||
func formatSearchResults(query string, results []skills.SearchResult, cached bool) string {
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No skills found for query: %q", query)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
source := ""
|
||||
if cached {
|
||||
source = " (cached)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source))
|
||||
|
||||
for i, r := range results {
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug))
|
||||
if r.Version != "" {
|
||||
sb.WriteString(fmt.Sprintf(" v%s", r.Version))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName))
|
||||
if r.DisplayName != "" && r.DisplayName != r.Slug {
|
||||
sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName))
|
||||
}
|
||||
if r.Summary != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Summary))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("Use install_skill with the slug to install a skill.")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFindSkillsToolName(t *testing.T) {
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
|
||||
assert.Equal(t, "find_skills", tool.Name())
|
||||
}
|
||||
|
||||
func TestFindSkillsToolMissingQuery(t *testing.T) {
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "query is required")
|
||||
}
|
||||
|
||||
func TestFindSkillsToolEmptyQuery(t *testing.T) {
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"query": " ",
|
||||
})
|
||||
assert.True(t, result.IsError)
|
||||
}
|
||||
|
||||
func TestFindSkillsToolCacheHit(t *testing.T) {
|
||||
cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min
|
||||
cache.Put("github", []skills.SearchResult{
|
||||
{Slug: "github", Score: 0.9, RegistryName: "clawhub"},
|
||||
})
|
||||
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), cache)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"query": "github",
|
||||
})
|
||||
|
||||
assert.False(t, result.IsError)
|
||||
assert.Contains(t, result.ForLLM, "github")
|
||||
assert.Contains(t, result.ForLLM, "cached")
|
||||
}
|
||||
|
||||
func TestFindSkillsToolParameters(t *testing.T) {
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
|
||||
params := tool.Parameters()
|
||||
|
||||
props, ok := params["properties"].(map[string]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, props, "query")
|
||||
assert.Contains(t, props, "limit")
|
||||
|
||||
required, ok := params["required"].([]string)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, required, "query")
|
||||
}
|
||||
|
||||
func TestFindSkillsToolDescription(t *testing.T) {
|
||||
tool := NewFindSkillsTool(skills.NewRegistryManager(), nil)
|
||||
assert.NotEmpty(t, tool.Description())
|
||||
assert.Contains(t, tool.Description(), "skill")
|
||||
}
|
||||
|
||||
func TestFormatSearchResultsEmpty(t *testing.T) {
|
||||
result := formatSearchResults("test query", nil, false)
|
||||
assert.Contains(t, result, "No skills found")
|
||||
}
|
||||
|
||||
func TestFormatSearchResultsWithData(t *testing.T) {
|
||||
results := []skills.SearchResult{
|
||||
{Slug: "github", Score: 0.95, DisplayName: "GitHub", Summary: "GitHub API integration", Version: "1.0.0", RegistryName: "clawhub"},
|
||||
}
|
||||
output := formatSearchResults("github", results, false)
|
||||
assert.Contains(t, output, "github")
|
||||
assert.Contains(t, output, "v1.0.0")
|
||||
assert.Contains(t, output, "0.950")
|
||||
assert.Contains(t, output, "clawhub")
|
||||
assert.Contains(t, output, "install_skill")
|
||||
}
|
||||
Reference in New Issue
Block a user