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/local.go | 42 +++++++++-- internal/executor/localtools.go | 152 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 8 deletions(-) (limited to 'internal/executor') diff --git a/internal/executor/local.go b/internal/executor/local.go index 6a240a4..43e76d3 100644 --- a/internal/executor/local.go +++ b/internal/executor/local.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" "strings" "time" @@ -73,6 +74,27 @@ func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Executio } defer stdout.Close() + // --- Git sandbox setup --- + var workDir string + if t.Agent.ProjectDir != "" { + // Reuse existing sandbox on resume (BLOCKED → QUEUED path). + if e.SandboxDir != "" { + workDir = e.SandboxDir + } else { + tmpDir, tmpErr := os.MkdirTemp("", "claudomator-local-*") + if tmpErr != nil { + return fmt.Errorf("local runner: create sandbox temp dir: %w", tmpErr) + } + cloneCmd := exec.CommandContext(ctx, "git", "clone", t.Agent.ProjectDir, tmpDir) + if cloneOut, cloneErr := cloneCmd.CombinedOutput(); cloneErr != nil { + os.RemoveAll(tmpDir) + return fmt.Errorf("local runner: git clone: %w\n%s", cloneErr, cloneOut) + } + workDir = tmpDir + e.SandboxDir = workDir + } + } + temperature := t.Agent.Temperature if temperature == nil && r.DefaultTemperature > 0 { v := r.DefaultTemperature @@ -140,7 +162,7 @@ func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Executio // Re-feed: record the assistant's tool-call turn, then each tool result. messages = append(messages, llm.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls}) for _, tc := range resp.ToolCalls { - result, didBlock, toolErr := dispatchAgentTool(ctx, ch, tc.Function.Name, tc.Function.Arguments) + result, didBlock, toolErr := dispatchAgentTool(ctx, ch, workDir, tc.Function.Name, tc.Function.Arguments) if toolErr != nil { writeResultLine(stdout, "error", toolErr.Error(), totalIn, totalOut) return fmt.Errorf("local runner: tool %s: %w", tc.Function.Name, toolErr) @@ -178,13 +200,25 @@ func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Executio ) } - // If the model asked the user, stop and block. Local resume re-feeds the - // conversation (per-runner sovereign state) — not yet wired, so the session - // ID is empty and resume starts fresh for now. + // If the model asked the user, stop and block. Preserve the sandbox so + // resume can reuse it. if q, isBlocked := channelPendingQuestion(ch); isBlocked { + // workDir is already stored in e.SandboxDir; don't clean up. return &BlockedError{QuestionJSON: q} } + // Push any commits made inside the sandbox back to the origin (project_dir). + if workDir != "" { + pushCmd := exec.CommandContext(ctx, "git", "-C", workDir, "push", "origin", "HEAD") + if pushOut, pushErr := pushCmd.CombinedOutput(); pushErr != nil { + if r.Logger != nil { + r.Logger.Warn("local runner: git push failed", "err", pushErr, "output", string(pushOut)) + } + } + os.RemoveAll(workDir) + e.SandboxDir = "" + } + // For tool-less models report_summary is never called, so synthesise a // summary from the raw assistant output so handleRunResult has something to // store. 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