feat: support agent skills

feat:support agent skills
This commit is contained in:
nullkey
2026-02-03 12:18:37 +08:00
committed by lyingbug
parent 6e03f1ea79
commit 33f7e7da66
26 changed files with 4349 additions and 6 deletions
@@ -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
}
+165
View File
@@ -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
}
+237
View File
@@ -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()
}
+482
View File
@@ -0,0 +1,482 @@
# Agent Skills 文档
## 概述
Agent Skills 是一种让 Agent 通过阅读"使用说明书"来学习新能力的扩展机制。与传统的硬编码工具不同,Skills 通过注入到 System Prompt 来扩展 Agent 的能力,遵循 **Progressive Disclosure(渐进式披露)** 的设计理念。
### 核心特性
- **非侵入式扩展**:不影响原有 Agent ReAct 流程
- **按需加载**:三级渐进式加载,优化 Token 使用
- **沙箱执行**:脚本在隔离环境中安全执行
- **灵活配置**:支持多目录、白名单过滤
## 设计理念
### Progressive Disclosure(渐进式披露)
Skills 采用三级加载机制,确保只在需要时才向 LLM 提供详细信息:
```
┌─────────────────────────────────────────────────────────────────┐
│ Level 1: 元数据 (Metadata) │
│ • 始终加载到 System Prompt │
│ • 约 100 tokens/skill │
│ • 包含:技能名称 + 简短描述 │
└─────────────────────────────────────────────────────────────────┘
↓ 用户请求匹配时
┌─────────────────────────────────────────────────────────────────┐
│ Level 2: 指令 (Instructions) │
│ • 通过 read_skill 工具按需加载 │
│ • SKILL.md 的指令内容 │
│ • 包含:详细指令、代码示例、使用方法 │
└─────────────────────────────────────────────────────────────────┘
↓ 需要更多信息时
┌─────────────────────────────────────────────────────────────────┐
│ Level 3: 附加资源 (Resources) │
│ • 通过 read_skill 工具加载特定文件 │
│ • 补充文档、配置模板、脚本文件 │
│ • 通过 execute_skill_script 执行脚本 │
└─────────────────────────────────────────────────────────────────┘
```
## Skill 目录结构
每个 Skill 是一个目录,包含 `SKILL.md` 主文件和可选的附加资源:
```
my-skill/
├── SKILL.md # 必需:主文件(含 YAML frontmatter
├── REFERENCE.md # 可选:补充文档
├── templates/ # 可选:模板文件
│ └── config.yaml
└── scripts/ # 可选:可执行脚本
├── analyze.py
└── generate.sh
```
## SKILL.md 格式
### YAML Frontmatter
每个 `SKILL.md` 必须以 YAML frontmatter 开头,定义元数据:
```markdown
---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
---
# PDF Processing
This skill provides utilities for working with PDF documents.
## Quick Start
Use pdfplumber to extract text from PDFs:
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
text = pdf.pages[0].extract_text()
print(text)
```
## Available Operations
1. **Text Extraction**: Extract text content from PDF pages
2. **Table Extraction**: Extract tabular data from PDFs
...
```
### 元数据验证规则
| 字段 | 要求 |
|------|------|
| `name` | 1-50 字符,仅允许 `a-z`, `0-9`, `-`, `_`,不能是保留词 |
| `description` | 1-500 字符,描述技能用途和触发条件 |
**保留词**`system`, `default`, `internal`, `core`, `base`, `root`, `admin`
### 最佳实践
**name 命名**
- ✅ `pdf-processing`, `code_review`, `api-client`
- ❌ `PDF Processing`, `my skill`, `system`
**description 编写**
- 清晰描述技能的功能
- 包含触发条件(如 "when working with PDF files"
- 避免过于模糊的描述
## 配置
### AgentConfig 配置项
```go
type AgentConfig struct {
// ... 其他配置 ...
// Skills 相关配置
SkillsEnabled bool `json:"skills_enabled"` // 是否启用 Skills
SkillDirs []string `json:"skill_dirs"` // Skill 目录列表
AllowedSkills []string `json:"allowed_skills"` // 白名单(空=全部允许)
SandboxMode string `json:"sandbox_mode"` // sandbox 模式
SandboxTimeout int `json:"sandbox_timeout"` // 脚本执行超时(秒)
}
```
### 配置示例
```json
{
"skills_enabled": true,
"skill_dirs": [
"/path/to/project/skills",
"/home/user/.agent-skills"
],
"allowed_skills": ["pdf-processing", "code-review"],
"sandbox_mode": "docker",
"sandbox_timeout": 30
}
```
### Sandbox 模式
| 模式 | 说明 |
|------|------|
| `docker` | 使用 Docker 容器隔离(推荐) |
| `local` | 本地进程执行(基础安全限制) |
| `disabled` | 禁用脚本执行 |
## Agent 工具
Skills 功能通过两个工具与 Agent 交互:
### read_skill
读取技能内容或特定文件。
**参数**
```json
{
"skill_name": "pdf-processing", // 必需:技能名称
"file_path": "FORMS.md" // 可选:相对路径
}
```
**使用场景**
1. 加载 Level 2 内容:仅传 `skill_name`
2. 加载 Level 3 资源:同时传 `skill_name``file_path`
**示例调用**
```json
// 加载技能主内容
{"skill_name": "pdf-processing"}
// 加载补充文档
{"skill_name": "pdf-processing", "file_path": "FORMS.md"}
// 查看脚本内容
{"skill_name": "pdf-processing", "file_path": "scripts/analyze.py"}
```
### execute_skill_script
在沙箱中执行技能脚本。
**参数**
```json
{
"skill_name": "pdf-processing", // 必需:技能名称
"script_path": "scripts/analyze.py", // 必需:脚本相对路径
"args": ["input.pdf", "--format", "json"] // 可选:命令行参数
}
```
**支持的脚本类型**
- Python (`.py`)
- Shell (`.sh`)
- JavaScript/Node.js (`.js`)
- Ruby (`.rb`)
- Go (`.go`)
## 创建自定义 Skill
### 第一步:创建目录结构
```bash
mkdir -p my-skills/code-review
cd my-skills/code-review
```
### 第二步:编写 SKILL.md
```markdown
---
name: code-review
description: Review code for best practices, security issues, and performance. Use when the user asks to review, analyze, or improve code quality.
---
# Code Review Skill
This skill helps analyze code for quality and security issues.
## How to Use
When reviewing code:
1. Check for common security vulnerabilities
2. Identify performance bottlenecks
3. Suggest best practice improvements
## Security Checklist
- [ ] SQL Injection prevention
- [ ] XSS protection
- [ ] Input validation
- [ ] Authentication checks
## Performance Tips
- Avoid N+1 queries
- Use appropriate data structures
- Consider caching strategies
```
### 第三步:添加辅助脚本(可选)
创建 `scripts/lint.py`
```python
#!/usr/bin/env python3
"""Simple code linter for demonstration."""
import sys
import json
def lint_code(filepath):
issues = []
with open(filepath) as f:
for i, line in enumerate(f, 1):
if len(line) > 120:
issues.append({
"line": i,
"issue": "Line too long",
"severity": "warning"
})
if "eval(" in line:
issues.append({
"line": i,
"issue": "Avoid using eval()",
"severity": "error"
})
return issues
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: lint.py <filepath>")
sys.exit(1)
result = lint_code(sys.argv[1])
print(json.dumps(result, indent=2))
```
### 第四步:配置 Agent
将 Skill 目录添加到 Agent 配置:
```json
{
"skills_enabled": true,
"skill_dirs": ["/path/to/my-skills"]
}
```
## 沙箱安全机制
### Docker 沙箱
Docker 模式提供最强的隔离:
- **非 root 用户**:容器内以普通用户运行
- **Capability 限制**:移除所有 Linux capabilities
- **只读文件系统**:根文件系统只读
- **资源限制**:内存 256MBCPU 限制
- **网络隔离**:默认无网络访问
- **临时挂载**: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!
```
+92
View File
@@ -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. 返回表单字段分析结果
```
+44
View File
@@ -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
+40
View File
@@ -0,0 +1,40 @@
---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
---
# PDF Processing
This skill provides utilities for working with PDF documents.
## Quick Start
Use pdfplumber to extract text from PDFs:
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
text = pdf.pages[0].extract_text()
print(text)
```
## Available Operations
1. **Text Extraction**: Extract text content from PDF pages
2. **Table Extraction**: Extract tabular data from PDFs
3. **Form Filling**: Fill PDF forms with provided data
4. **Document Merging**: Combine multiple PDFs into one
## Advanced Features
**Form filling**: See [FORMS.md](FORMS.md) for complete guide
**Utility scripts**:
- Run `scripts/analyze_form.py` to extract form fields
- Run `scripts/extract_text.py` to extract text from a PDF
## Best Practices
1. Always validate PDF files before processing
2. Handle password-protected PDFs gracefully
3. Check for scanned PDFs that may require OCR
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Analyze PDF form fields and output their structure.
Usage: python analyze_form.py <pdf_file>
"""
import sys
import json
def analyze_form(pdf_path):
"""Analyze form fields in a PDF file."""
# This is a mock implementation for testing
# In production, would use pypdf or pdfrw
print(f"Analyzing PDF: {pdf_path}")
print("=" * 50)
# Mock form fields for demonstration
fields = {
"name": {"type": "text", "required": True},
"email": {"type": "text", "required": True},
"date": {"type": "date", "required": False},
"agree_terms": {"type": "checkbox", "required": True},
"signature": {"type": "signature", "required": True}
}
print("\nDiscovered Form Fields:")
print("-" * 30)
for field_name, props in fields.items():
required_str = "[REQUIRED]" if props["required"] else "[optional]"
print(f" {field_name}: {props['type']} {required_str}")
print("\n" + "=" * 50)
print("Analysis complete.")
# Output JSON for programmatic use
return json.dumps(fields, indent=2)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python analyze_form.py <pdf_file>")
sys.exit(1)
result = analyze_form(sys.argv[1])
print("\nJSON Output:")
print(result)
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Extract text from PDF files.
Usage: python extract_text.py <pdf_file> [--page N]
"""
import sys
def extract_text(pdf_path, page_num=None):
"""Extract text from a PDF file."""
# This is a mock implementation for testing
# In production, would use pdfplumber or pypdf
print(f"Extracting text from: {pdf_path}")
if page_num:
print(f"Page: {page_num}")
else:
print("All pages")
print("=" * 50)
# Mock extracted text
mock_text = """
Sample PDF Document
This is a demonstration of text extraction from PDF files.
Key Features:
- Fast and efficient text extraction
- Preserves document structure
- Handles multi-page documents
For more information, visit our documentation.
"""
print(mock_text)
print("=" * 50)
print("Extraction complete.")
return mock_text.strip()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python extract_text.py <pdf_file> [--page N]")
sys.exit(1)
pdf_path = sys.argv[1]
page_num = None
if len(sys.argv) > 3 and sys.argv[2] == "--page":
page_num = int(sys.argv[3])
extract_text(pdf_path, page_num)
+61 -6
View File
@@ -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)
+50
View File
@@ -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
}
+161
View File
@@ -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] + "..."
}
+307
View File
@@ -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()
}
+282
View File
@@ -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
}
+218
View File
@@ -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"
}
+392
View File
@@ -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)
}
}
}
+5
View File
@@ -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: "在沙箱环境中执行技能脚本"},
}
}
+189
View File
@@ -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
}
+165
View File
@@ -0,0 +1,165 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/agent/skills"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/utils"
)
// Tool name constant for read_skill
var readSkillTool = BaseTool{
name: ToolReadSkill,
description: `Read skill content on demand to learn specialized capabilities.
## Usage
- Use this tool when a user request matches an available skill's description
- Provide the skill_name to load the skill's full instructions (SKILL.md content)
- Optionally provide file_path to read additional files within the skill directory
## When to Use
- When the system prompt shows an available skill that matches the user's request
- Before performing tasks that match a skill's description
- To read additional documentation or reference files within a skill
## Returns
- Skill instructions and guidance for completing the task
- File content if file_path is specified`,
schema: utils.GenerateSchema[ReadSkillInput](),
}
// ReadSkillInput defines the input parameters for the read_skill tool
type ReadSkillInput struct {
SkillName string `json:"skill_name" jsonschema:"Name of the skill to read"`
FilePath string `json:"file_path,omitempty" jsonschema:"Optional relative path to a specific file within the skill directory"`
}
// ReadSkillTool allows the agent to read skill content on demand
type ReadSkillTool struct {
BaseTool
skillManager *skills.Manager
}
// NewReadSkillTool creates a new read_skill tool instance
func NewReadSkillTool(skillManager *skills.Manager) *ReadSkillTool {
return &ReadSkillTool{
BaseTool: readSkillTool,
skillManager: skillManager,
}
}
// Execute executes the read_skill tool
func (t *ReadSkillTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error) {
logger.Infof(ctx, "[Tool][ReadSkill] Execute started")
// Parse input
var input ReadSkillInput
if err := json.Unmarshal(args, &input); err != nil {
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to parse args: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to parse args: %v", err),
}, nil
}
// Validate skill name
if input.SkillName == "" {
return &types.ToolResult{
Success: false,
Error: "skill_name is required",
}, nil
}
// Check if skill manager is available
if t.skillManager == nil || !t.skillManager.IsEnabled() {
return &types.ToolResult{
Success: false,
Error: "Skills are not enabled",
}, nil
}
var builder strings.Builder
var resultData = make(map[string]interface{})
if input.FilePath != "" {
// Read a specific file from the skill directory
content, err := t.skillManager.ReadSkillFile(ctx, input.SkillName, input.FilePath)
if err != nil {
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to read skill file: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to read skill file: %v", err),
}, nil
}
builder.WriteString(fmt.Sprintf("=== Skill File: %s/%s ===\n\n", input.SkillName, input.FilePath))
builder.WriteString(content)
resultData["skill_name"] = input.SkillName
resultData["file_path"] = input.FilePath
resultData["content"] = content
resultData["content_length"] = len(content)
} else {
// Read the main skill instructions (SKILL.md)
skill, err := t.skillManager.LoadSkill(ctx, input.SkillName)
if err != nil {
logger.Errorf(ctx, "[Tool][ReadSkill] Failed to load skill: %v", err)
return &types.ToolResult{
Success: false,
Error: fmt.Sprintf("Failed to load skill: %v", err),
}, nil
}
// List available files in the skill directory
files, err := t.skillManager.ListSkillFiles(ctx, input.SkillName)
if err != nil {
files = []string{} // Non-fatal error
}
builder.WriteString(fmt.Sprintf("=== Skill: %s ===\n\n", skill.Name))
builder.WriteString(fmt.Sprintf("**Description**: %s\n\n", skill.Description))
builder.WriteString("## Instructions\n\n")
builder.WriteString(skill.Instructions)
// Add available files section
if len(files) > 1 { // More than just SKILL.md
builder.WriteString("\n\n## Available Files\n\n")
builder.WriteString("The following files are available in this skill directory. Use `read_skill` with `file_path` to read them:\n\n")
for _, file := range files {
if file != skills.SkillFileName { // Don't list SKILL.md again
if skills.IsScript(file) {
builder.WriteString(fmt.Sprintf("- `%s` (script - can be executed)\n", file))
} else {
builder.WriteString(fmt.Sprintf("- `%s`\n", file))
}
}
}
}
resultData["skill_name"] = skill.Name
resultData["description"] = skill.Description
resultData["instructions"] = skill.Instructions
resultData["instructions_length"] = len(skill.Instructions)
resultData["files"] = files
}
logger.Infof(ctx, "[Tool][ReadSkill] Successfully read skill: %s", input.SkillName)
return &types.ToolResult{
Success: true,
Output: builder.String(),
Data: resultData,
}, nil
}
// Cleanup releases any resources (implements Tool interface if needed)
func (t *ReadSkillTool) Cleanup(ctx context.Context) error {
return nil
}
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/Tencent/WeKnora/internal/agent"
"github.com/Tencent/WeKnora/internal/agent/skills"
"github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/event"
@@ -13,6 +14,7 @@ import (
"github.com/Tencent/WeKnora/internal/mcp"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
@@ -197,9 +199,80 @@ func (s *agentService) CreateAgentEngine(
systemPromptTemplate,
)
// Initialize skills manager if skills are enabled
if config.SkillsEnabled && len(config.SkillDirs) > 0 {
skillsManager, err := s.initializeSkillsManager(ctx, config, toolRegistry)
if err != nil {
logger.Warnf(ctx, "Failed to initialize skills manager: %v", err)
} else if skillsManager != nil {
engine.SetSkillsManager(skillsManager)
logger.Infof(ctx, "Skills manager initialized with %d skills", len(skillsManager.GetAllMetadata()))
}
}
return engine, nil
}
// initializeSkillsManager creates and initializes the skills manager
func (s *agentService) initializeSkillsManager(
ctx context.Context,
config *types.AgentConfig,
toolRegistry *tools.ToolRegistry,
) (*skills.Manager, error) {
// Initialize sandbox manager based on configuration
var sandboxMgr sandbox.Manager
var err error
sandboxMode := config.SandboxMode
if sandboxMode == "" {
sandboxMode = "disabled"
}
switch sandboxMode {
case "docker":
sandboxMgr, err = sandbox.NewManagerFromType("docker", true) // Enable fallback to local
if err != nil {
logger.Warnf(ctx, "Failed to initialize Docker sandbox, falling back to disabled: %v", err)
sandboxMgr = sandbox.NewDisabledManager()
}
case "local":
sandboxMgr, err = sandbox.NewManagerFromType("local", false)
if err != nil {
logger.Warnf(ctx, "Failed to initialize local sandbox: %v", err)
sandboxMgr = sandbox.NewDisabledManager()
}
default:
sandboxMgr = sandbox.NewDisabledManager()
}
// Create skills manager
skillsConfig := &skills.ManagerConfig{
SkillDirs: config.SkillDirs,
AllowedSkills: config.AllowedSkills,
Enabled: config.SkillsEnabled,
}
skillsManager := skills.NewManager(skillsConfig, sandboxMgr)
// Initialize (discover skills)
if err := skillsManager.Initialize(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize skills: %w", err)
}
// Register skills tools
readSkillTool := tools.NewReadSkillTool(skillsManager)
toolRegistry.RegisterTool(readSkillTool)
logger.Infof(ctx, "Registered read_skill tool")
if sandboxMode != "disabled" {
executeSkillTool := tools.NewExecuteSkillScriptTool(skillsManager)
toolRegistry.RegisterTool(executeSkillTool)
logger.Infof(ctx, "Registered execute_skill_script tool")
}
return skillsManager, nil
}
// registerTools registers tools based on the agent configuration
func (s *agentService) registerTools(
ctx context.Context,
+196
View File
@@ -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
}
+252
View File
@@ -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
}
+159
View File
@@ -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{},
}
}
+233
View File
@@ -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
}
+275
View File
@@ -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)
}
+7
View File
@@ -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