summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/executor/local.go42
-rw-r--r--internal/executor/localtools.go152
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)
}