summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
commit67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch)
treebc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal
parent3d286974cdc28c68c5ee536ce9303899dae9540e (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal')
-rw-r--r--internal/agentchannel/agentchannel.go65
-rw-r--r--internal/agentloop/agentloop.go265
-rw-r--r--internal/agentloop/tools.go (renamed from internal/executor/localtools.go)163
-rw-r--r--internal/cli/run.go6
-rw-r--r--internal/cli/serve.go6
-rw-r--r--internal/config/config.go17
-rw-r--r--internal/config/config_test.go43
-rw-r--r--internal/executor/channel.go51
-rw-r--r--internal/executor/helpers.go11
-rw-r--r--internal/executor/local.go282
-rw-r--r--internal/executor/nativerunner.go127
-rw-r--r--internal/executor/nativerunner_test.go (renamed from internal/executor/local_test.go)160
-rw-r--r--internal/provider/openaicompat/openaicompat.go140
-rw-r--r--internal/provider/provider.go72
-rw-r--r--internal/sandbox/hostsandbox.go214
-rw-r--r--internal/sandbox/sandbox.go25
16 files changed, 1192 insertions, 455 deletions
diff --git a/internal/agentchannel/agentchannel.go b/internal/agentchannel/agentchannel.go
new file mode 100644
index 0000000..2730dfa
--- /dev/null
+++ b/internal/agentchannel/agentchannel.go
@@ -0,0 +1,65 @@
+// Package agentchannel holds the runner-agnostic types describing how a Runner
+// reports agent-originated signals (summary, questions, subtasks, progress)
+// back to the rest of the system, plus the sentinel/blocked-run error types
+// that flow through that channel.
+//
+// These types used to live in internal/executor. They were extracted here in
+// Phase 1 of the multi-provider refactor so that internal/agentloop (which
+// needs to construct/inspect them while running the provider-neutral tool-use
+// loop) does not have to import internal/executor — and internal/executor, in
+// turn, can import internal/agentloop (via NativeRunner) without creating an
+// import cycle. internal/executor re-exports everything here via type
+// aliases/var assignments (see internal/executor/channel.go) so existing code
+// that refers to executor.AgentChannel, executor.SubtaskSpec,
+// executor.BlockedError, and executor.ErrAgentBlocked keeps compiling
+// unchanged.
+package agentchannel
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+// ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot
+// deliver an answer within the current run — e.g. the file transport, which can
+// only write-and-exit. Runners translate it into a *BlockedError so the task
+// blocks and resumes later. Transports that can suspend the agent in-session
+// (MCP, Phase 2) instead block and return the answer.
+var ErrAgentBlocked = errors.New("agent blocked awaiting user input")
+
+// SubtaskSpec describes a child task an agent wants to spawn.
+type SubtaskSpec struct {
+ Name string
+ Instructions string
+ Model string
+ MaxBudgetUSD float64
+}
+
+// AgentChannel is how a Runner reports agent-originated signals to the rest of
+// the system. Implementations translate these into stored artifacts and events.
+// The transport by which a Runner detects these signals — post-exit files
+// today, MCP/tool-use in Phase 2 — is the Runner's private concern.
+type AgentChannel interface {
+ // AskUser records that the agent needs human input. Transports that can
+ // suspend the agent in-session return the answer; those that cannot return
+ // ErrAgentBlocked.
+ AskUser(ctx context.Context, questionJSON string) (answer string, err error)
+ // ReportSummary records the agent's final summary text.
+ ReportSummary(ctx context.Context, summary string) error
+ // SpawnSubtask creates a child task and returns its ID.
+ SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error)
+ // RecordProgress records a free-form progress note from the agent.
+ RecordProgress(ctx context.Context, message string) error
+}
+
+// BlockedError is returned by Run when the agent asked the user a question and
+// the run stopped to await an answer. The pool transitions the task to BLOCKED
+// and stores the question for the user.
+type BlockedError struct {
+ QuestionJSON string // raw JSON from the question
+ SessionID string // claude session to resume once the user answers
+ SandboxDir string // preserved sandbox path; resume must run here so the agent finds its session files
+}
+
+func (e *BlockedError) Error() string { return fmt.Sprintf("task blocked: %s", e.QuestionJSON) }
diff --git a/internal/agentloop/agentloop.go b/internal/agentloop/agentloop.go
new file mode 100644
index 0000000..73c4c83
--- /dev/null
+++ b/internal/agentloop/agentloop.go
@@ -0,0 +1,265 @@
+// Package agentloop implements the provider-neutral tool-use control flow
+// extracted from the former executor.LocalRunner.Run: build messages, call a
+// provider.Provider in a bounded loop, dispatch any tool calls the model
+// makes (back-channel tools to an agentchannel.AgentChannel, sandbox tools to
+// a sandbox.Sandbox), and re-feed results until the model stops calling
+// tools or the turn budget is exhausted.
+//
+// Loop deliberately knows nothing about *how* instructions were assembled
+// (planning preamble, tinyllama tool-gating, etc.) or how the sandbox's
+// working directory was established (fresh git clone vs. resumed BLOCKED
+// sandbox) — those decisions stay with the caller (executor.NativeRunner),
+// which lives in internal/executor and can freely reuse executor's existing
+// buildAgentInstructions/withPlanningPreamble helpers without agentloop
+// having to import executor (which would cycle back through NativeRunner).
+package agentloop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/agentchannel"
+ "github.com/thepeterstone/claudomator/internal/provider"
+ "github.com/thepeterstone/claudomator/internal/sandbox"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// DefaultMaxTurns bounds the tool-use loop so a misbehaving model cannot spin
+// forever calling tools without finishing. Matches the former
+// executor.maxLocalToolTurns.
+const DefaultMaxTurns = 12
+
+// Loop drives a provider-neutral tool-use conversation for a single task
+// execution.
+type Loop struct {
+ Provider provider.Provider
+ Sandbox sandbox.Sandbox
+ Channel agentchannel.AgentChannel
+ MaxTurns int
+
+ // DefaultTemperature is used when the task doesn't specify its own
+ // Agent.Temperature. Matches the former LocalRunner.DefaultTemperature.
+ DefaultTemperature float64
+
+ Logger *slog.Logger
+}
+
+// Run drives the chat completion loop against l.Provider with the agent
+// back-channel + sandbox tools declared (when toolsEnabled), writing
+// assistant text and a final result line to stdout in the same stream-json
+// envelope ClaudeRunner/ContainerRunner emit, so downstream parsers
+// (extractSummary, task.ParseChangestatFromFile) keep working unchanged.
+//
+// instructions is the fully-assembled user-turn text (already including any
+// planning preamble the caller decided to prepend). If the model calls
+// ask_user, Run stops and returns a *agentchannel.BlockedError, leaving the
+// sandbox uncleaned so a resume can reuse it.
+func (l *Loop) Run(ctx context.Context, t *task.Task, e *storage.Execution, stdout io.Writer, instructions string, toolsEnabled bool) error {
+ maxTurns := l.MaxTurns
+ if maxTurns <= 0 {
+ maxTurns = DefaultMaxTurns
+ }
+
+ temperature := t.Agent.Temperature
+ if temperature == nil && l.DefaultTemperature > 0 {
+ v := l.DefaultTemperature
+ temperature = &v
+ }
+
+ var tools []provider.ToolSpec
+ if toolsEnabled {
+ tools = agentToolSpecs()
+ }
+
+ system := strings.TrimSpace(t.Agent.SystemPromptAppend)
+ messages := []provider.Message{{Role: "user", Text: instructions}}
+
+ start := time.Now()
+ var totalIn, totalOut int
+ var lastFinish string
+ blocked := false
+ var fullText string
+
+ for turn := 0; turn < maxTurns; turn++ {
+ req := provider.ChatRequest{
+ Model: t.Agent.Model,
+ System: system,
+ Messages: messages,
+ Temperature: temperature,
+ MaxTokens: t.Agent.MaxTokens,
+ Tools: tools,
+ }
+ resp, chatErr := l.Provider.Chat(ctx, req)
+ if chatErr != nil {
+ // If the model doesn't support tool-use, retry once without tools.
+ if tools != nil && strings.Contains(strings.ToLower(chatErr.Error()), "does not support tools") {
+ tools = nil
+ req.Tools = nil
+ resp, chatErr = l.Provider.Chat(ctx, req)
+ }
+ if chatErr != nil {
+ writeResultLine(stdout, "error", chatErr.Error(), totalIn, totalOut)
+ return fmt.Errorf("agentloop: chat: %w", chatErr)
+ }
+ }
+ totalIn += resp.Usage.InputTokens
+ totalOut += resp.Usage.OutputTokens
+ lastFinish = resp.StopReason
+
+ if resp.Text != "" {
+ writeAssistantTextLine(stdout, resp.Text)
+ fullText += resp.Text
+ }
+
+ if len(resp.ToolCalls) == 0 {
+ break
+ }
+
+ // Re-feed: record the assistant's tool-call turn, then each tool result.
+ messages = append(messages, provider.Message{Role: "assistant", Text: resp.Text, ToolCalls: resp.ToolCalls})
+ for _, tc := range resp.ToolCalls {
+ result, didBlock, toolErr := l.dispatchTool(ctx, tc.Name, tc.ArgsJSON)
+ if toolErr != nil {
+ writeResultLine(stdout, "error", toolErr.Error(), totalIn, totalOut)
+ return fmt.Errorf("agentloop: tool %s: %w", tc.Name, toolErr)
+ }
+ if didBlock {
+ blocked = true
+ break
+ }
+ messages = append(messages, provider.Message{
+ Role: "tool",
+ ToolResults: []provider.ToolResult{{
+ ToolCallID: tc.ID,
+ Name: tc.Name,
+ Content: result,
+ }},
+ })
+ }
+ if blocked {
+ break
+ }
+ }
+ elapsed := time.Since(start)
+
+ e.CostUSD = 0
+ e.TokensIn = int64(totalIn)
+ e.TokensOut = int64(totalOut)
+
+ if l.Logger != nil {
+ l.Logger.Info("agentloop run completed",
+ "taskID", t.ID,
+ "tokens_in", totalIn,
+ "tokens_out", totalOut,
+ "finish_reason", lastFinish,
+ "blocked", blocked,
+ "elapsed_ms", elapsed.Milliseconds(),
+ )
+ }
+
+ // If the model asked the user, stop and block. Preserve the sandbox so
+ // resume can reuse it.
+ if q, isBlocked := pendingQuestion(l.Channel); isBlocked {
+ return &agentchannel.BlockedError{QuestionJSON: q}
+ }
+
+ // Push any commits made inside the sandbox back to the origin.
+ if l.Sandbox != nil && l.Sandbox.WorkDir() != "" {
+ if err := l.Sandbox.GitPush(ctx, ""); err != nil {
+ if l.Logger != nil {
+ l.Logger.Warn("agentloop: git push failed", "err", err)
+ }
+ }
+ if err := l.Sandbox.Cleanup(ctx); err != nil {
+ if l.Logger != nil {
+ l.Logger.Warn("agentloop: sandbox cleanup failed", "err", err)
+ }
+ }
+ 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.
+ if e.Summary == "" && fullText != "" {
+ s := strings.TrimSpace(fullText)
+ if len(s) > 500 {
+ s = s[:500]
+ }
+ e.Summary = s
+ }
+
+ writeResultLine(stdout, "success", "", totalIn, totalOut)
+ return nil
+}
+
+// pendingAsker is implemented by AgentChannel implementations that buffer an
+// ask_user call so Run can convert it into a BlockedError after the loop
+// exits. Defined structurally here (rather than imported) so agentloop does
+// not need to depend on executor.storeChannel's package to detect it — any
+// AgentChannel with this method shape satisfies it, including
+// executor.storeChannel.
+type pendingAsker interface {
+ PendingQuestion() (questionJSON string, blocked bool)
+}
+
+func pendingQuestion(ch agentchannel.AgentChannel) (string, bool) {
+ if pa, ok := ch.(pendingAsker); ok {
+ return pa.PendingQuestion()
+ }
+ return "", false
+}
+
+// writeAssistantTextLine writes a single stream-json line wrapping `text` as
+// an assistant text block. Format matches what ClaudeRunner/ContainerRunner
+// emit, so extractSummary and ParseChangestatFromFile read it transparently.
+func writeAssistantTextLine(w io.Writer, text string) {
+ line := struct {
+ Type string `json:"type"`
+ Message struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ } `json:"message"`
+ }{Type: "assistant"}
+ line.Message.Content = []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ }{{Type: "text", Text: text}}
+ b, err := json.Marshal(line)
+ if err != nil {
+ return
+ }
+ w.Write(b)
+ w.Write([]byte("\n"))
+}
+
+// writeResultLine writes a final stream-json terminator line that downstream
+// parsers can recognise. Mirrors the shape of the result line ClaudeRunner
+// emits.
+func writeResultLine(w io.Writer, subtype, errMsg string, promptTokens, outputTokens int) {
+ line := map[string]any{
+ "type": "result",
+ "subtype": subtype,
+ "is_error": errMsg != "",
+ "prompt_tokens": promptTokens,
+ "output_tokens": outputTokens,
+ "total_cost_usd": 0.0,
+ }
+ if errMsg != "" {
+ line["result"] = errMsg
+ }
+ b, err := json.Marshal(line)
+ if err != nil {
+ return
+ }
+ w.Write(b)
+ w.Write([]byte("\n"))
+}
diff --git a/internal/executor/localtools.go b/internal/agentloop/tools.go
index 9fd2cfc..e92a12f 100644
--- a/internal/executor/localtools.go
+++ b/internal/agentloop/tools.go
@@ -1,30 +1,29 @@
-package executor
+package agentloop
import (
- "bytes"
"context"
"encoding/json"
"errors"
"fmt"
- "os"
- "os/exec"
- "path/filepath"
- "github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/agentchannel"
+ "github.com/thepeterstone/claudomator/internal/provider"
)
-// agentToolDefs returns the four agent back-channel tools as OpenAI
-// function-calling definitions, for runners that talk to an OpenAI-compatible
-// endpoint (LocalRunner). They mirror the MCP tools the container runners expose.
-func agentToolDefs() []llm.Tool {
+// agentToolSpecs returns the eight tools available to the loop: the four
+// agent back-channel tools (mirroring the MCP tools ContainerRunner exposes)
+// plus the four sandbox tools, as provider-neutral ToolSpecs. Ported verbatim
+// (same names/descriptions/JSON schemas) from the former
+// executor.agentToolDefs (internal/executor/localtools.go).
+func agentToolSpecs() []provider.ToolSpec {
strProp := func(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
- return []llm.Tool{
- {Type: "function", Function: llm.ToolFunction{
+ return []provider.ToolSpec{
+ {
Name: "ask_user",
Description: "Ask the user a question when you genuinely need a decision to proceed. The task pauses until the user answers; do not call other tools after this.",
- Parameters: map[string]any{
+ ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"question": strProp("the question to ask, phrased as a real question"),
@@ -32,20 +31,20 @@ func agentToolDefs() []llm.Tool {
},
"required": []string{"question"},
},
- }},
- {Type: "function", Function: llm.ToolFunction{
+ },
+ {
Name: "report_summary",
Description: "Record a concise 2-5 sentence summary of what you accomplished. Call this before finishing.",
- Parameters: map[string]any{
+ ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{"summary": strProp("the summary text")},
"required": []string{"summary"},
},
- }},
- {Type: "function", Function: llm.ToolFunction{
+ },
+ {
Name: "spawn_subtask",
Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.",
- Parameters: map[string]any{
+ ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": strProp("short descriptive name for the subtask"),
@@ -55,29 +54,29 @@ func agentToolDefs() []llm.Tool {
},
"required": []string{"name", "instructions"},
},
- }},
- {Type: "function", Function: llm.ToolFunction{
+ },
+ {
Name: "record_progress",
Description: "Record a short progress note that appears in the task timeline.",
- Parameters: map[string]any{
+ ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{"message": strProp("a short progress note")},
"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{
+ ParametersJSONSchema: 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{
+ ParametersJSONSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": strProp("path to the file, relative or absolute"),
@@ -85,34 +84,37 @@ func agentToolDefs() []llm.Tool {
},
"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{
+ ParametersJSONSchema: 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{
+ ParametersJSONSchema: 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.
-// 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) {
+// dispatchTool invokes one model-requested tool. Back-channel tools
+// (ask_user/report_summary/spawn_subtask/record_progress) go to l.Channel;
+// sandbox tools (read_file/write_file/run_bash/glob) go to l.Sandbox. 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). Ported from the former
+// executor.dispatchAgentTool (internal/executor/localtools.go), preserving
+// identical result-string formatting.
+func (l *Loop) dispatchTool(ctx context.Context, name, argsJSON string) (result string, blocked bool, err error) {
switch name {
case "ask_user":
var a struct {
@@ -125,8 +127,8 @@ func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, args
q["options"] = a.Options
}
payload, _ := json.Marshal(q)
- ans, askErr := ch.AskUser(ctx, string(payload))
- if errors.Is(askErr, ErrAgentBlocked) {
+ ans, askErr := l.Channel.AskUser(ctx, string(payload))
+ if errors.Is(askErr, agentchannel.ErrAgentBlocked) {
return "", true, nil
}
if askErr != nil {
@@ -139,7 +141,7 @@ func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, args
Summary string `json:"summary"`
}
_ = json.Unmarshal([]byte(argsJSON), &a)
- if rsErr := ch.ReportSummary(ctx, a.Summary); rsErr != nil {
+ if rsErr := l.Channel.ReportSummary(ctx, a.Summary); rsErr != nil {
return "", false, rsErr
}
return "Summary recorded.", false, nil
@@ -152,7 +154,7 @@ func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, args
MaxBudgetUSD float64 `json:"max_budget_usd"`
}
_ = json.Unmarshal([]byte(argsJSON), &a)
- id, ssErr := ch.SpawnSubtask(ctx, SubtaskSpec{
+ id, ssErr := l.Channel.SpawnSubtask(ctx, agentchannel.SubtaskSpec{
Name: a.Name,
Instructions: a.Instructions,
Model: a.Model,
@@ -168,109 +170,54 @@ func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, args
Message string `json:"message"`
}
_ = json.Unmarshal([]byte(argsJSON), &a)
- if rpErr := ch.RecordProgress(ctx, a.Message); rpErr != nil {
+ if rpErr := l.Channel.RecordProgress(ctx, a.Message); rpErr != nil {
return "", false, rpErr
}
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)
+ content, readErr := l.Sandbox.ReadFile(ctx, a.Path)
if readErr != nil {
return "", false, readErr
}
- return string(data), false, nil
+ return content, 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 {
+ if writeErr := l.Sandbox.WriteFile(ctx, a.Path, a.Content); 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
+ out, errOut, exitCode, runErr := l.Sandbox.RunBash(ctx, a.Command)
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 "", false, runErr
}
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)
+ matches, globErr := l.Sandbox.Glob(ctx, a.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)
+ out, _ := json.Marshal(matches)
return string(out), false, nil
default:
diff --git a/internal/cli/run.go b/internal/cli/run.go
index 771c7b7..1c53b1a 100644
--- a/internal/cli/run.go
+++ b/internal/cli/run.go
@@ -8,6 +8,7 @@ import (
"syscall"
"github.com/thepeterstone/claudomator/internal/executor"
+ "github.com/thepeterstone/claudomator/internal/provider/openaicompat"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
"github.com/spf13/cobra"
@@ -106,11 +107,12 @@ func runTasks(file string, parallel int, dryRun bool) error {
localClient := buildLocalLLMClient(cfg.LocalModel, logger)
if localClient != nil && cfg.Runners.LocalEnabled() {
- runners["local"] = &executor.LocalRunner{
- Client: localClient,
+ runners["local"] = &executor.NativeRunner{
+ Provider: openaicompat.New(localClient),
Logger: logger,
LogDir: cfg.LogDir,
DefaultTemperature: cfg.LocalModel.DefaultTemperature,
+ DefaultModel: cfg.LocalModel.Model,
}
}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index 19db2f8..d8ec6a1 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -15,6 +15,7 @@ import (
"github.com/thepeterstone/claudomator/internal/config"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/notify"
+ "github.com/thepeterstone/claudomator/internal/provider/openaicompat"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/version"
"github.com/spf13/cobra"
@@ -139,11 +140,12 @@ func serve(addr, basePath string) error {
localClient := buildLocalLLMClient(cfg.LocalModel, logger)
if localClient != nil && cfg.Runners.LocalEnabled() {
- runners["local"] = &executor.LocalRunner{
- Client: localClient,
+ runners["local"] = &executor.NativeRunner{
+ Provider: openaicompat.New(localClient),
Logger: logger,
LogDir: cfg.LogDir,
DefaultTemperature: cfg.LocalModel.DefaultTemperature,
+ DefaultModel: cfg.LocalModel.Model,
}
logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model)
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 80777ff..640e933 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -63,6 +63,20 @@ func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Ge
func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local }
func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container }
+// ProviderConfig configures a native (or OpenAI-compatible) LLM provider
+// backend for the multi-provider harness — see docs/api-keys-setup.md. Phase
+// 1 only adds this type so config has somewhere for it to land; it is not yet
+// read by runner construction (no native provider adapters exist yet besides
+// the openaicompat one LocalModel already configures). Keyed by provider name
+// in the [providers.<name>] TOML table, e.g. [providers.groq],
+// [providers.anthropic].
+type ProviderConfig struct {
+ Endpoint string `toml:"endpoint"`
+ APIKey string `toml:"api_key"`
+ DefaultModel string `toml:"default_model"`
+ TimeoutSeconds int `toml:"timeout_seconds"`
+}
+
type Config struct {
DataDir string `toml:"data_dir"`
DBPath string `toml:"-"`
@@ -89,6 +103,9 @@ type Config struct {
LocalModel LocalModel `toml:"local_model"`
Runners RunnersConfig `toml:"runners"`
Budget BudgetConfig `toml:"budget"`
+ // Providers configures native/OpenAI-compatible LLM backends by name (see
+ // ProviderConfig). Unused by runner construction in Phase 1.
+ Providers map[string]ProviderConfig `toml:"providers"`
// ExternalBindAllowed must be explicitly true to bind a non-loopback address.
ExternalBindAllowed bool `toml:"external_bind_allowed"`
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 0d47ca0..6991354 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -165,3 +165,46 @@ func TestLocalModel_UseForElaborate_ExplicitTrue(t *testing.T) {
t.Error("explicit true should opt in")
}
}
+
+func TestProvidersConfig_TOMLRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ toml := `
+[providers.groq]
+endpoint = "https://api.groq.com/openai/v1"
+api_key = "gsk_abc"
+default_model = "llama-3.3-70b-versatile"
+timeout_seconds = 30
+
+[providers.anthropic]
+api_key = "sk-ant-xyz"
+default_model = "claude-sonnet-4-5"
+`
+ if err := os.WriteFile(path, []byte(toml), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", dir)
+
+ cfg, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(cfg.Providers) != 2 {
+ t.Fatalf("expected 2 providers, got %d: %+v", len(cfg.Providers), cfg.Providers)
+ }
+ groq, ok := cfg.Providers["groq"]
+ if !ok {
+ t.Fatal("expected [providers.groq] table")
+ }
+ if groq.Endpoint != "https://api.groq.com/openai/v1" || groq.APIKey != "gsk_abc" ||
+ groq.DefaultModel != "llama-3.3-70b-versatile" || groq.TimeoutSeconds != 30 {
+ t.Errorf("groq provider config: %+v", groq)
+ }
+ anthropic, ok := cfg.Providers["anthropic"]
+ if !ok {
+ t.Fatal("expected [providers.anthropic] table")
+ }
+ if anthropic.Endpoint != "" || anthropic.APIKey != "sk-ant-xyz" || anthropic.DefaultModel != "claude-sonnet-4-5" {
+ t.Errorf("anthropic provider config: %+v", anthropic)
+ }
+}
diff --git a/internal/executor/channel.go b/internal/executor/channel.go
index 8605ffa..debe21d 100644
--- a/internal/executor/channel.go
+++ b/internal/executor/channel.go
@@ -3,46 +3,33 @@ package executor
import (
"context"
"encoding/json"
- "errors"
"sync"
"time"
+ "github.com/thepeterstone/claudomator/internal/agentchannel"
"github.com/thepeterstone/claudomator/internal/event"
"github.com/thepeterstone/claudomator/internal/task"
"github.com/google/uuid"
)
-// ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot
-// deliver an answer within the current run — e.g. the file transport, which can
-// only write-and-exit. Runners translate it into a *BlockedError so the task
-// blocks and resumes later. Transports that can suspend the agent in-session
-// (MCP, Phase 2) instead block and return the answer.
-var ErrAgentBlocked = errors.New("agent blocked awaiting user input")
-
-// SubtaskSpec describes a child task an agent wants to spawn.
-type SubtaskSpec struct {
- Name string
- Instructions string
- Model string
- MaxBudgetUSD float64
-}
-
-// AgentChannel is how a Runner reports agent-originated signals to the rest of
-// the system. Implementations translate these into stored artifacts and events.
-// The transport by which a Runner detects these signals — post-exit files
-// today, MCP/tool-use in Phase 2 — is the Runner's private concern.
-type AgentChannel interface {
- // AskUser records that the agent needs human input. Transports that can
- // suspend the agent in-session return the answer; those that cannot return
- // ErrAgentBlocked.
- AskUser(ctx context.Context, questionJSON string) (answer string, err error)
- // ReportSummary records the agent's final summary text.
- ReportSummary(ctx context.Context, summary string) error
- // SpawnSubtask creates a child task and returns its ID.
- SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error)
- // RecordProgress records a free-form progress note from the agent.
- RecordProgress(ctx context.Context, message string) error
-}
+// AgentChannel, SubtaskSpec, BlockedError, and ErrAgentBlocked used to be
+// defined directly in this package. Phase 1 of the multi-provider refactor
+// moved them to internal/agentchannel (a small leaf package with no
+// executor dependency) so that internal/agentloop — the new provider-neutral
+// tool-use loop — can construct/inspect them without importing
+// internal/executor. That matters because internal/executor now imports
+// internal/agentloop (via nativerunner.go, which wires an agentloop.Loop up
+// to a Runner); if agentloop imported executor too, that would be a direct
+// import cycle. Re-exporting via type aliases (and a var assignment for the
+// sentinel error) here means every existing reference to
+// executor.AgentChannel / executor.SubtaskSpec / executor.BlockedError /
+// executor.ErrAgentBlocked keeps compiling and behaving identically — an
+// alias is the same type, not a copy.
+type AgentChannel = agentchannel.AgentChannel
+type SubtaskSpec = agentchannel.SubtaskSpec
+type BlockedError = agentchannel.BlockedError
+
+var ErrAgentBlocked = agentchannel.ErrAgentBlocked
// channelStore is the subset of storage the default channel needs.
type channelStore interface {
diff --git a/internal/executor/helpers.go b/internal/executor/helpers.go
index f2111a4..f4dd02c 100644
--- a/internal/executor/helpers.go
+++ b/internal/executor/helpers.go
@@ -10,15 +10,8 @@ import (
"strings"
)
-// BlockedError is returned by Run when the agent wrote a question file and exited.
-// The pool transitions the task to BLOCKED and stores the question for the user.
-type BlockedError struct {
- QuestionJSON string // raw JSON from the question file
- SessionID string // claude session to resume once the user answers
- SandboxDir string // preserved sandbox path; resume must run here so Claude finds its session files
-}
-
-func (e *BlockedError) Error() string { return fmt.Sprintf("task blocked: %s", e.QuestionJSON) }
+// BlockedError is now an alias for agentchannel.BlockedError, defined in
+// channel.go — see the comment there for the Phase 1 import-cycle rationale.
// parseStream reads streaming JSON from claude, writes to w, and returns
// (costUSD, error). error is non-nil if the stream signals task failure:
diff --git a/internal/executor/local.go b/internal/executor/local.go
deleted file mode 100644
index 43e76d3..0000000
--- a/internal/executor/local.go
+++ /dev/null
@@ -1,282 +0,0 @@
-package executor
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/thepeterstone/claudomator/internal/llm"
- "github.com/thepeterstone/claudomator/internal/storage"
- "github.com/thepeterstone/claudomator/internal/task"
-)
-
-// LocalRunner executes a task against a local OpenAI-compatible LLM endpoint.
-// Unlike ClaudeRunner/GeminiRunner it does not spawn a subprocess, does not
-// create a git sandbox, and does not edit files in project_dir — it produces
-// text completions that are streamed to stdout.log in the same stream-json
-// envelope Claude uses, so existing parsers (extractSummary, ParseChangestat)
-// keep working unchanged.
-type LocalRunner struct {
- Client *llm.Client
- Logger *slog.Logger
- LogDir string
- DefaultTemperature float64
-}
-
-// ExecLogDir implements LogPather so the pool can persist log paths before
-// execution starts.
-func (r *LocalRunner) ExecLogDir(execID string) string {
- if r.LogDir == "" {
- return ""
- }
- return filepath.Join(r.LogDir, execID)
-}
-
-// maxLocalToolTurns bounds the tool-use loop so a misbehaving model cannot spin
-// forever calling tools without finishing.
-const maxLocalToolTurns = 12
-
-// Run drives a chat completion against the local endpoint with the agent
-// back-channel tools (ask_user/report_summary/spawn_subtask/record_progress)
-// declared. It loops, feeding tool results back as message history, until the
-// model stops calling tools. Assistant text is written to stdout.log in the
-// same stream-json envelope Claude uses so downstream parsers keep working.
-// If the model calls ask_user, the run stops and returns a *BlockedError.
-func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
- if r.Client == nil {
- return fmt.Errorf("local runner: no LLM client configured")
- }
- if t.Agent.Instructions == "" {
- return fmt.Errorf("local runner: empty instructions")
- }
-
- logDir := r.ExecLogDir(e.ID)
- if logDir == "" {
- return fmt.Errorf("local runner: LogDir not set")
- }
- if err := os.MkdirAll(logDir, 0o700); err != nil {
- return fmt.Errorf("local runner: mkdir log: %w", err)
- }
- stdoutPath := filepath.Join(logDir, "stdout.log")
- stderrPath := filepath.Join(logDir, "stderr.log")
- e.StdoutPath = stdoutPath
- e.StderrPath = stderrPath
-
- stdout, err := os.Create(stdoutPath)
- if err != nil {
- return fmt.Errorf("local runner: create stdout: %w", err)
- }
- 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
- temperature = &v
- }
-
- // Only provide tools if we aren't using a tiny model known to lack support.
- effectiveModel := t.Agent.Model
- if effectiveModel == "" && r.Client != nil {
- effectiveModel = r.Client.Model
- }
- var tools []llm.Tool
- if !strings.Contains(strings.ToLower(effectiveModel), "tinyllama") {
- tools = agentToolDefs()
- }
-
- // Build messages after tools are decided so the planning preamble is only
- // included when the model actually supports the agent tools.
- messages := []llm.Message{}
- if sys := strings.TrimSpace(t.Agent.SystemPromptAppend); sys != "" {
- messages = append(messages, llm.Message{Role: "system", Content: sys})
- }
- messages = append(messages, llm.Message{Role: "user", Content: buildAgentInstructions(t, len(tools) > 0)})
-
- start := time.Now()
- var totalIn, totalOut int
- var lastModel, lastFinish string
- blocked := false
- var fullText string
-
- for turn := 0; turn < maxLocalToolTurns; turn++ {
- req := llm.ChatRequest{
- Model: t.Agent.Model,
- Messages: messages,
- Temperature: temperature,
- MaxTokens: t.Agent.MaxTokens,
- Tools: tools,
- }
- resp, chatErr := r.Client.Chat(ctx, req)
- if chatErr != nil {
- // If the model doesn't support tool-use, retry once without tools.
- if tools != nil && strings.Contains(strings.ToLower(chatErr.Error()), "does not support tools") {
- tools = nil
- req.Tools = nil
- resp, chatErr = r.Client.Chat(ctx, req)
- }
- if chatErr != nil {
- writeResultLine(stdout, "error", chatErr.Error(), totalIn, totalOut)
- return fmt.Errorf("local runner: chat: %w", chatErr)
- }
- }
- totalIn += resp.PromptTokens
- totalOut += resp.OutputTokens
- lastModel, lastFinish = resp.Model, resp.FinishReason
-
- if resp.Content != "" {
- writeAssistantTextLine(stdout, resp.Content)
- fullText += resp.Content
- }
-
- if len(resp.ToolCalls) == 0 {
- break
- }
-
- // 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, 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)
- }
- if didBlock {
- blocked = true
- break
- }
- messages = append(messages, llm.Message{
- Role: "tool",
- ToolCallID: tc.ID,
- Name: tc.Function.Name,
- Content: result,
- })
- }
- if blocked {
- break
- }
- }
- elapsed := time.Since(start)
-
- e.CostUSD = 0
- e.TokensIn = int64(totalIn)
- e.TokensOut = int64(totalOut)
-
- if r.Logger != nil {
- r.Logger.Info("local runner completed",
- "taskID", t.ID,
- "model", lastModel,
- "tokens_in", totalIn,
- "tokens_out", totalOut,
- "finish_reason", lastFinish,
- "blocked", blocked,
- "elapsed_ms", elapsed.Milliseconds(),
- )
- }
-
- // 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.
- if e.Summary == "" && fullText != "" {
- s := strings.TrimSpace(fullText)
- if len(s) > 500 {
- s = s[:500]
- }
- e.Summary = s
- }
-
- writeResultLine(stdout, "success", "", totalIn, totalOut)
- return nil
-}
-
-// writeAssistantTextLine writes a single stream-json line wrapping `text` as
-// an assistant text block. Format matches what ClaudeRunner emits, so
-// extractSummary and ParseChangestatFromFile read it transparently.
-func writeAssistantTextLine(w *os.File, text string) {
- line := struct {
- Type string `json:"type"`
- Message struct {
- Content []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- } `json:"content"`
- } `json:"message"`
- }{Type: "assistant"}
- line.Message.Content = []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- }{{Type: "text", Text: text}}
- b, err := json.Marshal(line)
- if err != nil {
- return
- }
- w.Write(b)
- w.Write([]byte("\n"))
-}
-
-// writeResultLine writes a final stream-json terminator line that downstream
-// parsers can recognise. Mirrors the shape of the result line ClaudeRunner emits.
-func writeResultLine(w *os.File, subtype, errMsg string, promptTokens, outputTokens int) {
- line := map[string]any{
- "type": "result",
- "subtype": subtype,
- "is_error": errMsg != "",
- "prompt_tokens": promptTokens,
- "output_tokens": outputTokens,
- "total_cost_usd": 0.0,
- }
- if errMsg != "" {
- line["result"] = errMsg
- }
- b, err := json.Marshal(line)
- if err != nil {
- return
- }
- w.Write(b)
- w.Write([]byte("\n"))
-}
diff --git a/internal/executor/nativerunner.go b/internal/executor/nativerunner.go
new file mode 100644
index 0000000..6fe4196
--- /dev/null
+++ b/internal/executor/nativerunner.go
@@ -0,0 +1,127 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/thepeterstone/claudomator/internal/agentloop"
+ "github.com/thepeterstone/claudomator/internal/provider"
+ "github.com/thepeterstone/claudomator/internal/sandbox"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// NativeRunner executes a task against a provider.Provider (a native chat/
+// tool-use backend for some LLM vendor's wire format) using the shared
+// agentloop.Loop tool-use control flow. It supersedes LocalRunner, which drove
+// the same control flow inline against internal/llm.Client directly; that
+// logic was extracted into internal/agentloop (and internal/sandbox for the
+// git/file/bash/glob tool implementations) so any provider.Provider — not
+// just the OpenAI-compatible one — can share it. Like LocalRunner, it does
+// not spawn a subprocess: it produces text/tool-call completions that are
+// streamed to stdout.log in the same stream-json envelope ClaudeRunner uses,
+// so existing parsers (extractSummary, task.ParseChangestatFromFile) keep
+// working unchanged.
+type NativeRunner struct {
+ Provider provider.Provider
+ Logger *slog.Logger
+ LogDir string
+
+ // DefaultTemperature is used when a task doesn't set its own
+ // Agent.Temperature.
+ DefaultTemperature float64
+
+ // DefaultModel names the provider's default model (e.g. config.LocalModel's
+ // configured model). It's used only to decide whether agent tools should be
+ // declared at all (some tiny local models — "tinyllama" in the model name —
+ // don't support tool-use) when the task itself doesn't specify a model,
+ // mirroring LocalRunner's historical fallback to its llm.Client.Model.
+ DefaultModel string
+
+ // MaxTurns bounds the tool-use loop; defaults to agentloop.DefaultMaxTurns
+ // (12, matching the former executor.maxLocalToolTurns) when zero.
+ MaxTurns int
+}
+
+var _ Runner = (*NativeRunner)(nil)
+var _ LogPather = (*NativeRunner)(nil)
+
+// ExecLogDir implements LogPather so the pool can persist log paths before
+// execution starts. Identical to LocalRunner.ExecLogDir.
+func (r *NativeRunner) ExecLogDir(execID string) string {
+ if r.LogDir == "" {
+ return ""
+ }
+ return filepath.Join(r.LogDir, execID)
+}
+
+// Run drives a chat completion against r.Provider with the agent back-channel
+// tools (ask_user/report_summary/spawn_subtask/record_progress) and sandbox
+// tools (read_file/write_file/run_bash/glob) declared, via agentloop.Loop. If
+// the model calls ask_user, the run stops and returns a *BlockedError.
+func (r *NativeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
+ if r.Provider == nil {
+ return fmt.Errorf("native runner: no provider configured")
+ }
+ if t.Agent.Instructions == "" {
+ return fmt.Errorf("native runner: empty instructions")
+ }
+
+ logDir := r.ExecLogDir(e.ID)
+ if logDir == "" {
+ return fmt.Errorf("native runner: LogDir not set")
+ }
+ if err := os.MkdirAll(logDir, 0o700); err != nil {
+ return fmt.Errorf("native runner: mkdir log: %w", err)
+ }
+ stdoutPath := filepath.Join(logDir, "stdout.log")
+ stderrPath := filepath.Join(logDir, "stderr.log")
+ e.StdoutPath = stdoutPath
+ e.StderrPath = stderrPath
+
+ stdout, err := os.Create(stdoutPath)
+ if err != nil {
+ return fmt.Errorf("native runner: create stdout: %w", err)
+ }
+ defer stdout.Close()
+
+ // --- Sandbox setup (git clone into a temp dir, or reuse on resume) ---
+ sb := sandbox.NewHostSandbox("")
+ if t.Agent.ProjectDir != "" {
+ if e.SandboxDir != "" {
+ // Reuse existing sandbox on resume (BLOCKED → QUEUED path).
+ sb = sandbox.NewHostSandbox(e.SandboxDir)
+ } else {
+ if cloneErr := sb.GitClone(ctx, t.Agent.ProjectDir, ""); cloneErr != nil {
+ return fmt.Errorf("native runner: %w", cloneErr)
+ }
+ e.SandboxDir = sb.WorkDir()
+ }
+ }
+
+ // Only provide tools if we aren't using a tiny model known to lack support.
+ effectiveModel := t.Agent.Model
+ if effectiveModel == "" {
+ effectiveModel = r.DefaultModel
+ }
+ toolsEnabled := !strings.Contains(strings.ToLower(effectiveModel), "tinyllama")
+
+ // Build the agent's instructions after the tools decision so the planning
+ // preamble is only included when the model actually supports the agent
+ // tools — same ordering LocalRunner used.
+ instructions := buildAgentInstructions(t, toolsEnabled)
+
+ loop := &agentloop.Loop{
+ Provider: r.Provider,
+ Sandbox: sb,
+ Channel: ch,
+ MaxTurns: r.MaxTurns,
+ DefaultTemperature: r.DefaultTemperature,
+ Logger: r.Logger,
+ }
+ return loop.Run(ctx, t, e, stdout, instructions, toolsEnabled)
+}
diff --git a/internal/executor/local_test.go b/internal/executor/nativerunner_test.go
index 2ae8380..f76b365 100644
--- a/internal/executor/local_test.go
+++ b/internal/executor/nativerunner_test.go
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "os/exec"
"path/filepath"
"strings"
"sync"
@@ -16,10 +17,50 @@ import (
"github.com/google/uuid"
"github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/provider/openaicompat"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
)
+// TestNativeRunner_Run_ChangestatsParseable confirms that a git diff --stat
+// summary line embedded in the model's assistant text (the only way such a
+// line can reach stdout.log — tool results themselves are never logged) is
+// still recoverable by task.ParseChangestatFromFile via the new
+// NativeRunner/agentloop path, exactly as it was via the old LocalRunner
+// (neither runner logs raw tool output; both only log assistant text +
+// result lines, so this has always depended on the model itself echoing the
+// diff stat in its reply).
+func TestNativeRunner_Run_ChangestatsParseable(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {content: "## Summary\nCommitted the fix.\n2 files changed, 30 insertions(+), 4 deletions(-)", promptTok: 3, outTok: 7},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log")
+ cs := task.ParseChangestatFromFile(stdoutPath)
+ if cs == nil {
+ t.Fatalf("ParseChangestatFromFile found nothing in %s", stdoutPath)
+ }
+ if cs.FilesChanged != 2 || cs.LinesAdded != 30 || cs.LinesRemoved != 4 {
+ t.Errorf("changestats: got %+v, want {2 30 4}", cs)
+ }
+}
+
+// This file exercises NativeRunner — the provider.Provider/agentloop.Loop
+// based successor to the former LocalRunner — via a fake OpenAI-compatible
+// HTTP server, using the same fake-server test pattern LocalRunner's tests
+// used (fakeChatServer/fakeTurn/toolCall below), so it verifies behavioral
+// equivalence: same stdout.log stream-json shape, same summary/changestats
+// extraction, same tool-loop semantics.
+
// fakeTurn is one canned chat-completion response the fake server returns.
type fakeTurn struct {
content string
@@ -60,12 +101,13 @@ func toolCall(id, name, args string) llm.ToolCall {
return llm.ToolCall{ID: id, Type: "function", Function: llm.ToolCallFunction{Name: name, Arguments: args}}
}
-func newLocalRunner(t *testing.T, srv *httptest.Server) *LocalRunner {
+func newLocalRunner(t *testing.T, srv *httptest.Server) *NativeRunner {
t.Helper()
- return &LocalRunner{
- Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"},
- Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
- LogDir: t.TempDir(),
+ client := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ return &NativeRunner{
+ Provider: openaicompat.New(client),
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: t.TempDir(),
}
}
@@ -82,7 +124,7 @@ func localTask() *task.Task {
}
}
-func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
+func TestNativeRunner_Run_WritesStreamJSON(t *testing.T) {
srv := fakeChatServer(t, []fakeTurn{{content: "## Summary\nAll good.", promptTok: 11, outTok: 22}})
defer srv.Close()
@@ -95,7 +137,7 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
}
if exec.CostUSD != 0 {
- t.Errorf("CostUSD should be 0 for local runner, got %v", exec.CostUSD)
+ t.Errorf("CostUSD should be 0 for native runner, got %v", exec.CostUSD)
}
if exec.TokensIn != 11 || exec.TokensOut != 22 {
t.Errorf("tokens: want 11/22 got %d/%d", exec.TokensIn, exec.TokensOut)
@@ -123,7 +165,7 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
}
}
-func TestLocalRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) {
+func TestNativeRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) {
// Turn 1: model spawns a subtask. Turn 2: reports summary. Turn 3: finishes.
srv := fakeChatServer(t, []fakeTurn{
{toolCalls: []llm.ToolCall{toolCall("c1", "spawn_subtask", `{"name":"sub A","instructions":"do sub"}`)}, promptTok: 5, outTok: 3},
@@ -157,7 +199,7 @@ func TestLocalRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) {
}
}
-func TestLocalRunner_Run_RecordProgress(t *testing.T) {
+func TestNativeRunner_Run_RecordProgress(t *testing.T) {
srv := fakeChatServer(t, []fakeTurn{
{toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}},
{content: "done"},
@@ -178,7 +220,7 @@ func TestLocalRunner_Run_RecordProgress(t *testing.T) {
}
}
-func TestLocalRunner_Run_AskUser_Blocks(t *testing.T) {
+func TestNativeRunner_Run_AskUser_Blocks(t *testing.T) {
srv := fakeChatServer(t, []fakeTurn{
{toolCalls: []llm.ToolCall{toolCall("c1", "ask_user", `{"question":"which branch?"}`)}},
{content: "should not be reached"},
@@ -198,20 +240,20 @@ func TestLocalRunner_Run_AskUser_Blocks(t *testing.T) {
}
}
-func TestLocalRunner_Run_NoClient_Errors(t *testing.T) {
- r := &LocalRunner{LogDir: t.TempDir()}
+func TestNativeRunner_Run_NoProvider_Errors(t *testing.T) {
+ r := &NativeRunner{LogDir: t.TempDir()}
tt := &task.Task{ID: "x", Agent: task.AgentConfig{Instructions: "hi"}}
exec := &storage.Execution{ID: "exec-x"}
err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID))
- if err == nil || !strings.Contains(err.Error(), "no LLM client") {
- t.Errorf("expected 'no LLM client' error, got %v", err)
+ if err == nil || !strings.Contains(err.Error(), "no provider configured") {
+ t.Errorf("expected 'no provider configured' error, got %v", err)
}
}
-func TestLocalRunner_Run_EmptyInstructions_Errors(t *testing.T) {
- r := &LocalRunner{
- Client: &llm.Client{Endpoint: "http://unused", Model: "x"},
- LogDir: t.TempDir(),
+func TestNativeRunner_Run_EmptyInstructions_Errors(t *testing.T) {
+ r := &NativeRunner{
+ Provider: openaicompat.New(&llm.Client{Endpoint: "http://unused", Model: "x"}),
+ LogDir: t.TempDir(),
}
tt := &task.Task{ID: "x", Agent: task.AgentConfig{}}
exec := &storage.Execution{ID: "exec-x"}
@@ -221,8 +263,8 @@ func TestLocalRunner_Run_EmptyInstructions_Errors(t *testing.T) {
}
}
-func TestLocalRunner_ExecLogDir(t *testing.T) {
- r := &LocalRunner{LogDir: "/tmp/logs"}
+func TestNativeRunner_ExecLogDir(t *testing.T) {
+ r := &NativeRunner{LogDir: "/tmp/logs"}
if got := r.ExecLogDir("abc"); got != "/tmp/logs/abc" {
t.Errorf("ExecLogDir: got %q", got)
}
@@ -231,3 +273,81 @@ func TestLocalRunner_ExecLogDir(t *testing.T) {
t.Errorf("ExecLogDir empty LogDir: got %q", got)
}
}
+
+// TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush drives a task with
+// Agent.ProjectDir set through write_file/read_file/glob/run_bash tool calls
+// against a real HostSandbox (git clone into a temp dir), confirming the
+// verbatim-ported sandbox.HostSandbox behavior end-to-end: tool dispatch
+// hits the real filesystem/shell, a commit made via run_bash is pushed back
+// to the origin repo on a clean finish, and the sandbox temp dir is removed
+// afterward — exactly the git-clone/push/cleanup sequence LocalRunner used
+// to run inline. No prior test (LocalRunner's included) exercised this path;
+// it's new coverage for logic that was previously only exercised manually.
+func TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not available")
+ }
+
+ // Bare origin repo so `git push origin HEAD` from the sandbox clone
+ // doesn't hit receive.denyCurrentBranch.
+ origin := t.TempDir()
+ run := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run(origin, "init", "--bare", "-b", "main")
+
+ // Seed the bare repo with an initial commit (an empty bare repo has no
+ // HEAD, so LocalRunner's plain `git clone <remote> <dir>` needs something
+ // to check out).
+ seed := t.TempDir()
+ run(seed, "init", "-b", "main")
+ run(seed, "remote", "add", "origin", origin)
+ if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(seed, "add", "-A")
+ run(seed, "commit", "-m", "seed")
+ run(seed, "push", "origin", "main")
+
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "write_file", `{"path":"hello.txt","content":"hello world"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c2", "read_file", `{"path":"hello.txt"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c3", "glob", `{"pattern":"*.txt"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c4", "run_bash", `{"command":"git add -A && git -c user.name=test -c user.email=test@example.com commit -m 'add hello'"}`)}},
+ {content: "## Summary\nAdded hello.txt and committed."},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ tt.Agent.ProjectDir = origin
+ exec_ := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec_, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if exec_.SandboxDir != "" {
+ t.Errorf("expected SandboxDir cleared after clean finish, got %q", exec_.SandboxDir)
+ }
+
+ // Verify the commit actually landed on origin.
+ logCmd := exec.Command("git", "-C", origin, "log", "--oneline", "main")
+ out, err := logCmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git log on origin: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "add hello") {
+ t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", out)
+ }
+}
diff --git a/internal/provider/openaicompat/openaicompat.go b/internal/provider/openaicompat/openaicompat.go
new file mode 100644
index 0000000..164ebeb
--- /dev/null
+++ b/internal/provider/openaicompat/openaicompat.go
@@ -0,0 +1,140 @@
+// Package openaicompat adapts the existing internal/llm.Client (a small
+// OpenAI-compatible chat-completions HTTP client) to the provider-neutral
+// provider.Provider interface, unchanged in its own behavior — this package
+// only translates request/response shapes.
+package openaicompat
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/provider"
+)
+
+// Provider wraps an *llm.Client so it can be driven through the
+// provider-neutral interface.
+type Provider struct {
+ Client *llm.Client
+}
+
+// New returns a provider.Provider backed by client.
+func New(client *llm.Client) *Provider {
+ return &Provider{Client: client}
+}
+
+var _ provider.Provider = (*Provider)(nil)
+
+func (p *Provider) Name() string { return "openaicompat" }
+
+// Chat translates req into an llm.ChatRequest, performs the call via the
+// wrapped client, and translates the result back.
+func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
+ if p == nil || p.Client == nil {
+ return nil, fmt.Errorf("openaicompat: nil client")
+ }
+ llmReq := toLLMRequest(req)
+ resp, err := p.Client.Chat(ctx, llmReq)
+ if err != nil {
+ return nil, err
+ }
+ return fromLLMResponse(resp), nil
+}
+
+// toLLMRequest translates a provider-neutral ChatRequest into the wire shape
+// llm.Client understands. System, if set, becomes a leading role:"system"
+// message — llm.Client/the OpenAI-compatible wire format has no separate
+// top-level system field.
+func toLLMRequest(req provider.ChatRequest) llm.ChatRequest {
+ messages := make([]llm.Message, 0, len(req.Messages)+1)
+ if req.System != "" {
+ messages = append(messages, llm.Message{Role: "system", Content: req.System})
+ }
+ for _, m := range req.Messages {
+ messages = append(messages, toLLMMessages(m)...)
+ }
+
+ var tools []llm.Tool
+ if len(req.Tools) > 0 {
+ tools = make([]llm.Tool, 0, len(req.Tools))
+ for _, ts := range req.Tools {
+ tools = append(tools, llm.Tool{
+ Type: "function",
+ Function: llm.ToolFunction{
+ Name: ts.Name,
+ Description: ts.Description,
+ Parameters: ts.ParametersJSONSchema,
+ },
+ })
+ }
+ }
+
+ return llm.ChatRequest{
+ Model: req.Model,
+ Messages: messages,
+ Temperature: req.Temperature,
+ MaxTokens: req.MaxTokens,
+ Tools: tools,
+ }
+}
+
+// toLLMMessages translates a single provider-neutral Message into zero or more
+// llm.Message values. Assistant turns (with ToolCalls) and plain text turns
+// translate 1:1. Tool-result turns translate to one llm.Message per
+// ToolResult, since the OpenAI wire format represents each tool result as its
+// own role:"tool" message (agentloop always emits one ToolResult per turn
+// today, matching that shape exactly; the loop here is future-proofing for
+// providers/loops that batch multiple results into one turn).
+func toLLMMessages(m provider.Message) []llm.Message {
+ if len(m.ToolResults) > 0 {
+ out := make([]llm.Message, 0, len(m.ToolResults))
+ for _, tr := range m.ToolResults {
+ out = append(out, llm.Message{
+ Role: "tool",
+ ToolCallID: tr.ToolCallID,
+ Name: tr.Name,
+ Content: tr.Content,
+ })
+ }
+ return out
+ }
+
+ lm := llm.Message{Role: m.Role, Content: m.Text}
+ if len(m.ToolCalls) > 0 {
+ lm.ToolCalls = make([]llm.ToolCall, 0, len(m.ToolCalls))
+ for _, tc := range m.ToolCalls {
+ lm.ToolCalls = append(lm.ToolCalls, llm.ToolCall{
+ ID: tc.ID,
+ Type: "function",
+ Function: llm.ToolCallFunction{
+ Name: tc.Name,
+ Arguments: tc.ArgsJSON,
+ },
+ })
+ }
+ }
+ return []llm.Message{lm}
+}
+
+func fromLLMResponse(r *llm.ChatResponse) *provider.ChatResponse {
+ var calls []provider.ToolCall
+ if len(r.ToolCalls) > 0 {
+ calls = make([]provider.ToolCall, 0, len(r.ToolCalls))
+ for _, tc := range r.ToolCalls {
+ calls = append(calls, provider.ToolCall{
+ ID: tc.ID,
+ Name: tc.Function.Name,
+ ArgsJSON: tc.Function.Arguments,
+ })
+ }
+ }
+ return &provider.ChatResponse{
+ Text: r.Content,
+ ToolCalls: calls,
+ StopReason: r.FinishReason,
+ Usage: provider.Usage{
+ InputTokens: r.PromptTokens,
+ OutputTokens: r.OutputTokens,
+ },
+ }
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
new file mode 100644
index 0000000..fd1022d
--- /dev/null
+++ b/internal/provider/provider.go
@@ -0,0 +1,72 @@
+// Package provider defines a provider-neutral chat/tool-use interface. It is
+// independent of any one wire format (OpenAI-compatible, Anthropic Messages,
+// Gemini, etc.) so that internal/agentloop's tool-use control flow can drive
+// any backend that implements Provider.
+//
+// Phase 1 ships exactly one implementation, internal/provider/openaicompat,
+// which adapts the existing internal/llm.Client. Later phases add native
+// Anthropic/OpenAI/Google/Groq/OpenRouter adapters without touching agentloop.
+package provider
+
+import "context"
+
+// Message is one turn in a chat conversation, in provider-neutral shape.
+type Message struct {
+ Role string // "system" | "user" | "assistant" | "tool"
+ Text string
+ ToolCalls []ToolCall // set on assistant turns that invoke tools
+ ToolResults []ToolResult // set on tool-result turns
+}
+
+// ToolCall is a single tool invocation requested by the model.
+type ToolCall struct {
+ ID string
+ Name string
+ ArgsJSON string
+}
+
+// ToolResult is the outcome of executing a ToolCall, fed back to the model.
+type ToolResult struct {
+ ToolCallID string
+ Name string
+ Content string
+ IsError bool
+}
+
+// ToolSpec declares a tool the model may call.
+type ToolSpec struct {
+ Name string
+ Description string
+ ParametersJSONSchema map[string]any
+}
+
+// ChatRequest captures the parameters of a single chat completion call.
+type ChatRequest struct {
+ Model string
+ System string
+ Messages []Message
+ Tools []ToolSpec
+ Temperature *float64
+ MaxTokens int
+}
+
+// Usage reports token accounting and (when known) cost for a single call.
+type Usage struct {
+ InputTokens int
+ OutputTokens int
+ CostUSD float64
+}
+
+// ChatResponse is the aggregated result of a chat completion.
+type ChatResponse struct {
+ Text string
+ ToolCalls []ToolCall
+ StopReason string
+ Usage Usage
+}
+
+// Provider is a chat/tool-use backend: one per LLM vendor/wire-format.
+type Provider interface {
+ Name() string
+ Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
+}
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 <branch>`; LocalRunner never set a
+// branch, so passing "" reproduces its exact `git clone <remote> <dir>`
+// 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
+}