diff options
| -rw-r--r-- | internal/executor/local.go | 42 | ||||
| -rw-r--r-- | internal/executor/localtools.go | 152 |
2 files changed, 186 insertions, 8 deletions
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) } |
