diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 08:49:43 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 08:49:43 +0000 |
| commit | 67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch) | |
| tree | bc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal | |
| parent | 3d286974cdc28c68c5ee536ce9303899dae9540e (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.go | 65 | ||||
| -rw-r--r-- | internal/agentloop/agentloop.go | 265 | ||||
| -rw-r--r-- | internal/agentloop/tools.go (renamed from internal/executor/localtools.go) | 163 | ||||
| -rw-r--r-- | internal/cli/run.go | 6 | ||||
| -rw-r--r-- | internal/cli/serve.go | 6 | ||||
| -rw-r--r-- | internal/config/config.go | 17 | ||||
| -rw-r--r-- | internal/config/config_test.go | 43 | ||||
| -rw-r--r-- | internal/executor/channel.go | 51 | ||||
| -rw-r--r-- | internal/executor/helpers.go | 11 | ||||
| -rw-r--r-- | internal/executor/local.go | 282 | ||||
| -rw-r--r-- | internal/executor/nativerunner.go | 127 | ||||
| -rw-r--r-- | internal/executor/nativerunner_test.go (renamed from internal/executor/local_test.go) | 160 | ||||
| -rw-r--r-- | internal/provider/openaicompat/openaicompat.go | 140 | ||||
| -rw-r--r-- | internal/provider/provider.go | 72 | ||||
| -rw-r--r-- | internal/sandbox/hostsandbox.go | 214 | ||||
| -rw-r--r-- | internal/sandbox/sandbox.go | 25 |
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 +} |
