feat: converge dynamic plugins onto canonical homeagent-sdk

- Separate built-in plugin interface from external plugin interface
- Route dynamic plugin loading through homeagent-sdk/sdk using reflection
- Turn internal/sdk into an enhanced wrapper over canonical SDK types
- Vendor SDK repo snapshot under third_party/homeagent-sdk for stable builds
- Keep internal constructors/adapters for memory, knowledge, llm, settings
- Align dynamic QQ loading with canonical SDK chain
This commit is contained in:
root
2026-07-06 19:27:51 +08:00
parent a7e06fba77
commit 50e5dec745
37 changed files with 4505 additions and 420 deletions

View File

@ -0,0 +1,84 @@
# files 插件讲解
文件系统操作插件,提供文件的读写编辑和目录浏览能力。
## 工具清单
| 工具 | 功能 | 源码 |
|------|------|------|
| `files_read` | 读取文件内容,支持 offset/limit 分段 | `handleRead` |
| `files_write` | 写入文件,支持 4 种模式 | `handleWrite` |
| `files_edit` | 精确字符串替换编辑 | `handleEdit` |
| `files_ls` | 列出目录内容 | `handleLs` |
## 核心设计
### 沙箱路径隔离
`resolvePath()` 方法将用户传入的路径解析为沙箱内的绝对路径。关键逻辑:
```go
// 相对路径以沙箱根目录为基准拼接
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
// 检查是否越界
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox")
}
```
当沙箱根设为 `/` 时放行所有路径;设为特定目录时拒绝访问外部。配置项 `plugin.files.dir` 控制此值。
### 分段读取
`files_read` 支持 `offset`(行号,1-indexed)和 `limit`(行数上限),用于大文件分段查看:
```go
// plugin.go:handleRead
lines := strings.Split(text, "\n")
offset := 0 // 从 args["offset"] 解析,1-indexed 转 0-indexed
limit := totalLines - offset
// ...
end := offset + limit
selected := lines[offset:end]
```
如果未读完会在末尾追加提示 `[Showing lines X-Y of Z. Use offset=N to continue.]`。
### 四种写入模式
`files_write` 通过 `mode` 参数区分:
- **overwrite**(默认):`os.WriteFile` 覆盖写入,自动创建父目录
- **append**:`os.OpenFile` 以 `O_APPEND|O_CREATE|O_WRONLY` 打开,追加内容
- **insert**:将文件按行分割,在指定行号前插入新内容,再写回
- **create**:先检查文件是否已存在,存在则报错,不存在才创建
### 精确编辑
`files_edit` 接收 `edits` 数组,每个元素有 `old` 和 `new`。要求每个 `old` 在原文中**恰好出现一次**,防止 LLM 误替换:
```go
count := strings.Count(content, oldText)
if count == 0 { /* 报错未找到 */ }
if count > 1 { /* 报错存在多处匹配 */ }
content = strings.Replace(content, oldText, newText, 1)
```
### 目录列表
`files_ls` 按字母序排序,目录加 `/` 后缀,同时显示文件大小。默认上限 500 条。
## 配置项
| Key | 默认值 | 说明 |
|-----|--------|------|
| `plugin.files.dir` | `/` | 文件操作沙箱根目录 |
## 注意事项
- 所有路径操作前都经过 `resolvePath` 沙箱检查
- 错误结果统一用 `errorResult()` 返回 `{isError: true, content: msg}` 格式,LLM 可据此判断
- `files_write` 的 insert/append 模式不检查文件是否存在(不存在则报错),overwrite/create 模式自动创建父目录

View File

@ -0,0 +1,482 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.files.dir",
Default: "/",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "/")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
p.filesDir = abs
os.MkdirAll(p.filesDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
},
"required": []string{"path"},
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"edits": map[string]interface{}{
"type": "array",
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"},
},
},
}, p.handleLs)
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
return nil
}
// resolvePath resolves user-provided path to an absolute path within filesDir.
func (p *Plugin) resolvePath(userPath string) (string, error) {
if userPath == "" {
userPath = "."
}
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
abs, err := filepath.Abs(userPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
}
// handleRead implements the read tool.
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if info.IsDir() {
return errorResult("is a directory, use ls instead: " + path), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
text := string(data)
lines := strings.Split(text, "\n")
totalLines := len(lines)
offset := 0
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = int(v) - 1
}
if offset >= totalLines {
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
}
limit := totalLines - offset
if v, ok := args["limit"].(float64); ok && v > 0 {
if int(v) < limit {
limit = int(v)
}
}
end := offset + limit
if end > totalLines {
end = totalLines
}
selected := lines[offset:end]
output := strings.Join(selected, "\n")
truncated := false
if limit < totalLines-offset {
truncated = true
}
var sb strings.Builder
sb.WriteString(output)
if truncated {
nextOffset := end + 1
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
} else if offset > 0 || end < totalLines {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
}, nil
}
// handleWrite implements the write tool.
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
content, _ := args["content"].(string)
mode, _ := args["mode"].(string)
if mode == "" {
mode = "overwrite"
}
line := 0
if v, ok := args["line"].(float64); ok && v > 0 {
line = int(v)
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
return errorResult("file already exists: " + path), nil
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
}, nil
case "append":
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errorResult("open error: " + err.Error()), nil
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return errorResult("append error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
}, nil
case "insert":
if line < 1 {
return errorResult("line must be >= 1 for insert mode"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
lines := strings.Split(string(data), "\n")
if line > len(lines)+1 {
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
}
idx := line - 1
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:idx]...)
newLines = append(newLines, content)
newLines = append(newLines, lines[idx:]...)
result := strings.Join(newLines, "\n")
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
}, nil
default: // overwrite
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
}, nil
}
}
// handleEdit implements the edit tool.
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
content := original
applied := 0
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
oldText, _ := edit["old"].(string)
newText, _ := edit["new"].(string)
if oldText == "" {
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
continue
}
count := strings.Count(content, oldText)
if count == 0 {
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
continue
}
if count > 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
continue
}
content = strings.Replace(content, oldText, newText, 1)
applied++
}
if applied == 0 {
msg := "no edits applied"
if len(errors) > 0 {
msg += ": " + strings.Join(errors, "; ")
}
return errorResult(msg), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
if len(errors) > 0 {
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
}
return map[string]interface{}{
"content": msg,
}, nil
}
// handleLs implements the ls tool.
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("path not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if !info.IsDir() {
return errorResult("not a directory: " + path), nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return errorResult("readdir error: " + err.Error()), nil
}
limit := 500
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var lines []string
entryLimitReached := false
for i, entry := range entries {
if i >= limit {
entryLimitReached = true
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
lines = append(lines, name)
}
if len(lines) == 0 {
return map[string]interface{}{
"content": "(empty directory)",
}, nil
}
output := strings.Join(lines, "\n")
if entryLimitReached {
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
}
return map[string]interface{}{
"content": output,
}, nil
}
// errorResult returns a standardized error result.
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// getSetting reads a setting with generic type assertion.
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}

View File

@ -0,0 +1,8 @@
{
"name": "files",
"version": "1.0.0",
"description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["files", "filesystem", "io"]
}