summaryrefslogtreecommitdiff
path: root/internal/executor/local.go
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/local.go
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/local.go')
-rw-r--r--internal/executor/local.go282
1 files changed, 0 insertions, 282 deletions
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"))
-}