From b4fbb7147b98ffbab08c266b217f57f40bb0a443 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Feb 2026 17:42:34 +0000 Subject: [PATCH] chore: consolidate filesystem tools and fix build errors --- pkg/agent/loop.go | 14 +- pkg/tools/edit.go | 176 ---------------------- pkg/tools/filesystem.go | 325 +++++++++++++++++++++++++++------------- pkg/tools/repo_map.go | 48 ++++-- 4 files changed, 270 insertions(+), 293 deletions(-) delete mode 100644 pkg/tools/edit.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0bdc13d..2b975ac 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -125,9 +125,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers }) toolsRegistry := tools.NewToolRegistry() - toolsRegistry.Register(&tools.ReadFileTool{}) - toolsRegistry.Register(&tools.WriteFileTool{}) - toolsRegistry.Register(&tools.ListDirTool{}) + toolsRegistry.Register(tools.NewReadFileTool("")) + toolsRegistry.Register(tools.NewWriteFileTool("")) + toolsRegistry.Register(tools.NewListDirTool("")) toolsRegistry.Register(tools.NewExecTool(cfg.Tools.Shell, workspace)) if cs != nil { @@ -164,7 +164,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(tools.NewPipelineDispatchTool(orchestrator, subagentManager)) // Register edit file tool - editFileTool := tools.NewEditFileTool(workspace) + editFileTool := tools.NewEditFileTool("") toolsRegistry.Register(editFileTool) // Register memory search tool @@ -277,7 +277,7 @@ func (al *AgentLoop) enqueueMessage(ctx context.Context, msg bus.InboundMessage) select { case worker.queue <- msg: case <-ctx.Done(): - case <-time.After(2 * time.Second): + case <-time.After(5 * time.Second): al.bus.PublishOutbound(bus.OutboundMessage{ Buttons: nil, Channel: msg.Channel, @@ -443,7 +443,7 @@ func (al *AgentLoop) startAutonomy(msg bus.InboundMessage, idleInterval time.Dur al.autonomyMu.Unlock() go al.runAutonomyLoop(sessionCtx, msg) - return fmt.Sprintf("自主模式已开启:自动拆解执行 + 阶段回报;空闲超过 %s 会主动推进并汇报。", idleInterval.Truncate(time.Second)) + return fmt.Sprintf("自主模式已开启:自动拆解执行 + 阶段汇报;空闲超过 %s 会主动推进并汇报。", idleInterval.Truncate(time.Second)) } func (al *AgentLoop) stopAutonomy(sessionKey string) bool { @@ -984,7 +984,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe al.sessions.AddMessage(sessionKey, "user", fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content)) // 如果 finalContent 中没有包含 tool calls (即最后一次 LLM 返回的结果) - // 我们已经通过循环内部的 AddMessageFull 存储了前面的步骤 + // 我们已经通过循环内部의 AddMessageFull 存储了前面的步骤 // 这里的 AddMessageFull 会存储最终回复 al.sessions.AddMessageFull(sessionKey, providers.Message{ Role: "assistant", diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go deleted file mode 100644 index 339148e..0000000 --- a/pkg/tools/edit.go +++ /dev/null @@ -1,176 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" -) - -// EditFileTool edits a file by replacing old_text with new_text. -// The old_text must exist exactly in the file. -type EditFileTool struct { - allowedDir string // Optional directory restriction for security -} - -// NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool(allowedDir string) *EditFileTool { - return &EditFileTool{ - allowedDir: allowedDir, - } -} - -func (t *EditFileTool) Name() string { - return "edit_file" -} - -func (t *EditFileTool) Description() string { - return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." -} - -func (t *EditFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "The file path to edit", - }, - "old_text": map[string]interface{}{ - "type": "string", - "description": "The exact text to find and replace", - }, - "new_text": map[string]interface{}{ - "type": "string", - "description": "The text to replace with", - }, - }, - "required": []string{"path", "old_text", "new_text"}, - } -} - -func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - path, ok := args["path"].(string) - if !ok { - return "", fmt.Errorf("path is required") - } - - oldText, ok := args["old_text"].(string) - if !ok { - return "", fmt.Errorf("old_text is required") - } - - newText, ok := args["new_text"].(string) - if !ok { - return "", fmt.Errorf("new_text is required") - } - - // Resolve path and enforce directory restriction if configured - resolvedPath := path - if filepath.IsAbs(path) { - resolvedPath = filepath.Clean(path) - } else { - abs, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - resolvedPath = abs - } - - // Check directory restriction - if t.allowedDir != "" { - allowedAbs, err := filepath.Abs(t.allowedDir) - if err != nil { - return "", fmt.Errorf("failed to resolve allowed directory: %w", err) - } - if !strings.HasPrefix(resolvedPath, allowedAbs) { - return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir) - } - } - - if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", path) - } - - content, err := os.ReadFile(resolvedPath) - if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) - } - - contentStr := string(content) - - if !strings.Contains(contentStr, oldText) { - return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly") - } - - count := strings.Count(contentStr, oldText) - if count > 1 { - return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) - } - - newContent := strings.Replace(contentStr, oldText, newText, 1) - - if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) - } - - return fmt.Sprintf("Successfully edited %s", path), nil -} - -type AppendFileTool struct{} - -func NewAppendFileTool() *AppendFileTool { - return &AppendFileTool{} -} - -func (t *AppendFileTool) Name() string { - return "append_file" -} - -func (t *AppendFileTool) Description() string { - return "Append content to the end of a file" -} - -func (t *AppendFileTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "The file path to append to", - }, - "content": map[string]interface{}{ - "type": "string", - "description": "The content to append", - }, - }, - "required": []string{"path", "content"}, - } -} - -func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - path, ok := args["path"].(string) - if !ok { - return "", fmt.Errorf("path is required") - } - - content, ok := args["content"].(string) - if !ok { - return "", fmt.Errorf("content is required") - } - - filePath := filepath.Clean(path) - - f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) - } - defer f.Close() - - if _, err := f.WriteString(content); err != nil { - return "", fmt.Errorf("failed to append to file: %w", err) - } - - return fmt.Sprintf("Successfully appended to %s", path), nil -} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 49d0a1a..39560be 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -3,14 +3,19 @@ package tools import ( "context" "fmt" - "io" "os" "path/filepath" - "sort" "strings" ) -type ReadFileTool struct{} +// ReadFileTool reads the contents of a file. +type ReadFileTool struct { + allowedDir string +} + +func NewReadFileTool(allowedDir string) *ReadFileTool { + return &ReadFileTool{allowedDir: allowedDir} +} func (t *ReadFileTool) Name() string { return "read_file" @@ -28,14 +33,14 @@ func (t *ReadFileTool) Parameters() map[string]interface{} { "type": "string", "description": "Path to the file to read", }, - "limit": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of bytes to read", - }, "offset": map[string]interface{}{ "type": "integer", "description": "Byte offset to start reading from", }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of bytes to read", + }, }, "required": []string{"path"}, } @@ -47,68 +52,66 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) return "", fmt.Errorf("path is required") } - limit := int64(0) - if val, ok := args["limit"].(float64); ok { - limit = int64(val) + resolvedPath := path + if filepath.IsAbs(path) { + resolvedPath = filepath.Clean(path) + } else { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + resolvedPath = abs } - offset := int64(0) - if val, ok := args["offset"].(float64); ok { - offset = int64(val) + if t.allowedDir != "" { + allowedAbs, _ := filepath.Abs(t.allowedDir) + if !strings.HasPrefix(resolvedPath, allowedAbs) { + return "", fmt.Errorf("path %s is outside allowed directory", path) + } } - f, err := os.Open(path) + f, err := os.Open(resolvedPath) if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) + return "", err } defer f.Close() - info, err := f.Stat() + stat, err := f.Stat() if err != nil { - return "", fmt.Errorf("failed to stat file: %w", err) + return "", err } - if offset >= info.Size() { - return "", nil // Offset beyond file size + offset := int64(0) + if o, ok := args["offset"].(float64); ok { + offset = int64(o) } - if _, err := f.Seek(offset, 0); err != nil { - return "", fmt.Errorf("failed to seek: %w", err) + limit := int64(stat.Size()) + if l, ok := args["limit"].(float64); ok { + limit = int64(l) } - // Default read all if limit is not set or 0 - readLimit := info.Size() - offset - if limit > 0 && limit < readLimit { - readLimit = limit + if offset >= stat.Size() { + return "", fmt.Errorf("offset %d is beyond file size %d", offset, stat.Size()) } - // Safety cap: don't read insanely large files into memory unless requested - // But tool says "read file", so we respect limit. - // If limit is 0 (unspecified), maybe we should default to a reasonable max? - // The original code used os.ReadFile which reads ALL. So I should probably keep that behavior if limit is 0. - // However, if limit is explicitly passed as 0, it might mean "read 0 bytes". But usually in JSON APIs 0 means default or none. - // Let's assume limit > 0 means limit. If limit <= 0, read until EOF. - - var content []byte - if limit > 0 { - content = make([]byte, readLimit) - n, err := io.ReadFull(f, content) - if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { - return "", fmt.Errorf("failed to read file: %w", err) - } - content = content[:n] - } else { - // Read until EOF - content, err = io.ReadAll(f) - if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) - } + data := make([]byte, limit) + n, err := f.ReadAt(data, offset) + if err != nil && err.Error() != "EOF" { + return "", err } - return string(content), nil + return string(data[:n]), nil } -type WriteFileTool struct{} +// WriteFileTool writes content to a file. +type WriteFileTool struct { + allowedDir string +} + +func NewWriteFileTool(allowedDir string) *WriteFileTool { + return &WriteFileTool{allowedDir: allowedDir} +} func (t *WriteFileTool) Name() string { return "write_file" @@ -146,19 +149,39 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{} return "", fmt.Errorf("content is required") } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("failed to create directory: %w", err) + resolvedPath := path + if filepath.IsAbs(path) { + resolvedPath = filepath.Clean(path) + } else { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + resolvedPath = abs } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + if t.allowedDir != "" { + allowedAbs, _ := filepath.Abs(t.allowedDir) + if !strings.HasPrefix(resolvedPath, allowedAbs) { + return "", fmt.Errorf("path %s is outside allowed directory", path) + } } - return "File written successfully", nil + if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil { + return "", err + } + + return fmt.Sprintf("File written successfully: %s", path), nil } -type ListDirTool struct{} +// ListDirTool lists files and directories in a path. +type ListDirTool struct { + allowedDir string +} + +func NewListDirTool(allowedDir string) *ListDirTool { + return &ListDirTool{allowedDir: allowedDir} +} func (t *ListDirTool) Name() string { return "list_dir" @@ -188,60 +211,162 @@ func (t *ListDirTool) Parameters() map[string]interface{} { func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { path, ok := args["path"].(string) if !ok { - path = "." + return "", fmt.Errorf("path is required") } recursive, _ := args["recursive"].(bool) - var result strings.Builder - - if recursive { - err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error { - if err != nil { - return err - } - relPath, err := filepath.Rel(path, p) - if err != nil { - relPath = p - } - if relPath == "." { - return nil - } - if info.IsDir() { - result.WriteString(fmt.Sprintf("DIR: %s\n", relPath)) - } else { - result.WriteString(fmt.Sprintf("FILE: %s\n", relPath)) - } - return nil - }) - if err != nil { - return "", fmt.Errorf("failed to walk directory: %w", err) - } + resolvedPath := path + if filepath.IsAbs(path) { + resolvedPath = filepath.Clean(path) } else { - entries, err := os.ReadDir(path) + abs, err := filepath.Abs(path) if err != nil { - return "", fmt.Errorf("failed to read directory: %w", err) + return "", fmt.Errorf("failed to resolve path: %w", err) } + resolvedPath = abs + } - // Sort entries: directories first, then files - sort.Slice(entries, func(i, j int) bool { - if entries[i].IsDir() && !entries[j].IsDir() { - return true - } - if !entries[i].IsDir() && entries[j].IsDir() { - return false - } - return entries[i].Name() < entries[j].Name() - }) - - for _, entry := range entries { - if entry.IsDir() { - result.WriteString(fmt.Sprintf("DIR: %s\n", entry.Name())) - } else { - result.WriteString(fmt.Sprintf("FILE: %s\n", entry.Name())) - } + if t.allowedDir != "" { + allowedAbs, _ := filepath.Abs(t.allowedDir) + if !strings.HasPrefix(resolvedPath, allowedAbs) { + return "", fmt.Errorf("path %s is outside allowed directory", path) } } - return result.String(), nil + var results []string + if recursive { + err := filepath.Walk(resolvedPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(resolvedPath, path) + if rel == "." { + return nil + } + prefix := "FILE: " + if info.IsDir() { + prefix = "DIR: " + } + results = append(results, prefix+rel) + return nil + }) + if err != nil { + return "", err + } + } else { + entries, err := os.ReadDir(resolvedPath) + if err != nil { + return "", err + } + for _, entry := range entries { + prefix := "FILE: " + if entry.IsDir() { + prefix = "DIR: " + } + results = append(results, prefix+entry.Name()) + } + } + + if len(results) == 0 { + return "(empty)", nil + } + + return strings.Join(results, "\n"), nil +} + +// EditFileTool edits a file by replacing old_text with new_text. +// The old_text must exist exactly in the file. +type EditFileTool struct { + allowedDir string +} + +func NewEditFileTool(allowedDir string) *EditFileTool { + return &EditFileTool{allowedDir: allowedDir} +} + +func (t *EditFileTool) Name() string { + return "edit_file" +} + +func (t *EditFileTool) Description() string { + return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." +} + +func (t *EditFileTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "The file path to edit", + }, + "old_text": map[string]interface{}{ + "type": "string", + "description": "The exact text to find and replace", + }, + "new_text": map[string]interface{}{ + "type": "string", + "description": "The text to replace with", + }, + }, + "required": []string{"path", "old_text", "new_text"}, + } +} + +func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + path, ok := args["path"].(string) + if !ok { + return "", fmt.Errorf("path is required") + } + + oldText, ok := args["old_text"].(string) + if !ok { + return "", fmt.Errorf("old_text is required") + } + + newText, ok := args["new_text"].(string) + if !ok { + return "", fmt.Errorf("new_text is required") + } + + resolvedPath := path + if filepath.IsAbs(path) { + resolvedPath = filepath.Clean(path) + } else { + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + resolvedPath = abs + } + + if t.allowedDir != "" { + allowedAbs, _ := filepath.Abs(t.allowedDir) + if !strings.HasPrefix(resolvedPath, allowedAbs) { + return "", fmt.Errorf("path %s is outside allowed directory", path) + } + } + + content, err := os.ReadFile(resolvedPath) + if err != nil { + return "", err + } + + contentStr := string(content) + if !strings.Contains(contentStr, oldText) { + return "", fmt.Errorf("old_text not found in file") + } + + count := strings.Count(contentStr, oldText) + if count > 1 { + return "", fmt.Errorf("old_text appears %d times, please make it unique", count) + } + + newContent := strings.Replace(contentStr, oldText, newText, 1) + if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil { + return "", err + } + + return fmt.Sprintf("Successfully edited %s", path), nil } diff --git a/pkg/tools/repo_map.go b/pkg/tools/repo_map.go index fca9b79..1772284 100644 --- a/pkg/tools/repo_map.go +++ b/pkg/tools/repo_map.go @@ -247,35 +247,63 @@ func extractSymbols(path, lang string) []string { switch lang { case "go": - re := regexp.MustCompile(`(?m)^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) - for _, m := range re.FindAllStringSubmatch(content, 12) { + // Top-level functions + reFunc := regexp.MustCompile(`(?m)^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) + for _, m := range reFunc.FindAllStringSubmatch(content, 20) { if len(m) > 1 { out = append(out, m[1]) } } - typeRe := regexp.MustCompile(`(?m)^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) - for _, m := range typeRe.FindAllStringSubmatch(content, 12) { + // Methods: func (r *Receiver) MethodName(...) + reMethod := regexp.MustCompile(`(?m)^func\s+\([^)]+\)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) + for _, m := range reMethod.FindAllStringSubmatch(content, 20) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + // Types + reType := regexp.MustCompile(`(?m)^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+`) + for _, m := range reType.FindAllStringSubmatch(content, 20) { if len(m) > 1 { out = append(out, m[1]) } } case "python": - re := regexp.MustCompile(`(?m)^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`) - for _, m := range re.FindAllStringSubmatch(content, 12) { + // Functions and Classes + re := regexp.MustCompile(`(?m)^(?:def|class)\s+([A-Za-z_][A-Za-z0-9_]*)`) + for _, m := range re.FindAllStringSubmatch(content, 30) { if len(m) > 1 { out = append(out, m[1]) } } + case "javascript", "typescript": + // function Name(...) or class Name ... or const Name = (...) => + re := regexp.MustCompile(`(?m)^(?:export\s+)?(?:function|class|const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)`) + for _, m := range re.FindAllStringSubmatch(content, 30) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + case "markdown": + // Headers as symbols + re := regexp.MustCompile(`(?m)^#+\s+(.+)$`) + for _, m := range re.FindAllStringSubmatch(content, 20) { + if len(m) > 1 { + out = append(out, strings.TrimSpace(m[1])) + } + } } if len(out) == 0 { return nil } sort.Strings(out) - uniq := out[:1] - for i := 1; i < len(out); i++ { - if out[i] != out[i-1] { - uniq = append(uniq, out[i]) + uniq := []string{} + seen := make(map[string]bool) + for _, s := range out { + if !seen[s] { + uniq = append(uniq, s) + seen[s] = true } } return uniq