From 67e0081c6d573b701ed931f96e14dbe5b4258a17 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 08:49:43 +0000 Subject: refactor(executor): extract provider-neutral tool-use loop (Phase 1) Splits LocalRunner's OpenAI-specific agentic loop into reusable, provider- agnostic pieces so later phases can add native Anthropic/OpenAI/Google/Groq/ OpenRouter adapters without duplicating the control flow: - internal/provider: neutral Provider/ChatRequest/ChatResponse types, plus an openaicompat adapter wrapping the existing internal/llm.Client unchanged - internal/sandbox: Sandbox interface + HostSandbox (git clone/push/cleanup, read_file/write_file/run_bash/glob), lifted verbatim from local.go/localtools.go - internal/agentloop: the extracted tool-use loop (request/response/tool- dispatch/loop, ask_user blocking, stream-json envelope, summary fallback) - internal/agentchannel: AgentChannel/SubtaskSpec/BlockedError/ErrAgentBlocked moved out of internal/executor so agentloop can use them without an import cycle; internal/executor re-exports via type aliases, so no call site changes - internal/executor/nativerunner.go: NativeRunner replaces LocalRunner, wiring agentloop.Loop + openaicompat + HostSandbox together - config.Providers map[string]ProviderConfig added (unused until Phase 2+) Zero intended behavior change: go test -race ./... passes across all packages, and end-to-end stream-json/summary/changestats output was verified byte-compatible against a fake OpenAI-compatible server. Adds test coverage for sandbox tool-dispatch (git clone/push, read/write/bash/glob) that LocalRunner never had. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/sandbox/hostsandbox.go | 214 ++++++++++++++++++++++++++++++++++++++++ internal/sandbox/sandbox.go | 25 +++++ 2 files changed, 239 insertions(+) create mode 100644 internal/sandbox/hostsandbox.go create mode 100644 internal/sandbox/sandbox.go (limited to 'internal/sandbox') diff --git a/internal/sandbox/hostsandbox.go b/internal/sandbox/hostsandbox.go new file mode 100644 index 0000000..76ed9f7 --- /dev/null +++ b/internal/sandbox/hostsandbox.go @@ -0,0 +1,214 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" +) + +// HostSandbox runs tool calls directly on the host filesystem/shell — no +// container isolation, no new safety checks beyond what LocalRunner already +// did. It is safe for concurrent use; a mutex guards the working-directory +// field since GitClone/Cleanup mutate it. +// +// A HostSandbox with an empty working directory (the zero value, or one +// constructed via NewHostSandbox("")) represents "no sandbox configured" — +// matching LocalRunner's historical behavior for tasks without a +// project_dir: file/bash/glob tool calls fail with a "no sandbox working +// directory" error identical to the original dispatchAgentTool guard, and +// GitClone must be called (or the sandbox constructed with an existing dir) +// before those tools will work. +type HostSandbox struct { + mu sync.Mutex + dir string +} + +var _ Sandbox = (*HostSandbox)(nil) + +// NewHostSandbox returns a HostSandbox rooted at dir. Pass "" to construct a +// not-yet-cloned sandbox (GitClone will establish the directory); pass an +// existing directory to reuse a prior sandbox (e.g. resuming a BLOCKED task) +// without cloning again. +func NewHostSandbox(dir string) *HostSandbox { + return &HostSandbox{dir: dir} +} + +// WorkDir returns the current working directory, or "" if none has been +// established yet. +func (s *HostSandbox) WorkDir() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.dir +} + +// GitClone clones remote into a fresh temp directory (if this sandbox has no +// working directory yet) or into the existing one otherwise. branch, if +// non-empty, is passed as `git clone -b `; LocalRunner never set a +// branch, so passing "" reproduces its exact `git clone ` +// invocation. +func (s *HostSandbox) GitClone(ctx context.Context, remote, branch string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.dir == "" { + tmpDir, err := os.MkdirTemp("", "claudomator-local-*") + if err != nil { + return fmt.Errorf("sandbox: create temp dir: %w", err) + } + s.dir = tmpDir + } + + args := []string{"clone"} + if branch != "" { + args = append(args, "-b", branch) + } + args = append(args, remote, s.dir) + cmd := exec.CommandContext(ctx, "git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + os.RemoveAll(s.dir) + s.dir = "" + return fmt.Errorf("sandbox: git clone: %w\n%s", err, out) + } + return nil +} + +// GitPush pushes the working directory's current HEAD to origin. branch, if +// non-empty, is used as the push ref; "" reproduces LocalRunner's exact +// `git push origin HEAD` invocation. +func (s *HostSandbox) GitPush(ctx context.Context, branch string) error { + s.mu.Lock() + dir := s.dir + s.mu.Unlock() + if dir == "" { + return fmt.Errorf("sandbox: git push: no sandbox working directory") + } + ref := branch + if ref == "" { + ref = "HEAD" + } + cmd := exec.CommandContext(ctx, "git", "-C", dir, "push", "origin", ref) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("sandbox: git push: %w\n%s", err, out) + } + return nil +} + +// Cleanup removes the sandbox's working directory and resets it to "". A +// no-op if no working directory was ever established. +func (s *HostSandbox) Cleanup(_ context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.dir == "" { + return nil + } + err := os.RemoveAll(s.dir) + s.dir = "" + return err +} + +// resolvePath joins a relative path against the working directory, leaving +// absolute paths untouched — matching dispatchAgentTool's historical +// behavior exactly. +func (s *HostSandbox) resolvePath(p string) (string, error) { + s.mu.Lock() + dir := s.dir + s.mu.Unlock() + if dir == "" { + return "", fmt.Errorf("no sandbox working directory") + } + if !filepath.IsAbs(p) { + p = filepath.Join(dir, p) + } + return p, nil +} + +func (s *HostSandbox) ReadFile(_ context.Context, path string) (string, error) { + p, err := s.resolvePath(path) + if err != nil { + return "", fmt.Errorf("read_file: %w", err) + } + data, err := os.ReadFile(p) + if err != nil { + return "", err + } + return string(data), nil +} + +func (s *HostSandbox) WriteFile(_ context.Context, path, content string) error { + p, err := s.resolvePath(path) + if err != nil { + return fmt.Errorf("write_file: %w", err) + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte(content), 0o644) +} + +// maxToolOutput caps stdout/stderr returned from RunBash, matching +// dispatchAgentTool's historical 8KB truncation. +const maxToolOutput = 8 * 1024 + +func (s *HostSandbox) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { + s.mu.Lock() + dir := s.dir + s.mu.Unlock() + if dir == "" { + return "", "", 0, fmt.Errorf("run_bash: no sandbox working directory") + } + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = dir + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + runErr := cmd.Run() + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return "", "", 0, runErr + } + } + + stdout = outBuf.String() + stderr = errBuf.String() + if len(stdout) > maxToolOutput { + stdout = stdout[:maxToolOutput] + "\n[truncated]" + } + if len(stderr) > maxToolOutput { + stderr = stderr[:maxToolOutput] + "\n[truncated]" + } + return stdout, stderr, exitCode, nil +} + +func (s *HostSandbox) Glob(_ context.Context, pattern string) ([]string, error) { + s.mu.Lock() + dir := s.dir + s.mu.Unlock() + if dir == "" { + return nil, fmt.Errorf("glob: no sandbox working directory") + } + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(dir, pattern) + } + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + rel := make([]string, 0, len(matches)) + for _, m := range matches { + r, relErr := filepath.Rel(dir, m) + if relErr != nil { + r = m + } + rel = append(rel, r) + } + return rel, nil +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000..4275475 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,25 @@ +// Package sandbox defines the filesystem/shell/git surface an agent loop +// executes tool calls against. Sandbox is intentionally narrow — just the +// operations internal/agentloop's read_file/write_file/run_bash/glob tools +// and its git clone/push/cleanup lifecycle need. +// +// Phase 1 ships exactly one implementation, HostSandbox, which lifts the +// LocalRunner's existing behavior (host git clone into a temp dir, plain +// os.Exec-backed file/bash tools) verbatim. A DockerSandbox (isolation +// improvements, new safety checks) is explicitly out of scope for this phase. +package sandbox + +import "context" + +// Sandbox is the environment a single task execution runs its tool calls +// against. +type Sandbox interface { + ReadFile(ctx context.Context, path string) (string, error) + WriteFile(ctx context.Context, path, content string) error + RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) + Glob(ctx context.Context, pattern string) ([]string, error) + GitClone(ctx context.Context, remote, branch string) error + GitPush(ctx context.Context, branch string) error + WorkDir() string + Cleanup(ctx context.Context) error +} -- cgit v1.2.3