Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions examples/post_edit.yaml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions pkg/config/v1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion pkg/teamloader/teamloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
57 changes: 55 additions & 2 deletions pkg/tools/builtin/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"`
Expand All @@ -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
}

Expand Down
76 changes: 76 additions & 0 deletions pkg/tools/builtin/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down