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

16 KiB
Raw Blame History

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: 创建工具定义文件

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: 提交

git add internal/room/tools/definitions.go
git commit -m "feat: 添加文件操作工具定义"

Task 2: 实现工具执行器

Files:

  • Create: internal/room/tools/executor.go

Step 1: 实现工具执行器

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: 提交

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 结构体中添加:

ToolExecutor *tools.Executor

Step 2: 修改 handle.go 初始化工具执行器

在 Load 函数中添加:

r.ToolExecutor = tools.NewExecutor(filepath.Join(roomDir, "workspace"))

Step 3: 修改 room 调用逻辑以支持工具调用

在需要的地方(如 build 模式下 Agent 调用),传入工具定义并处理工具调用结果。

Step 4: 提交

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 切片中添加:

{
	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: 提交

git add internal/room/tools/definitions.go
git commit -m "feat: 添加 Git 工具定义"

Task 5: 测试文件操作工具

Files:

  • Create: internal/room/tools/executor_test.go

Step 1: 创建测试文件

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: 运行测试

go test ./internal/room/tools/... -v

Step 3: 提交

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?