diff --git a/examples/post_edit.yaml b/examples/post_edit.yaml new file mode 100644 index 00000000..876589f0 --- /dev/null +++ b/examples/post_edit.yaml @@ -0,0 +1,17 @@ +agents: + go_developer: + description: A Go developer agent with post-edit formatting + model: gpt-4 + instruction: | + You are a Go developer assistant. + toolsets: + - type: filesystem + post_edit: + - path: "*.go" + cmd: "gofmt -w $path" + +models: + gpt-4: + provider: openai + model: gpt-4o + temperature: 0.1 diff --git a/pkg/config/v1/types.go b/pkg/config/v1/types.go index 8c9ff829..29e8decf 100644 --- a/pkg/config/v1/types.go +++ b/pkg/config/v1/types.go @@ -17,6 +17,12 @@ type ScriptShellToolConfig struct { WorkingDir string `json:"working_dir,omitempty" yaml:"working_dir,omitempty"` } +// PostEditConfig represents a post-edit command configuration +type PostEditConfig struct { + Path string `json:"path" yaml:"path"` + Cmd string `json:"cmd" yaml:"cmd"` +} + // Toolset represents a tool configuration type Toolset struct { Type string `json:"type,omitempty" yaml:"type,omitempty"` @@ -34,6 +40,9 @@ type Toolset struct { // For the script tool Shell map[string]ScriptShellToolConfig `json:"shell,omitempty" yaml:"shell,omitempty"` + + // For the filesystem tool - post-edit commands + PostEdit []PostEditConfig `json:"post_edit,omitempty" yaml:"post_edit,omitempty"` } type Remote struct { diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index 38a0ef3d..7a6ad8d2 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -245,7 +245,19 @@ func getToolsForAgent(a *latest.AgentConfig, parentDir string, sharedTools map[s return nil, fmt.Errorf("failed to get working directory: %w", err) } - t = append(t, builtin.NewFilesystemTool([]string{wd}, builtin.WithAllowedTools(toolset.Tools))) + opts := []builtin.FileSystemOpt{builtin.WithAllowedTools(toolset.Tools)} + if len(toolset.PostEdit) > 0 { + postEditConfigs := make([]builtin.PostEditConfig, len(toolset.PostEdit)) + for i, pe := range toolset.PostEdit { + postEditConfigs[i] = builtin.PostEditConfig{ + Path: pe.Path, + Cmd: pe.Cmd, + } + } + opts = append(opts, builtin.WithPostEditCommands(postEditConfigs)) + } + + t = append(t, builtin.NewFilesystemTool([]string{wd}, opts...)) case toolset.Type == "mcp" && toolset.Command != "": if gateway != "" { diff --git a/pkg/tools/builtin/filesystem.go b/pkg/tools/builtin/filesystem.go index 7ad06f08..e412aefa 100644 --- a/pkg/tools/builtin/filesystem.go +++ b/pkg/tools/builtin/filesystem.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io/fs" + "log/slog" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -14,9 +16,16 @@ import ( "github.com/docker/cagent/pkg/tools" ) +// PostEditConfig represents a post-edit command configuration +type PostEditConfig struct { + Path string // File path pattern (glob-style) + Cmd string // Command to execute (with $path placeholder) +} + type FilesystemTool struct { allowedDirectories []string allowedTools []string + postEditCommands []PostEditConfig } type FileSystemOpt func(*FilesystemTool) @@ -27,6 +36,12 @@ func WithAllowedTools(allowedTools []string) FileSystemOpt { } } +func WithPostEditCommands(postEditCommands []PostEditConfig) FileSystemOpt { + return func(t *FilesystemTool) { + t.postEditCommands = postEditCommands + } +} + func NewFilesystemTool(allowedDirectories []string, opts ...FileSystemOpt) *FilesystemTool { t := &FilesystemTool{ allowedDirectories: allowedDirectories, @@ -439,6 +454,34 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { return allowedTools, nil } +// executePostEditCommands executes any matching post-edit commands for the given file path +func (t *FilesystemTool) executePostEditCommands(ctx context.Context, filePath string) error { + if len(t.postEditCommands) == 0 { + return nil + } + + for _, postEdit := range t.postEditCommands { + matched, err := filepath.Match(postEdit.Path, filepath.Base(filePath)) + if err != nil { + slog.WarnContext(ctx, "Invalid post-edit pattern", "pattern", postEdit.Path, "error", err) + continue + } + if !matched { + continue + } + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", postEdit.Cmd) + cmd.Env = cmd.Environ() + cmd.Env = append(cmd.Env, "path="+filePath) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("post-edit command failed for %s: %w", filePath, err) + } + + } + return nil +} + // Security helper to check if path is allowed func (t *FilesystemTool) isPathAllowed(path string) error { absPath, err := filepath.Abs(path) @@ -554,7 +597,7 @@ func (t *FilesystemTool) buildDirectoryTree(path string, maxDepth *int, currentD return node, nil } -func (t *FilesystemTool) handleEditFile(_ context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { +func (t *FilesystemTool) handleEditFile(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { var args struct { Path string `json:"path"` Edits []struct { @@ -596,6 +639,11 @@ func (t *FilesystemTool) handleEditFile(_ context.Context, toolCall tools.ToolCa return &tools.ToolCallResult{Output: fmt.Sprintf("Error writing file: %s", err)}, nil } + // Execute post-edit commands + if err := t.executePostEditCommands(ctx, args.Path); err != nil { + return &tools.ToolCallResult{Output: fmt.Sprintf("File edited successfully but post-edit command failed: %s", err)}, nil + } + return &tools.ToolCallResult{Output: fmt.Sprintf("File edited successfully. Changes:\n%s", strings.Join(changes, "\n"))}, nil } @@ -1010,7 +1058,7 @@ func (t *FilesystemTool) handleSearchFilesContent(_ context.Context, toolCall to return &tools.ToolCallResult{Output: strings.Join(results, "\n")}, nil } -func (t *FilesystemTool) handleWriteFile(_ context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { +func (t *FilesystemTool) handleWriteFile(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { var args struct { Path string `json:"path"` Content string `json:"content"` @@ -1027,6 +1075,11 @@ func (t *FilesystemTool) handleWriteFile(_ context.Context, toolCall tools.ToolC return &tools.ToolCallResult{Output: fmt.Sprintf("Error writing file: %s", err)}, nil } + // Execute post-edit commands + if err := t.executePostEditCommands(ctx, args.Path); err != nil { + return &tools.ToolCallResult{Output: fmt.Sprintf("File written successfully but post-edit command failed: %s", err)}, nil + } + return &tools.ToolCallResult{Output: fmt.Sprintf("File written successfully: %s (%d bytes)", args.Path, len(args.Content))}, nil } diff --git a/pkg/tools/builtin/filesystem_test.go b/pkg/tools/builtin/filesystem_test.go index 42213ffc..d477c677 100644 --- a/pkg/tools/builtin/filesystem_test.go +++ b/pkg/tools/builtin/filesystem_test.go @@ -625,6 +625,82 @@ func TestFilesystemTool_StartStop(t *testing.T) { require.NoError(t, err) } +func TestFilesystemTool_PostEditCommands(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "test.go") + testContent := `package main + +func main() { + fmt.Println("hello") +}` + + postEditConfigs := []PostEditConfig{ + { + Path: "*.go", + Cmd: "touch $path.formatted", + }, + } + tool := NewFilesystemTool([]string{tmpDir}, WithPostEditCommands(postEditConfigs)) + + formattedFile := testFile + ".formatted" + t.Run("write_file", func(t *testing.T) { + handler := getToolHandler(t, tool, "write_file") + + // Use proper JSON marshaling for the arguments + args := map[string]any{ + "path": testFile, + "content": testContent, + } + argsBytes, err := json.Marshal(args) + require.NoError(t, err) + + toolCall := tools.ToolCall{ + Function: tools.FunctionCall{ + Arguments: string(argsBytes), + }, + } + + result, err := handler(t.Context(), toolCall) + require.NoError(t, err) + assert.Contains(t, result.Output, "File written successfully") + + _, err = os.Stat(formattedFile) + require.NoError(t, err, "Post-edit command should have created formatted file") + require.NoError(t, os.Remove(formattedFile)) + }) + + t.Run("edit_file", func(t *testing.T) { + editHandler := getToolHandler(t, tool, "edit_file") + + editArgs := map[string]any{ + "path": testFile, + "edits": []map[string]any{ + { + "oldText": "fmt.Println", + "newText": "fmt.Printf", + }, + }, + } + editArgsBytes, err := json.Marshal(editArgs) + require.NoError(t, err) + + editCall := tools.ToolCall{ + Function: tools.FunctionCall{ + Arguments: string(editArgsBytes), + }, + } + + editResult, err := editHandler(t.Context(), editCall) + require.NoError(t, err) + assert.Contains(t, editResult.Output, "File edited successfully") + + // Check that post-edit was run again + _, err = os.Stat(formattedFile) + require.NoError(t, err, "Post-edit command should have run after edit") + }) +} + // Helper functions func getToolHandler(t *testing.T, tool *FilesystemTool, toolName string) tools.ToolHandler {