diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-06-05 09:42:56 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-06-05 09:42:56 +0000 |
| commit | 3d286974cdc28c68c5ee536ce9303899dae9540e (patch) | |
| tree | c24d1729bebfee142e5521cdc1c91d00bd90c7fd /internal/executor/localtools.go | |
| parent | 6ee7bd83433eacabfbf948ab9ecacb22bcd705ae (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor/localtools.go')
| -rw-r--r-- | internal/executor/localtools.go | 152 |
1 files changed, 148 insertions, 4 deletions
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) } |
