755 lines
16 KiB
Markdown
755 lines
16 KiB
Markdown
# Agent 文件操作能力增强实施计划
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** 为 Agent Team 实现类似 opencode 的文件操作工具(glob, grep, read_file, edit_file, write_file, git 等),支持智能搜索、大文件处理、精细编辑
|
||
|
||
**Architecture:** 在 Room 层为 Agent 注册文件操作工具,通过 Tool Calling 让 LLM 自主调用工具执行文件操作
|
||
|
||
**Tech Stack:** Go + go-openai (Tool Calling)
|
||
|
||
---
|
||
|
||
## Task 1: 创建文件操作工具定义
|
||
|
||
**Files:**
|
||
- Create: `internal/room/tools/definitions.go`
|
||
|
||
**Step 1: 创建工具定义文件**
|
||
|
||
```go
|
||
package tools
|
||
|
||
import "github.com/sashabaranov/go-openai"
|
||
|
||
var FileTools = []openai.Tool{
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "glob",
|
||
Description: "模式匹配查找文件,支持 * ** ? 等通配符",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"pattern": {
|
||
"type": "string",
|
||
"description": "glob 模式,如 **/*.go, *.ts"
|
||
}
|
||
},
|
||
"required": ["pattern"]
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "grep",
|
||
Description: "在文件中搜索内容,支持正则表达式",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"pattern": {
|
||
"type": "string",
|
||
"description": "正则表达式搜索模式"
|
||
},
|
||
"path": {
|
||
"type": "string",
|
||
"description": "搜索目录路径"
|
||
},
|
||
"include": {
|
||
"type": "string",
|
||
"description": "文件过滤,如 *.go, *.ts"
|
||
}
|
||
},
|
||
"required": ["pattern"]
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "read_file",
|
||
Description: "读取文件内容,支持大文件分段读取",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"filename": {
|
||
"type": "string",
|
||
"description": "文件名(相对于 workspace)"
|
||
},
|
||
"offset": {
|
||
"type": "integer",
|
||
"description": "起始行号(从 0 开始)"
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "读取行数,默认 100"
|
||
}
|
||
},
|
||
"required": ["filename"]
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "edit_file",
|
||
Description: "编辑文件内容,使用字符串替换",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"filename": {
|
||
"type": "string",
|
||
"description": "文件名(相对于 workspace)"
|
||
},
|
||
"old_string": {
|
||
"type": "string",
|
||
"description": "要替换的原始内容"
|
||
},
|
||
"new_string": {
|
||
"type": "string",
|
||
"description": "替换后的新内容"
|
||
}
|
||
},
|
||
"required": ["filename", "old_string", "new_string"]
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "write_file",
|
||
Description: "写入或创建文件",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"filename": {
|
||
"type": "string",
|
||
"description": "文件名(相对于 workspace)"
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "文件内容"
|
||
}
|
||
},
|
||
"required": ["filename", "content"]
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "list_workspace",
|
||
Description: "列出工作区的所有文件",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {}
|
||
}`,
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
**Step 2: 提交**
|
||
|
||
```bash
|
||
git add internal/room/tools/definitions.go
|
||
git commit -m "feat: 添加文件操作工具定义"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 实现工具执行器
|
||
|
||
**Files:**
|
||
- Create: `internal/room/tools/executor.go`
|
||
|
||
**Step 1: 实现工具执行器**
|
||
|
||
```go
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/sdaduanbilei/agent-team/internal/llm"
|
||
)
|
||
|
||
type Executor struct {
|
||
workspaceDir string
|
||
}
|
||
|
||
func NewExecutor(workspaceDir string) *Executor {
|
||
return &Executor{workspaceDir: workspaceDir}
|
||
}
|
||
|
||
func (e *Executor) Execute(toolCall llm.ToolCall) (string, error) {
|
||
switch toolCall.Function.Name {
|
||
case "glob":
|
||
return e.glob(toolCall.Function.Arguments)
|
||
case "grep":
|
||
return e.grep(toolCall.Function.Arguments)
|
||
case "read_file":
|
||
return e.readFile(toolCall.Function.Arguments)
|
||
case "edit_file":
|
||
return e.editFile(toolCall.Function.Arguments)
|
||
case "write_file":
|
||
return e.writeFile(toolCall.Function.Arguments)
|
||
case "list_workspace":
|
||
return e.listWorkspace()
|
||
case "git_status":
|
||
return e.gitStatus()
|
||
case "git_diff":
|
||
return e.gitDiff(toolCall.Function.Arguments)
|
||
case "git_commit":
|
||
return e.gitCommit(toolCall.Function.Arguments)
|
||
default:
|
||
return "", nil
|
||
}
|
||
}
|
||
|
||
type GlobArgs struct {
|
||
Pattern string `json:"pattern"`
|
||
}
|
||
|
||
func (e *Executor) glob(args string) (string, error) {
|
||
var a GlobArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
pattern := filepath.Join(e.workspaceDir, a.Pattern)
|
||
files, err := filepath.Glob(pattern)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var result []string
|
||
for _, f := range files {
|
||
rel, _ := filepath.Rel(e.workspaceDir, f)
|
||
result = append(result, rel)
|
||
}
|
||
|
||
if len(result) == 0 {
|
||
return "未找到匹配的文件", nil
|
||
}
|
||
return strings.Join(result, "\n"), nil
|
||
}
|
||
|
||
type GrepArgs struct {
|
||
Pattern string `json:"pattern"`
|
||
Path string `json:"path"`
|
||
Include string `json:"include"`
|
||
}
|
||
|
||
func (e *Executor) grep(args string) (string, error) {
|
||
var a GrepArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
searchDir := e.workspaceDir
|
||
if a.Path != "" {
|
||
searchDir = filepath.Join(e.workspaceDir, a.Path)
|
||
}
|
||
|
||
re, err := regexp.Compile(a.Pattern)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var results []string
|
||
err = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if info.IsDir() {
|
||
return nil
|
||
}
|
||
if a.Include != "" {
|
||
matched, _ := filepath.Match(a.Include, info.Name())
|
||
if !matched {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
content, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
lines := strings.Split(string(content), "\n")
|
||
for i, line := range lines {
|
||
if re.MatchString(line) {
|
||
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||
results = append(results, fmt.Sprintf("%s:%d: %s", rel, i+1, line))
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if len(results) == 0 {
|
||
return "未找到匹配的内容", nil
|
||
}
|
||
|
||
// 限制结果数量
|
||
if len(results) > 100 {
|
||
results = results[:100]
|
||
results = append(results, "... (还有更多结果)")
|
||
}
|
||
return strings.Join(results, "\n"), nil
|
||
}
|
||
|
||
type ReadFileArgs struct {
|
||
Filename string `json:"filename"`
|
||
Offset int `json:"offset"`
|
||
Limit int `json:"limit"`
|
||
}
|
||
|
||
func (e *Executor) readFile(args string) (string, error) {
|
||
var a ReadFileArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if a.Limit == 0 {
|
||
a.Limit = 100
|
||
}
|
||
|
||
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||
content, err := os.ReadFile(fpath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
lines := strings.Split(string(content), "\n")
|
||
if a.Offset >= len(lines) {
|
||
return "起始位置超出文件行数", nil
|
||
}
|
||
|
||
end := a.Offset + a.Limit
|
||
if end > len(lines) {
|
||
end = len(lines)
|
||
}
|
||
|
||
result := strings.Join(lines[a.Offset:end], "\n")
|
||
|
||
if end < len(lines) {
|
||
result += fmt.Sprintf("\n\n... (共 %d 行,当前显示 %d-%d)", len(lines), a.Offset+1, end)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
type EditFileArgs struct {
|
||
Filename string `json:"filename"`
|
||
OldString string `json:"old_string"`
|
||
NewString string `json:"new_string"`
|
||
}
|
||
|
||
func (e *Executor) editFile(args string) (string, error) {
|
||
var a EditFileArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||
original, err := os.ReadFile(fpath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if !strings.Contains(string(original), a.OldString) {
|
||
return "", fmt.Errorf("文件中未找到要替换的内容")
|
||
}
|
||
|
||
updated := strings.Replace(string(original), a.OldString, a.NewString, 1)
|
||
if err := os.WriteFile(fpath, []byte(updated), 0644); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return fmt.Sprintf("已更新文件: %s", a.Filename), nil
|
||
}
|
||
|
||
type WriteFileArgs struct {
|
||
Filename string `json:"filename"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
func (e *Executor) writeFile(args string) (string, error) {
|
||
var a WriteFileArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
fpath := filepath.Join(e.workspaceDir, a.Filename)
|
||
os.MkdirAll(filepath.Dir(fpath), 0755)
|
||
|
||
exists := ""
|
||
if _, err := os.Stat(fpath); err == nil {
|
||
exists = " (已存在,已覆盖)"
|
||
}
|
||
|
||
if err := os.WriteFile(fpath, []byte(a.Content), 0644); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return fmt.Sprintf("已写入文件: %s%s", a.Filename, exists), nil
|
||
}
|
||
|
||
func (e *Executor) listWorkspace() (string, error) {
|
||
var files []string
|
||
err := filepath.Walk(e.workspaceDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if info.IsDir() {
|
||
return nil
|
||
}
|
||
rel, _ := filepath.Rel(e.workspaceDir, path)
|
||
if !strings.HasPrefix(rel, ".") {
|
||
files = append(files, rel)
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if len(files) == 0 {
|
||
return "工作区为空", nil
|
||
}
|
||
|
||
return strings.Join(files, "\n"), nil
|
||
}
|
||
|
||
func (e *Executor) gitStatus() (string, error) {
|
||
cmd := exec.Command("git", "status", "--porcelain")
|
||
cmd.Dir = e.workspaceDir
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(out) == 0 {
|
||
return "工作区干净,无待提交更改", nil
|
||
}
|
||
return string(out), nil
|
||
}
|
||
|
||
type GitDiffArgs struct {
|
||
Filename string `json:"filename"`
|
||
}
|
||
|
||
func (e *Executor) gitDiff(args string) (string, error) {
|
||
var a GitDiffArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var cmd *exec.Cmd
|
||
if a.Filename != "" {
|
||
cmd = exec.Command("git", "diff", a.Filename)
|
||
} else {
|
||
cmd = exec.Command("git", "diff")
|
||
}
|
||
cmd.Dir = e.workspaceDir
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(out) == 0 {
|
||
return "无更改", nil
|
||
}
|
||
return string(out), nil
|
||
}
|
||
|
||
type GitCommitArgs struct {
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
func (e *Executor) gitCommit(args string) (string, error) {
|
||
var a GitCommitArgs
|
||
if err := json.Unmarshal([]byte(args), &a); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// git add -A
|
||
cmd := exec.Command("git", "add", "-A")
|
||
cmd.Dir = e.workspaceDir
|
||
if _, err := cmd.Output(); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// git commit
|
||
cmd = exec.Command("git", "commit", "-m", a.Message)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return string(out), nil
|
||
}
|
||
```
|
||
|
||
**Step 2: 提交**
|
||
|
||
```bash
|
||
git add internal/room/tools/executor.go
|
||
git commit -m "feat: 实现文件操作工具执行器"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 集成工具到 Room
|
||
|
||
**Files:**
|
||
- Modify: `internal/room/handle.go`
|
||
- Modify: `internal/room/types.go`
|
||
|
||
**Step 1: 修改 types.go 添加工具执行器字段**
|
||
|
||
在 Room 结构体中添加:
|
||
```go
|
||
ToolExecutor *tools.Executor
|
||
```
|
||
|
||
**Step 2: 修改 handle.go 初始化工具执行器**
|
||
|
||
在 Load 函数中添加:
|
||
```go
|
||
r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))
|
||
```
|
||
|
||
**Step 3: 修改 room 调用逻辑以支持工具调用**
|
||
|
||
在需要的地方(如 build 模式下 Agent 调用),传入工具定义并处理工具调用结果。
|
||
|
||
**Step 4: 提交**
|
||
|
||
```bash
|
||
git add internal/room/handle.go internal/room/types.go
|
||
git commit -m "feat: 集成文件操作工具到 Room"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 注册 Git 工具定义
|
||
|
||
**Files:**
|
||
- Modify: `internal/room/tools/definitions.go`
|
||
|
||
**Step 1: 添加 Git 工具定义**
|
||
|
||
在 FileTools 切片中添加:
|
||
```go
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "git_status",
|
||
Description: "查看 Git 工作区状态",
|
||
Parameters: `{"type": "object", "properties": {}}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "git_diff",
|
||
Description: "查看文件变更",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"filename": {"type": "string", "description": "文件名"}
|
||
}
|
||
}`,
|
||
},
|
||
},
|
||
{
|
||
Type: openai.ToolTypeFunction,
|
||
Function: openai.FunctionDefinition{
|
||
Name: "git_commit",
|
||
Description: "提交更改",
|
||
Parameters: `{
|
||
"type": "object",
|
||
"properties": {
|
||
"message": {"type": "string", "description": "提交信息"}
|
||
},
|
||
"required": ["message"]
|
||
}`,
|
||
},
|
||
},
|
||
```
|
||
|
||
**Step 2: 提交**
|
||
|
||
```bash
|
||
git add internal/room/tools/definitions.go
|
||
git commit -m "feat: 添加 Git 工具定义"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 测试文件操作工具
|
||
|
||
**Files:**
|
||
- Create: `internal/room/tools/executor_test.go`
|
||
|
||
**Step 1: 创建测试文件**
|
||
|
||
```go
|
||
package tools
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
func TestGlob(t *testing.T) {
|
||
// 创建临时目录和测试文件
|
||
tmpDir := t.TempDir()
|
||
os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("package test"), 0644)
|
||
os.WriteFile(filepath.Join(tmpDir, "test.ts"), []byte("const x = 1"), 0644)
|
||
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||
os.WriteFile(filepath.Join(tmpDir, "sub", "nested.go"), []byte("package sub"), 0644)
|
||
|
||
exec := NewExecutor(tmpDir)
|
||
|
||
// 测试 *.go
|
||
result, err := exec.glob(`{"pattern": "*.go"}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !contains(result, "test.go") {
|
||
t.Errorf("expected test.go in result, got: %s", result)
|
||
}
|
||
|
||
// 测试 **/*.go
|
||
result, err = exec.glob(`{"pattern": "**/*.go"}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !contains(result, "test.go") || !contains(result, "sub/nested.go") {
|
||
t.Errorf("expected test.go and sub/nested.go, got: %s", result)
|
||
}
|
||
}
|
||
|
||
func TestReadFile(t *testing.T) {
|
||
tmpDir := t.TempDir()
|
||
content := "line1\nline2\nline3\nline4\nline5"
|
||
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte(content), 0644)
|
||
|
||
exec := NewExecutor(tmpDir)
|
||
|
||
// 测试基本读取
|
||
result, err := exec.readFile(`{"filename": "test.txt"}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !contains(result, "line1") {
|
||
t.Errorf("expected line1 in result, got: %s", result)
|
||
}
|
||
|
||
// 测试 offset 和 limit
|
||
result, err = exec.readFile(`{"filename": "test.txt", "offset": 1, "limit": 2}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !contains(result, "line2") || !contains(result, "line3") {
|
||
t.Errorf("expected line2 and: %s", result)
|
||
}
|
||
line3, got}
|
||
|
||
func TestEditFile(t *testing.T) {
|
||
tmpDir := t.TempDir()
|
||
os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("hello world"), 0644)
|
||
|
||
exec := NewExecutor(tmpDir)
|
||
|
||
result, err := exec.editFile(`{"filename": "test.txt", "old_string": "world", "new_string": "opencode"}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 验证文件内容
|
||
data, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt"))
|
||
if string(data) != "hello opencode" {
|
||
t.Errorf("expected 'hello opencode', got: %s", string(data))
|
||
}
|
||
|
||
_ = result // ignore
|
||
}
|
||
|
||
func TestWriteFile(t *testing.T) {
|
||
tmpDir := t.TempDir()
|
||
|
||
exec := NewExecutor(tmpDir)
|
||
|
||
// 测试新建文件
|
||
result, err := exec.writeFile(`{"filename": "new.txt", "content": "new content"}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := os.ReadFile(filepath.Join(tmpDir, "new.txt"))
|
||
if string(data) != "new content" {
|
||
t.Errorf("expected 'new content', got: %s", string(data))
|
||
}
|
||
|
||
_ = result // ignore
|
||
}
|
||
|
||
func TestListWorkspace(t *testing.T) {
|
||
tmpDir := t.TempDir()
|
||
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("a"), 0644)
|
||
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("b"), 0644)
|
||
os.MkdirAll(filepath.Join(tmpDir, "sub"), 0755)
|
||
os.WriteFile(filepath.Join(tmpDir, "sub", "c.txt"), []byte("c"), 0644)
|
||
|
||
exec := NewExecutor(tmpDir)
|
||
|
||
result, err := exec.listWorkspace()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if !contains(result, "a.txt") || !contains(result, "b.txt") || !contains(result, "sub/c.txt") {
|
||
t.Errorf("expected all files, got: %s", result)
|
||
}
|
||
}
|
||
|
||
func contains(s, substr string) bool {
|
||
return len(s) > 0 && strings.Contains(s, substr)
|
||
}
|
||
```
|
||
|
||
**Step 2: 运行测试**
|
||
|
||
```bash
|
||
go test ./internal/room/tools/... -v
|
||
```
|
||
|
||
**Step 3: 提交**
|
||
|
||
```bash
|
||
git add internal/room/tools/executor_test.go
|
||
git commit -m "test: 添加文件操作工具测试"
|
||
```
|
||
|
||
---
|
||
|
||
## Plan complete
|
||
|
||
**Two execution options:**
|
||
|
||
1. **Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
|
||
|
||
2. **Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
|
||
|
||
**Which approach?**
|