agent-team/docs/plans/2026-03-10-file-operations-enhancement.md

755 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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?**