summaryrefslogtreecommitdiff
path: root/internal/executor
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/executor
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/executor')
-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/localtools.go279
-rw-r--r--internal/executor/nativerunner.go127
-rw-r--r--internal/executor/nativerunner_test.go (renamed from internal/executor/local_test.go)160
6 files changed, 288 insertions, 622 deletions
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/localtools.go b/internal/executor/localtools.go
deleted file mode 100644
index 9fd2cfc..0000000
--- a/internal/executor/localtools.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package executor
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
-
- "github.com/thepeterstone/claudomator/internal/llm"
-)
-
-// 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 {
- strProp := func(desc string) map[string]any {
- return map[string]any{"type": "string", "description": desc}
- }
- return []llm.Tool{
- {Type: "function", Function: llm.ToolFunction{
- 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{
- "type": "object",
- "properties": map[string]any{
- "question": strProp("the question to ask, phrased as a real question"),
- "options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional suggested answer choices"},
- },
- "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{
- "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{
- "type": "object",
- "properties": map[string]any{
- "name": strProp("short descriptive name for the subtask"),
- "instructions": strProp("complete instructions for the subtask agent"),
- "model": strProp("optional model override"),
- "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
- },
- "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{
- "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{
- "type": "object",
- "properties": map[string]any{"path": strProp("path to the file, relative or absolute")},
- "required": []string{"path"},
- },
- }},
- {Type: "function", Function: llm.ToolFunction{
- Name: "write_file",
- Description: "Write or overwrite a file in the sandbox working directory.",
- Parameters: map[string]any{
- "type": "object",
- "properties": map[string]any{
- "path": strProp("path to the file, relative or absolute"),
- "content": strProp("content to write"),
- },
- "required": []string{"path", "content"},
- },
- }},
- {Type: "function", Function: llm.ToolFunction{
- Name: "run_bash",
- Description: "Run a shell command in the sandbox working directory. Returns exit code, stdout, and stderr.",
- Parameters: map[string]any{
- "type": "object",
- "properties": map[string]any{"command": strProp("shell command to execute")},
- "required": []string{"command"},
- },
- }},
- {Type: "function", Function: llm.ToolFunction{
- Name: "glob",
- Description: "List files matching a glob pattern relative to the sandbox working directory.",
- Parameters: map[string]any{
- "type": "object",
- "properties": map[string]any{"pattern": strProp("glob pattern, e.g. **/*.go")},
- "required": []string{"pattern"},
- },
- }},
- }
-}
-
-// dispatchAgentTool invokes one model-requested tool against the AgentChannel.
-// workDir is the sandbox working directory; file/bash tools return an error if
-// it is empty. It returns the text to feed back to the model as the tool
-// result, and a blocked flag set when ask_user could not be answered in-session
-// (the run must stop and the task block).
-func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, argsJSON string) (result string, blocked bool, err error) {
- switch name {
- case "ask_user":
- var a struct {
- Question string `json:"question"`
- Options []string `json:"options"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- q := map[string]any{"text": a.Question}
- if len(a.Options) > 0 {
- q["options"] = a.Options
- }
- payload, _ := json.Marshal(q)
- ans, askErr := ch.AskUser(ctx, string(payload))
- if errors.Is(askErr, ErrAgentBlocked) {
- return "", true, nil
- }
- if askErr != nil {
- return "", false, askErr
- }
- return ans, false, nil
-
- case "report_summary":
- var a struct {
- Summary string `json:"summary"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- if rsErr := ch.ReportSummary(ctx, a.Summary); rsErr != nil {
- return "", false, rsErr
- }
- return "Summary recorded.", false, nil
-
- case "spawn_subtask":
- var a struct {
- Name string `json:"name"`
- Instructions string `json:"instructions"`
- Model string `json:"model"`
- MaxBudgetUSD float64 `json:"max_budget_usd"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- id, ssErr := ch.SpawnSubtask(ctx, SubtaskSpec{
- Name: a.Name,
- Instructions: a.Instructions,
- Model: a.Model,
- MaxBudgetUSD: a.MaxBudgetUSD,
- })
- if ssErr != nil {
- return "", false, ssErr
- }
- return "Created subtask " + id, false, nil
-
- case "record_progress":
- var a struct {
- Message string `json:"message"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- if rpErr := ch.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)
- if readErr != nil {
- return "", false, readErr
- }
- return string(data), false, nil
-
- case "write_file":
- if workDir == "" {
- return "", false, fmt.Errorf("write_file: no sandbox working directory")
- }
- var a struct {
- Path string `json:"path"`
- Content string `json:"content"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- p := a.Path
- if !filepath.IsAbs(p) {
- p = filepath.Join(workDir, p)
- }
- if mkErr := os.MkdirAll(filepath.Dir(p), 0o755); mkErr != nil {
- return "", false, mkErr
- }
- if writeErr := os.WriteFile(p, []byte(a.Content), 0o644); writeErr != nil {
- return "", false, writeErr
- }
- return "Written.", false, nil
-
- case "run_bash":
- if workDir == "" {
- return "", false, fmt.Errorf("run_bash: no sandbox working directory")
- }
- var a struct {
- Command string `json:"command"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- cmd := exec.CommandContext(ctx, "sh", "-c", a.Command)
- cmd.Dir = workDir
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- runErr := cmd.Run()
- exitCode := 0
- if runErr != nil {
- if exitErr, ok := runErr.(*exec.ExitError); ok {
- exitCode = exitErr.ExitCode()
- } else {
- return "", false, runErr
- }
- }
- const maxOutput = 8 * 1024
- out := stdout.String()
- errOut := stderr.String()
- if len(out) > maxOutput {
- out = out[:maxOutput] + "\n[truncated]"
- }
- if len(errOut) > maxOutput {
- errOut = errOut[:maxOutput] + "\n[truncated]"
- }
- return fmt.Sprintf("exit_code: %d\nstdout:\n%s\nstderr:\n%s", exitCode, out, errOut), false, nil
-
- case "glob":
- if workDir == "" {
- return "", false, fmt.Errorf("glob: no sandbox working directory")
- }
- var a struct {
- Pattern string `json:"pattern"`
- }
- _ = json.Unmarshal([]byte(argsJSON), &a)
- pattern := a.Pattern
- if !filepath.IsAbs(pattern) {
- pattern = filepath.Join(workDir, pattern)
- }
- matches, globErr := filepath.Glob(pattern)
- if globErr != nil {
- return "", false, globErr
- }
- // Return paths relative to workDir for readability.
- rel := make([]string, 0, len(matches))
- for _, m := range matches {
- r, relErr := filepath.Rel(workDir, m)
- if relErr != nil {
- r = m
- }
- rel = append(rel, r)
- }
- out, _ := json.Marshal(rel)
- return string(out), false, nil
-
- default:
- return "", false, fmt.Errorf("unknown tool %q", name)
- }
-}
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)
+ }
+}