mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
feat: support agent skills
feat:support agent skills
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// +build ignore
|
||||
|
||||
// Docker sandbox test program
|
||||
// Usage: go run cmd/skills-demo/docker_test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println(" Docker Sandbox Test Suite")
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get the path to examples/skills
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
fmt.Println("Error: Failed to get current file path")
|
||||
os.Exit(1)
|
||||
}
|
||||
scriptsDir := filepath.Join(filepath.Dir(filename), "..", "..", "examples", "skills", "pdf-processing", "scripts")
|
||||
|
||||
// Create Docker sandbox
|
||||
config := sandbox.DefaultConfig()
|
||||
config.Type = sandbox.SandboxTypeDocker
|
||||
config.FallbackEnabled = false
|
||||
config.DockerImage = "python:3.11-slim"
|
||||
|
||||
mgr, err := sandbox.NewManager(config)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to create sandbox manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Sandbox Type: %s\n\n", mgr.GetType())
|
||||
|
||||
// Test 1: Basic script execution
|
||||
fmt.Println("Test 1: Basic Script Execution")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"sample.pdf"},
|
||||
})
|
||||
|
||||
// Test 2: Script with different arguments
|
||||
fmt.Println("\nTest 2: Script with Different Arguments")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "extract_text.py"),
|
||||
Args: []string{"document.pdf", "--page", "1"},
|
||||
})
|
||||
|
||||
// Test 3: Environment variables
|
||||
fmt.Println("\nTest 3: Environment Variables")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
Env: map[string]string{
|
||||
"DEBUG": "true",
|
||||
"LOG_LEVEL": "verbose",
|
||||
},
|
||||
})
|
||||
|
||||
// Test 4: Network isolation (default: no network)
|
||||
fmt.Println("\nTest 4: Network Isolation (network disabled)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
AllowNetwork: false,
|
||||
})
|
||||
|
||||
// Test 5: Memory limits
|
||||
fmt.Println("\nTest 5: Memory Limits (128MB)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
MemoryLimit: 128 * 1024 * 1024, // 128MB
|
||||
})
|
||||
|
||||
// Test 6: CPU limits
|
||||
fmt.Println("\nTest 6: CPU Limits (0.5 cores)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
CPULimit: 0.5,
|
||||
})
|
||||
|
||||
// Test 7: Read-only filesystem
|
||||
fmt.Println("\nTest 7: Read-only Root Filesystem")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
ReadOnlyRootfs: true,
|
||||
})
|
||||
|
||||
// Test 8: Custom timeout
|
||||
fmt.Println("\nTest 8: Custom Timeout (10s)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
fmt.Println("\n" + strings.Repeat("=", 71))
|
||||
fmt.Println(" All Tests Completed!")
|
||||
fmt.Println(strings.Repeat("=", 71))
|
||||
}
|
||||
|
||||
func runTest(ctx context.Context, mgr sandbox.Manager, config *sandbox.ExecuteConfig) {
|
||||
startTime := time.Now()
|
||||
result, err := mgr.Execute(ctx, config)
|
||||
totalTime := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Script: %s\n", filepath.Base(config.Script))
|
||||
fmt.Printf(" Args: %v\n", config.Args)
|
||||
fmt.Printf(" Exit Code: %d\n", result.ExitCode)
|
||||
fmt.Printf(" Duration: %v (total: %v)\n", result.Duration, totalTime)
|
||||
fmt.Printf(" Success: %v\n", result.IsSuccess())
|
||||
|
||||
if result.Stdout != "" {
|
||||
// Show just the first 3 lines of output
|
||||
lines := strings.Split(result.Stdout, "\n")
|
||||
preview := strings.Join(lines[:min(3, len(lines))], "\n")
|
||||
fmt.Printf(" Output (preview):\n %s\n", strings.ReplaceAll(preview, "\n", "\n "))
|
||||
}
|
||||
|
||||
if result.Stderr != "" {
|
||||
fmt.Printf(" Stderr: %s\n", strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
fmt.Printf(" Error: %s\n", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
// Docker sandbox test program
|
||||
// Usage: go run cmd/skills-demo/docker_test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println(" Docker Sandbox Test Suite")
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get the path to examples/skills
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
fmt.Println("Error: Failed to get current file path")
|
||||
os.Exit(1)
|
||||
}
|
||||
scriptsDir := filepath.Join(filepath.Dir(filename), "..", "..", "examples", "skills", "pdf-processing", "scripts")
|
||||
|
||||
// Create Docker sandbox
|
||||
config := sandbox.DefaultConfig()
|
||||
config.Type = sandbox.SandboxTypeDocker
|
||||
config.FallbackEnabled = false
|
||||
config.DockerImage = "python:3.11-slim"
|
||||
|
||||
mgr, err := sandbox.NewManager(config)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to create sandbox manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Sandbox Type: %s\n\n", mgr.GetType())
|
||||
|
||||
// Test 1: Basic script execution
|
||||
fmt.Println("Test 1: Basic Script Execution")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"sample.pdf"},
|
||||
})
|
||||
|
||||
// Test 2: Script with different arguments
|
||||
fmt.Println("\nTest 2: Script with Different Arguments")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "extract_text.py"),
|
||||
Args: []string{"document.pdf", "--page", "1"},
|
||||
})
|
||||
|
||||
// Test 3: Environment variables
|
||||
fmt.Println("\nTest 3: Environment Variables")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
Env: map[string]string{
|
||||
"DEBUG": "true",
|
||||
"LOG_LEVEL": "verbose",
|
||||
},
|
||||
})
|
||||
|
||||
// Test 4: Network isolation (default: no network)
|
||||
fmt.Println("\nTest 4: Network Isolation (network disabled)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
AllowNetwork: false,
|
||||
})
|
||||
|
||||
// Test 5: Memory limits
|
||||
fmt.Println("\nTest 5: Memory Limits (128MB)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
MemoryLimit: 128 * 1024 * 1024, // 128MB
|
||||
})
|
||||
|
||||
// Test 6: CPU limits
|
||||
fmt.Println("\nTest 6: CPU Limits (0.5 cores)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
CPULimit: 0.5,
|
||||
})
|
||||
|
||||
// Test 7: Read-only filesystem
|
||||
fmt.Println("\nTest 7: Read-only Root Filesystem")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
ReadOnlyRootfs: true,
|
||||
})
|
||||
|
||||
// Test 8: Custom timeout
|
||||
fmt.Println("\nTest 8: Custom Timeout (10s)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
runTest(ctx, mgr, &sandbox.ExecuteConfig{
|
||||
Script: filepath.Join(scriptsDir, "analyze_form.py"),
|
||||
Args: []string{"test.pdf"},
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
fmt.Println("\n" + strings.Repeat("=", 71))
|
||||
fmt.Println(" All Tests Completed!")
|
||||
fmt.Println(strings.Repeat("=", 71))
|
||||
}
|
||||
|
||||
func runTest(ctx context.Context, mgr sandbox.Manager, config *sandbox.ExecuteConfig) {
|
||||
startTime := time.Now()
|
||||
result, err := mgr.Execute(ctx, config)
|
||||
totalTime := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Script: %s\n", filepath.Base(config.Script))
|
||||
fmt.Printf(" Args: %v\n", config.Args)
|
||||
fmt.Printf(" Exit Code: %d\n", result.ExitCode)
|
||||
fmt.Printf(" Duration: %v (total: %v)\n", result.Duration, totalTime)
|
||||
fmt.Printf(" Success: %v\n", result.IsSuccess())
|
||||
|
||||
if result.Stdout != "" {
|
||||
// Show just the first 3 lines of output
|
||||
lines := strings.Split(result.Stdout, "\n")
|
||||
preview := strings.Join(lines[:min(3, len(lines))], "\n")
|
||||
fmt.Printf(" Output (preview):\n %s\n", strings.ReplaceAll(preview, "\n", "\n "))
|
||||
}
|
||||
|
||||
if result.Stderr != "" {
|
||||
fmt.Printf(" Stderr: %s\n", strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
fmt.Printf(" Error: %s\n", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package main provides a demo program to test the Agent Skills functionality.
|
||||
// This simulates the agent workflow without requiring a full server setup.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run cmd/skills-demo/main.go [sandbox-type]
|
||||
//
|
||||
// sandbox-type can be: local, docker (default: local)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println(" Agent Skills Demo - Progressive Disclosure in Action")
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse sandbox type from command line
|
||||
sandboxType := "local"
|
||||
if len(os.Args) > 1 {
|
||||
sandboxType = os.Args[1]
|
||||
}
|
||||
|
||||
// Get the path to examples/skills
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
fmt.Println("Error: Failed to get current file path")
|
||||
os.Exit(1)
|
||||
}
|
||||
skillsDir := filepath.Join(filepath.Dir(filename), "..", "..", "examples", "skills")
|
||||
|
||||
fmt.Printf("📁 Skills directory: %s\n\n", skillsDir)
|
||||
|
||||
// ========================================
|
||||
// Step 1: Initialize Sandbox Manager
|
||||
// ========================================
|
||||
fmt.Println("Step 1: Initialize Sandbox Manager")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
fmt.Printf(" Requested sandbox type: %s\n", sandboxType)
|
||||
|
||||
sandboxMgr, err := sandbox.NewManagerFromType(sandboxType, false) // Disable fallback to test specific mode
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating sandbox: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✅ Sandbox initialized (type: %s)\n\n", sandboxMgr.GetType())
|
||||
|
||||
// ========================================
|
||||
// Step 2: Initialize Skills Manager
|
||||
// ========================================
|
||||
fmt.Println("Step 2: Initialize Skills Manager")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
skillsConfig := &skills.ManagerConfig{
|
||||
SkillDirs: []string{skillsDir},
|
||||
AllowedSkills: []string{}, // Allow all
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
skillsManager := skills.NewManager(skillsConfig, sandboxMgr)
|
||||
if err := skillsManager.Initialize(ctx); err != nil {
|
||||
fmt.Printf("Error initializing skills: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
metadata := skillsManager.GetAllMetadata()
|
||||
fmt.Printf("✅ Discovered %d skills\n\n", len(metadata))
|
||||
|
||||
// ========================================
|
||||
// Step 3: Show Level 1 - Metadata (System Prompt)
|
||||
// ========================================
|
||||
fmt.Println("Step 3: Level 1 - Skill Metadata (injected into System Prompt)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
// Simulate what gets injected into system prompt
|
||||
fmt.Println("\n### Available Skills\n")
|
||||
fmt.Println("The following skills are available. When a user request matches a skill's description,")
|
||||
fmt.Println("use the `read_skill` tool to load its full instructions before proceeding.\n")
|
||||
for i, m := range metadata {
|
||||
fmt.Printf("%d. **%s**: %s\n", i+1, m.Name, m.Description)
|
||||
}
|
||||
fmt.Println("\nUse `read_skill` with the skill name to load detailed instructions when needed.")
|
||||
fmt.Println("Use `execute_skill_script` to run utility scripts bundled with a skill.")
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Step 4: Simulate Agent Tool Calls
|
||||
// ========================================
|
||||
fmt.Println("Step 4: Simulate Agent Tool Calls")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
// Scenario: User asks "Help me extract text from a PDF"
|
||||
fmt.Println("\n🤖 Scenario: User asks 'Help me extract text from a PDF'")
|
||||
fmt.Println(" Agent recognizes this matches 'pdf-processing' skill")
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Step 5: Level 2 - Load Skill Instructions
|
||||
// ========================================
|
||||
fmt.Println("Step 5: Level 2 - Agent calls read_skill tool")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
// Simulate tool call: read_skill(skill_name="pdf-processing")
|
||||
skill, err := skillsManager.LoadSkill(ctx, "pdf-processing")
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading skill: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✅ Tool: read_skill(skill_name=\"pdf-processing\")\n")
|
||||
fmt.Printf(" Success: true\n")
|
||||
fmt.Printf(" Skill: %s\n", skill.Name)
|
||||
fmt.Printf(" Description: %s\n", skill.Description)
|
||||
fmt.Printf(" Instructions preview (first 500 chars):\n")
|
||||
instructions := skill.Instructions
|
||||
if len(instructions) > 500 {
|
||||
instructions = instructions[:500] + "\n... (truncated)"
|
||||
}
|
||||
for _, line := range strings.Split(instructions, "\n") {
|
||||
fmt.Printf(" │ %s\n", line)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Step 6: Level 3 - Load Additional Resource
|
||||
// ========================================
|
||||
fmt.Println("Step 6: Level 3 - Agent reads additional file (FORMS.md)")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
// Simulate tool call: read_skill(skill_name="pdf-processing", file_path="FORMS.md")
|
||||
formsContent, err := skillsManager.ReadSkillFile(ctx, "pdf-processing", "FORMS.md")
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading FORMS.md: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✅ Tool: read_skill(skill_name=\"pdf-processing\", file_path=\"FORMS.md\")\n")
|
||||
fmt.Printf(" Success: true\n")
|
||||
fmt.Printf(" Content length: %d characters\n", len(formsContent))
|
||||
// Show first few lines
|
||||
lines := strings.Split(formsContent, "\n")
|
||||
fmt.Printf(" Preview (first 10 lines):\n")
|
||||
for i, line := range lines {
|
||||
if i >= 10 {
|
||||
fmt.Printf(" │ ... (truncated)\n")
|
||||
break
|
||||
}
|
||||
fmt.Printf(" │ %s\n", line)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Step 7: Execute Skill Script
|
||||
// ========================================
|
||||
fmt.Println("Step 7: Agent executes skill script in sandbox")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
// Simulate tool call: execute_skill_script(skill_name="pdf-processing", script_path="scripts/analyze_form.py", args=["test.pdf"])
|
||||
args := []string{"test.pdf"}
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
fmt.Printf("✅ Tool: execute_skill_script\n")
|
||||
fmt.Printf(" skill_name: \"pdf-processing\"\n")
|
||||
fmt.Printf(" script_path: \"scripts/analyze_form.py\"\n")
|
||||
fmt.Printf(" args: %s\n", string(argsJSON))
|
||||
|
||||
result, err := skillsManager.ExecuteScript(ctx, "pdf-processing", "scripts/analyze_form.py", args)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Exit Code: %d\n", result.ExitCode)
|
||||
fmt.Printf(" Duration: %v\n", result.Duration)
|
||||
fmt.Printf(" Output:\n")
|
||||
for _, line := range strings.Split(result.Stdout, "\n") {
|
||||
fmt.Printf(" │ %s\n", line)
|
||||
}
|
||||
if result.Stderr != "" {
|
||||
fmt.Printf(" Stderr:\n")
|
||||
for _, line := range strings.Split(result.Stderr, "\n") {
|
||||
fmt.Printf(" │ %s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Step 8: List all files in skill
|
||||
// ========================================
|
||||
fmt.Println("Step 8: List all available files in skill")
|
||||
fmt.Println("-" + strings.Repeat("-", 50))
|
||||
|
||||
files, err := skillsManager.ListSkillFiles(ctx, "pdf-processing")
|
||||
if err != nil {
|
||||
fmt.Printf("Error listing files: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✅ Files in pdf-processing skill:\n")
|
||||
for _, f := range files {
|
||||
isScript := skills.IsScript(f)
|
||||
scriptTag := ""
|
||||
if isScript {
|
||||
scriptTag = " [executable]"
|
||||
}
|
||||
fmt.Printf(" - %s%s\n", f, scriptTag)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// ========================================
|
||||
// Summary
|
||||
// ========================================
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println(" Summary: Progressive Disclosure Flow")
|
||||
fmt.Println("=" + strings.Repeat("=", 70))
|
||||
fmt.Println()
|
||||
fmt.Println(" Level 1 (Metadata) : ~100 tokens/skill in system prompt")
|
||||
fmt.Println(" → Agent knows which skills exist")
|
||||
fmt.Println()
|
||||
fmt.Println(" Level 2 (Instructions) : Loaded on-demand via read_skill")
|
||||
fmt.Println(" → Agent learns skill instructions")
|
||||
fmt.Println()
|
||||
fmt.Println(" Level 3 (Resources) : Additional files loaded as needed")
|
||||
fmt.Println(" → Agent accesses reference docs & scripts")
|
||||
fmt.Println()
|
||||
fmt.Println(" 🎉 Demo completed successfully!")
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
# Agent Skills 文档
|
||||
|
||||
## 概述
|
||||
|
||||
Agent Skills 是一种让 Agent 通过阅读"使用说明书"来学习新能力的扩展机制。与传统的硬编码工具不同,Skills 通过注入到 System Prompt 来扩展 Agent 的能力,遵循 **Progressive Disclosure(渐进式披露)** 的设计理念。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **非侵入式扩展**:不影响原有 Agent ReAct 流程
|
||||
- **按需加载**:三级渐进式加载,优化 Token 使用
|
||||
- **沙箱执行**:脚本在隔离环境中安全执行
|
||||
- **灵活配置**:支持多目录、白名单过滤
|
||||
|
||||
## 设计理念
|
||||
|
||||
### Progressive Disclosure(渐进式披露)
|
||||
|
||||
Skills 采用三级加载机制,确保只在需要时才向 LLM 提供详细信息:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Level 1: 元数据 (Metadata) │
|
||||
│ • 始终加载到 System Prompt │
|
||||
│ • 约 100 tokens/skill │
|
||||
│ • 包含:技能名称 + 简短描述 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓ 用户请求匹配时
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Level 2: 指令 (Instructions) │
|
||||
│ • 通过 read_skill 工具按需加载 │
|
||||
│ • SKILL.md 的指令内容 │
|
||||
│ • 包含:详细指令、代码示例、使用方法 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓ 需要更多信息时
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Level 3: 附加资源 (Resources) │
|
||||
│ • 通过 read_skill 工具加载特定文件 │
|
||||
│ • 补充文档、配置模板、脚本文件 │
|
||||
│ • 通过 execute_skill_script 执行脚本 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Skill 目录结构
|
||||
|
||||
每个 Skill 是一个目录,包含 `SKILL.md` 主文件和可选的附加资源:
|
||||
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md # 必需:主文件(含 YAML frontmatter)
|
||||
├── REFERENCE.md # 可选:补充文档
|
||||
├── templates/ # 可选:模板文件
|
||||
│ └── config.yaml
|
||||
└── scripts/ # 可选:可执行脚本
|
||||
├── analyze.py
|
||||
└── generate.sh
|
||||
```
|
||||
|
||||
## SKILL.md 格式
|
||||
|
||||
### YAML Frontmatter
|
||||
|
||||
每个 `SKILL.md` 必须以 YAML frontmatter 开头,定义元数据:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
|
||||
---
|
||||
|
||||
# PDF Processing
|
||||
|
||||
This skill provides utilities for working with PDF documents.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Use pdfplumber to extract text from PDFs:
|
||||
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
text = pdf.pages[0].extract_text()
|
||||
print(text)
|
||||
```
|
||||
|
||||
## Available Operations
|
||||
|
||||
1. **Text Extraction**: Extract text content from PDF pages
|
||||
2. **Table Extraction**: Extract tabular data from PDFs
|
||||
...
|
||||
```
|
||||
|
||||
### 元数据验证规则
|
||||
|
||||
| 字段 | 要求 |
|
||||
|------|------|
|
||||
| `name` | 1-50 字符,仅允许 `a-z`, `0-9`, `-`, `_`,不能是保留词 |
|
||||
| `description` | 1-500 字符,描述技能用途和触发条件 |
|
||||
|
||||
**保留词**:`system`, `default`, `internal`, `core`, `base`, `root`, `admin`
|
||||
|
||||
### 最佳实践
|
||||
|
||||
**name 命名**:
|
||||
- ✅ `pdf-processing`, `code_review`, `api-client`
|
||||
- ❌ `PDF Processing`, `my skill`, `system`
|
||||
|
||||
**description 编写**:
|
||||
- 清晰描述技能的功能
|
||||
- 包含触发条件(如 "when working with PDF files")
|
||||
- 避免过于模糊的描述
|
||||
|
||||
## 配置
|
||||
|
||||
### AgentConfig 配置项
|
||||
|
||||
```go
|
||||
type AgentConfig struct {
|
||||
// ... 其他配置 ...
|
||||
|
||||
// Skills 相关配置
|
||||
SkillsEnabled bool `json:"skills_enabled"` // 是否启用 Skills
|
||||
SkillDirs []string `json:"skill_dirs"` // Skill 目录列表
|
||||
AllowedSkills []string `json:"allowed_skills"` // 白名单(空=全部允许)
|
||||
SandboxMode string `json:"sandbox_mode"` // sandbox 模式
|
||||
SandboxTimeout int `json:"sandbox_timeout"` // 脚本执行超时(秒)
|
||||
}
|
||||
```
|
||||
|
||||
### 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"skills_enabled": true,
|
||||
"skill_dirs": [
|
||||
"/path/to/project/skills",
|
||||
"/home/user/.agent-skills"
|
||||
],
|
||||
"allowed_skills": ["pdf-processing", "code-review"],
|
||||
"sandbox_mode": "docker",
|
||||
"sandbox_timeout": 30
|
||||
}
|
||||
```
|
||||
|
||||
### Sandbox 模式
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `docker` | 使用 Docker 容器隔离(推荐) |
|
||||
| `local` | 本地进程执行(基础安全限制) |
|
||||
| `disabled` | 禁用脚本执行 |
|
||||
|
||||
## Agent 工具
|
||||
|
||||
Skills 功能通过两个工具与 Agent 交互:
|
||||
|
||||
### read_skill
|
||||
|
||||
读取技能内容或特定文件。
|
||||
|
||||
**参数**:
|
||||
```json
|
||||
{
|
||||
"skill_name": "pdf-processing", // 必需:技能名称
|
||||
"file_path": "FORMS.md" // 可选:相对路径
|
||||
}
|
||||
```
|
||||
|
||||
**使用场景**:
|
||||
1. 加载 Level 2 内容:仅传 `skill_name`
|
||||
2. 加载 Level 3 资源:同时传 `skill_name` 和 `file_path`
|
||||
|
||||
**示例调用**:
|
||||
```json
|
||||
// 加载技能主内容
|
||||
{"skill_name": "pdf-processing"}
|
||||
|
||||
// 加载补充文档
|
||||
{"skill_name": "pdf-processing", "file_path": "FORMS.md"}
|
||||
|
||||
// 查看脚本内容
|
||||
{"skill_name": "pdf-processing", "file_path": "scripts/analyze.py"}
|
||||
```
|
||||
|
||||
### execute_skill_script
|
||||
|
||||
在沙箱中执行技能脚本。
|
||||
|
||||
**参数**:
|
||||
```json
|
||||
{
|
||||
"skill_name": "pdf-processing", // 必需:技能名称
|
||||
"script_path": "scripts/analyze.py", // 必需:脚本相对路径
|
||||
"args": ["input.pdf", "--format", "json"] // 可选:命令行参数
|
||||
}
|
||||
```
|
||||
|
||||
**支持的脚本类型**:
|
||||
- Python (`.py`)
|
||||
- Shell (`.sh`)
|
||||
- JavaScript/Node.js (`.js`)
|
||||
- Ruby (`.rb`)
|
||||
- Go (`.go`)
|
||||
|
||||
## 创建自定义 Skill
|
||||
|
||||
### 第一步:创建目录结构
|
||||
|
||||
```bash
|
||||
mkdir -p my-skills/code-review
|
||||
cd my-skills/code-review
|
||||
```
|
||||
|
||||
### 第二步:编写 SKILL.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-review
|
||||
description: Review code for best practices, security issues, and performance. Use when the user asks to review, analyze, or improve code quality.
|
||||
---
|
||||
|
||||
# Code Review Skill
|
||||
|
||||
This skill helps analyze code for quality and security issues.
|
||||
|
||||
## How to Use
|
||||
|
||||
When reviewing code:
|
||||
|
||||
1. Check for common security vulnerabilities
|
||||
2. Identify performance bottlenecks
|
||||
3. Suggest best practice improvements
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SQL Injection prevention
|
||||
- [ ] XSS protection
|
||||
- [ ] Input validation
|
||||
- [ ] Authentication checks
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Avoid N+1 queries
|
||||
- Use appropriate data structures
|
||||
- Consider caching strategies
|
||||
```
|
||||
|
||||
### 第三步:添加辅助脚本(可选)
|
||||
|
||||
创建 `scripts/lint.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Simple code linter for demonstration."""
|
||||
import sys
|
||||
import json
|
||||
|
||||
def lint_code(filepath):
|
||||
issues = []
|
||||
with open(filepath) as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if len(line) > 120:
|
||||
issues.append({
|
||||
"line": i,
|
||||
"issue": "Line too long",
|
||||
"severity": "warning"
|
||||
})
|
||||
if "eval(" in line:
|
||||
issues.append({
|
||||
"line": i,
|
||||
"issue": "Avoid using eval()",
|
||||
"severity": "error"
|
||||
})
|
||||
return issues
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: lint.py <filepath>")
|
||||
sys.exit(1)
|
||||
|
||||
result = lint_code(sys.argv[1])
|
||||
print(json.dumps(result, indent=2))
|
||||
```
|
||||
|
||||
### 第四步:配置 Agent
|
||||
|
||||
将 Skill 目录添加到 Agent 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills_enabled": true,
|
||||
"skill_dirs": ["/path/to/my-skills"]
|
||||
}
|
||||
```
|
||||
|
||||
## 沙箱安全机制
|
||||
|
||||
### Docker 沙箱
|
||||
|
||||
Docker 模式提供最强的隔离:
|
||||
|
||||
- **非 root 用户**:容器内以普通用户运行
|
||||
- **Capability 限制**:移除所有 Linux capabilities
|
||||
- **只读文件系统**:根文件系统只读
|
||||
- **资源限制**:内存 256MB,CPU 限制
|
||||
- **网络隔离**:默认无网络访问
|
||||
- **临时挂载**:Skill 目录只读挂载
|
||||
|
||||
```bash
|
||||
# Docker 执行示例
|
||||
docker run --rm \
|
||||
--user 1000:1000 \
|
||||
--cap-drop ALL \
|
||||
--read-only \
|
||||
--memory=256m \
|
||||
--network=none \
|
||||
-v /path/to/skill:/skill:ro \
|
||||
-w /skill \
|
||||
python:3.11-slim \
|
||||
python scripts/analyze.py input.pdf
|
||||
```
|
||||
|
||||
### Local 沙箱
|
||||
|
||||
Local 模式提供基础保护:
|
||||
|
||||
- **命令白名单**:仅允许特定解释器
|
||||
- **工作目录限制**:限定在 Skill 目录
|
||||
- **环境变量过滤**:仅传递安全变量
|
||||
- **超时控制**:默认 30 秒超时
|
||||
- **路径遍历防护**:防止访问 Skill 目录外文件
|
||||
|
||||
**允许的命令**:
|
||||
- `python`, `python3`
|
||||
- `node`, `nodejs`
|
||||
- `bash`, `sh`
|
||||
- `ruby`
|
||||
- `go run`
|
||||
|
||||
## API 参考
|
||||
|
||||
### SkillManager
|
||||
|
||||
```go
|
||||
type Manager interface {
|
||||
// 初始化,发现所有 Skills
|
||||
Initialize(ctx context.Context) error
|
||||
|
||||
// 获取所有 Skill 元数据(Level 1)
|
||||
GetAllMetadata() []*SkillMetadata
|
||||
|
||||
// 加载 Skill 指令(Level 2)
|
||||
LoadSkill(ctx context.Context, skillName string) (*Skill, error)
|
||||
|
||||
// 读取 Skill 文件内容(Level 3)
|
||||
ReadSkillFile(ctx context.Context, skillName, filePath string) (string, error)
|
||||
|
||||
// 列出 Skill 中的所有文件
|
||||
ListSkillFiles(ctx context.Context, skillName string) ([]string, error)
|
||||
|
||||
// 执行 Skill 脚本
|
||||
ExecuteScript(ctx context.Context, skillName, scriptPath string, args []string) (*sandbox.ExecuteResult, error)
|
||||
|
||||
// 检查是否启用
|
||||
IsEnabled() bool
|
||||
}
|
||||
```
|
||||
|
||||
### Skill 结构
|
||||
|
||||
```go
|
||||
type Skill struct {
|
||||
Name string // 技能名称
|
||||
Description string // 技能描述
|
||||
BasePath string // 目录绝对路径
|
||||
FilePath string // SKILL.md 绝对路径
|
||||
Instructions string // SKILL.md 主体指令内容
|
||||
Loaded bool // 是否已加载 Level 2
|
||||
}
|
||||
|
||||
type SkillMetadata struct {
|
||||
Name string // 技能名称
|
||||
Description string // 技能描述
|
||||
BasePath string // 目录路径
|
||||
}
|
||||
```
|
||||
|
||||
### ExecuteResult 结构
|
||||
|
||||
```go
|
||||
type ExecuteResult struct {
|
||||
ExitCode int // 退出码
|
||||
Stdout string // 标准输出
|
||||
Stderr string // 标准错误
|
||||
Duration time.Duration // 执行时长
|
||||
Error error // 执行错误
|
||||
}
|
||||
```
|
||||
|
||||
## 示例:完整工作流
|
||||
|
||||
以下是 Agent 处理用户请求的完整流程:
|
||||
|
||||
```
|
||||
用户: "帮我从 report.pdf 提取表格数据"
|
||||
|
||||
Agent 思考:
|
||||
→ 查看 System Prompt 中的 Skills 列表
|
||||
→ 发现 "pdf-processing" 技能匹配
|
||||
|
||||
Agent 行动 1: 调用 read_skill
|
||||
→ {"skill_name": "pdf-processing"}
|
||||
→ 获取 SKILL.md 指令内容
|
||||
→ 学习如何使用 pdfplumber
|
||||
|
||||
Agent 行动 2: 调用 execute_skill_script
|
||||
→ {"skill_name": "pdf-processing",
|
||||
"script_path": "scripts/extract_text.py",
|
||||
"args": ["report.pdf"]}
|
||||
→ 脚本在沙箱中执行,返回提取的表格数据
|
||||
|
||||
Agent 回复:
|
||||
→ 向用户展示提取的表格数据
|
||||
→ 提供数据使用建议
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Skill 未被发现
|
||||
|
||||
1. 检查 `skill_dirs` 配置是否正确
|
||||
2. 确认目录中存在 `SKILL.md` 文件
|
||||
3. 验证 YAML frontmatter 格式
|
||||
|
||||
```bash
|
||||
# 运行 demo 验证
|
||||
go run ./cmd/skills-demo/main.go
|
||||
```
|
||||
|
||||
### 脚本执行失败
|
||||
|
||||
1. 检查 `sandbox_mode` 配置
|
||||
2. Docker 模式:确认 Docker 服务运行中
|
||||
3. Local 模式:确认解释器已安装
|
||||
4. 检查脚本权限和语法
|
||||
|
||||
### 元数据验证错误
|
||||
|
||||
常见错误:
|
||||
- `skill name too long`: 名称超过 50 字符
|
||||
- `skill name contains invalid characters`: 包含非法字符
|
||||
- `skill name is reserved`: 使用了保留词
|
||||
- `skill description too long`: 描述超过 500 字符
|
||||
|
||||
## 运行 Demo
|
||||
|
||||
```bash
|
||||
cd /path/to/WeKnora
|
||||
go run ./cmd/skills-demo/main.go
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
=======================================================================
|
||||
Agent Skills Demo - Progressive Disclosure in Action
|
||||
=======================================================================
|
||||
|
||||
📁 Skills directory: /path/to/WeKnora/examples/skills
|
||||
|
||||
Step 1: Initialize Sandbox Manager
|
||||
---------------------------------------------------
|
||||
✅ Sandbox initialized (type: local)
|
||||
|
||||
Step 2: Initialize Skills Manager
|
||||
---------------------------------------------------
|
||||
✅ Discovered 1 skills
|
||||
|
||||
...
|
||||
|
||||
🎉 Demo completed successfully!
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Skills 示例
|
||||
|
||||
本目录包含 Agent Skills 功能的示例。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
skills/
|
||||
├── README.md # 本文件
|
||||
└── pdf-processing/ # PDF 处理技能示例
|
||||
├── SKILL.md # 主文件(Level 2)
|
||||
├── FORMS.md # 补充文档(Level 3)
|
||||
└── scripts/ # 可执行脚本
|
||||
├── analyze_form.py
|
||||
└── extract_text.py
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 运行 Demo
|
||||
|
||||
```bash
|
||||
go run ./cmd/skills-demo/main.go
|
||||
```
|
||||
|
||||
### 创建新 Skill
|
||||
|
||||
1. 在本目录创建新文件夹:
|
||||
|
||||
```bash
|
||||
mkdir my-new-skill
|
||||
```
|
||||
|
||||
2. 创建 `SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-new-skill
|
||||
description: Description of what this skill does and when to use it.
|
||||
---
|
||||
|
||||
# My New Skill
|
||||
|
||||
Instructions for the agent...
|
||||
```
|
||||
|
||||
3. 添加脚本(可选):
|
||||
|
||||
```bash
|
||||
mkdir my-new-skill/scripts
|
||||
# 添加你的脚本
|
||||
```
|
||||
|
||||
## 详细文档
|
||||
|
||||
完整文档请参阅:[Agent Skills 文档](../../docs/agent-skills.md)
|
||||
|
||||
## 示例:pdf-processing
|
||||
|
||||
这是一个功能完整的示例技能,展示了:
|
||||
|
||||
- **SKILL.md**: 包含 YAML frontmatter 的主文件
|
||||
- **FORMS.md**: 补充参考文档
|
||||
- **scripts/**: 可在沙箱中执行的 Python 脚本
|
||||
|
||||
### 技能描述
|
||||
|
||||
```yaml
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDF files, fill forms, merge documents.
|
||||
```
|
||||
|
||||
### 包含的脚本
|
||||
|
||||
| 脚本 | 功能 |
|
||||
|------|------|
|
||||
| `analyze_form.py` | 分析 PDF 表单字段 |
|
||||
| `extract_text.py` | 从 PDF 提取文本 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
Agent 会根据用户请求自动调用:
|
||||
|
||||
```
|
||||
用户: "分析一下这个 PDF 表单有哪些字段"
|
||||
|
||||
Agent:
|
||||
1. 识别匹配 pdf-processing 技能
|
||||
2. 调用 read_skill 加载技能内容
|
||||
3. 调用 execute_skill_script 执行 analyze_form.py
|
||||
4. 返回表单字段分析结果
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# PDF Form Filling Guide
|
||||
|
||||
This guide covers how to fill PDF forms programmatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install required packages:
|
||||
```bash
|
||||
pip install pypdf pdfrw
|
||||
```
|
||||
|
||||
## Basic Form Filling
|
||||
|
||||
```python
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
def fill_form(input_path, output_path, field_data):
|
||||
reader = PdfReader(input_path)
|
||||
writer = PdfWriter()
|
||||
|
||||
# Clone the original PDF
|
||||
writer.clone_document_from_reader(reader)
|
||||
|
||||
# Fill form fields
|
||||
for page in writer.pages:
|
||||
writer.update_page_form_field_values(page, field_data)
|
||||
|
||||
# Save the filled PDF
|
||||
with open(output_path, "wb") as f:
|
||||
writer.write(f)
|
||||
```
|
||||
|
||||
## Supported Field Types
|
||||
|
||||
- Text fields
|
||||
- Checkboxes
|
||||
- Radio buttons
|
||||
- Dropdown lists
|
||||
|
||||
## Tips
|
||||
|
||||
1. Use `scripts/analyze_form.py` to discover available fields
|
||||
2. Field names are case-sensitive
|
||||
3. Always verify output after filling
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: pdf-processing
|
||||
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
|
||||
---
|
||||
# PDF Processing
|
||||
|
||||
This skill provides utilities for working with PDF documents.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Use pdfplumber to extract text from PDFs:
|
||||
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
text = pdf.pages[0].extract_text()
|
||||
print(text)
|
||||
```
|
||||
|
||||
## Available Operations
|
||||
|
||||
1. **Text Extraction**: Extract text content from PDF pages
|
||||
2. **Table Extraction**: Extract tabular data from PDFs
|
||||
3. **Form Filling**: Fill PDF forms with provided data
|
||||
4. **Document Merging**: Combine multiple PDFs into one
|
||||
|
||||
## Advanced Features
|
||||
|
||||
**Form filling**: See [FORMS.md](FORMS.md) for complete guide
|
||||
|
||||
**Utility scripts**:
|
||||
- Run `scripts/analyze_form.py` to extract form fields
|
||||
- Run `scripts/extract_text.py` to extract text from a PDF
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always validate PDF files before processing
|
||||
2. Handle password-protected PDFs gracefully
|
||||
3. Check for scanned PDFs that may require OCR
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze PDF form fields and output their structure.
|
||||
Usage: python analyze_form.py <pdf_file>
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
|
||||
def analyze_form(pdf_path):
|
||||
"""Analyze form fields in a PDF file."""
|
||||
# This is a mock implementation for testing
|
||||
# In production, would use pypdf or pdfrw
|
||||
|
||||
print(f"Analyzing PDF: {pdf_path}")
|
||||
print("=" * 50)
|
||||
|
||||
# Mock form fields for demonstration
|
||||
fields = {
|
||||
"name": {"type": "text", "required": True},
|
||||
"email": {"type": "text", "required": True},
|
||||
"date": {"type": "date", "required": False},
|
||||
"agree_terms": {"type": "checkbox", "required": True},
|
||||
"signature": {"type": "signature", "required": True}
|
||||
}
|
||||
|
||||
print("\nDiscovered Form Fields:")
|
||||
print("-" * 30)
|
||||
for field_name, props in fields.items():
|
||||
required_str = "[REQUIRED]" if props["required"] else "[optional]"
|
||||
print(f" {field_name}: {props['type']} {required_str}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Analysis complete.")
|
||||
|
||||
# Output JSON for programmatic use
|
||||
return json.dumps(fields, indent=2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python analyze_form.py <pdf_file>")
|
||||
sys.exit(1)
|
||||
|
||||
result = analyze_form(sys.argv[1])
|
||||
print("\nJSON Output:")
|
||||
print(result)
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract text from PDF files.
|
||||
Usage: python extract_text.py <pdf_file> [--page N]
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
def extract_text(pdf_path, page_num=None):
|
||||
"""Extract text from a PDF file."""
|
||||
# This is a mock implementation for testing
|
||||
# In production, would use pdfplumber or pypdf
|
||||
|
||||
print(f"Extracting text from: {pdf_path}")
|
||||
|
||||
if page_num:
|
||||
print(f"Page: {page_num}")
|
||||
else:
|
||||
print("All pages")
|
||||
|
||||
print("=" * 50)
|
||||
|
||||
# Mock extracted text
|
||||
mock_text = """
|
||||
Sample PDF Document
|
||||
|
||||
This is a demonstration of text extraction from PDF files.
|
||||
|
||||
Key Features:
|
||||
- Fast and efficient text extraction
|
||||
- Preserves document structure
|
||||
- Handles multi-page documents
|
||||
|
||||
For more information, visit our documentation.
|
||||
"""
|
||||
|
||||
print(mock_text)
|
||||
print("=" * 50)
|
||||
print("Extraction complete.")
|
||||
|
||||
return mock_text.strip()
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python extract_text.py <pdf_file> [--page N]")
|
||||
sys.exit(1)
|
||||
|
||||
pdf_path = sys.argv[1]
|
||||
page_num = None
|
||||
|
||||
if len(sys.argv) > 3 and sys.argv[2] == "--page":
|
||||
page_num = int(sys.argv[3])
|
||||
|
||||
extract_text(pdf_path, page_num)
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/common"
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
@@ -33,6 +34,7 @@ type AgentEngine struct {
|
||||
contextManager interfaces.ContextManager // Context manager for writing agent conversation to LLM context
|
||||
sessionID string // Session ID for context management
|
||||
systemPromptTemplate string // System prompt template (optional, uses default if empty)
|
||||
skillsManager *skills.Manager // Skills manager for Progressive Disclosure (optional)
|
||||
}
|
||||
|
||||
// listToolNames returns tool.function names for logging
|
||||
@@ -72,6 +74,44 @@ func NewAgentEngine(
|
||||
}
|
||||
}
|
||||
|
||||
// NewAgentEngineWithSkills creates a new agent engine with skills support
|
||||
func NewAgentEngineWithSkills(
|
||||
config *types.AgentConfig,
|
||||
chatModel chat.Chat,
|
||||
toolRegistry *tools.ToolRegistry,
|
||||
eventBus *event.EventBus,
|
||||
knowledgeBasesInfo []*KnowledgeBaseInfo,
|
||||
selectedDocs []*SelectedDocumentInfo,
|
||||
contextManager interfaces.ContextManager,
|
||||
sessionID string,
|
||||
systemPromptTemplate string,
|
||||
skillsManager *skills.Manager,
|
||||
) *AgentEngine {
|
||||
engine := NewAgentEngine(
|
||||
config,
|
||||
chatModel,
|
||||
toolRegistry,
|
||||
eventBus,
|
||||
knowledgeBasesInfo,
|
||||
selectedDocs,
|
||||
contextManager,
|
||||
sessionID,
|
||||
systemPromptTemplate,
|
||||
)
|
||||
engine.skillsManager = skillsManager
|
||||
return engine
|
||||
}
|
||||
|
||||
// SetSkillsManager sets the skills manager for the engine
|
||||
func (e *AgentEngine) SetSkillsManager(manager *skills.Manager) {
|
||||
e.skillsManager = manager
|
||||
}
|
||||
|
||||
// GetSkillsManager returns the skills manager
|
||||
func (e *AgentEngine) GetSkillsManager() *skills.Manager {
|
||||
return e.skillsManager
|
||||
}
|
||||
|
||||
// Execute executes the agent with conversation history and streaming output
|
||||
// All events are emitted to EventBus and handled by subscribers (like Handler layer)
|
||||
func (e *AgentEngine) Execute(
|
||||
@@ -102,12 +142,27 @@ func (e *AgentEngine) Execute(
|
||||
}
|
||||
|
||||
// Build system prompt using progressive RAG prompt
|
||||
systemPrompt := BuildSystemPrompt(
|
||||
e.knowledgeBasesInfo,
|
||||
e.config.WebSearchEnabled,
|
||||
e.selectedDocs,
|
||||
e.systemPromptTemplate,
|
||||
)
|
||||
// If skills are enabled, include skills metadata (Level 1 - Progressive Disclosure)
|
||||
var systemPrompt string
|
||||
if e.skillsManager != nil && e.skillsManager.IsEnabled() {
|
||||
skillsMetadata := e.skillsManager.GetAllMetadata()
|
||||
systemPrompt = BuildSystemPromptWithOptions(
|
||||
e.knowledgeBasesInfo,
|
||||
e.config.WebSearchEnabled,
|
||||
e.selectedDocs,
|
||||
&BuildSystemPromptOptions{
|
||||
SkillsMetadata: skillsMetadata,
|
||||
},
|
||||
e.systemPromptTemplate,
|
||||
)
|
||||
} else {
|
||||
systemPrompt = BuildSystemPrompt(
|
||||
e.knowledgeBasesInfo,
|
||||
e.config.WebSearchEnabled,
|
||||
e.selectedDocs,
|
||||
e.systemPromptTemplate,
|
||||
)
|
||||
}
|
||||
logger.Debugf(ctx, "[Agent] SystemPrompt Length: %d characters", len(systemPrompt))
|
||||
logger.Debugf(ctx, "[Agent] SystemPrompt (stream)\n----\n%s\n----", systemPrompt)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
@@ -193,6 +194,28 @@ func renderPromptPlaceholders(template string, knowledgeBases []*KnowledgeBaseIn
|
||||
return result
|
||||
}
|
||||
|
||||
// formatSkillsMetadata formats skills metadata for the system prompt (Level 1 - Progressive Disclosure)
|
||||
// This is a lightweight representation that only includes skill name and description
|
||||
func formatSkillsMetadata(skillsMetadata []*skills.SkillMetadata) string {
|
||||
if len(skillsMetadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString("\n### Available Skills\n\n")
|
||||
builder.WriteString("The following skills are available. When a user request matches a skill's description, ")
|
||||
builder.WriteString("use the `read_skill` tool to load its full instructions before proceeding.\n\n")
|
||||
|
||||
for i, skill := range skillsMetadata {
|
||||
builder.WriteString(fmt.Sprintf("%d. **%s**: %s\n", i+1, skill.Name, skill.Description))
|
||||
}
|
||||
|
||||
builder.WriteString("\nUse `read_skill` with the skill name to load detailed instructions when needed.\n")
|
||||
builder.WriteString("Use `execute_skill_script` to run utility scripts bundled with a skill.\n")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// formatSelectedDocuments formats selected documents for the prompt (summary only, no content)
|
||||
func formatSelectedDocuments(docs []*SelectedDocumentInfo) string {
|
||||
if len(docs) == 0 {
|
||||
@@ -230,6 +253,7 @@ func formatSelectedDocuments(docs []*SelectedDocumentInfo) string {
|
||||
// - {{knowledge_bases}}
|
||||
// - {{web_search_status}} -> "Enabled" or "Disabled"
|
||||
// - {{current_time}} -> current time string
|
||||
// - {{skills}} -> formatted skills metadata (if any)
|
||||
func renderPromptPlaceholdersWithStatus(
|
||||
template string,
|
||||
knowledgeBases []*KnowledgeBaseInfo,
|
||||
@@ -247,6 +271,11 @@ func renderPromptPlaceholdersWithStatus(
|
||||
if strings.Contains(result, "{{current_time}}") {
|
||||
result = strings.ReplaceAll(result, "{{current_time}}", currentTime)
|
||||
}
|
||||
// Remove {{skills}} placeholder if present but no skills provided
|
||||
// (it will be appended separately if skills exist)
|
||||
if strings.Contains(result, "{{skills}}") {
|
||||
result = strings.ReplaceAll(result, "{{skills}}", "")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -298,6 +327,11 @@ func BuildPureAgentSystemPrompt(
|
||||
return renderPromptPlaceholdersWithStatus(template, []*KnowledgeBaseInfo{}, webSearchEnabled, currentTime)
|
||||
}
|
||||
|
||||
// BuildSystemPromptOptions contains optional parameters for BuildSystemPrompt
|
||||
type BuildSystemPromptOptions struct {
|
||||
SkillsMetadata []*skills.SkillMetadata
|
||||
}
|
||||
|
||||
// BuildSystemPrompt builds the progressive RAG system prompt
|
||||
// This is the main function to use - it uses a unified template with dynamic web search status
|
||||
func BuildSystemPrompt(
|
||||
@@ -305,6 +339,17 @@ func BuildSystemPrompt(
|
||||
webSearchEnabled bool,
|
||||
selectedDocs []*SelectedDocumentInfo,
|
||||
systemPromptTemplate ...string,
|
||||
) string {
|
||||
return BuildSystemPromptWithOptions(knowledgeBases, webSearchEnabled, selectedDocs, nil, systemPromptTemplate...)
|
||||
}
|
||||
|
||||
// BuildSystemPromptWithOptions builds the system prompt with additional options like skills
|
||||
func BuildSystemPromptWithOptions(
|
||||
knowledgeBases []*KnowledgeBaseInfo,
|
||||
webSearchEnabled bool,
|
||||
selectedDocs []*SelectedDocumentInfo,
|
||||
options *BuildSystemPromptOptions,
|
||||
systemPromptTemplate ...string,
|
||||
) string {
|
||||
var basePrompt string
|
||||
var template string
|
||||
@@ -326,6 +371,11 @@ func BuildSystemPrompt(
|
||||
basePrompt += formatSelectedDocuments(selectedDocs)
|
||||
}
|
||||
|
||||
// Append skills metadata if available (Level 1 - Progressive Disclosure)
|
||||
if options != nil && len(options.SkillsMetadata) > 0 {
|
||||
basePrompt += formatSkillsMetadata(options.SkillsMetadata)
|
||||
}
|
||||
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExampleSkillsIntegration tests with the actual example skills in examples/skills
|
||||
func TestExampleSkillsIntegration(t *testing.T) {
|
||||
// Get the path to examples/skills relative to this test file
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("Failed to get current file path")
|
||||
}
|
||||
|
||||
// Navigate from internal/agent/skills to examples/skills
|
||||
skillsDir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "examples", "skills")
|
||||
|
||||
// Create loader
|
||||
loader := NewLoader([]string{skillsDir})
|
||||
|
||||
// Discover skills
|
||||
metadata, err := loader.DiscoverSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to discover skills: %v", err)
|
||||
}
|
||||
|
||||
if len(metadata) == 0 {
|
||||
t.Skip("No example skills found in examples/skills directory")
|
||||
}
|
||||
|
||||
t.Logf("Discovered %d example skills:", len(metadata))
|
||||
for _, m := range metadata {
|
||||
t.Logf(" - %s: %s", m.Name, truncate(m.Description, 60))
|
||||
}
|
||||
|
||||
// Test loading the pdf-processing skill
|
||||
pdfSkill, err := loader.LoadSkillInstructions("pdf-processing")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load pdf-processing skill: %v", err)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if pdfSkill.Name != "pdf-processing" {
|
||||
t.Errorf("Expected name 'pdf-processing', got '%s'", pdfSkill.Name)
|
||||
}
|
||||
|
||||
if pdfSkill.Instructions == "" {
|
||||
t.Error("Expected instructions to be non-empty")
|
||||
}
|
||||
|
||||
t.Logf("PDF Processing skill instructions length: %d characters", len(pdfSkill.Instructions))
|
||||
|
||||
// Test loading additional file (FORMS.md)
|
||||
formsFile, err := loader.LoadSkillFile("pdf-processing", "FORMS.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load FORMS.md: %v", err)
|
||||
}
|
||||
|
||||
if formsFile.Content == "" {
|
||||
t.Error("Expected FORMS.md content to be non-empty")
|
||||
}
|
||||
|
||||
t.Logf("FORMS.md content length: %d characters", len(formsFile.Content))
|
||||
|
||||
// Test loading script
|
||||
scriptFile, err := loader.LoadSkillFile("pdf-processing", "scripts/analyze_form.py")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load analyze_form.py: %v", err)
|
||||
}
|
||||
|
||||
if !scriptFile.IsScript {
|
||||
t.Error("analyze_form.py should be marked as script")
|
||||
}
|
||||
|
||||
t.Logf("analyze_form.py content length: %d characters", len(scriptFile.Content))
|
||||
|
||||
// Test list files
|
||||
files, err := loader.ListSkillFiles("pdf-processing")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list skill files: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Files in pdf-processing skill:")
|
||||
for _, f := range files {
|
||||
t.Logf(" - %s (script: %v)", f, IsScript(f))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerWithExampleSkills tests the Manager with example skills
|
||||
func TestManagerWithExampleSkills(t *testing.T) {
|
||||
// Get the path to examples/skills
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("Failed to get current file path")
|
||||
}
|
||||
|
||||
skillsDir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "examples", "skills")
|
||||
|
||||
// Create manager
|
||||
config := &ManagerConfig{
|
||||
SkillDirs: []string{skillsDir},
|
||||
AllowedSkills: []string{}, // Allow all
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
manager := NewManager(config, nil)
|
||||
|
||||
// Initialize
|
||||
ctx := context.Background()
|
||||
if err := manager.Initialize(ctx); err != nil {
|
||||
t.Fatalf("Failed to initialize manager: %v", err)
|
||||
}
|
||||
|
||||
// Get metadata for system prompt
|
||||
metadata := manager.GetAllMetadata()
|
||||
if len(metadata) == 0 {
|
||||
t.Skip("No example skills found")
|
||||
}
|
||||
|
||||
t.Logf("Manager discovered %d skills for system prompt injection", len(metadata))
|
||||
|
||||
// Simulate what the agent would do:
|
||||
// 1. First, get metadata (Level 1 - already in system prompt)
|
||||
for _, m := range metadata {
|
||||
t.Logf("Level 1 (metadata): %s - %s", m.Name, truncate(m.Description, 50))
|
||||
}
|
||||
|
||||
// 2. When user request matches, load full skill instructions (Level 2)
|
||||
skill, err := manager.LoadSkill(ctx, "pdf-processing")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load skill: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Level 2 (instructions): Loaded %d characters of instructions", len(skill.Instructions))
|
||||
|
||||
// 3. If skill references additional files, read them (Level 3)
|
||||
formsContent, err := manager.ReadSkillFile(ctx, "pdf-processing", "FORMS.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read skill file: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Level 3 (resources): Loaded FORMS.md with %d characters", len(formsContent))
|
||||
|
||||
// Test GetSkillInfo
|
||||
info, err := manager.GetSkillInfo(ctx, "pdf-processing")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get skill info: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Skill info: name=%s, files=%d", info.Name, len(info.Files))
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Loader handles skill discovery and loading from the filesystem
|
||||
// It implements the Progressive Disclosure pattern by separating
|
||||
// metadata discovery (Level 1) from instructions loading (Level 2/3)
|
||||
type Loader struct {
|
||||
// skillDirs are the directories to search for skills
|
||||
skillDirs []string
|
||||
// discoveredSkills caches discovered skill metadata
|
||||
discoveredSkills map[string]*Skill
|
||||
}
|
||||
|
||||
// NewLoader creates a new skill loader with the specified search directories
|
||||
func NewLoader(skillDirs []string) *Loader {
|
||||
return &Loader{
|
||||
skillDirs: skillDirs,
|
||||
discoveredSkills: make(map[string]*Skill),
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverSkills scans all configured directories for SKILL.md files
|
||||
// and extracts their metadata (Level 1). This is a lightweight operation
|
||||
// that only reads the frontmatter of each skill file.
|
||||
func (l *Loader) DiscoverSkills() ([]*SkillMetadata, error) {
|
||||
var allMetadata []*SkillMetadata
|
||||
|
||||
for _, dir := range l.skillDirs {
|
||||
metadata, err := l.discoverInDirectory(dir)
|
||||
if err != nil {
|
||||
// Log warning but continue with other directories
|
||||
continue
|
||||
}
|
||||
allMetadata = append(allMetadata, metadata...)
|
||||
}
|
||||
|
||||
return allMetadata, nil
|
||||
}
|
||||
|
||||
// discoverInDirectory scans a single directory for skill subdirectories
|
||||
func (l *Loader) discoverInDirectory(dir string) ([]*SkillMetadata, error) {
|
||||
var metadata []*SkillMetadata
|
||||
|
||||
// Check if directory exists
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // Directory doesn't exist, skip silently
|
||||
}
|
||||
return nil, fmt.Errorf("failed to access skill directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", dir)
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read skill directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(dir, entry.Name())
|
||||
skillFile := filepath.Join(skillPath, SkillFileName)
|
||||
|
||||
// Check if SKILL.md exists
|
||||
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read and parse SKILL.md
|
||||
content, err := os.ReadFile(skillFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
skill, err := ParseSkillFile(string(content))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set filesystem paths
|
||||
skill.BasePath = skillPath
|
||||
skill.FilePath = skillFile
|
||||
|
||||
// Cache the skill
|
||||
l.discoveredSkills[skill.Name] = skill
|
||||
|
||||
metadata = append(metadata, skill.ToMetadata())
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// LoadSkillInstructions loads the full instructions of a skill (Level 2)
|
||||
// Returns the cached skill if already loaded
|
||||
func (l *Loader) LoadSkillInstructions(skillName string) (*Skill, error) {
|
||||
// Check cache first
|
||||
if skill, ok := l.discoveredSkills[skillName]; ok {
|
||||
if skill.Loaded {
|
||||
return skill, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the skill in all directories
|
||||
for _, dir := range l.skillDirs {
|
||||
skill, err := l.loadSkillFromDirectory(dir, skillName)
|
||||
if err == nil {
|
||||
l.discoveredSkills[skillName] = skill
|
||||
return skill, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("skill not found: %s", skillName)
|
||||
}
|
||||
|
||||
// loadSkillFromDirectory attempts to load a skill from a specific directory
|
||||
func (l *Loader) loadSkillFromDirectory(dir, skillName string) (*Skill, error) {
|
||||
// First, check if we can find by directory name matching skill name
|
||||
skillPath := filepath.Join(dir, skillName)
|
||||
skillFile := filepath.Join(skillPath, SkillFileName)
|
||||
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
return l.loadSkillFile(skillPath, skillFile)
|
||||
}
|
||||
|
||||
// Otherwise, scan all subdirectories to find the skill by name
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(dir, entry.Name())
|
||||
skillFile := filepath.Join(skillPath, SkillFileName)
|
||||
|
||||
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(skillFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
skill, err := ParseSkillFile(string(content))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if skill.Name == skillName {
|
||||
skill.BasePath = skillPath
|
||||
skill.FilePath = skillFile
|
||||
return skill, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("skill not found in %s: %s", dir, skillName)
|
||||
}
|
||||
|
||||
// loadSkillFile reads and parses a SKILL.md file
|
||||
func (l *Loader) loadSkillFile(basePath, filePath string) (*Skill, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read skill file: %w", err)
|
||||
}
|
||||
|
||||
skill, err := ParseSkillFile(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skill.BasePath = basePath
|
||||
skill.FilePath = filePath
|
||||
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// LoadSkillFile loads an additional file from a skill directory (Level 3)
|
||||
// The filePath should be relative to the skill's base directory
|
||||
func (l *Loader) LoadSkillFile(skillName, relativePath string) (*SkillFile, error) {
|
||||
// Get the skill first
|
||||
skill, ok := l.discoveredSkills[skillName]
|
||||
if !ok {
|
||||
// Try to load the skill
|
||||
var err error
|
||||
skill, err = l.LoadSkillInstructions(skillName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skill not found: %s", skillName)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and resolve the file path
|
||||
cleanPath := filepath.Clean(relativePath)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
|
||||
return nil, fmt.Errorf("invalid file path: %s", relativePath)
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(skill.BasePath, cleanPath)
|
||||
|
||||
// Verify the file is within the skill directory
|
||||
absSkillPath, err := filepath.Abs(skill.BasePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
absFilePath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(absFilePath, absSkillPath) {
|
||||
return nil, fmt.Errorf("file path outside skill directory: %s", relativePath)
|
||||
}
|
||||
|
||||
// Read the file
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return &SkillFile{
|
||||
Name: relativePath,
|
||||
Path: fullPath,
|
||||
Content: string(content),
|
||||
IsScript: IsScript(relativePath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListSkillFiles lists all files in a skill directory
|
||||
func (l *Loader) ListSkillFiles(skillName string) ([]string, error) {
|
||||
skill, ok := l.discoveredSkills[skillName]
|
||||
if !ok {
|
||||
var err error
|
||||
skill, err = l.LoadSkillInstructions(skillName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skill not found: %s", skillName)
|
||||
}
|
||||
}
|
||||
|
||||
var files []string
|
||||
|
||||
err := filepath.Walk(skill.BasePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(skill.BasePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files = append(files, relPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list skill files: %w", err)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetSkillByName returns a cached skill by name
|
||||
func (l *Loader) GetSkillByName(name string) (*Skill, bool) {
|
||||
skill, ok := l.discoveredSkills[name]
|
||||
return skill, ok
|
||||
}
|
||||
|
||||
// GetSkillBasePath returns the base path for a skill
|
||||
func (l *Loader) GetSkillBasePath(skillName string) (string, error) {
|
||||
skill, ok := l.discoveredSkills[skillName]
|
||||
if !ok {
|
||||
var err error
|
||||
skill, err = l.LoadSkillInstructions(skillName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill not found: %s", skillName)
|
||||
}
|
||||
}
|
||||
return skill.BasePath, nil
|
||||
}
|
||||
|
||||
// Reload clears the cache and rediscovers all skills
|
||||
func (l *Loader) Reload() ([]*SkillMetadata, error) {
|
||||
l.discoveredSkills = make(map[string]*Skill)
|
||||
return l.DiscoverSkills()
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
)
|
||||
|
||||
// Manager manages skills lifecycle including discovery, loading, and script execution
|
||||
// It coordinates between the Loader (filesystem operations) and Sandbox (script execution)
|
||||
type Manager struct {
|
||||
loader *Loader
|
||||
sandboxMgr sandbox.Manager
|
||||
|
||||
// Configuration
|
||||
skillDirs []string
|
||||
allowedSkills []string // Empty means all skills are allowed
|
||||
enabled bool
|
||||
|
||||
// Cache
|
||||
metadataCache []*SkillMetadata
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ManagerConfig holds configuration for the skill manager
|
||||
type ManagerConfig struct {
|
||||
SkillDirs []string // Directories to search for skills
|
||||
AllowedSkills []string // Skill names whitelist (empty = allow all)
|
||||
Enabled bool // Whether skills are enabled
|
||||
}
|
||||
|
||||
// NewManager creates a new skill manager with the given configuration
|
||||
func NewManager(config *ManagerConfig, sandboxMgr sandbox.Manager) *Manager {
|
||||
if config == nil {
|
||||
config = &ManagerConfig{
|
||||
Enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
loader: NewLoader(config.SkillDirs),
|
||||
sandboxMgr: sandboxMgr,
|
||||
skillDirs: config.SkillDirs,
|
||||
allowedSkills: config.AllowedSkills,
|
||||
enabled: config.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether skills are enabled
|
||||
func (m *Manager) IsEnabled() bool {
|
||||
return m.enabled
|
||||
}
|
||||
|
||||
// Initialize discovers all skills and caches their metadata
|
||||
// This should be called at startup
|
||||
func (m *Manager) Initialize(ctx context.Context) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, err := m.loader.DiscoverSkills()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to discover skills: %w", err)
|
||||
}
|
||||
|
||||
// Filter by allowed skills if specified
|
||||
if len(m.allowedSkills) > 0 {
|
||||
metadata = m.filterAllowedSkills(metadata)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.metadataCache = metadata
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterAllowedSkills filters metadata to only include allowed skills
|
||||
func (m *Manager) filterAllowedSkills(metadata []*SkillMetadata) []*SkillMetadata {
|
||||
if len(m.allowedSkills) == 0 {
|
||||
return metadata
|
||||
}
|
||||
|
||||
allowedSet := make(map[string]bool)
|
||||
for _, name := range m.allowedSkills {
|
||||
allowedSet[name] = true
|
||||
}
|
||||
|
||||
var filtered []*SkillMetadata
|
||||
for _, meta := range metadata {
|
||||
if allowedSet[meta.Name] {
|
||||
filtered = append(filtered, meta)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetAllMetadata returns metadata for all discovered skills
|
||||
// This is used for system prompt injection (Level 1)
|
||||
func (m *Manager) GetAllMetadata() []*SkillMetadata {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]*SkillMetadata, len(m.metadataCache))
|
||||
copy(result, m.metadataCache)
|
||||
return result
|
||||
}
|
||||
|
||||
// LoadSkill loads the full instructions of a skill (Level 2)
|
||||
func (m *Manager) LoadSkill(ctx context.Context, skillName string) (*Skill, error) {
|
||||
if !m.enabled {
|
||||
return nil, fmt.Errorf("skills are not enabled")
|
||||
}
|
||||
|
||||
// Check if skill is allowed
|
||||
if !m.isSkillAllowed(skillName) {
|
||||
return nil, fmt.Errorf("skill not allowed: %s", skillName)
|
||||
}
|
||||
|
||||
return m.loader.LoadSkillInstructions(skillName)
|
||||
}
|
||||
|
||||
// isSkillAllowed checks if a skill is in the allowed list
|
||||
func (m *Manager) isSkillAllowed(skillName string) bool {
|
||||
if len(m.allowedSkills) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, name := range m.allowedSkills {
|
||||
if name == skillName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadSkillFile reads an additional file from a skill directory (Level 3)
|
||||
func (m *Manager) ReadSkillFile(ctx context.Context, skillName, filePath string) (string, error) {
|
||||
if !m.enabled {
|
||||
return "", fmt.Errorf("skills are not enabled")
|
||||
}
|
||||
|
||||
if !m.isSkillAllowed(skillName) {
|
||||
return "", fmt.Errorf("skill not allowed: %s", skillName)
|
||||
}
|
||||
|
||||
file, err := m.loader.LoadSkillFile(skillName, filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return file.Content, nil
|
||||
}
|
||||
|
||||
// ListSkillFiles lists all files in a skill directory
|
||||
func (m *Manager) ListSkillFiles(ctx context.Context, skillName string) ([]string, error) {
|
||||
if !m.enabled {
|
||||
return nil, fmt.Errorf("skills are not enabled")
|
||||
}
|
||||
|
||||
if !m.isSkillAllowed(skillName) {
|
||||
return nil, fmt.Errorf("skill not allowed: %s", skillName)
|
||||
}
|
||||
|
||||
return m.loader.ListSkillFiles(skillName)
|
||||
}
|
||||
|
||||
// ExecuteScript executes a script from a skill in the sandbox
|
||||
func (m *Manager) ExecuteScript(ctx context.Context, skillName, scriptPath string, args []string) (*sandbox.ExecuteResult, error) {
|
||||
if !m.enabled {
|
||||
return nil, fmt.Errorf("skills are not enabled")
|
||||
}
|
||||
|
||||
if !m.isSkillAllowed(skillName) {
|
||||
return nil, fmt.Errorf("skill not allowed: %s", skillName)
|
||||
}
|
||||
|
||||
// Verify sandbox manager is available
|
||||
if m.sandboxMgr == nil {
|
||||
return nil, fmt.Errorf("sandbox is not configured")
|
||||
}
|
||||
|
||||
// Get the skill base path
|
||||
basePath, err := m.loader.GetSkillBasePath(skillName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load the script file to verify it exists and is a script
|
||||
file, err := m.loader.LoadSkillFile(skillName, scriptPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load script: %w", err)
|
||||
}
|
||||
|
||||
if !file.IsScript {
|
||||
return nil, fmt.Errorf("file is not an executable script: %s", scriptPath)
|
||||
}
|
||||
|
||||
// Prepare execution config
|
||||
config := &sandbox.ExecuteConfig{
|
||||
Script: file.Path,
|
||||
Args: args,
|
||||
WorkDir: basePath,
|
||||
}
|
||||
|
||||
// Execute in sandbox
|
||||
return m.sandboxMgr.Execute(ctx, config)
|
||||
}
|
||||
|
||||
// GetSkillInfo returns detailed information about a skill
|
||||
func (m *Manager) GetSkillInfo(ctx context.Context, skillName string) (*SkillInfo, error) {
|
||||
if !m.enabled {
|
||||
return nil, fmt.Errorf("skills are not enabled")
|
||||
}
|
||||
|
||||
if !m.isSkillAllowed(skillName) {
|
||||
return nil, fmt.Errorf("skill not allowed: %s", skillName)
|
||||
}
|
||||
|
||||
skill, err := m.loader.LoadSkillInstructions(skillName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := m.loader.ListSkillFiles(skillName)
|
||||
if err != nil {
|
||||
files = []string{} // Non-fatal error
|
||||
}
|
||||
|
||||
return &SkillInfo{
|
||||
Name: skill.Name,
|
||||
Description: skill.Description,
|
||||
BasePath: skill.BasePath,
|
||||
Instructions: skill.Instructions,
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SkillInfo provides detailed information about a skill
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BasePath string `json:"base_path"`
|
||||
Instructions string `json:"instructions"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// Reload refreshes the skill cache by rediscovering all skills
|
||||
func (m *Manager) Reload(ctx context.Context) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, err := m.loader.Reload()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(m.allowedSkills) > 0 {
|
||||
metadata = m.filterAllowedSkills(metadata)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.metadataCache = metadata
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup releases resources
|
||||
func (m *Manager) Cleanup(ctx context.Context) error {
|
||||
if m.sandboxMgr != nil {
|
||||
return m.sandboxMgr.Cleanup(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Package skills provides Agent Skills functionality following Claude's Progressive Disclosure pattern.
|
||||
// Skills are modular capabilities that extend the agent's functionality through instruction files.
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Skill validation constants following Claude's specification
|
||||
const (
|
||||
MaxNameLength = 64
|
||||
MaxDescriptionLength = 1024
|
||||
SkillFileName = "SKILL.md"
|
||||
)
|
||||
|
||||
// Reserved words that cannot be used in skill names
|
||||
var reservedWords = []string{"anthropic", "claude"}
|
||||
|
||||
// namePattern validates skill names: lowercase letters, numbers, and hyphens only
|
||||
var namePattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||
|
||||
// xmlTagPattern detects XML tags in content
|
||||
var xmlTagPattern = regexp.MustCompile(`<[^>]+>`)
|
||||
|
||||
// Skill represents a loaded skill with its metadata and content
|
||||
// It follows the Progressive Disclosure pattern:
|
||||
// - Level 1 (Metadata): Name and Description are always loaded
|
||||
// - Level 2 (Instructions): The main body of SKILL.md, loaded on demand
|
||||
// - Level 3 (Resources): Additional files in the skill directory, loaded as needed
|
||||
type Skill struct {
|
||||
// Metadata (Level 1) - always loaded
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
|
||||
// Filesystem information
|
||||
BasePath string // Absolute path to the skill directory
|
||||
FilePath string // Absolute path to SKILL.md
|
||||
|
||||
// Instructions (Level 2) - loaded on demand
|
||||
Instructions string // The main body of SKILL.md (after frontmatter)
|
||||
Loaded bool // Whether Level 2 instructions have been loaded
|
||||
}
|
||||
|
||||
// SkillMetadata represents the minimal metadata for system prompt injection (Level 1)
|
||||
// This is the lightweight representation used during skill discovery
|
||||
type SkillMetadata struct {
|
||||
Name string
|
||||
Description string
|
||||
BasePath string // Path to skill directory for later loading
|
||||
}
|
||||
|
||||
// SkillFile represents an additional file within a skill directory (Level 3)
|
||||
type SkillFile struct {
|
||||
Name string // Filename (e.g., "FORMS.md", "scripts/validate.py")
|
||||
Path string // Absolute path to the file
|
||||
Content string // File content
|
||||
IsScript bool // Whether this is an executable script
|
||||
}
|
||||
|
||||
// Validate checks if the skill metadata is valid according to Claude's specification
|
||||
func (s *Skill) Validate() error {
|
||||
// Validate name
|
||||
if s.Name == "" {
|
||||
return errors.New("skill name is required")
|
||||
}
|
||||
if len(s.Name) > MaxNameLength {
|
||||
return fmt.Errorf("skill name exceeds maximum length of %d characters", MaxNameLength)
|
||||
}
|
||||
if !namePattern.MatchString(s.Name) {
|
||||
return errors.New("skill name must contain only lowercase letters, numbers, and hyphens")
|
||||
}
|
||||
for _, reserved := range reservedWords {
|
||||
if strings.Contains(s.Name, reserved) {
|
||||
return fmt.Errorf("skill name cannot contain reserved word: %s", reserved)
|
||||
}
|
||||
}
|
||||
if xmlTagPattern.MatchString(s.Name) {
|
||||
return errors.New("skill name cannot contain XML tags")
|
||||
}
|
||||
|
||||
// Validate description
|
||||
if s.Description == "" {
|
||||
return errors.New("skill description is required")
|
||||
}
|
||||
if len(s.Description) > MaxDescriptionLength {
|
||||
return fmt.Errorf("skill description exceeds maximum length of %d characters", MaxDescriptionLength)
|
||||
}
|
||||
if xmlTagPattern.MatchString(s.Description) {
|
||||
return errors.New("skill description cannot contain XML tags")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToMetadata converts a Skill to its lightweight metadata representation
|
||||
func (s *Skill) ToMetadata() *SkillMetadata {
|
||||
return &SkillMetadata{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
BasePath: s.BasePath,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSkillFile parses a SKILL.md file content and extracts metadata and body
|
||||
// It handles YAML frontmatter enclosed in --- delimiters
|
||||
func ParseSkillFile(content string) (*Skill, error) {
|
||||
skill := &Skill{}
|
||||
|
||||
// Check for YAML frontmatter
|
||||
if !strings.HasPrefix(strings.TrimSpace(content), "---") {
|
||||
return nil, errors.New("SKILL.md must start with YAML frontmatter (---)")
|
||||
}
|
||||
|
||||
// Find the end of frontmatter
|
||||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||
var frontmatterLines []string
|
||||
var bodyLines []string
|
||||
inFrontmatter := false
|
||||
frontmatterEnded := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !inFrontmatter && !frontmatterEnded && strings.TrimSpace(line) == "---" {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inFrontmatter && strings.TrimSpace(line) == "---" {
|
||||
inFrontmatter = false
|
||||
frontmatterEnded = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inFrontmatter {
|
||||
frontmatterLines = append(frontmatterLines, line)
|
||||
} else if frontmatterEnded {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading SKILL.md: %w", err)
|
||||
}
|
||||
|
||||
if !frontmatterEnded {
|
||||
return nil, errors.New("SKILL.md frontmatter is not properly closed with ---")
|
||||
}
|
||||
|
||||
// Parse YAML frontmatter
|
||||
frontmatter := strings.Join(frontmatterLines, "\n")
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML frontmatter: %w", err)
|
||||
}
|
||||
|
||||
// Set body instructions
|
||||
skill.Instructions = strings.TrimSpace(strings.Join(bodyLines, "\n"))
|
||||
skill.Loaded = true
|
||||
|
||||
// Validate
|
||||
if err := skill.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("skill validation failed: %w", err)
|
||||
}
|
||||
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// ParseSkillMetadata parses only the metadata from a SKILL.md file content
|
||||
// This is a lightweight operation for skill discovery (Level 1 only)
|
||||
func ParseSkillMetadata(content string) (*SkillMetadata, error) {
|
||||
skill, err := ParseSkillFile(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skill.ToMetadata(), nil
|
||||
}
|
||||
|
||||
// IsScript checks if a file path represents an executable script
|
||||
func IsScript(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
scriptExtensions := map[string]bool{
|
||||
".py": true,
|
||||
".sh": true,
|
||||
".bash": true,
|
||||
".js": true,
|
||||
".ts": true,
|
||||
".rb": true,
|
||||
".pl": true,
|
||||
".php": true,
|
||||
}
|
||||
return scriptExtensions[ext]
|
||||
}
|
||||
|
||||
// GetScriptLanguage returns the language/interpreter for a script file
|
||||
func GetScriptLanguage(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
languages := map[string]string{
|
||||
".py": "python",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".js": "node",
|
||||
".ts": "ts-node",
|
||||
".rb": "ruby",
|
||||
".pl": "perl",
|
||||
".php": "php",
|
||||
}
|
||||
if lang, ok := languages[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSkillFile(t *testing.T) {
|
||||
content := `---
|
||||
name: test-skill
|
||||
description: A test skill for unit testing purposes.
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
This is the content of the test skill.
|
||||
|
||||
## Usage
|
||||
|
||||
Use this skill when testing.
|
||||
`
|
||||
|
||||
skill, err := ParseSkillFile(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse skill file: %v", err)
|
||||
}
|
||||
|
||||
if skill.Name != "test-skill" {
|
||||
t.Errorf("Expected name 'test-skill', got '%s'", skill.Name)
|
||||
}
|
||||
|
||||
if skill.Description != "A test skill for unit testing purposes." {
|
||||
t.Errorf("Expected description 'A test skill for unit testing purposes.', got '%s'", skill.Description)
|
||||
}
|
||||
|
||||
if skill.Instructions == "" {
|
||||
t.Error("Expected instructions to be non-empty")
|
||||
}
|
||||
|
||||
if !skill.Loaded {
|
||||
t.Error("Expected Loaded to be true after parsing")
|
||||
}
|
||||
|
||||
t.Logf("Parsed skill: name=%s, description=%s, instructions_len=%d",
|
||||
skill.Name, skill.Description, len(skill.Instructions))
|
||||
}
|
||||
|
||||
func TestSkillValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
skillName string
|
||||
description string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid skill",
|
||||
skillName: "my-skill",
|
||||
description: "A valid skill",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
skillName: "",
|
||||
description: "A skill",
|
||||
wantErr: true,
|
||||
errContains: "name is required",
|
||||
},
|
||||
{
|
||||
name: "invalid characters in name",
|
||||
skillName: "My Skill",
|
||||
description: "A skill",
|
||||
wantErr: true,
|
||||
errContains: "lowercase letters",
|
||||
},
|
||||
{
|
||||
name: "reserved word in name",
|
||||
skillName: "my-claude-skill",
|
||||
description: "A skill",
|
||||
wantErr: true,
|
||||
errContains: "reserved word",
|
||||
},
|
||||
{
|
||||
name: "empty description",
|
||||
skillName: "my-skill",
|
||||
description: "",
|
||||
wantErr: true,
|
||||
errContains: "description is required",
|
||||
},
|
||||
{
|
||||
name: "name too long",
|
||||
skillName: "this-is-a-very-long-skill-name-that-exceeds-the-maximum-allowed-length-of-64-characters",
|
||||
description: "A skill",
|
||||
wantErr: true,
|
||||
errContains: "exceeds maximum length",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
skill := &Skill{
|
||||
Name: tt.skillName,
|
||||
Description: tt.description,
|
||||
}
|
||||
|
||||
err := skill.Validate()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tt.errContains)
|
||||
} else if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||
t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestLoaderDiscoverSkills(t *testing.T) {
|
||||
// Create a temporary skills directory
|
||||
tmpDir, err := os.MkdirTemp("", "skills-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test skill directory
|
||||
skillDir := filepath.Join(tmpDir, "test-skill")
|
||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create skill dir: %v", err)
|
||||
}
|
||||
|
||||
// Write SKILL.md
|
||||
skillContent := `---
|
||||
name: test-skill
|
||||
description: A test skill for loader testing.
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
This is the test skill content.
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write SKILL.md: %v", err)
|
||||
}
|
||||
|
||||
// Create loader and discover skills
|
||||
loader := NewLoader([]string{tmpDir})
|
||||
metadata, err := loader.DiscoverSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to discover skills: %v", err)
|
||||
}
|
||||
|
||||
if len(metadata) != 1 {
|
||||
t.Fatalf("Expected 1 skill, got %d", len(metadata))
|
||||
}
|
||||
|
||||
if metadata[0].Name != "test-skill" {
|
||||
t.Errorf("Expected skill name 'test-skill', got '%s'", metadata[0].Name)
|
||||
}
|
||||
|
||||
t.Logf("Discovered %d skills: %v", len(metadata), metadata[0].Name)
|
||||
}
|
||||
|
||||
func TestLoaderLoadSkillInstructions(t *testing.T) {
|
||||
// Create a temporary skills directory
|
||||
tmpDir, err := os.MkdirTemp("", "skills-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test skill directory
|
||||
skillDir := filepath.Join(tmpDir, "test-skill")
|
||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create skill dir: %v", err)
|
||||
}
|
||||
|
||||
// Write SKILL.md
|
||||
skillContent := `---
|
||||
name: test-skill
|
||||
description: A test skill for content loading.
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
This is the main content.
|
||||
|
||||
## Section 1
|
||||
|
||||
More content here.
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write SKILL.md: %v", err)
|
||||
}
|
||||
|
||||
// Create loader and load skill instructions
|
||||
loader := NewLoader([]string{tmpDir})
|
||||
skill, err := loader.LoadSkillInstructions("test-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load skill instructions: %v", err)
|
||||
}
|
||||
|
||||
if skill.Name != "test-skill" {
|
||||
t.Errorf("Expected skill name 'test-skill', got '%s'", skill.Name)
|
||||
}
|
||||
|
||||
if skill.Instructions == "" {
|
||||
t.Error("Expected instructions to be non-empty")
|
||||
}
|
||||
|
||||
if !skill.Loaded {
|
||||
t.Error("Expected Loaded to be true")
|
||||
}
|
||||
|
||||
t.Logf("Loaded skill: name=%s, instructions_len=%d", skill.Name, len(skill.Instructions))
|
||||
}
|
||||
|
||||
func TestLoaderLoadSkillFile(t *testing.T) {
|
||||
// Create a temporary skills directory
|
||||
tmpDir, err := os.MkdirTemp("", "skills-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test skill directory with additional files
|
||||
skillDir := filepath.Join(tmpDir, "test-skill")
|
||||
scriptsDir := filepath.Join(skillDir, "scripts")
|
||||
if err := os.MkdirAll(scriptsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create skill dir: %v", err)
|
||||
}
|
||||
|
||||
// Write SKILL.md
|
||||
skillContent := `---
|
||||
name: test-skill
|
||||
description: A test skill with additional files.
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
See [GUIDE.md](GUIDE.md) for more info.
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write SKILL.md: %v", err)
|
||||
}
|
||||
|
||||
// Write additional file
|
||||
guideContent := "# Guide\n\nThis is the guide content."
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "GUIDE.md"), []byte(guideContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write GUIDE.md: %v", err)
|
||||
}
|
||||
|
||||
// Write a script
|
||||
scriptContent := "#!/usr/bin/env python3\nprint('Hello from script')"
|
||||
if err := os.WriteFile(filepath.Join(scriptsDir, "hello.py"), []byte(scriptContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write script: %v", err)
|
||||
}
|
||||
|
||||
// Create loader and discover skills first
|
||||
loader := NewLoader([]string{tmpDir})
|
||||
_, err = loader.DiscoverSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to discover skills: %v", err)
|
||||
}
|
||||
|
||||
// Load additional file
|
||||
file, err := loader.LoadSkillFile("test-skill", "GUIDE.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load skill file: %v", err)
|
||||
}
|
||||
|
||||
if file.Content != guideContent {
|
||||
t.Errorf("Expected guide content, got '%s'", file.Content)
|
||||
}
|
||||
|
||||
if file.IsScript {
|
||||
t.Error("GUIDE.md should not be marked as script")
|
||||
}
|
||||
|
||||
// Load script file
|
||||
scriptFile, err := loader.LoadSkillFile("test-skill", "scripts/hello.py")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load script file: %v", err)
|
||||
}
|
||||
|
||||
if !scriptFile.IsScript {
|
||||
t.Error("hello.py should be marked as script")
|
||||
}
|
||||
|
||||
t.Logf("Loaded files: GUIDE.md=%d bytes, hello.py=%d bytes (isScript=%v)",
|
||||
len(file.Content), len(scriptFile.Content), scriptFile.IsScript)
|
||||
}
|
||||
|
||||
func TestManagerIntegration(t *testing.T) {
|
||||
// Create a temporary skills directory
|
||||
tmpDir, err := os.MkdirTemp("", "skills-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create a test skill directory
|
||||
skillDir := filepath.Join(tmpDir, "test-skill")
|
||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create skill dir: %v", err)
|
||||
}
|
||||
|
||||
// Write SKILL.md
|
||||
skillContent := `---
|
||||
name: test-skill
|
||||
description: A test skill for manager integration testing.
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
Integration test content.
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write SKILL.md: %v", err)
|
||||
}
|
||||
|
||||
// Create manager with config
|
||||
config := &ManagerConfig{
|
||||
SkillDirs: []string{tmpDir},
|
||||
AllowedSkills: []string{}, // Allow all
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
manager := NewManager(config, nil) // No sandbox for this test
|
||||
|
||||
// Initialize
|
||||
ctx := context.Background()
|
||||
if err := manager.Initialize(ctx); err != nil {
|
||||
t.Fatalf("Failed to initialize manager: %v", err)
|
||||
}
|
||||
|
||||
// Get all metadata
|
||||
metadata := manager.GetAllMetadata()
|
||||
if len(metadata) != 1 {
|
||||
t.Fatalf("Expected 1 skill, got %d", len(metadata))
|
||||
}
|
||||
|
||||
// Load skill
|
||||
skill, err := manager.LoadSkill(ctx, "test-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load skill: %v", err)
|
||||
}
|
||||
|
||||
if skill.Name != "test-skill" {
|
||||
t.Errorf("Expected skill name 'test-skill', got '%s'", skill.Name)
|
||||
}
|
||||
|
||||
t.Logf("Manager integration test passed: %d skills discovered", len(metadata))
|
||||
}
|
||||
|
||||
func TestIsScript(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"script.py", true},
|
||||
{"script.sh", true},
|
||||
{"script.bash", true},
|
||||
{"script.js", true},
|
||||
{"script.ts", true},
|
||||
{"script.rb", true},
|
||||
{"README.md", false},
|
||||
{"data.json", false},
|
||||
{"config.yaml", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := IsScript(tt.path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsScript(%s) = %v, expected %v", tt.path, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ const (
|
||||
ToolDataSchema = "data_schema"
|
||||
ToolWebSearch = "web_search"
|
||||
ToolWebFetch = "web_fetch"
|
||||
// Skills-related tools (only available when skills are enabled)
|
||||
ToolExecuteSkillScript = "execute_skill_script"
|
||||
ToolReadSkill = "read_skill"
|
||||
)
|
||||
|
||||
// AvailableTool defines a simple tool metadata used by settings APIs.
|
||||
@@ -37,6 +40,8 @@ func AvailableToolDefinitions() []AvailableTool {
|
||||
{Name: ToolDatabaseQuery, Label: "查询数据库", Description: "查询数据库中的信息"},
|
||||
{Name: ToolDataAnalysis, Label: "数据分析", Description: "理解数据文件并进行数据分析"},
|
||||
{Name: ToolDataSchema, Label: "查看数据元信息", Description: "获取表格文件的元信息"},
|
||||
{Name: ToolReadSkill, Label: "读取技能", Description: "按需读取技能内容以学习专业能力"},
|
||||
{Name: ToolExecuteSkillScript, Label: "执行技能脚本", Description: "在沙箱环境中执行技能脚本"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// Tool name constant for execute_skill_script
|
||||
|
||||
var executeSkillScriptTool = BaseTool{
|
||||
name: ToolExecuteSkillScript,
|
||||
description: `Execute a script from a skill in a sandboxed environment.
|
||||
|
||||
## Usage
|
||||
- Use this tool to run utility scripts bundled with a skill
|
||||
- Scripts are executed in an isolated sandbox for security
|
||||
- Only scripts from loaded skills can be executed
|
||||
|
||||
## When to Use
|
||||
- When a skill's instructions reference a utility script (e.g., "Run scripts/analyze_form.py")
|
||||
- When automation or data processing is needed as part of skill workflow
|
||||
- For deterministic operations where script execution is more reliable than generating code
|
||||
|
||||
## Security
|
||||
- Scripts run in a sandboxed environment with limited permissions
|
||||
- Network access is disabled by default
|
||||
- File access is restricted to the skill directory
|
||||
|
||||
## Returns
|
||||
- Script stdout and stderr output
|
||||
- Exit code indicating success (0) or failure (non-zero)`,
|
||||
schema: utils.GenerateSchema[ExecuteSkillScriptInput](),
|
||||
}
|
||||
|
||||
// ExecuteSkillScriptInput defines the input parameters for the execute_skill_script tool
|
||||
type ExecuteSkillScriptInput struct {
|
||||
SkillName string `json:"skill_name" jsonschema:"Name of the skill containing the script"`
|
||||
ScriptPath string `json:"script_path" jsonschema:"Relative path to the script within the skill directory (e.g. scripts/analyze.py)"`
|
||||
Args []string `json:"args,omitempty" jsonschema:"Optional command-line arguments to pass to the script"`
|
||||
}
|
||||
|
||||
// ExecuteSkillScriptTool allows the agent to execute skill scripts in a sandbox
|
||||
type ExecuteSkillScriptTool struct {
|
||||
BaseTool
|
||||
skillManager *skills.Manager
|
||||
}
|
||||
|
||||
// NewExecuteSkillScriptTool creates a new execute_skill_script tool instance
|
||||
func NewExecuteSkillScriptTool(skillManager *skills.Manager) *ExecuteSkillScriptTool {
|
||||
return &ExecuteSkillScriptTool{
|
||||
BaseTool: executeSkillScriptTool,
|
||||
skillManager: skillManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the execute_skill_script tool
|
||||
func (t *ExecuteSkillScriptTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
|
||||
logger.Infof(ctx, "[Tool][ExecuteSkillScript] Execute started")
|
||||
|
||||
// Parse input
|
||||
var input ExecuteSkillScriptInput
|
||||
if err := json.Unmarshal(args, &input); err != nil {
|
||||
logger.Errorf(ctx, "[Tool][ExecuteSkillScript] Failed to parse args: %v", err)
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to parse args: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if input.SkillName == "" {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "skill_name is required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if input.ScriptPath == "" {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "script_path is required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if skill manager is available
|
||||
if t.skillManager == nil || !t.skillManager.IsEnabled() {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "Skills are not enabled",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute the script in sandbox
|
||||
logger.Infof(ctx, "[Tool][ExecuteSkillScript] Executing script: %s/%s with args: %v",
|
||||
input.SkillName, input.ScriptPath, input.Args)
|
||||
|
||||
result, err := t.skillManager.ExecuteScript(ctx, input.SkillName, input.ScriptPath, input.Args)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "[Tool][ExecuteSkillScript] Script execution failed: %v", err)
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Script execution failed: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Build output
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("=== Script Execution: %s/%s ===\n\n", input.SkillName, input.ScriptPath))
|
||||
|
||||
if len(input.Args) > 0 {
|
||||
builder.WriteString(fmt.Sprintf("**Arguments**: %v\n", input.Args))
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("**Exit Code**: %d\n", result.ExitCode))
|
||||
builder.WriteString(fmt.Sprintf("**Duration**: %v\n\n", result.Duration))
|
||||
|
||||
if result.Killed {
|
||||
builder.WriteString("**Warning**: Script was terminated (timeout or killed)\n\n")
|
||||
}
|
||||
|
||||
if result.Stdout != "" {
|
||||
builder.WriteString("## Standard Output\n\n")
|
||||
builder.WriteString("```\n")
|
||||
builder.WriteString(result.Stdout)
|
||||
if !strings.HasSuffix(result.Stdout, "\n") {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("```\n\n")
|
||||
}
|
||||
|
||||
if result.Stderr != "" {
|
||||
builder.WriteString("## Standard Error\n\n")
|
||||
builder.WriteString("```\n")
|
||||
builder.WriteString(result.Stderr)
|
||||
if !strings.HasSuffix(result.Stderr, "\n") {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("```\n\n")
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
builder.WriteString("## Error\n\n")
|
||||
builder.WriteString(result.Error)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
// Determine success based on exit code
|
||||
success := result.IsSuccess()
|
||||
|
||||
resultData := map[string]interface{}{
|
||||
"skill_name": input.SkillName,
|
||||
"script_path": input.ScriptPath,
|
||||
"args": input.Args,
|
||||
"exit_code": result.ExitCode,
|
||||
"stdout": result.Stdout,
|
||||
"stderr": result.Stderr,
|
||||
"duration_ms": result.Duration.Milliseconds(),
|
||||
"killed": result.Killed,
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[Tool][ExecuteSkillScript] Script completed with exit code: %d", result.ExitCode)
|
||||
|
||||
return &types.ToolResult{
|
||||
Success: success,
|
||||
Output: builder.String(),
|
||||
Data: resultData,
|
||||
Error: func() string {
|
||||
if !success {
|
||||
if result.Error != "" {
|
||||
return result.Error
|
||||
}
|
||||
return fmt.Sprintf("Script exited with code %d", result.ExitCode)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cleanup releases any resources
|
||||
func (t *ExecuteSkillScriptTool) Cleanup(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// Tool name constant for read_skill
|
||||
|
||||
var readSkillTool = BaseTool{
|
||||
name: ToolReadSkill,
|
||||
description: `Read skill content on demand to learn specialized capabilities.
|
||||
|
||||
## Usage
|
||||
- Use this tool when a user request matches an available skill's description
|
||||
- Provide the skill_name to load the skill's full instructions (SKILL.md content)
|
||||
- Optionally provide file_path to read additional files within the skill directory
|
||||
|
||||
## When to Use
|
||||
- When the system prompt shows an available skill that matches the user's request
|
||||
- Before performing tasks that match a skill's description
|
||||
- To read additional documentation or reference files within a skill
|
||||
|
||||
## Returns
|
||||
- Skill instructions and guidance for completing the task
|
||||
- File content if file_path is specified`,
|
||||
schema: utils.GenerateSchema[ReadSkillInput](),
|
||||
}
|
||||
|
||||
// ReadSkillInput defines the input parameters for the read_skill tool
|
||||
type ReadSkillInput struct {
|
||||
SkillName string `json:"skill_name" jsonschema:"Name of the skill to read"`
|
||||
FilePath string `json:"file_path,omitempty" jsonschema:"Optional relative path to a specific file within the skill directory"`
|
||||
}
|
||||
|
||||
// ReadSkillTool allows the agent to read skill content on demand
|
||||
type ReadSkillTool struct {
|
||||
BaseTool
|
||||
skillManager *skills.Manager
|
||||
}
|
||||
|
||||
// NewReadSkillTool creates a new read_skill tool instance
|
||||
func NewReadSkillTool(skillManager *skills.Manager) *ReadSkillTool {
|
||||
return &ReadSkillTool{
|
||||
BaseTool: readSkillTool,
|
||||
skillManager: skillManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the read_skill tool
|
||||
func (t *ReadSkillTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
|
||||
logger.Infof(ctx, "[Tool][ReadSkill] Execute started")
|
||||
|
||||
// Parse input
|
||||
var input ReadSkillInput
|
||||
if err := json.Unmarshal(args, &input); err != nil {
|
||||
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to parse args: %v", err)
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to parse args: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate skill name
|
||||
if input.SkillName == "" {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "skill_name is required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if skill manager is available
|
||||
if t.skillManager == nil || !t.skillManager.IsEnabled() {
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "Skills are not enabled",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
var resultData = make(map[string]interface{})
|
||||
|
||||
if input.FilePath != "" {
|
||||
// Read a specific file from the skill directory
|
||||
content, err := t.skillManager.ReadSkillFile(ctx, input.SkillName, input.FilePath)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to read skill file: %v", err)
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to read skill file: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("=== Skill File: %s/%s ===\n\n", input.SkillName, input.FilePath))
|
||||
builder.WriteString(content)
|
||||
|
||||
resultData["skill_name"] = input.SkillName
|
||||
resultData["file_path"] = input.FilePath
|
||||
resultData["content"] = content
|
||||
resultData["content_length"] = len(content)
|
||||
|
||||
} else {
|
||||
// Read the main skill instructions (SKILL.md)
|
||||
skill, err := t.skillManager.LoadSkill(ctx, input.SkillName)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to load skill: %v", err)
|
||||
return &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to load skill: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List available files in the skill directory
|
||||
files, err := t.skillManager.ListSkillFiles(ctx, input.SkillName)
|
||||
if err != nil {
|
||||
files = []string{} // Non-fatal error
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("=== Skill: %s ===\n\n", skill.Name))
|
||||
builder.WriteString(fmt.Sprintf("**Description**: %s\n\n", skill.Description))
|
||||
builder.WriteString("## Instructions\n\n")
|
||||
builder.WriteString(skill.Instructions)
|
||||
|
||||
// Add available files section
|
||||
if len(files) > 1 { // More than just SKILL.md
|
||||
builder.WriteString("\n\n## Available Files\n\n")
|
||||
builder.WriteString("The following files are available in this skill directory. Use `read_skill` with `file_path` to read them:\n\n")
|
||||
for _, file := range files {
|
||||
if file != skills.SkillFileName { // Don't list SKILL.md again
|
||||
if skills.IsScript(file) {
|
||||
builder.WriteString(fmt.Sprintf("- `%s` (script - can be executed)\n", file))
|
||||
} else {
|
||||
builder.WriteString(fmt.Sprintf("- `%s`\n", file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultData["skill_name"] = skill.Name
|
||||
resultData["description"] = skill.Description
|
||||
resultData["instructions"] = skill.Instructions
|
||||
resultData["instructions_length"] = len(skill.Instructions)
|
||||
resultData["files"] = files
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[Tool][ReadSkill] Successfully read skill: %s", input.SkillName)
|
||||
|
||||
return &types.ToolResult{
|
||||
Success: true,
|
||||
Output: builder.String(),
|
||||
Data: resultData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cleanup releases any resources (implements Tool interface if needed)
|
||||
func (t *ReadSkillTool) Cleanup(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent"
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/mcp"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/models/rerank"
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
@@ -197,9 +199,80 @@ func (s *agentService) CreateAgentEngine(
|
||||
systemPromptTemplate,
|
||||
)
|
||||
|
||||
// Initialize skills manager if skills are enabled
|
||||
if config.SkillsEnabled && len(config.SkillDirs) > 0 {
|
||||
skillsManager, err := s.initializeSkillsManager(ctx, config, toolRegistry)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "Failed to initialize skills manager: %v", err)
|
||||
} else if skillsManager != nil {
|
||||
engine.SetSkillsManager(skillsManager)
|
||||
logger.Infof(ctx, "Skills manager initialized with %d skills", len(skillsManager.GetAllMetadata()))
|
||||
}
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// initializeSkillsManager creates and initializes the skills manager
|
||||
func (s *agentService) initializeSkillsManager(
|
||||
ctx context.Context,
|
||||
config *types.AgentConfig,
|
||||
toolRegistry *tools.ToolRegistry,
|
||||
) (*skills.Manager, error) {
|
||||
// Initialize sandbox manager based on configuration
|
||||
var sandboxMgr sandbox.Manager
|
||||
var err error
|
||||
|
||||
sandboxMode := config.SandboxMode
|
||||
if sandboxMode == "" {
|
||||
sandboxMode = "disabled"
|
||||
}
|
||||
|
||||
switch sandboxMode {
|
||||
case "docker":
|
||||
sandboxMgr, err = sandbox.NewManagerFromType("docker", true) // Enable fallback to local
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "Failed to initialize Docker sandbox, falling back to disabled: %v", err)
|
||||
sandboxMgr = sandbox.NewDisabledManager()
|
||||
}
|
||||
case "local":
|
||||
sandboxMgr, err = sandbox.NewManagerFromType("local", false)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "Failed to initialize local sandbox: %v", err)
|
||||
sandboxMgr = sandbox.NewDisabledManager()
|
||||
}
|
||||
default:
|
||||
sandboxMgr = sandbox.NewDisabledManager()
|
||||
}
|
||||
|
||||
// Create skills manager
|
||||
skillsConfig := &skills.ManagerConfig{
|
||||
SkillDirs: config.SkillDirs,
|
||||
AllowedSkills: config.AllowedSkills,
|
||||
Enabled: config.SkillsEnabled,
|
||||
}
|
||||
|
||||
skillsManager := skills.NewManager(skillsConfig, sandboxMgr)
|
||||
|
||||
// Initialize (discover skills)
|
||||
if err := skillsManager.Initialize(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize skills: %w", err)
|
||||
}
|
||||
|
||||
// Register skills tools
|
||||
readSkillTool := tools.NewReadSkillTool(skillsManager)
|
||||
toolRegistry.RegisterTool(readSkillTool)
|
||||
logger.Infof(ctx, "Registered read_skill tool")
|
||||
|
||||
if sandboxMode != "disabled" {
|
||||
executeSkillTool := tools.NewExecuteSkillScriptTool(skillsManager)
|
||||
toolRegistry.RegisterTool(executeSkillTool)
|
||||
logger.Infof(ctx, "Registered execute_skill_script tool")
|
||||
}
|
||||
|
||||
return skillsManager, nil
|
||||
}
|
||||
|
||||
// registerTools registers tools based on the agent configuration
|
||||
func (s *agentService) registerTools(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DockerSandbox implements the Sandbox interface using Docker containers
|
||||
type DockerSandbox struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewDockerSandbox creates a new Docker-based sandbox
|
||||
func NewDockerSandbox(config *Config) *DockerSandbox {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
if config.DockerImage == "" {
|
||||
config.DockerImage = "python:3.11-slim"
|
||||
}
|
||||
return &DockerSandbox{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns the sandbox type
|
||||
func (s *DockerSandbox) Type() SandboxType {
|
||||
return SandboxTypeDocker
|
||||
}
|
||||
|
||||
// IsAvailable checks if Docker is available
|
||||
func (s *DockerSandbox) IsAvailable(ctx context.Context) bool {
|
||||
cmd := exec.CommandContext(ctx, "docker", "version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Execute runs a script in a Docker container
|
||||
func (s *DockerSandbox) Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error) {
|
||||
if config == nil {
|
||||
return nil, ErrInvalidScript
|
||||
}
|
||||
|
||||
// Set default timeout
|
||||
timeout := config.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = s.config.DefaultTimeout
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Build docker run command
|
||||
args := s.buildDockerArgs(config)
|
||||
|
||||
startTime := time.Now()
|
||||
cmd := exec.CommandContext(execCtx, "docker", args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if config.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(config.Stdin)
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
result := &ExecuteResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Duration: duration,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if execCtx.Err() == context.DeadlineExceeded {
|
||||
result.Killed = true
|
||||
result.Error = ErrTimeout.Error()
|
||||
result.ExitCode = -1
|
||||
} else if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.Error = err.Error()
|
||||
result.ExitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildDockerArgs constructs the docker run command arguments
|
||||
func (s *DockerSandbox) buildDockerArgs(config *ExecuteConfig) []string {
|
||||
args := []string{"run", "--rm"}
|
||||
|
||||
// Security: run as non-root user
|
||||
args = append(args, "--user", "1000:1000")
|
||||
|
||||
// Security: drop all capabilities
|
||||
args = append(args, "--cap-drop", "ALL")
|
||||
|
||||
// Security: read-only root filesystem (optional)
|
||||
if config.ReadOnlyRootfs {
|
||||
args = append(args, "--read-only")
|
||||
// Add writable tmp directory
|
||||
args = append(args, "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m")
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
memLimit := config.MemoryLimit
|
||||
if memLimit == 0 {
|
||||
memLimit = s.config.MaxMemory
|
||||
}
|
||||
if memLimit > 0 {
|
||||
args = append(args, "--memory", fmt.Sprintf("%d", memLimit))
|
||||
args = append(args, "--memory-swap", fmt.Sprintf("%d", memLimit)) // Disable swap
|
||||
}
|
||||
|
||||
cpuLimit := config.CPULimit
|
||||
if cpuLimit == 0 {
|
||||
cpuLimit = s.config.MaxCPU
|
||||
}
|
||||
if cpuLimit > 0 {
|
||||
args = append(args, "--cpus", fmt.Sprintf("%.2f", cpuLimit))
|
||||
}
|
||||
|
||||
// Network isolation
|
||||
if !config.AllowNetwork {
|
||||
args = append(args, "--network", "none")
|
||||
}
|
||||
|
||||
// Security: disable privileged mode and limit PIDs
|
||||
args = append(args, "--pids-limit", "100")
|
||||
args = append(args, "--security-opt", "no-new-privileges")
|
||||
|
||||
// Mount the script and working directory as read-only
|
||||
scriptDir := filepath.Dir(config.Script)
|
||||
args = append(args, "-v", fmt.Sprintf("%s:/workspace:ro", scriptDir))
|
||||
|
||||
// Working directory
|
||||
args = append(args, "-w", "/workspace")
|
||||
|
||||
// Environment variables
|
||||
for key, value := range config.Env {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
// Image
|
||||
args = append(args, s.config.DockerImage)
|
||||
|
||||
// Script execution command
|
||||
scriptName := filepath.Base(config.Script)
|
||||
interpreter := getInterpreter(scriptName)
|
||||
|
||||
args = append(args, interpreter, scriptName)
|
||||
args = append(args, config.Args...)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// getInterpreter returns the appropriate interpreter for a script
|
||||
func getInterpreter(scriptName string) string {
|
||||
ext := strings.ToLower(filepath.Ext(scriptName))
|
||||
switch ext {
|
||||
case ".py":
|
||||
return "python3"
|
||||
case ".sh", ".bash":
|
||||
return "bash"
|
||||
case ".js":
|
||||
return "node"
|
||||
case ".rb":
|
||||
return "ruby"
|
||||
case ".pl":
|
||||
return "perl"
|
||||
default:
|
||||
return "sh"
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes any lingering resources
|
||||
func (s *DockerSandbox) Cleanup(ctx context.Context) error {
|
||||
// Docker --rm flag should handle container cleanup
|
||||
// This is here for any additional cleanup if needed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalSandbox implements the Sandbox interface using local process isolation
|
||||
// This is a fallback option when Docker is not available
|
||||
// It provides basic isolation through:
|
||||
// - Command whitelist validation
|
||||
// - Working directory restriction
|
||||
// - Timeout enforcement
|
||||
// - Environment variable filtering
|
||||
type LocalSandbox struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewLocalSandbox creates a new local process-based sandbox
|
||||
func NewLocalSandbox(config *Config) *LocalSandbox {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
return &LocalSandbox{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns the sandbox type
|
||||
func (s *LocalSandbox) Type() SandboxType {
|
||||
return SandboxTypeLocal
|
||||
}
|
||||
|
||||
// IsAvailable checks if local sandbox is available
|
||||
func (s *LocalSandbox) IsAvailable(ctx context.Context) bool {
|
||||
// Local sandbox is always available
|
||||
return true
|
||||
}
|
||||
|
||||
// Execute runs a script locally with basic isolation
|
||||
func (s *LocalSandbox) Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error) {
|
||||
if config == nil {
|
||||
return nil, ErrInvalidScript
|
||||
}
|
||||
|
||||
// Validate the script path
|
||||
if err := s.validateScript(config.Script); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine interpreter
|
||||
interpreter := s.getInterpreter(config.Script)
|
||||
if !s.isAllowedCommand(interpreter) {
|
||||
return nil, fmt.Errorf("interpreter not allowed: %s", interpreter)
|
||||
}
|
||||
|
||||
// Set default timeout
|
||||
timeout := config.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = s.config.DefaultTimeout
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Build command
|
||||
args := append([]string{config.Script}, config.Args...)
|
||||
cmd := exec.CommandContext(execCtx, interpreter, args...)
|
||||
|
||||
// Set working directory
|
||||
if config.WorkDir != "" {
|
||||
cmd.Dir = config.WorkDir
|
||||
} else {
|
||||
cmd.Dir = filepath.Dir(config.Script)
|
||||
}
|
||||
|
||||
// Setup minimal environment
|
||||
cmd.Env = s.buildEnvironment(config.Env)
|
||||
|
||||
// Setup process group for cleanup
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if config.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(config.Stdin)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := cmd.Run()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
result := &ExecuteResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
Duration: duration,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if execCtx.Err() == context.DeadlineExceeded {
|
||||
// Kill the process group
|
||||
if cmd.Process != nil {
|
||||
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
result.Killed = true
|
||||
result.Error = ErrTimeout.Error()
|
||||
result.ExitCode = -1
|
||||
} else if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.Error = err.Error()
|
||||
result.ExitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateScript checks if the script path is valid and safe
|
||||
func (s *LocalSandbox) validateScript(scriptPath string) error {
|
||||
// Check if script exists
|
||||
info, err := os.Stat(scriptPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrScriptNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to access script: %w", err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return ErrInvalidScript
|
||||
}
|
||||
|
||||
// Check path is absolute
|
||||
if !filepath.IsAbs(scriptPath) {
|
||||
return fmt.Errorf("script path must be absolute: %s", scriptPath)
|
||||
}
|
||||
|
||||
// Validate against allowed paths if configured
|
||||
if len(s.config.AllowedPaths) > 0 {
|
||||
allowed := false
|
||||
absPath, _ := filepath.Abs(scriptPath)
|
||||
for _, allowedPath := range s.config.AllowedPaths {
|
||||
absAllowed, _ := filepath.Abs(allowedPath)
|
||||
if strings.HasPrefix(absPath, absAllowed) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("script path not in allowed paths: %s", scriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getInterpreter returns the appropriate interpreter for a script
|
||||
func (s *LocalSandbox) getInterpreter(scriptPath string) string {
|
||||
ext := strings.ToLower(filepath.Ext(scriptPath))
|
||||
switch ext {
|
||||
case ".py":
|
||||
return "python3"
|
||||
case ".sh", ".bash":
|
||||
return "bash"
|
||||
case ".js":
|
||||
return "node"
|
||||
case ".rb":
|
||||
return "ruby"
|
||||
case ".pl":
|
||||
return "perl"
|
||||
case ".php":
|
||||
return "php"
|
||||
default:
|
||||
return "sh"
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedCommand checks if a command is in the allowed list
|
||||
func (s *LocalSandbox) isAllowedCommand(cmd string) bool {
|
||||
if len(s.config.AllowedCommands) == 0 {
|
||||
// Use default allowed commands
|
||||
defaults := defaultAllowedCommands()
|
||||
for _, allowed := range defaults {
|
||||
if cmd == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, allowed := range s.config.AllowedCommands {
|
||||
if cmd == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildEnvironment creates a safe environment for script execution
|
||||
func (s *LocalSandbox) buildEnvironment(extra map[string]string) []string {
|
||||
// Start with minimal environment
|
||||
env := []string{
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin",
|
||||
"HOME=/tmp",
|
||||
"LANG=en_US.UTF-8",
|
||||
"LC_ALL=en_US.UTF-8",
|
||||
}
|
||||
|
||||
// Dangerous environment variables to exclude
|
||||
dangerous := map[string]bool{
|
||||
"LD_PRELOAD": true,
|
||||
"LD_LIBRARY_PATH": true,
|
||||
"PYTHONPATH": true,
|
||||
"NODE_OPTIONS": true,
|
||||
"BASH_ENV": true,
|
||||
"ENV": true,
|
||||
"SHELL": true,
|
||||
}
|
||||
|
||||
// Add extra environment variables (filtered)
|
||||
for key, value := range extra {
|
||||
upperKey := strings.ToUpper(key)
|
||||
if dangerous[upperKey] {
|
||||
continue
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// Cleanup releases any resources
|
||||
func (s *LocalSandbox) Cleanup(ctx context.Context) error {
|
||||
// Local sandbox doesn't need cleanup
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultManager implements the Manager interface
|
||||
// It handles sandbox selection and fallback logic
|
||||
type DefaultManager struct {
|
||||
config *Config
|
||||
sandbox Sandbox
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new sandbox manager with the given configuration
|
||||
func NewManager(config *Config) (Manager, error) {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
if err := ValidateConfig(config); err != nil {
|
||||
return nil, fmt.Errorf("invalid sandbox config: %w", err)
|
||||
}
|
||||
|
||||
manager := &DefaultManager{
|
||||
config: config,
|
||||
}
|
||||
|
||||
// Initialize the appropriate sandbox
|
||||
if err := manager.initializeSandbox(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// initializeSandbox creates and configures the sandbox based on configuration
|
||||
func (m *DefaultManager) initializeSandbox(ctx context.Context) error {
|
||||
switch m.config.Type {
|
||||
case SandboxTypeDisabled:
|
||||
m.sandbox = &disabledSandbox{}
|
||||
return nil
|
||||
|
||||
case SandboxTypeDocker:
|
||||
dockerSandbox := NewDockerSandbox(m.config)
|
||||
if dockerSandbox.IsAvailable(ctx) {
|
||||
m.sandbox = dockerSandbox
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback to local if enabled
|
||||
if m.config.FallbackEnabled {
|
||||
m.sandbox = NewLocalSandbox(m.config)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("docker is not available and fallback is disabled")
|
||||
|
||||
case SandboxTypeLocal:
|
||||
m.sandbox = NewLocalSandbox(m.config)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown sandbox type: %s", m.config.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a script using the configured sandbox
|
||||
func (m *DefaultManager) Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error) {
|
||||
m.mu.RLock()
|
||||
sandbox := m.sandbox
|
||||
m.mu.RUnlock()
|
||||
|
||||
if sandbox == nil {
|
||||
return nil, ErrSandboxDisabled
|
||||
}
|
||||
|
||||
return sandbox.Execute(ctx, config)
|
||||
}
|
||||
|
||||
// Cleanup releases all sandbox resources
|
||||
func (m *DefaultManager) Cleanup(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
sandbox := m.sandbox
|
||||
m.mu.RUnlock()
|
||||
|
||||
if sandbox != nil {
|
||||
return sandbox.Cleanup(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSandbox returns the active sandbox
|
||||
func (m *DefaultManager) GetSandbox() Sandbox {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sandbox
|
||||
}
|
||||
|
||||
// GetType returns the current sandbox type
|
||||
func (m *DefaultManager) GetType() SandboxType {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.sandbox != nil {
|
||||
return m.sandbox.Type()
|
||||
}
|
||||
return SandboxTypeDisabled
|
||||
}
|
||||
|
||||
// disabledSandbox is a no-op sandbox that rejects all execution requests
|
||||
type disabledSandbox struct{}
|
||||
|
||||
func (s *disabledSandbox) Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error) {
|
||||
return nil, ErrSandboxDisabled
|
||||
}
|
||||
|
||||
func (s *disabledSandbox) Cleanup(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *disabledSandbox) Type() SandboxType {
|
||||
return SandboxTypeDisabled
|
||||
}
|
||||
|
||||
func (s *disabledSandbox) IsAvailable(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewManagerFromType creates a sandbox manager with the specified type
|
||||
func NewManagerFromType(sandboxType string, fallbackEnabled bool) (Manager, error) {
|
||||
var sType SandboxType
|
||||
switch sandboxType {
|
||||
case "docker":
|
||||
sType = SandboxTypeDocker
|
||||
case "local":
|
||||
sType = SandboxTypeLocal
|
||||
case "disabled", "":
|
||||
sType = SandboxTypeDisabled
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown sandbox type: %s", sandboxType)
|
||||
}
|
||||
|
||||
config := DefaultConfig()
|
||||
config.Type = sType
|
||||
config.FallbackEnabled = fallbackEnabled
|
||||
|
||||
return NewManager(config)
|
||||
}
|
||||
|
||||
// NewDisabledManager creates a manager that rejects all execution requests
|
||||
func NewDisabledManager() Manager {
|
||||
return &DefaultManager{
|
||||
config: DefaultConfig(),
|
||||
sandbox: &disabledSandbox{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package sandbox provides isolated execution environments for running untrusted scripts.
|
||||
// It supports multiple backends including Docker containers and local process isolation.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SandboxType represents the type of sandbox environment
|
||||
type SandboxType string
|
||||
|
||||
const (
|
||||
// SandboxTypeDocker uses Docker containers for isolation
|
||||
SandboxTypeDocker SandboxType = "docker"
|
||||
// SandboxTypeLocal uses local process with restrictions
|
||||
SandboxTypeLocal SandboxType = "local"
|
||||
// SandboxTypeDisabled means script execution is disabled
|
||||
SandboxTypeDisabled SandboxType = "disabled"
|
||||
)
|
||||
|
||||
// Default configuration values
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DefaultMemoryLimit = 256 * 1024 * 1024 // 256MB
|
||||
DefaultCPULimit = 1.0 // 1 CPU core
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrSandboxDisabled = errors.New("sandbox is disabled")
|
||||
ErrTimeout = errors.New("execution timed out")
|
||||
ErrScriptNotFound = errors.New("script not found")
|
||||
ErrInvalidScript = errors.New("invalid script")
|
||||
ErrExecutionFailed = errors.New("script execution failed")
|
||||
)
|
||||
|
||||
// Sandbox defines the interface for isolated script execution
|
||||
type Sandbox interface {
|
||||
// Execute runs a script in an isolated environment
|
||||
Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error)
|
||||
|
||||
// Cleanup releases sandbox resources
|
||||
Cleanup(ctx context.Context) error
|
||||
|
||||
// Type returns the sandbox type
|
||||
Type() SandboxType
|
||||
|
||||
// IsAvailable checks if the sandbox is available for use
|
||||
IsAvailable(ctx context.Context) bool
|
||||
}
|
||||
|
||||
// Manager provides a unified interface for sandbox operations
|
||||
// It handles sandbox selection and fallback logic
|
||||
type Manager interface {
|
||||
// Execute runs a script using the configured sandbox
|
||||
Execute(ctx context.Context, config *ExecuteConfig) (*ExecuteResult, error)
|
||||
|
||||
// Cleanup releases all sandbox resources
|
||||
Cleanup(ctx context.Context) error
|
||||
|
||||
// GetSandbox returns the active sandbox
|
||||
GetSandbox() Sandbox
|
||||
|
||||
// GetType returns the current sandbox type
|
||||
GetType() SandboxType
|
||||
}
|
||||
|
||||
// ExecuteConfig contains configuration for script execution
|
||||
type ExecuteConfig struct {
|
||||
// Script is the absolute path to the script file
|
||||
Script string
|
||||
|
||||
// Args are command-line arguments to pass to the script
|
||||
Args []string
|
||||
|
||||
// WorkDir is the working directory for script execution
|
||||
WorkDir string
|
||||
|
||||
// Timeout is the maximum execution time (0 = use default)
|
||||
Timeout time.Duration
|
||||
|
||||
// Env is additional environment variables
|
||||
Env map[string]string
|
||||
|
||||
// AllowedCmds is a whitelist of commands that can be executed
|
||||
// If empty, a default safe list is used
|
||||
AllowedCmds []string
|
||||
|
||||
// AllowNetwork enables network access (Docker only)
|
||||
AllowNetwork bool
|
||||
|
||||
// MemoryLimit is the maximum memory in bytes (Docker only)
|
||||
MemoryLimit int64
|
||||
|
||||
// CPULimit is the maximum CPU cores (Docker only)
|
||||
CPULimit float64
|
||||
|
||||
// ReadOnlyRootfs makes the root filesystem read-only (Docker only)
|
||||
ReadOnlyRootfs bool
|
||||
|
||||
// Stdin provides input to the script
|
||||
Stdin string
|
||||
}
|
||||
|
||||
// ExecuteResult contains the result of script execution
|
||||
type ExecuteResult struct {
|
||||
// Stdout is the standard output from the script
|
||||
Stdout string
|
||||
|
||||
// Stderr is the standard error from the script
|
||||
Stderr string
|
||||
|
||||
// ExitCode is the process exit code
|
||||
ExitCode int
|
||||
|
||||
// Duration is the actual execution time
|
||||
Duration time.Duration
|
||||
|
||||
// Killed indicates if the process was killed (e.g., timeout)
|
||||
Killed bool
|
||||
|
||||
// Error contains any execution error
|
||||
Error string
|
||||
}
|
||||
|
||||
// IsSuccess returns true if the script executed successfully
|
||||
func (r *ExecuteResult) IsSuccess() bool {
|
||||
return r.ExitCode == 0 && !r.Killed && r.Error == ""
|
||||
}
|
||||
|
||||
// GetOutput returns the combined stdout and stderr, preferring stdout
|
||||
func (r *ExecuteResult) GetOutput() string {
|
||||
if r.Stdout != "" {
|
||||
return r.Stdout
|
||||
}
|
||||
return r.Stderr
|
||||
}
|
||||
|
||||
// Config holds sandbox manager configuration
|
||||
type Config struct {
|
||||
// Type is the preferred sandbox type
|
||||
Type SandboxType
|
||||
|
||||
// FallbackEnabled allows falling back to local sandbox if Docker is unavailable
|
||||
FallbackEnabled bool
|
||||
|
||||
// DefaultTimeout is the default execution timeout
|
||||
DefaultTimeout time.Duration
|
||||
|
||||
// DockerImage is the Docker image to use (Docker sandbox only)
|
||||
DockerImage string
|
||||
|
||||
// AllowedCommands is the default list of allowed commands
|
||||
AllowedCommands []string
|
||||
|
||||
// AllowedPaths is the list of paths that can be accessed
|
||||
AllowedPaths []string
|
||||
|
||||
// MaxMemory is the maximum memory limit in bytes
|
||||
MaxMemory int64
|
||||
|
||||
// MaxCPU is the maximum CPU cores
|
||||
MaxCPU float64
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default sandbox configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Type: SandboxTypeLocal,
|
||||
FallbackEnabled: true,
|
||||
DefaultTimeout: DefaultTimeout,
|
||||
DockerImage: "python:3.11-slim",
|
||||
AllowedCommands: defaultAllowedCommands(),
|
||||
MaxMemory: DefaultMemoryLimit,
|
||||
MaxCPU: DefaultCPULimit,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultAllowedCommands returns the default list of safe commands
|
||||
func defaultAllowedCommands() []string {
|
||||
return []string{
|
||||
"python",
|
||||
"python3",
|
||||
"node",
|
||||
"bash",
|
||||
"sh",
|
||||
"cat",
|
||||
"echo",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"sed",
|
||||
"awk",
|
||||
"sort",
|
||||
"uniq",
|
||||
"wc",
|
||||
"cut",
|
||||
"tr",
|
||||
"ls",
|
||||
"pwd",
|
||||
"date",
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates sandbox configuration
|
||||
func ValidateConfig(config *Config) error {
|
||||
if config == nil {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
|
||||
switch config.Type {
|
||||
case SandboxTypeDocker, SandboxTypeLocal, SandboxTypeDisabled:
|
||||
// Valid types
|
||||
default:
|
||||
return errors.New("invalid sandbox type")
|
||||
}
|
||||
|
||||
if config.DefaultTimeout < 0 {
|
||||
return errors.New("timeout cannot be negative")
|
||||
}
|
||||
|
||||
if config.MaxMemory < 0 {
|
||||
return errors.New("memory limit cannot be negative")
|
||||
}
|
||||
|
||||
if config.MaxCPU < 0 {
|
||||
return errors.New("CPU limit cannot be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
if config.Type != SandboxTypeLocal {
|
||||
t.Errorf("Expected default type to be local, got %s", config.Type)
|
||||
}
|
||||
|
||||
if config.DefaultTimeout != DefaultTimeout {
|
||||
t.Errorf("Expected default timeout %v, got %v", DefaultTimeout, config.DefaultTimeout)
|
||||
}
|
||||
|
||||
if !config.FallbackEnabled {
|
||||
t.Error("Expected fallback to be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
config: &Config{
|
||||
Type: SandboxTypeLocal,
|
||||
DefaultTimeout: 30 * time.Second,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
config: &Config{
|
||||
Type: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative timeout",
|
||||
config: &Config{
|
||||
Type: SandboxTypeLocal,
|
||||
DefaultTimeout: -1 * time.Second,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateConfig(tt.config)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSandboxExecute(t *testing.T) {
|
||||
// Create a temporary script
|
||||
tmpDir, err := os.MkdirTemp("", "sandbox-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write a simple test script
|
||||
scriptPath := filepath.Join(tmpDir, "test.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "Hello from sandbox"
|
||||
echo "Args: $@"
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to write script: %v", err)
|
||||
}
|
||||
|
||||
// Create local sandbox
|
||||
config := DefaultConfig()
|
||||
config.Type = SandboxTypeLocal
|
||||
sandbox := NewLocalSandbox(config)
|
||||
|
||||
// Check availability
|
||||
ctx := context.Background()
|
||||
if !sandbox.IsAvailable(ctx) {
|
||||
t.Error("Local sandbox should always be available")
|
||||
}
|
||||
|
||||
// Execute script
|
||||
result, err := sandbox.Execute(ctx, &ExecuteConfig{
|
||||
Script: scriptPath,
|
||||
Args: []string{"arg1", "arg2"},
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute script: %v", err)
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
t.Errorf("Expected exit code 0, got %d", result.ExitCode)
|
||||
}
|
||||
|
||||
if result.Stdout == "" {
|
||||
t.Error("Expected stdout to be non-empty")
|
||||
}
|
||||
|
||||
t.Logf("Script output: %s", result.Stdout)
|
||||
t.Logf("Duration: %v", result.Duration)
|
||||
}
|
||||
|
||||
func TestLocalSandboxTimeout(t *testing.T) {
|
||||
// Create a temporary script that sleeps
|
||||
tmpDir, err := os.MkdirTemp("", "sandbox-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write a script that sleeps
|
||||
scriptPath := filepath.Join(tmpDir, "sleep.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
sleep 10
|
||||
echo "Done"
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to write script: %v", err)
|
||||
}
|
||||
|
||||
// Create local sandbox
|
||||
config := DefaultConfig()
|
||||
config.Type = SandboxTypeLocal
|
||||
sandbox := NewLocalSandbox(config)
|
||||
|
||||
// Execute with short timeout
|
||||
ctx := context.Background()
|
||||
result, err := sandbox.Execute(ctx, &ExecuteConfig{
|
||||
Script: scriptPath,
|
||||
Timeout: 1 * time.Second,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Execute should not return error, got: %v", err)
|
||||
}
|
||||
|
||||
if !result.Killed {
|
||||
t.Error("Expected script to be killed due to timeout")
|
||||
}
|
||||
|
||||
t.Logf("Script was killed: %v, Duration: %v", result.Killed, result.Duration)
|
||||
}
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.Type = SandboxTypeLocal
|
||||
|
||||
manager, err := NewManager(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create manager: %v", err)
|
||||
}
|
||||
|
||||
if manager.GetType() != SandboxTypeLocal {
|
||||
t.Errorf("Expected type local, got %s", manager.GetType())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDisabledManager(t *testing.T) {
|
||||
manager := NewDisabledManager()
|
||||
|
||||
if manager.GetType() != SandboxTypeDisabled {
|
||||
t.Errorf("Expected type disabled, got %s", manager.GetType())
|
||||
}
|
||||
|
||||
// Execute should fail
|
||||
ctx := context.Background()
|
||||
_, err := manager.Execute(ctx, &ExecuteConfig{
|
||||
Script: "/some/script.sh",
|
||||
})
|
||||
|
||||
if err != ErrSandboxDisabled {
|
||||
t.Errorf("Expected ErrSandboxDisabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteResultHelpers(t *testing.T) {
|
||||
// Test IsSuccess
|
||||
successResult := &ExecuteResult{
|
||||
ExitCode: 0,
|
||||
Stdout: "output",
|
||||
}
|
||||
if !successResult.IsSuccess() {
|
||||
t.Error("Expected IsSuccess() to return true for exit code 0")
|
||||
}
|
||||
|
||||
failResult := &ExecuteResult{
|
||||
ExitCode: 1,
|
||||
Stderr: "error",
|
||||
}
|
||||
if failResult.IsSuccess() {
|
||||
t.Error("Expected IsSuccess() to return false for exit code 1")
|
||||
}
|
||||
|
||||
killedResult := &ExecuteResult{
|
||||
ExitCode: 0,
|
||||
Killed: true,
|
||||
}
|
||||
if killedResult.IsSuccess() {
|
||||
t.Error("Expected IsSuccess() to return false when killed")
|
||||
}
|
||||
|
||||
// Test GetOutput
|
||||
if successResult.GetOutput() != "output" {
|
||||
t.Errorf("Expected GetOutput() to return stdout, got %s", successResult.GetOutput())
|
||||
}
|
||||
|
||||
if failResult.GetOutput() != "error" {
|
||||
t.Errorf("Expected GetOutput() to return stderr when stdout is empty, got %s", failResult.GetOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonScriptExecution(t *testing.T) {
|
||||
// Create a temporary Python script
|
||||
tmpDir, err := os.MkdirTemp("", "sandbox-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write a Python script
|
||||
scriptPath := filepath.Join(tmpDir, "test.py")
|
||||
scriptContent := `#!/usr/bin/env python3
|
||||
import sys
|
||||
print("Hello from Python")
|
||||
print(f"Arguments: {sys.argv[1:]}")
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||
t.Fatalf("Failed to write script: %v", err)
|
||||
}
|
||||
|
||||
// Create local sandbox
|
||||
config := DefaultConfig()
|
||||
config.Type = SandboxTypeLocal
|
||||
sandbox := NewLocalSandbox(config)
|
||||
|
||||
// Execute Python script
|
||||
ctx := context.Background()
|
||||
result, err := sandbox.Execute(ctx, &ExecuteConfig{
|
||||
Script: scriptPath,
|
||||
Args: []string{"test", "args"},
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Python script: %v", err)
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
t.Errorf("Expected exit code 0, got %d. Stderr: %s", result.ExitCode, result.Stderr)
|
||||
}
|
||||
|
||||
t.Logf("Python script output: %s", result.Stdout)
|
||||
}
|
||||
@@ -33,6 +33,13 @@ type AgentConfig struct {
|
||||
Thinking *bool `json:"thinking"`
|
||||
// Whether to retrieve knowledge base only when explicitly mentioned with @ (default: false)
|
||||
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
|
||||
|
||||
// Skills configuration (Progressive Disclosure pattern)
|
||||
SkillsEnabled bool `json:"skills_enabled"` // Whether skills are enabled (default: false)
|
||||
SkillDirs []string `json:"skill_dirs"` // Directories to search for skills
|
||||
AllowedSkills []string `json:"allowed_skills"` // Skill names whitelist (empty = allow all)
|
||||
SandboxMode string `json:"sandbox_mode"` // Sandbox mode: "docker", "local", "disabled" (default: "disabled")
|
||||
SandboxTimeout int `json:"sandbox_timeout"` // Script execution timeout in seconds (default: 60)
|
||||
}
|
||||
|
||||
// SessionAgentConfig represents session-level agent configuration
|
||||
|
||||
Reference in New Issue
Block a user