From 33f7e7da66cd40f01068ce548c5dfdd678e020c7 Mon Sep 17 00:00:00 2001 From: nullkey Date: Tue, 3 Feb 2026 12:18:37 +0800 Subject: [PATCH] feat: support agent skills feat:support agent skills --- cmd/skills-demo/docker_sandbox_test_runner.go | 164 ++++++ cmd/skills-demo/docker_test.go | 165 ++++++ cmd/skills-demo/main.go | 237 +++++++++ docs/agent-skills.md | 482 ++++++++++++++++++ examples/skills/README.md | 92 ++++ examples/skills/pdf-processing/FORMS.md | 44 ++ examples/skills/pdf-processing/SKILL.md | 40 ++ .../pdf-processing/scripts/analyze_form.py | 46 ++ .../pdf-processing/scripts/extract_text.py | 54 ++ internal/agent/engine.go | 67 ++- internal/agent/prompts.go | 50 ++ internal/agent/skills/integration_test.go | 161 ++++++ internal/agent/skills/loader.go | 307 +++++++++++ internal/agent/skills/manager.go | 282 ++++++++++ internal/agent/skills/skill.go | 218 ++++++++ internal/agent/skills/skills_test.go | 392 ++++++++++++++ internal/agent/tools/definitions.go | 5 + internal/agent/tools/skill_execute.go | 189 +++++++ internal/agent/tools/skill_read.go | 165 ++++++ internal/application/service/agent_service.go | 73 +++ internal/sandbox/docker.go | 196 +++++++ internal/sandbox/local.go | 252 +++++++++ internal/sandbox/manager.go | 159 ++++++ internal/sandbox/sandbox.go | 233 +++++++++ internal/sandbox/sandbox_test.go | 275 ++++++++++ internal/types/agent.go | 7 + 26 files changed, 4349 insertions(+), 6 deletions(-) create mode 100644 cmd/skills-demo/docker_sandbox_test_runner.go create mode 100644 cmd/skills-demo/docker_test.go create mode 100644 cmd/skills-demo/main.go create mode 100644 docs/agent-skills.md create mode 100644 examples/skills/README.md create mode 100644 examples/skills/pdf-processing/FORMS.md create mode 100644 examples/skills/pdf-processing/SKILL.md create mode 100644 examples/skills/pdf-processing/scripts/analyze_form.py create mode 100644 examples/skills/pdf-processing/scripts/extract_text.py create mode 100644 internal/agent/skills/integration_test.go create mode 100644 internal/agent/skills/loader.go create mode 100644 internal/agent/skills/manager.go create mode 100644 internal/agent/skills/skill.go create mode 100644 internal/agent/skills/skills_test.go create mode 100644 internal/agent/tools/skill_execute.go create mode 100644 internal/agent/tools/skill_read.go create mode 100644 internal/sandbox/docker.go create mode 100644 internal/sandbox/local.go create mode 100644 internal/sandbox/manager.go create mode 100644 internal/sandbox/sandbox.go create mode 100644 internal/sandbox/sandbox_test.go diff --git a/cmd/skills-demo/docker_sandbox_test_runner.go b/cmd/skills-demo/docker_sandbox_test_runner.go new file mode 100644 index 000000000..8c2387416 --- /dev/null +++ b/cmd/skills-demo/docker_sandbox_test_runner.go @@ -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 +} diff --git a/cmd/skills-demo/docker_test.go b/cmd/skills-demo/docker_test.go new file mode 100644 index 000000000..ef6f8061d --- /dev/null +++ b/cmd/skills-demo/docker_test.go @@ -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 +} diff --git a/cmd/skills-demo/main.go b/cmd/skills-demo/main.go new file mode 100644 index 000000000..0a6c7967b --- /dev/null +++ b/cmd/skills-demo/main.go @@ -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() +} diff --git a/docs/agent-skills.md b/docs/agent-skills.md new file mode 100644 index 000000000..daee07497 --- /dev/null +++ b/docs/agent-skills.md @@ -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 ") + 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! +``` diff --git a/examples/skills/README.md b/examples/skills/README.md new file mode 100644 index 000000000..206a7c3ae --- /dev/null +++ b/examples/skills/README.md @@ -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. 返回表单字段分析结果 +``` diff --git a/examples/skills/pdf-processing/FORMS.md b/examples/skills/pdf-processing/FORMS.md new file mode 100644 index 000000000..fd2834649 --- /dev/null +++ b/examples/skills/pdf-processing/FORMS.md @@ -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 diff --git a/examples/skills/pdf-processing/SKILL.md b/examples/skills/pdf-processing/SKILL.md new file mode 100644 index 000000000..6f02bccb8 --- /dev/null +++ b/examples/skills/pdf-processing/SKILL.md @@ -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 diff --git a/examples/skills/pdf-processing/scripts/analyze_form.py b/examples/skills/pdf-processing/scripts/analyze_form.py new file mode 100644 index 000000000..7f1aad1bc --- /dev/null +++ b/examples/skills/pdf-processing/scripts/analyze_form.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Analyze PDF form fields and output their structure. +Usage: python analyze_form.py +""" + +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 ") + sys.exit(1) + + result = analyze_form(sys.argv[1]) + print("\nJSON Output:") + print(result) diff --git a/examples/skills/pdf-processing/scripts/extract_text.py b/examples/skills/pdf-processing/scripts/extract_text.py new file mode 100644 index 000000000..547e13cad --- /dev/null +++ b/examples/skills/pdf-processing/scripts/extract_text.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Extract text from PDF files. +Usage: python extract_text.py [--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 [--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) diff --git a/internal/agent/engine.go b/internal/agent/engine.go index 3c923af1c..ae878c929 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -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) diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 898fdc0a4..e8733c568 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -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 } diff --git a/internal/agent/skills/integration_test.go b/internal/agent/skills/integration_test.go new file mode 100644 index 000000000..30f9ea2dc --- /dev/null +++ b/internal/agent/skills/integration_test.go @@ -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] + "..." +} diff --git a/internal/agent/skills/loader.go b/internal/agent/skills/loader.go new file mode 100644 index 000000000..7a6c14836 --- /dev/null +++ b/internal/agent/skills/loader.go @@ -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() +} diff --git a/internal/agent/skills/manager.go b/internal/agent/skills/manager.go new file mode 100644 index 000000000..d879d5ab8 --- /dev/null +++ b/internal/agent/skills/manager.go @@ -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 +} diff --git a/internal/agent/skills/skill.go b/internal/agent/skills/skill.go new file mode 100644 index 000000000..65fa299ae --- /dev/null +++ b/internal/agent/skills/skill.go @@ -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" +} diff --git a/internal/agent/skills/skills_test.go b/internal/agent/skills/skills_test.go new file mode 100644 index 000000000..4e0fe0f9a --- /dev/null +++ b/internal/agent/skills/skills_test.go @@ -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) + } + } +} diff --git a/internal/agent/tools/definitions.go b/internal/agent/tools/definitions.go index f96f0a640..39e3b9aee 100644 --- a/internal/agent/tools/definitions.go +++ b/internal/agent/tools/definitions.go @@ -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: "在沙箱环境中执行技能脚本"}, } } diff --git a/internal/agent/tools/skill_execute.go b/internal/agent/tools/skill_execute.go new file mode 100644 index 000000000..743b1262a --- /dev/null +++ b/internal/agent/tools/skill_execute.go @@ -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 +} diff --git a/internal/agent/tools/skill_read.go b/internal/agent/tools/skill_read.go new file mode 100644 index 000000000..2a511a7a5 --- /dev/null +++ b/internal/agent/tools/skill_read.go @@ -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 +} diff --git a/internal/application/service/agent_service.go b/internal/application/service/agent_service.go index d332f52c4..b0eeecbca 100644 --- a/internal/application/service/agent_service.go +++ b/internal/application/service/agent_service.go @@ -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, diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go new file mode 100644 index 000000000..a9176b96d --- /dev/null +++ b/internal/sandbox/docker.go @@ -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 +} diff --git a/internal/sandbox/local.go b/internal/sandbox/local.go new file mode 100644 index 000000000..46d0e0600 --- /dev/null +++ b/internal/sandbox/local.go @@ -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 +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go new file mode 100644 index 000000000..c09fa5cf2 --- /dev/null +++ b/internal/sandbox/manager.go @@ -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{}, + } +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 000000000..7eb6d7905 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -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 +} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go new file mode 100644 index 000000000..dfc1383ce --- /dev/null +++ b/internal/sandbox/sandbox_test.go @@ -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) +} diff --git a/internal/types/agent.go b/internal/types/agent.go index 03557bbe0..1673c8b26 100644 --- a/internal/types/agent.go +++ b/internal/types/agent.go @@ -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