From 3d286974cdc28c68c5ee536ce9303899dae9540e Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Fri, 5 Jun 2026 09:42:56 +0000 Subject: feat(local-runner): add file I/O tools and git sandbox Add read_file, write_file, run_bash, and glob tool definitions to agentToolDefs() in localtools.go, with dispatch in dispatchAgentTool() (signature now includes workDir). File and bash tools return an error when workDir is empty. LocalRunner.Run() clones t.Agent.ProjectDir into a temp dir when set, passes workDir to dispatchAgentTool, pushes commits back on success, preserves the sandbox in e.SandboxDir on BLOCKED, and cleans up on completion. Co-Authored-By: Claude Sonnet 4.6 --- internal/executor/localtools.go | 152 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 4 deletions(-) (limited to 'internal/executor/localtools.go') diff --git a/internal/executor/localtools.go b/internal/executor/localtools.go index 48b862f..9fd2cfc 100644 --- a/internal/executor/localtools.go +++ b/internal/executor/localtools.go @@ -1,10 +1,14 @@ package executor import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "os" + "os/exec" + "path/filepath" "github.com/thepeterstone/claudomator/internal/llm" ) @@ -61,14 +65,54 @@ func agentToolDefs() []llm.Tool { "required": []string{"message"}, }, }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "read_file", + Description: "Read the contents of a file in the sandbox working directory.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"path": strProp("path to the file, relative or absolute")}, + "required": []string{"path"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "write_file", + Description: "Write or overwrite a file in the sandbox working directory.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": strProp("path to the file, relative or absolute"), + "content": strProp("content to write"), + }, + "required": []string{"path", "content"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "run_bash", + Description: "Run a shell command in the sandbox working directory. Returns exit code, stdout, and stderr.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"command": strProp("shell command to execute")}, + "required": []string{"command"}, + }, + }}, + {Type: "function", Function: llm.ToolFunction{ + Name: "glob", + Description: "List files matching a glob pattern relative to the sandbox working directory.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{"pattern": strProp("glob pattern, e.g. **/*.go")}, + "required": []string{"pattern"}, + }, + }}, } } // dispatchAgentTool invokes one model-requested tool against the AgentChannel. -// It returns the text to feed back to the model as the tool result, and a -// blocked flag set when ask_user could not be answered in-session (the run must -// stop and the task block). -func dispatchAgentTool(ctx context.Context, ch AgentChannel, name, argsJSON string) (result string, blocked bool, err error) { +// workDir is the sandbox working directory; file/bash tools return an error if +// it is empty. It returns the text to feed back to the model as the tool +// result, and a blocked flag set when ask_user could not be answered in-session +// (the run must stop and the task block). +func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, argsJSON string) (result string, blocked bool, err error) { switch name { case "ask_user": var a struct { @@ -129,6 +173,106 @@ func dispatchAgentTool(ctx context.Context, ch AgentChannel, name, argsJSON stri } return "Noted.", false, nil + case "read_file": + if workDir == "" { + return "", false, fmt.Errorf("read_file: no sandbox working directory") + } + var a struct { + Path string `json:"path"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + p := a.Path + if !filepath.IsAbs(p) { + p = filepath.Join(workDir, p) + } + data, readErr := os.ReadFile(p) + if readErr != nil { + return "", false, readErr + } + return string(data), false, nil + + case "write_file": + if workDir == "" { + return "", false, fmt.Errorf("write_file: no sandbox working directory") + } + var a struct { + Path string `json:"path"` + Content string `json:"content"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + p := a.Path + if !filepath.IsAbs(p) { + p = filepath.Join(workDir, p) + } + if mkErr := os.MkdirAll(filepath.Dir(p), 0o755); mkErr != nil { + return "", false, mkErr + } + if writeErr := os.WriteFile(p, []byte(a.Content), 0o644); writeErr != nil { + return "", false, writeErr + } + return "Written.", false, nil + + case "run_bash": + if workDir == "" { + return "", false, fmt.Errorf("run_bash: no sandbox working directory") + } + var a struct { + Command string `json:"command"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + cmd := exec.CommandContext(ctx, "sh", "-c", a.Command) + cmd.Dir = workDir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + exitCode := 0 + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return "", false, runErr + } + } + const maxOutput = 8 * 1024 + out := stdout.String() + errOut := stderr.String() + if len(out) > maxOutput { + out = out[:maxOutput] + "\n[truncated]" + } + if len(errOut) > maxOutput { + errOut = errOut[:maxOutput] + "\n[truncated]" + } + return fmt.Sprintf("exit_code: %d\nstdout:\n%s\nstderr:\n%s", exitCode, out, errOut), false, nil + + case "glob": + if workDir == "" { + return "", false, fmt.Errorf("glob: no sandbox working directory") + } + var a struct { + Pattern string `json:"pattern"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + pattern := a.Pattern + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(workDir, pattern) + } + matches, globErr := filepath.Glob(pattern) + if globErr != nil { + return "", false, globErr + } + // Return paths relative to workDir for readability. + rel := make([]string, 0, len(matches)) + for _, m := range matches { + r, relErr := filepath.Rel(workDir, m) + if relErr != nil { + r = m + } + rel = append(rel, r) + } + out, _ := json.Marshal(rel) + return string(out), false, nil + default: return "", false, fmt.Errorf("unknown tool %q", name) } -- cgit v1.2.3