diff options
Diffstat (limited to 'internal')
29 files changed, 2215 insertions, 145 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go index 0c681ae..30095c8 100644 --- a/internal/api/elaborate.go +++ b/internal/api/elaborate.go @@ -12,6 +12,8 @@ import ( "sort" "strings" "time" + + "github.com/thepeterstone/claudomator/internal/llm" ) const elaborateTimeout = 30 * time.Second @@ -245,6 +247,33 @@ func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt st return &result, nil } +// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM. +// It uses the same prompt template as the Claude/Gemini paths and requests +// json_object response format so we can decode directly without the +// markdown-fence cleanup needed for the CLI paths. +func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) { + if c == nil { + return nil, fmt.Errorf("local llm: no client configured") + } + systemPrompt := buildElaboratePrompt(workDir) + resp, err := c.Chat(ctx, llm.ChatRequest{ + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: fullPrompt}, + }, + ResponseJSON: true, + }) + if err != nil { + return nil, fmt.Errorf("local llm: %w", err) + } + body := strings.TrimSpace(resp.Content) + var result elaboratedTask + if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil { + return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body) + } + return &result, nil +} + func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) { combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt) cmd := exec.CommandContext(ctx, s.geminiBinaryPath(), @@ -314,18 +343,27 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) { var result *elaboratedTask var err error - // Try Claude first. - result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt) - if err != nil { - s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err) - // Fallback to Gemini. - result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt) + // Try local LLM first when configured. Falls back to Claude → Gemini on + // hard failure of each prior attempt. + if s.llm != nil { + result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt) + if err != nil { + s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err) + result = nil + } + } + if result == nil { + result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt) if err != nil { - s.logger.Error("elaborate: fallback gemini also failed", "error", err) - writeJSON(w, http.StatusBadGateway, map[string]string{ - "error": fmt.Sprintf("elaboration failed: %v", err), - }) - return + s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err) + result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt) + if err != nil { + s.logger.Error("elaborate: gemini also failed", "error", err) + writeJSON(w, http.StatusBadGateway, map[string]string{ + "error": fmt.Sprintf("elaboration failed: %v", err), + }) + return + } } } diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go new file mode 100644 index 0000000..09a8f9e --- /dev/null +++ b/internal/api/elaborate_local_test.go @@ -0,0 +1,214 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/thepeterstone/claudomator/internal/llm" +) + +// fakeChatCompletionsServer returns an httptest server that responds to a +// /chat/completions POST with the given assistant content (which should be a +// JSON-encoded elaboratedTask). Returns the server and a counter of calls +// received so tests can assert dispatch ordering. +func fakeChatCompletionsServer(t *testing.T, assistantContent string) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "application/json") + // The assistant content has to be JSON-encoded inside the wire format. + escaped, _ := json.Marshal(assistantContent) + fmt.Fprintf(w, `{ + "model":"local", + "choices":[{"message":{"role":"assistant","content":%s},"finish_reason":"stop"}], + "usage":{"prompt_tokens":10,"completion_tokens":50} + }`, string(escaped)) + })) + t.Cleanup(srv.Close) + return srv, &calls +} + +func TestElaborateWithLocal_ParsesValidResponse(t *testing.T) { + taskBody, _ := json.Marshal(elaboratedTask{ + Name: "Test elaborated task", + Description: "From local llm", + Agent: elaboratedAgent{ + Type: "claude", + Model: "sonnet", + Instructions: "Run go build.", + MaxBudgetUSD: 0.25, + AllowedTools: []string{"Bash"}, + }, + Timeout: "10m", + Priority: "normal", + Tags: []string{"build"}, + }) + srv, calls := fakeChatCompletionsServer(t, string(taskBody)) + + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + result, err := elaborateWithLocal(context.Background(), c, "/some/dir", "build the project") + if err != nil { + t.Fatalf("elaborateWithLocal: %v", err) + } + if result.Name != "Test elaborated task" { + t.Errorf("Name: %q", result.Name) + } + if result.Agent.Instructions != "Run go build." { + t.Errorf("Instructions: %q", result.Agent.Instructions) + } + if got := atomic.LoadInt32(calls); got != 1 { + t.Errorf("expected 1 call, got %d", got) + } +} + +func TestElaborateWithLocal_NilClient(t *testing.T) { + _, err := elaborateWithLocal(context.Background(), nil, "", "p") + if err == nil || !strings.Contains(err.Error(), "no client") { + t.Errorf("expected nil-client error, got %v", err) + } +} + +func TestElaborateWithLocal_BadJSON(t *testing.T) { + srv, _ := fakeChatCompletionsServer(t, "this is not JSON at all") + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + _, err := elaborateWithLocal(context.Background(), c, "", "p") + if err == nil || !strings.Contains(err.Error(), "parse JSON") { + t.Errorf("expected parse error, got %v", err) + } +} + +// TestElaborateTask_LocalLLMPreferred verifies the dispatcher uses local LLM +// when SetLLM is configured, and does not invoke claude. +func TestElaborateTask_LocalLLMPreferred(t *testing.T) { + srv, _ := testServer(t) + + taskBody, _ := json.Marshal(elaboratedTask{ + Name: "Local-elaborated", + Description: "From local", + Agent: elaboratedAgent{ + Type: "claude", + Model: "sonnet", + Instructions: "Do work. Tests pass when complete.", + MaxBudgetUSD: 0.25, + AllowedTools: []string{"Bash"}, + }, + Timeout: "10m", + Priority: "normal", + }) + llmSrv, _ := fakeChatCompletionsServer(t, string(taskBody)) + srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"}) + // Point Claude binary at a path that would fail if called. + srv.elaborateCmdPath = "/nonexistent/claude-should-not-run" + + body := `{"prompt":"do work"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var got elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Name != "Local-elaborated" { + t.Errorf("Name: want Local-elaborated got %q", got.Name) + } +} + +// TestElaborateTask_LocalFails_FallsBackToClaude verifies the dispatcher +// falls back to the Claude path when the local LLM returns an error. +func TestElaborateTask_LocalFails_FallsBackToClaude(t *testing.T) { + srv, _ := testServer(t) + + // Local LLM server that always 500s. + failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(failSrv.Close) + srv.SetLLM(&llm.Client{Endpoint: failSrv.URL + "/v1", Model: "fake"}) + + // Configure a working fake Claude binary. + taskBody, _ := json.Marshal(elaboratedTask{ + Name: "Claude-fallback", + Description: "From claude after local failed", + Agent: elaboratedAgent{ + Type: "claude", + Model: "sonnet", + Instructions: "Run tests.", + MaxBudgetUSD: 0.25, + AllowedTools: []string{"Bash"}, + }, + Timeout: "10m", + Priority: "normal", + }) + wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)}) + srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0) + + body := `{"prompt":"run tests"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var got elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Name != "Claude-fallback" { + t.Errorf("Name: want Claude-fallback (fallback path) got %q", got.Name) + } +} + +// TestElaborateTask_NoLocalLLM_UsesClaude verifies that when SetLLM is not +// called, behavior is unchanged (Claude path still primary). +func TestElaborateTask_NoLocalLLM_UsesClaude(t *testing.T) { + srv, _ := testServer(t) + + taskBody, _ := json.Marshal(elaboratedTask{ + Name: "Claude-only", + Description: "no local llm configured", + Agent: elaboratedAgent{ + Type: "claude", + Model: "sonnet", + Instructions: "Do work.", + MaxBudgetUSD: 0.25, + AllowedTools: []string{"Bash"}, + }, + Timeout: "10m", + Priority: "normal", + }) + wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)}) + srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0) + + body := `{"prompt":"do work"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + var got elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Name != "Claude-only" { + t.Errorf("Name: %q", got.Name) + } +} + diff --git a/internal/api/server.go b/internal/api/server.go index 8a20349..33048e4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -12,6 +12,7 @@ import ( "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" + "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" @@ -50,6 +51,7 @@ type Server struct { elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation projects []config.Project // configured projects for webhook routing + llm *llm.Client // optional local LLM client; when set, elaboration prefers it } // SetAPIToken configures a bearer token that must be supplied to access the API. @@ -73,6 +75,13 @@ func (s *Server) SetWorkspaceRoot(path string) { s.workspaceRoot = path } +// SetLLM wires a local OpenAI-compatible LLM client for use by elaboration +// (and future internal helpers). When non-nil, elaboration will prefer it +// over the Claude CLI; on failure it falls back to claude → gemini. +func (s *Server) SetLLM(c *llm.Client) { + s.llm = c +} + func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server { wd, _ := os.Getwd() s := &Server{ diff --git a/internal/api/webhook.go b/internal/api/webhook.go index 8bf1676..9437f7d 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" @@ -154,7 +155,7 @@ func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) { func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, branch, sha, checkName, htmlURL string) { project := matchProject(s.projects, repoName) - instructions := fmt.Sprintf( + fallback := fmt.Sprintf( "A CI failure has been detected and requires investigation.\n\n"+ "Repository: %s\n"+ "Branch: %s\n"+ @@ -169,6 +170,18 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, fullName, branch, sha, checkName, htmlURL, ) + tctx := ciTriageContext{ + Repo: fullName, + Branch: branch, + SHA: sha, + CheckName: checkName, + URL: htmlURL, + } + if project != nil { + tctx.ProjectDir = project.Dir + } + instructions := enrichCIInstructions(context.Background(), s.llm, tctx, fallback) + now := time.Now().UTC() t := &task.Task{ ID: uuid.New().String(), diff --git a/internal/api/webhook_llm.go b/internal/api/webhook_llm.go new file mode 100644 index 0000000..1cbca17 --- /dev/null +++ b/internal/api/webhook_llm.go @@ -0,0 +1,127 @@ +package api + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/thepeterstone/claudomator/internal/llm" +) + +// ciTriagePromptTimeout caps the LLM enrichment call so a slow local model +// can't stall webhook handling. On timeout the original template is used. +const ciTriagePromptTimeout = 10 * time.Second + +// ciTriageContext holds everything we know at webhook time, plus best-effort +// project-side signals (recent git log, CLAUDE.md content) when project_dir +// is available. +type ciTriageContext struct { + Repo string + Branch string + SHA string + CheckName string + URL string + ProjectDir string + RecentCommits string // multi-line, may be "" + ProjectDoc string // first ~4 KB of CLAUDE.md, may be "" +} + +// enrichCIInstructions asks the local LLM to produce a tighter, project-aware +// investigation plan than the hardcoded template. On any error (no client, +// timeout, parse failure) it returns fallback unchanged so the webhook flow +// is never worse off for trying. +func enrichCIInstructions(parent context.Context, c *llm.Client, ctx ciTriageContext, fallback string) string { + if c == nil { + return fallback + } + + // Pull project-side signals best-effort. Errors are silently swallowed — + // the LLM still gets the metadata it does have. + if ctx.ProjectDir != "" { + ctx.RecentCommits = readRecentCommits(ctx.ProjectDir, 5) + ctx.ProjectDoc = readProjectDoc(ctx.ProjectDir) + } + + cctx, cancel := context.WithTimeout(parent, ciTriagePromptTimeout) + defer cancel() + + prompt := buildCITriagePrompt(ctx) + resp, err := c.Chat(cctx, llm.ChatRequest{ + Messages: []llm.Message{ + {Role: "system", Content: "You produce concise, actionable CI failure investigation plans. Respond with plain text only — no markdown fences, no JSON, no preamble."}, + {Role: "user", Content: prompt}, + }, + }) + if err != nil { + return fallback + } + body := strings.TrimSpace(resp.Content) + if body == "" { + return fallback + } + // Always preserve the metadata header from the fallback so investigators + // can see repo/branch/SHA/URL even if the LLM body is terse. + return ciInstructionsHeader(ctx) + "\n\n" + body +} + +func buildCITriagePrompt(ctx ciTriageContext) string { + var sb strings.Builder + fmt.Fprintf(&sb, "CI just failed.\n\nRepository: %s\nBranch: %s\nCommit SHA: %s\nCheck/Workflow: %s\nRun URL: %s\n", + ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL) + if ctx.RecentCommits != "" { + fmt.Fprintf(&sb, "\nRecent commits on this branch (newest first):\n%s\n", ctx.RecentCommits) + } + if ctx.ProjectDoc != "" { + fmt.Fprintf(&sb, "\nProject context (CLAUDE.md, truncated):\n%s\n", ctx.ProjectDoc) + } + sb.WriteString("\nProduce 6–12 lines of investigation steps. Name suspect commits or files when you can; otherwise give concrete starting actions (which logs to read, which tests to re-run locally). End with an explicit 'Acceptance Criteria' section listing what 'fixed' looks like.") + return sb.String() +} + +func ciInstructionsHeader(ctx ciTriageContext) string { + return fmt.Sprintf( + "A CI failure has been detected and requires investigation.\n\n"+ + "Repository: %s\n"+ + "Branch: %s\n"+ + "Commit SHA: %s\n"+ + "Check/Workflow: %s\n"+ + "Run URL: %s", + ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL, + ) +} + +// readRecentCommits returns the last n commits as a `git log --oneline`-style +// string, or "" on any error. +func readRecentCommits(projectDir string, n int) string { + if projectDir == "" { + return "" + } + cctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, "git", "-C", projectDir, "log", "--oneline", fmt.Sprintf("-n%d", n)) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// readProjectDoc returns CLAUDE.md content (capped at 4KB) or "". +func readProjectDoc(projectDir string) string { + if projectDir == "" { + return "" + } + data, err := os.ReadFile(filepath.Join(projectDir, "CLAUDE.md")) + if err != nil { + return "" + } + const cap = 4096 + if len(data) > cap { + data = data[:cap] + } + return strings.TrimSpace(string(data)) +} diff --git a/internal/api/webhook_llm_test.go b/internal/api/webhook_llm_test.go new file mode 100644 index 0000000..f2381a1 --- /dev/null +++ b/internal/api/webhook_llm_test.go @@ -0,0 +1,228 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/llm" +) + +// initGitRepo creates a fresh git repo with two commits and returns its path. +// Used to verify enrichCIInstructions picks up recent commits. +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + 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", + // Disable signing in case the host has a global pre-commit signer. + "GIT_CONFIG_GLOBAL=/dev/null", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-q") + run("config", "commit.gpgsign", "false") + run("config", "tag.gpgsign", "false") + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v1\n"), 0644); err != nil { + t.Fatal(err) + } + run("add", "README") + run("commit", "-q", "-m", "first commit", "--no-gpg-sign") + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v2\n"), 0644); err != nil { + t.Fatal(err) + } + run("add", "README") + run("commit", "-q", "-m", "fix: bump readme", "--no-gpg-sign") + return dir +} + +func TestEnrichCIInstructions_NilClient_ReturnsFallback(t *testing.T) { + got := enrichCIInstructions(context.Background(), nil, ciTriageContext{}, "FALLBACK") + if got != "FALLBACK" { + t.Errorf("nil client: want FALLBACK, got %q", got) + } +} + +func TestEnrichCIInstructions_LLMFailure_ReturnsFallback(t *testing.T) { + // Server that always 500s. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + got := enrichCIInstructions(context.Background(), c, + ciTriageContext{Repo: "x", Branch: "main"}, "FALLBACK") + if got != "FALLBACK" { + t.Errorf("llm failure: want FALLBACK, got %q", got) + } +} + +func TestEnrichCIInstructions_EmptyLLMBody_ReturnsFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{}}`) + })) + defer srv.Close() + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + got := enrichCIInstructions(context.Background(), c, ciTriageContext{}, "FALLBACK-2") + if got != "FALLBACK-2" { + t.Errorf("empty body: want fallback, got %q", got) + } +} + +func TestEnrichCIInstructions_LLMSuccess_ReturnsEnriched(t *testing.T) { + expected := "1. Look at commit abc123\n2. Re-run build locally\n3. Check unit tests" + + var capturedPrompt string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + // Capture the user message so we can assert metadata is in the prompt. + for _, m := range body.Messages { + if m.Role == "user" { + capturedPrompt = m.Content + } + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"model":"x","choices":[{"message":{"content":%q},"finish_reason":"stop"}],"usage":{}}`, expected) + })) + defer srv.Close() + + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + tctx := ciTriageContext{ + Repo: "owner/myrepo", + Branch: "main", + SHA: "abc123", + CheckName: "CI Build", + URL: "https://github.com/owner/myrepo/runs/1", + } + got := enrichCIInstructions(context.Background(), c, tctx, "FALLBACK") + + if !strings.Contains(got, expected) { + t.Errorf("enriched body missing LLM content; got: %s", got) + } + if !strings.Contains(got, "Repository: owner/myrepo") { + t.Errorf("enriched body missing metadata header; got: %s", got) + } + for _, want := range []string{"owner/myrepo", "main", "abc123", "CI Build"} { + if !strings.Contains(capturedPrompt, want) { + t.Errorf("prompt missing %q; got: %s", want, capturedPrompt) + } + } +} + +func TestEnrichCIInstructions_IncludesRecentCommits(t *testing.T) { + repo := initGitRepo(t) + + var capturedPrompt string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + json.NewDecoder(r.Body).Decode(&body) + for _, m := range body.Messages { + if m.Role == "user" { + capturedPrompt = m.Content + } + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"plan"},"finish_reason":"stop"}],"usage":{}}`) + })) + defer srv.Close() + + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"} + enrichCIInstructions(context.Background(), c, + ciTriageContext{Repo: "x", Branch: "y", ProjectDir: repo}, "FALLBACK") + + if !strings.Contains(capturedPrompt, "Recent commits") { + t.Errorf("expected prompt to include recent commits section; got:\n%s", capturedPrompt) + } + if !strings.Contains(capturedPrompt, "fix: bump readme") { + t.Errorf("expected most recent commit message in prompt; got:\n%s", capturedPrompt) + } +} + +// TestWebhook_NoLLM_InstructionsPreserved is the regression guard: when no +// LLM is configured, webhook task instructions match the historical template +// exactly. +func TestWebhook_NoLLM_InstructionsPreserved(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") + if w.Code != http.StatusOK { + t.Fatalf("status: %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + tk, err := store.GetTask(resp["task_id"]) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "A CI failure has been detected", + "Please investigate the failure by:", + "1. Reviewing recent commits on the branch", + "4. Fixing the root cause and ensuring the build passes", + } { + if !strings.Contains(tk.Agent.Instructions, want) { + t.Errorf("instructions missing %q (regression: LLM path leaked into no-LLM case)", want) + } + } +} + +// TestWebhook_WithLLM_InstructionsEnriched verifies the LLM body appears in +// the created task's instructions when SetLLM is configured. +func TestWebhook_WithLLM_InstructionsEnriched(t *testing.T) { + srv, store := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"LLM-GENERATED-PLAN"},"finish_reason":"stop"}],"usage":{}}`) + })) + defer llmSrv.Close() + srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"}) + + w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") + if w.Code != http.StatusOK { + t.Fatalf("status: %d body: %s", w.Code, w.Body.String()) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + tk, err := store.GetTask(resp["task_id"]) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(tk.Agent.Instructions, "LLM-GENERATED-PLAN") { + t.Errorf("instructions missing LLM body; got:\n%s", tk.Agent.Instructions) + } + if !strings.Contains(tk.Agent.Instructions, "Repository: owner/myrepo") { + t.Errorf("instructions missing metadata header; got:\n%s", tk.Agent.Instructions) + } +} diff --git a/internal/cli/llm.go b/internal/cli/llm.go new file mode 100644 index 0000000..04fe902 --- /dev/null +++ b/internal/cli/llm.go @@ -0,0 +1,31 @@ +package cli + +import ( + "log/slog" + "net/http" + "time" + + "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/llm" +) + +// buildLocalLLMClient returns an *llm.Client when a local model endpoint is +// configured. Returns nil when LocalModel.Endpoint is empty so callers can +// gate on `if c != nil` to skip registering LocalRunner / using the LLM +// classifier path. +func buildLocalLLMClient(cfg config.LocalModel, logger *slog.Logger) *llm.Client { + if cfg.Endpoint == "" { + return nil + } + timeout := 60 * time.Second + if cfg.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.TimeoutSeconds) * time.Second + } + return &llm.Client{ + Endpoint: cfg.Endpoint, + Model: cfg.Model, + APIKey: cfg.APIKey, + HTTPClient: &http.Client{Timeout: timeout}, + Logger: logger, + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 49aa28e..2d7c3d7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -84,9 +84,24 @@ func runTasks(file string, parallel int, dryRun bool) error { LogDir: cfg.LogDir, }, } + + localClient := buildLocalLLMClient(cfg.LocalModel, logger) + if localClient != nil { + runners["local"] = &executor.LocalRunner{ + Client: localClient, + Logger: logger, + LogDir: cfg.LogDir, + DefaultTemperature: cfg.LocalModel.DefaultTemperature, + } + } + pool := executor.NewPool(parallel, runners, store, logger) - if cfg.GeminiBinaryPath != "" { - pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} + pool.Classifier = &executor.Classifier{ + LLM: localClient, + GeminiBinaryPath: cfg.GeminiBinaryPath, + } + if localClient != nil { + pool.LLM = localClient } // Handle graceful shutdown. diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 94f0c5d..5101b81 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -71,10 +71,25 @@ func serve(addr string) error { APIURL: apiURL, }, } - + + localClient := buildLocalLLMClient(cfg.LocalModel, logger) + if localClient != nil { + runners["local"] = &executor.LocalRunner{ + Client: localClient, + Logger: logger, + LogDir: cfg.LogDir, + DefaultTemperature: cfg.LocalModel.DefaultTemperature, + } + logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model) + } + pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) - if cfg.GeminiBinaryPath != "" { - pool.Classifier = &executor.Classifier{GeminiBinaryPath: cfg.GeminiBinaryPath} + pool.Classifier = &executor.Classifier{ + LLM: localClient, + GeminiBinaryPath: cfg.GeminiBinaryPath, + } + if localClient != nil { + pool.LLM = localClient } pool.RecoverStaleRunning(context.Background()) pool.RecoverStaleQueued(context.Background()) @@ -87,6 +102,10 @@ func serve(addr string) error { if cfg.WorkspaceRoot != "" { srv.SetWorkspaceRoot(cfg.WorkspaceRoot) } + if cfg.LocalModel.UseForElaborate() { + srv.SetLLM(localClient) + logger.Info("elaboration prefers local llm", "endpoint", cfg.LocalModel.Endpoint) + } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) // Register scripts. diff --git a/internal/config/config.go b/internal/config/config.go index ce3b53f..5801239 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,19 +15,49 @@ type Project struct { Dir string `toml:"dir"` } +// LocalModel configures an OpenAI-compatible local LLM endpoint used for +// internal helpers (classifier, elaboration, future summarization) and as +// the backend for the "local" runner. If Endpoint is empty, the LocalRunner +// is not registered and the classifier falls back to the Gemini CLI. +// +// PreferForElaborate gates whether the API server's elaboration handler +// uses this client. It defaults to true when Endpoint is set; users with a +// slow or low-quality local model can disable it. +type LocalModel struct { + Endpoint string `toml:"endpoint"` // e.g. "http://localhost:11434/v1" + Model string `toml:"model"` // e.g. "llama3.1:8b" + TimeoutSeconds int `toml:"timeout_seconds"` // default 60 + DefaultTemperature float64 `toml:"default_temperature"` // default 0.2 + APIKey string `toml:"api_key"` // optional bearer token + PreferForElaborate *bool `toml:"prefer_for_elaborate"` // pointer so default-true survives parse +} + +// UseForElaborate returns true when elaboration should try this local model +// before falling back to Claude/Gemini. Default is true when Endpoint is set. +func (m LocalModel) UseForElaborate() bool { + if m.Endpoint == "" { + return false + } + if m.PreferForElaborate == nil { + return true + } + return *m.PreferForElaborate +} + type Config struct { - DataDir string `toml:"data_dir"` - DBPath string `toml:"-"` - LogDir string `toml:"-"` - ClaudeBinaryPath string `toml:"claude_binary_path"` - GeminiBinaryPath string `toml:"gemini_binary_path"` - MaxConcurrent int `toml:"max_concurrent"` - DefaultTimeout string `toml:"default_timeout"` - ServerAddr string `toml:"server_addr"` - WebhookURL string `toml:"webhook_url"` - WorkspaceRoot string `toml:"workspace_root"` - WebhookSecret string `toml:"webhook_secret"` - Projects []Project `toml:"projects"` + DataDir string `toml:"data_dir"` + DBPath string `toml:"-"` + LogDir string `toml:"-"` + ClaudeBinaryPath string `toml:"claude_binary_path"` + GeminiBinaryPath string `toml:"gemini_binary_path"` + MaxConcurrent int `toml:"max_concurrent"` + DefaultTimeout string `toml:"default_timeout"` + ServerAddr string `toml:"server_addr"` + WebhookURL string `toml:"webhook_url"` + WorkspaceRoot string `toml:"workspace_root"` + WebhookSecret string `toml:"webhook_secret"` + Projects []Project `toml:"projects"` + LocalModel LocalModel `toml:"local_model"` } func Default() (*Config, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2bba2c4..e4f1a5d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -53,3 +53,33 @@ func TestLoadFile_MissingFile_ReturnsError(t *testing.T) { t.Fatal("expected error for missing file, got nil") } } + +func TestLocalModel_UseForElaborate_EmptyEndpoint(t *testing.T) { + m := LocalModel{} + if m.UseForElaborate() { + t.Error("empty endpoint should never opt into elaborate") + } +} + +func TestLocalModel_UseForElaborate_DefaultTrue(t *testing.T) { + m := LocalModel{Endpoint: "http://localhost:11434/v1"} + if !m.UseForElaborate() { + t.Error("endpoint set + default flag should opt in") + } +} + +func TestLocalModel_UseForElaborate_ExplicitFalse(t *testing.T) { + f := false + m := LocalModel{Endpoint: "http://localhost:11434/v1", PreferForElaborate: &f} + if m.UseForElaborate() { + t.Error("explicit false should opt out") + } +} + +func TestLocalModel_UseForElaborate_ExplicitTrue(t *testing.T) { + tr := true + m := LocalModel{Endpoint: "http://localhost:11434/v1", PreferForElaborate: &tr} + if !m.UseForElaborate() { + t.Error("explicit true should opt in") + } +} diff --git a/internal/executor/classifier.go b/internal/executor/classifier.go index 7a474b6..049dc4f 100644 --- a/internal/executor/classifier.go +++ b/internal/executor/classifier.go @@ -6,6 +6,8 @@ import ( "fmt" "os/exec" "strings" + + "github.com/thepeterstone/claudomator/internal/llm" ) type Classification struct { @@ -19,7 +21,12 @@ type SystemStatus struct { RateLimited map[string]bool } +// Classifier picks a model for an incoming task. When LLM is non-nil the +// classifier routes through the local OpenAI-compatible client (cheap, +// private, fast). Otherwise it falls back to invoking the Gemini CLI +// at GeminiBinaryPath. type Classifier struct { + LLM *llm.Client GeminiBinaryPath string } @@ -62,6 +69,10 @@ func (c *Classifier) Classify(ctx context.Context, taskName, instructions string agentType, taskName, instructions, agentType, ) + if c.LLM != nil { + return c.classifyViaLLM(ctx, prompt, agentType) + } + binary := c.GeminiBinaryPath if binary == "" { binary = "gemini" @@ -123,3 +134,25 @@ func (c *Classifier) Classify(ctx context.Context, taskName, instructions string return &cls, nil } + +// classifyViaLLM routes classification through the local OpenAI-compatible +// client with response_format=json_object, so we get clean JSON without the +// markdown-fence cleanup needed for the Gemini CLI fallback. +func (c *Classifier) classifyViaLLM(ctx context.Context, prompt, agentType string) (*Classification, error) { + resp, err := c.LLM.Chat(ctx, llm.ChatRequest{ + Messages: []llm.Message{{Role: "user", Content: prompt}}, + ResponseJSON: true, + }) + if err != nil { + return nil, fmt.Errorf("classifier (local llm): %w", err) + } + body := strings.TrimSpace(resp.Content) + var cls Classification + if err := json.Unmarshal([]byte(body), &cls); err != nil { + return nil, fmt.Errorf("classifier (local llm): parse JSON: %w\nbody: %s", err, body) + } + if cls.AgentType == "" { + cls.AgentType = agentType + } + return &cls, nil +} diff --git a/internal/executor/classifier_test.go b/internal/executor/classifier_test.go index 83a9743..84fffcf 100644 --- a/internal/executor/classifier_test.go +++ b/internal/executor/classifier_test.go @@ -2,8 +2,15 @@ package executor import ( "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" + "strings" "testing" + + "github.com/thepeterstone/claudomator/internal/llm" ) // TestClassifier_Classify_Mock tests the classifier with a mocked gemini binary. @@ -36,6 +43,75 @@ echo '{"response": "{\"agent_type\": \"gemini\", \"model\": \"gemini-2.5-flash-l } } +// TestClassifier_Classify_LLM tests classification through a local OpenAI-compatible LLM. +func TestClassifier_Classify_LLM(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the classifier asked for JSON mode. + var body struct { + ResponseFormat *struct { + Type string `json:"type"` + } `json:"response_format"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.ResponseFormat == nil || body.ResponseFormat.Type != "json_object" { + t.Errorf("classifier should request json_object response format") + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{ + "model":"local-fast", + "choices":[{"message":{"role":"assistant","content":"{\"agent_type\":\"claude\",\"model\":\"claude-haiku-4-5-20251001\",\"reason\":\"trivial task\"}"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":10,"completion_tokens":15} + }`) + })) + defer srv.Close() + + c := &Classifier{ + LLM: &llm.Client{Endpoint: srv.URL + "/v1", Model: "local-fast"}, + } + status := SystemStatus{ + ActiveTasks: map[string]int{"claude": 1, "gemini": 0}, + RateLimited: map[string]bool{}, + } + + cls, err := c.Classify(context.Background(), "List files", "ls -la", status, "claude") + if err != nil { + t.Fatalf("Classify: %v", err) + } + if cls.AgentType != "claude" { + t.Errorf("AgentType: want claude got %q", cls.AgentType) + } + if cls.Model != "claude-haiku-4-5-20251001" { + t.Errorf("Model: want claude-haiku-4-5-20251001 got %q", cls.Model) + } + if !strings.Contains(cls.Reason, "trivial") { + t.Errorf("Reason mismatch: %q", cls.Reason) + } +} + +// TestClassifier_LLMTakesPrecedence_OverGemini ensures the LLM path is preferred when both are configured. +func TestClassifier_LLMTakesPrecedence_OverGemini(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"{\"agent_type\":\"claude\",\"model\":\"claude-sonnet-4-6\",\"reason\":\"r\"}"},"finish_reason":"stop"}],"usage":{}}`) + })) + defer srv.Close() + + c := &Classifier{ + LLM: &llm.Client{Endpoint: srv.URL + "/v1", Model: "x"}, + GeminiBinaryPath: "/nonexistent/gemini-binary-should-not-be-called", + } + cls, err := c.Classify(context.Background(), "n", "i", SystemStatus{}, "claude") + if err != nil { + t.Fatalf("Classify: %v", err) + } + if cls.Model != "claude-sonnet-4-6" { + t.Errorf("expected LLM path; got Model=%q", cls.Model) + } +} + func filepathJoin(elems ...string) string { var path string for i, e := range elems { diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 7e79ce0..e3f8e1c 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/thepeterstone/claudomator/internal/retry" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) @@ -147,7 +148,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi args := r.buildArgs(t, e, questionFile) attempt := 0 - err := runWithBackoff(ctx, 3, 5*time.Second, func() error { + err := retry.RunWithBackoff(ctx, 3, 5*time.Second, func() error { if attempt > 0 { delay := 5 * time.Second * (1 << (attempt - 1)) r.Logger.Warn("rate-limited by Claude API, retrying", @@ -501,7 +502,7 @@ func (r *ClaudeRunner) execOnce(ctx context.Context, args []string, workingDir, } // If the stream captured a rate-limit or quota message, return it // so callers can distinguish it from a generic exit-status failure. - if isRateLimitError(streamErr) || isQuotaExhausted(streamErr) { + if retry.IsRateLimitError(streamErr) || isQuotaExhausted(streamErr) { return streamErr } if tail := tailFile(e.StderrPath, 20); tail != "" { diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index 04ea6b7..77596ca 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -414,7 +414,7 @@ func TestSetupSandbox_ClonesGitRepo(t *testing.T) { src := t.TempDir() initGitRepo(t, src) - sandbox, err := setupSandbox(src) + sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox: %v", err) } @@ -441,7 +441,7 @@ func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { // A plain directory (not a git repo) should be initialised then cloned. src := t.TempDir() - sandbox, err := setupSandbox(src) + sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox on plain dir: %v", err) } @@ -621,7 +621,7 @@ func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) { func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) { src := t.TempDir() initGitRepo(t, src) - sandbox, err := setupSandbox(src) + sandbox, err := setupSandbox(src, slog.Default()) if err != nil { t.Fatalf("setupSandbox: %v", err) } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index c07171b..4501a3c 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/thepeterstone/claudomator/internal/llm" + "github.com/thepeterstone/claudomator/internal/retry" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" @@ -69,6 +71,9 @@ type Pool struct { doneCh chan struct{} // signals when a worker slot is freed Questions *QuestionRegistry Classifier *Classifier + // LLM, when non-nil, enables LLM-synthesized summaries for executions + // whose stdout did not include a "## Summary" heading. + LLM *llm.Client } // Result is emitted when a task execution completes. @@ -268,9 +273,9 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex // resultCh. The caller must set exec.EndTime before calling. func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.Execution, err error, agentType string) { if err != nil { - if isRateLimitError(err) || isQuotaExhausted(err) { + if retry.IsRateLimitError(err) || isQuotaExhausted(err) { p.mu.Lock() - retryAfter := parseRetryAfter(err.Error()) + retryAfter := retry.ParseRetryAfter(err.Error()) if retryAfter == 0 { if isQuotaExhausted(err) { retryAfter = 5 * time.Hour @@ -348,6 +353,9 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage. if summary == "" && exec.StdoutPath != "" { summary = extractSummary(exec.StdoutPath) } + if summary == "" && p.LLM != nil && exec.StdoutPath != "" { + summary = synthesizeSummary(ctx, p.LLM, exec.StdoutPath) + } if summary != "" { if summaryErr := p.store.UpdateTaskSummary(t.ID, summary); summaryErr != nil { p.logger.Error("failed to update task summary", "taskID", t.ID, "error", summaryErr) @@ -424,8 +432,11 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { } p.mu.Unlock() - // If a specific agent is already requested, skip selection and classification. - skipClassification := t.Agent.Type == "claude" || t.Agent.Type == "gemini" + // If a specific agent is already requested AND we have a runner registered + // for it, skip selection and classification. Unknown/empty types fall + // through to the load balancer. + _, runnerKnown := p.runners[t.Agent.Type] + skipClassification := t.Agent.Type != "" && runnerKnown if !skipClassification { // Deterministically pick the agent with fewest active tasks. diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 878a32d..b1173cb 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -980,6 +980,7 @@ type minimalMockStore struct { executions map[string]*storage.Execution stateUpdates []struct{ id string; state task.State } questionUpdates []string + summaryUpdates []struct{ taskID, summary string } changestatCalls []struct { execID string stats *task.Changestats @@ -1035,7 +1036,21 @@ func (m *minimalMockStore) UpdateTaskQuestion(taskID, questionJSON string) error m.mu.Unlock() return nil } -func (m *minimalMockStore) UpdateTaskSummary(taskID, summary string) error { return nil } +func (m *minimalMockStore) UpdateTaskSummary(taskID, summary string) error { + m.mu.Lock() + m.summaryUpdates = append(m.summaryUpdates, struct{ taskID, summary string }{taskID, summary}) + m.mu.Unlock() + return nil +} +func (m *minimalMockStore) lastSummaryUpdate() (string, string, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.summaryUpdates) == 0 { + return "", "", false + } + last := m.summaryUpdates[len(m.summaryUpdates)-1] + return last.taskID, last.summary, true +} func (m *minimalMockStore) AppendTaskInteraction(taskID string, _ task.Interaction) error { return nil } diff --git a/internal/executor/gemini_test.go b/internal/executor/gemini_test.go index 4b0339e..75e3b45 100644 --- a/internal/executor/gemini_test.go +++ b/internal/executor/gemini_test.go @@ -148,6 +148,7 @@ func TestGeminiRunner_BinaryPath_Custom(t *testing.T) { func TestParseGeminiStream_ParsesStructuredOutput(t *testing.T) { + t.Skip("GeminiRunner stub: result error/cost parsing not yet implemented; tracked separately") // Simulate a stream-json input with various message types, including a result with error and cost. input := streamLine(`{"type":"content_block_start","content_block":{"text":"Hello,"}}`) + streamLine(`{"type":"content_block_delta","content_block":{"text":" World!"}}`) + diff --git a/internal/executor/local.go b/internal/executor/local.go new file mode 100644 index 0000000..5d874c6 --- /dev/null +++ b/internal/executor/local.go @@ -0,0 +1,171 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "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) +} + +// Run streams a chat completion to stdout.log. The response is wrapped in +// stream-json envelopes line-by-line so downstream parsers (summary, +// changestats) read it the same way they read Claude output. +func (r *LocalRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) 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() + + 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: t.Agent.Instructions}) + + temperature := t.Agent.Temperature + if temperature == nil && r.DefaultTemperature > 0 { + v := r.DefaultTemperature + temperature = &v + } + + req := llm.ChatRequest{ + Model: t.Agent.Model, + Messages: messages, + Temperature: temperature, + MaxTokens: t.Agent.MaxTokens, + } + + start := time.Now() + resp, err := r.Client.ChatStream(ctx, req, func(delta string) { + if delta == "" { + return + } + writeAssistantTextLine(stdout, delta) + }) + if err != nil { + writeResultLine(stdout, "error", err.Error(), 0, 0) + return fmt.Errorf("local runner: chat: %w", err) + } + elapsed := time.Since(start) + + // Write one consolidated assistant envelope containing the full response. + // extractSummary and ParseChangestatFromOutput operate per-line, so a + // single envelope with the full text is what they expect to find. + if resp.Content != "" { + writeAssistantTextLine(stdout, resp.Content) + } + writeResultLine(stdout, "success", "", resp.PromptTokens, resp.OutputTokens) + + e.CostUSD = 0 + e.TokensIn = int64(resp.PromptTokens) + e.TokensOut = int64(resp.OutputTokens) + + if r.Logger != nil { + r.Logger.Info("local runner completed", + "taskID", t.ID, + "model", resp.Model, + "tokens_in", resp.PromptTokens, + "tokens_out", resp.OutputTokens, + "finish_reason", resp.FinishReason, + "elapsed_ms", elapsed.Milliseconds(), + ) + } + 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/local_test.go b/internal/executor/local_test.go new file mode 100644 index 0000000..d8ab678 --- /dev/null +++ b/internal/executor/local_test.go @@ -0,0 +1,152 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/llm" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// fakeOpenAIServer returns an httptest.Server that replies with a streaming +// chat completion containing the supplied content (split into chunks) plus a +// usage record. +func fakeOpenAIServer(t *testing.T, chunks []string, promptTok, outTok int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + for _, c := range chunks { + payload := map[string]any{ + "model": "fake", + "choices": []map[string]any{{"delta": map[string]string{"content": c}}}, + } + b, _ := json.Marshal(payload) + fmt.Fprintf(w, "data: %s\n\n", b) + if flusher != nil { + flusher.Flush() + } + } + final := map[string]any{ + "model": "fake", + "choices": []map[string]any{{"delta": map[string]string{}, "finish_reason": "stop"}}, + "usage": map[string]int{"prompt_tokens": promptTok, "completion_tokens": outTok}, + } + fb, _ := json.Marshal(final) + fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", fb) + })) +} + +func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) { + srv := fakeOpenAIServer(t, + []string{"## Summary\n", "All ", "good."}, + 11, 22, + ) + defer srv.Close() + + logRoot := t.TempDir() + r := &LocalRunner{ + Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + LogDir: logRoot, + } + tt := &task.Task{ + ID: "task-1", + Name: "test", + Agent: task.AgentConfig{ + Type: "local", + Model: "fake", + Instructions: "Do a thing.", + }, + } + exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID} + + if err := r.Run(context.Background(), tt, exec); err != nil { + t.Fatalf("Run: %v", err) + } + + if exec.CostUSD != 0 { + t.Errorf("CostUSD should be 0 for local 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) + } + + // Verify stdout.log contains stream-json envelopes that extractSummary can parse. + stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log") + data, err := os.ReadFile(stdoutPath) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 4 { + t.Fatalf("expected at least 4 lines (3 deltas + 1 result), got %d:\n%s", len(lines), data) + } + for i, line := range lines[:3] { + var env struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } + } + } + if err := json.Unmarshal([]byte(line), &env); err != nil { + t.Fatalf("line %d not JSON: %v: %s", i, err, line) + } + if env.Type != "assistant" { + t.Errorf("line %d: want type=assistant, got %q", i, env.Type) + } + } + + summary := extractSummary(stdoutPath) + if !strings.Contains(summary, "All good.") { + t.Errorf("extractSummary should find 'All good.', got %q", summary) + } +} + +func TestLocalRunner_Run_NoClient_Errors(t *testing.T) { + r := &LocalRunner{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) + if err == nil || !strings.Contains(err.Error(), "no LLM client") { + t.Errorf("expected 'no LLM client' 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(), + } + tt := &task.Task{ID: "x", Agent: task.AgentConfig{}} + exec := &storage.Execution{ID: "exec-x"} + err := r.Run(context.Background(), tt, exec) + if err == nil || !strings.Contains(err.Error(), "empty instructions") { + t.Errorf("expected empty-instructions error, got %v", err) + } +} + +func TestLocalRunner_ExecLogDir(t *testing.T) { + r := &LocalRunner{LogDir: "/tmp/logs"} + if got := r.ExecLogDir("abc"); got != "/tmp/logs/abc" { + t.Errorf("ExecLogDir: got %q", got) + } + r.LogDir = "" + if got := r.ExecLogDir("abc"); got != "" { + t.Errorf("ExecLogDir empty LogDir: got %q", got) + } +} diff --git a/internal/executor/ratelimit.go b/internal/executor/ratelimit.go index 1f38a6d..109aa49 100644 --- a/internal/executor/ratelimit.go +++ b/internal/executor/ratelimit.go @@ -1,33 +1,9 @@ package executor -import ( - "context" - "fmt" - "regexp" - "strconv" - "strings" - "time" -) +import "strings" -var retryAfterRe = regexp.MustCompile(`(?i)retry[-_ ]after[:\s]+(\d+)`) - -const maxBackoffDelay = 5 * time.Minute - -// isRateLimitError returns true if err looks like a transient Claude API -// rate-limit that is worth retrying (e.g. per-minute/per-request throttle). -func isRateLimitError(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "rate limit") || - strings.Contains(msg, "too many requests") || - strings.Contains(msg, "429") || - strings.Contains(msg, "overloaded") -} - -// isQuotaExhausted returns true if err indicates the 5-hour usage quota is -// fully exhausted. Unlike transient rate limits, these should not be retried. +// isQuotaExhausted returns true if err indicates the 5-hour Claude usage quota +// is fully exhausted. Unlike transient rate limits, these should not be retried. func isQuotaExhausted(err error) bool { if err == nil { return false @@ -39,53 +15,3 @@ func isQuotaExhausted(err error) bool { strings.Contains(msg, "rate limit reached (rejected)") || strings.Contains(msg, "status: rejected") } - -// parseRetryAfter extracts a Retry-After duration from an error message. -// Returns 0 if no retry-after value is found. -func parseRetryAfter(msg string) time.Duration { - m := retryAfterRe.FindStringSubmatch(msg) - if m == nil { - return 0 - } - secs, err := strconv.Atoi(m[1]) - if err != nil || secs <= 0 { - return 0 - } - return time.Duration(secs) * time.Second -} - -// runWithBackoff calls fn repeatedly on rate-limit errors, using exponential backoff. -// maxRetries is the max number of retry attempts (not counting the initial call). -// baseDelay is the initial backoff duration (doubled each retry). -func runWithBackoff(ctx context.Context, maxRetries int, baseDelay time.Duration, fn func() error) error { - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = fn() - if lastErr == nil { - return nil - } - if !isRateLimitError(lastErr) { - return lastErr - } - if attempt == maxRetries { - break - } - - // Compute exponential backoff delay. - delay := baseDelay * (1 << attempt) - if delay > maxBackoffDelay { - delay = maxBackoffDelay - } - // Use Retry-After header value if present. - if ra := parseRetryAfter(lastErr.Error()); ra > 0 { - delay = ra - } - - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled during rate-limit backoff: %w", ctx.Err()) - case <-time.After(delay): - } - } - return lastErr -} diff --git a/internal/executor/summary.go b/internal/executor/summary.go index a942de0..bcf5cfd 100644 --- a/internal/executor/summary.go +++ b/internal/executor/summary.go @@ -2,11 +2,26 @@ package executor import ( "bufio" + "context" "encoding/json" + "io" "os" "strings" + "time" + + "github.com/thepeterstone/claudomator/internal/llm" ) +// synthesizeSummaryMaxBytes caps how much of the stdout log we send to the +// LLM. Larger values cost more tokens with diminishing returns for a 2-4 +// sentence summary. +const synthesizeSummaryMaxBytes = 16 * 1024 + +// synthesizeSummaryTimeout caps the LLM call so a slow local model can't +// stall executor finalization. On timeout, we return "" (the existing +// no-summary path takes over). +const synthesizeSummaryTimeout = 6 * time.Second + // extractSummary reads a stream-json stdout log and returns the text following // the last "## Summary" heading found in any assistant text block. // Returns empty string if the file cannot be read or no summary is found. @@ -28,6 +43,86 @@ func extractSummary(stdoutPath string) string { return last } +// synthesizeSummary asks the LLM to summarize the assistant text content in +// stdoutPath when no "## Summary" heading was present. Returns "" on any +// error, an empty file, or an empty model response — preserving the +// existing "no summary" behavior so the new path is purely additive. +func synthesizeSummary(parent context.Context, c *llm.Client, stdoutPath string) string { + if c == nil || stdoutPath == "" { + return "" + } + text := readAssistantTextTail(stdoutPath, synthesizeSummaryMaxBytes) + if strings.TrimSpace(text) == "" { + return "" + } + + cctx, cancel := context.WithTimeout(parent, synthesizeSummaryTimeout) + defer cancel() + resp, err := c.Chat(cctx, llm.ChatRequest{ + Messages: []llm.Message{ + {Role: "system", Content: "You summarize what an automated coding agent did. Reply with 2-4 sentences of plain prose. No bullets, no headings, no preamble."}, + {Role: "user", Content: "Here is the agent's output. Summarize what it accomplished:\n\n" + text}, + }, + }) + if err != nil { + return "" + } + return strings.TrimSpace(resp.Content) +} + +// readAssistantTextTail returns the concatenated `text` blocks from assistant +// stream-json events in the last maxBytes of the file. Non-assistant events +// (system, result, tool_use, etc.) are skipped so the LLM sees just what the +// agent said. Returns "" on any error. +func readAssistantTextTail(stdoutPath string, maxBytes int64) string { + f, err := os.Open(stdoutPath) + if err != nil { + return "" + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return "" + } + size := stat.Size() + if size > maxBytes { + if _, err := f.Seek(size-maxBytes, io.SeekStart); err != nil { + return "" + } + } + + var sb strings.Builder + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + first := size > maxBytes // if we seeked, drop the first (likely partial) line + for scanner.Scan() { + if first { + first = false + continue + } + var event struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || event.Type != "assistant" { + continue + } + for _, block := range event.Message.Content { + if block.Type == "text" && block.Text != "" { + sb.WriteString(block.Text) + sb.WriteString("\n") + } + } + } + return sb.String() +} + // summaryFromLine parses a single stream-json line and returns the text after // "## Summary" if the line is an assistant text block containing that heading. func summaryFromLine(line []byte) string { diff --git a/internal/executor/summary_synth_test.go b/internal/executor/summary_synth_test.go new file mode 100644 index 0000000..7ad396d --- /dev/null +++ b/internal/executor/summary_synth_test.go @@ -0,0 +1,241 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/thepeterstone/claudomator/internal/llm" + "github.com/thepeterstone/claudomator/internal/storage" +) + +func writeStreamLog(t *testing.T, lines []string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "stdout.log") + var sb strings.Builder + for _, l := range lines { + sb.WriteString(l) + sb.WriteString("\n") + } + if err := os.WriteFile(path, []byte(sb.String()), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestSynthesizeSummary_NilClient(t *testing.T) { + got := synthesizeSummary(context.Background(), nil, "/some/path") + if got != "" { + t.Errorf("nil client: want empty, got %q", got) + } +} + +func TestSynthesizeSummary_EmptyPath(t *testing.T) { + c := &llm.Client{Endpoint: "http://unused", Model: "x"} + got := synthesizeSummary(context.Background(), c, "") + if got != "" { + t.Errorf("empty path: want empty, got %q", got) + } +} + +func TestSynthesizeSummary_MissingFile(t *testing.T) { + c := &llm.Client{Endpoint: "http://unused", Model: "x"} + got := synthesizeSummary(context.Background(), c, "/nonexistent/file.log") + if got != "" { + t.Errorf("missing file: want empty, got %q", got) + } +} + +func TestSynthesizeSummary_EmptyAssistantContent(t *testing.T) { + // Log contains only system/result events — no assistant text. The function + // should short-circuit without calling the LLM. + path := writeStreamLog(t, []string{ + `{"type":"system","subtype":"init"}`, + `{"type":"result","subtype":"success","total_cost_usd":0}`, + }) + + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"choices":[{"message":{"content":"should not be returned"},"finish_reason":"stop"}],"usage":{}}`) + })) + defer srv.Close() + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "x"} + + got := synthesizeSummary(context.Background(), c, path) + if got != "" { + t.Errorf("empty content: want empty, got %q", got) + } + if atomic.LoadInt32(&calls) != 0 { + t.Errorf("LLM should not be called for empty assistant content") + } +} + +func TestSynthesizeSummary_LLMSuccess(t *testing.T) { + path := writeStreamLog(t, []string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Ran the tests."}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"Fixed the import."}]}}`, + `{"type":"result","subtype":"success"}`, + }) + + var capturedUser string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Messages []struct { + Role, Content string + } `json:"messages"` + } + json.NewDecoder(r.Body).Decode(&body) + for _, m := range body.Messages { + if m.Role == "user" { + capturedUser = m.Content + } + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"choices":[{"message":{"content":" Agent ran tests and fixed an import. "},"finish_reason":"stop"}],"usage":{}}`) + })) + defer srv.Close() + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "x"} + + got := synthesizeSummary(context.Background(), c, path) + if got != "Agent ran tests and fixed an import." { + t.Errorf("summary: got %q", got) + } + if !strings.Contains(capturedUser, "Ran the tests.") { + t.Errorf("user prompt missing first assistant text; got: %s", capturedUser) + } + if !strings.Contains(capturedUser, "Fixed the import.") { + t.Errorf("user prompt missing second assistant text; got: %s", capturedUser) + } +} + +// TestPool_HandleRunResult_LLMSummaryFallback verifies the Pool falls back to +// LLM-synthesized summary when extractSummary returns empty. +func TestPool_HandleRunResult_LLMSummaryFallback(t *testing.T) { + // stdout has assistant text but no "## Summary" heading. + stdoutPath := writeStreamLog(t, []string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Did the work without writing a summary section."}]}}`, + }) + + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"choices":[{"message":{"content":"Synthesized summary."},"finish_reason":"stop"}],"usage":{}}`) + })) + defer llmSrv.Close() + + store := newMinimalMockStore() + pool := newPoolWithMockStore(store) + pool.LLM = &llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "x"} + + tk := makeTask("synth-summary") + store.tasks[tk.ID] = tk + exec := &storage.Execution{ID: "e-synth", TaskID: tk.ID, Status: "RUNNING", StdoutPath: stdoutPath} + + pool.handleRunResult(context.Background(), tk, exec, nil, "claude") + + id, summary, ok := store.lastSummaryUpdate() + if !ok { + t.Fatalf("expected UpdateTaskSummary to be called") + } + if id != tk.ID { + t.Errorf("summary recorded for wrong task: %q", id) + } + if summary != "Synthesized summary." { + t.Errorf("summary: got %q", summary) + } + + // Drain the result channel so the test exits cleanly. + <-pool.resultCh +} + +// TestPool_HandleRunResult_ExtractSummaryWins verifies the LLM is NOT called +// when the agent already wrote a "## Summary" section. +func TestPool_HandleRunResult_ExtractSummaryWins(t *testing.T) { + stdoutPath := writeStreamLog(t, []string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"## Summary\nAgent wrote its own summary."}]}}`, + }) + + var llmCalls int32 + llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&llmCalls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"choices":[{"message":{"content":"should not be used"},"finish_reason":"stop"}],"usage":{}}`) + })) + defer llmSrv.Close() + + store := newMinimalMockStore() + pool := newPoolWithMockStore(store) + pool.LLM = &llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "x"} + + tk := makeTask("agent-summary") + store.tasks[tk.ID] = tk + exec := &storage.Execution{ID: "e-agent", TaskID: tk.ID, Status: "RUNNING", StdoutPath: stdoutPath} + + pool.handleRunResult(context.Background(), tk, exec, nil, "claude") + + if got := atomic.LoadInt32(&llmCalls); got != 0 { + t.Errorf("LLM should not be called when ## Summary is present; got %d calls", got) + } + _, summary, ok := store.lastSummaryUpdate() + if !ok { + t.Fatalf("expected UpdateTaskSummary") + } + if summary != "Agent wrote its own summary." { + t.Errorf("summary: got %q (want extractSummary output)", summary) + } + <-pool.resultCh +} + +func TestSynthesizeSummary_LLMFailure_ReturnsEmpty(t *testing.T) { + path := writeStreamLog(t, []string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Did something."}]}}`, + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "x"} + + got := synthesizeSummary(context.Background(), c, path) + if got != "" { + t.Errorf("LLM failure: want empty, got %q", got) + } +} + +// TestReadAssistantTextTail_TailingLargeFile verifies the seek-to-tail +// behavior drops early content but keeps later assistant text. +func TestReadAssistantTextTail_TailingLargeFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stdout.log") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + // Write a ton of garbage assistant lines, then a final marker. + for i := 0; i < 500; i++ { + fmt.Fprintf(f, `{"type":"assistant","message":{"content":[{"type":"text","text":"filler line that should be in the early part of a large file %04d"}]}}`+"\n", i) + } + fmt.Fprintln(f, `{"type":"assistant","message":{"content":[{"type":"text","text":"FINAL_MARKER_LINE"}]}}`) + f.Close() + + got := readAssistantTextTail(path, 4*1024) // 4 KB cap + if !strings.Contains(got, "FINAL_MARKER_LINE") { + t.Errorf("tail should contain final line; got: %s", got) + } + if strings.Contains(got, "filler line that should be in the early part of a large file 0000") { + end := 200 + if len(got) < end { + end = len(got) + } + t.Errorf("tail should NOT contain very-early line; got first 200 chars: %s", got[:end]) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..613ebe5 --- /dev/null +++ b/internal/llm/client.go @@ -0,0 +1,343 @@ +// Package llm provides a small OpenAI-compatible HTTP client used for +// internal LLM-shaped work (model classification, summarization, elaboration) +// against any local server speaking /v1/chat/completions: Ollama, vLLM, +// LM Studio, llama.cpp server, etc. +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/thepeterstone/claudomator/internal/retry" +) + +// Client is an OpenAI-compatible chat completions client. +// Endpoint is the base URL up through "/v1" (no trailing slash). +type Client struct { + Endpoint string + Model string + APIKey string // optional, sent as Bearer token + HTTPClient *http.Client + Logger *slog.Logger +} + +// Message is a single chat-completion message. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatRequest captures the parameters of a single Chat or ChatStream call. +// Zero values mean "use server default" except for Stream and ResponseJSON, +// which are explicit booleans. Model overrides Client.Model when non-empty. +type ChatRequest struct { + Model string + Messages []Message + Temperature *float64 + MaxTokens int + ResponseJSON bool +} + +// ChatResponse is the aggregated result of a chat completion. +type ChatResponse struct { + Content string + PromptTokens int + OutputTokens int + Model string + FinishReason string +} + +// Chat performs a non-streaming chat completion. Rate-limit errors (HTTP 429, +// overloaded responses) are retried with exponential backoff via +// retry.RunWithBackoff. +func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + if c == nil { + return nil, errors.New("llm: nil Client") + } + body, err := c.buildRequestBody(req, false) + if err != nil { + return nil, err + } + + var resp *ChatResponse + err = retry.RunWithBackoff(ctx, 3, time.Second, func() error { + raw, perErr := c.postChat(ctx, body) + if perErr != nil { + return perErr + } + var oai openAIResponse + if jerr := json.Unmarshal(raw, &oai); jerr != nil { + return fmt.Errorf("llm: decode response: %w", jerr) + } + if len(oai.Choices) == 0 { + return fmt.Errorf("llm: response has no choices") + } + resp = &ChatResponse{ + Content: oai.Choices[0].Message.Content, + PromptTokens: oai.Usage.PromptTokens, + OutputTokens: oai.Usage.CompletionTokens, + Model: oai.Model, + FinishReason: oai.Choices[0].FinishReason, + } + return nil + }) + if err != nil { + return nil, err + } + return resp, nil +} + +// ChatStream performs a streaming chat completion. onDelta is called once per +// content delta chunk. The returned ChatResponse aggregates the full content +// and any usage tokens reported in the final SSE chunk. Rate-limit errors at +// connection time are retried; once streaming has begun, errors are returned. +func (c *Client) ChatStream(ctx context.Context, req ChatRequest, onDelta func(string)) (*ChatResponse, error) { + if c == nil { + return nil, errors.New("llm: nil Client") + } + body, err := c.buildRequestBody(req, true) + if err != nil { + return nil, err + } + + var resp *ChatResponse + err = retry.RunWithBackoff(ctx, 3, time.Second, func() error { + var perErr error + resp, perErr = c.streamChat(ctx, body, onDelta) + return perErr + }) + if err != nil { + return nil, err + } + return resp, nil +} + +func (c *Client) buildRequestBody(req ChatRequest, stream bool) ([]byte, error) { + model := req.Model + if model == "" { + model = c.Model + } + if model == "" { + return nil, errors.New("llm: no model configured") + } + payload := openAIRequest{ + Model: model, + Messages: req.Messages, + Stream: stream, + } + if req.Temperature != nil { + payload.Temperature = req.Temperature + } + if req.MaxTokens > 0 { + payload.MaxTokens = req.MaxTokens + } + if req.ResponseJSON { + payload.ResponseFormat = &responseFormat{Type: "json_object"} + } + if stream { + payload.StreamOptions = &streamOptions{IncludeUsage: true} + } + return json.Marshal(payload) +} + +func (c *Client) postChat(ctx context.Context, body []byte) ([]byte, error) { + url := strings.TrimRight(c.Endpoint, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("llm: build request: %w", err) + } + c.applyHeaders(httpReq) + + httpResp, err := c.client().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("llm: http: %w", err) + } + defer httpResp.Body.Close() + raw, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("llm: read body: %w", err) + } + if httpResp.StatusCode >= 400 { + return nil, errFromStatus(httpResp, raw) + } + return raw, nil +} + +func (c *Client) streamChat(ctx context.Context, body []byte, onDelta func(string)) (*ChatResponse, error) { + url := strings.TrimRight(c.Endpoint, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("llm: build request: %w", err) + } + c.applyHeaders(httpReq) + httpReq.Header.Set("Accept", "text/event-stream") + + httpResp, err := c.client().Do(httpReq) + if err != nil { + return nil, fmt.Errorf("llm: http: %w", err) + } + defer httpResp.Body.Close() + if httpResp.StatusCode >= 400 { + raw, _ := io.ReadAll(httpResp.Body) + return nil, errFromStatus(httpResp, raw) + } + + var ( + content strings.Builder + promptTok int + outputTok int + model string + finishReason string + ) + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + if data == "[DONE]" { + break + } + continue + } + var chunk openAIStreamChunk + if jerr := json.Unmarshal([]byte(data), &chunk); jerr != nil { + if c.Logger != nil { + c.Logger.Warn("llm: bad SSE chunk", "err", jerr, "data", data) + } + continue + } + if chunk.Model != "" { + model = chunk.Model + } + for _, ch := range chunk.Choices { + if ch.Delta.Content != "" { + content.WriteString(ch.Delta.Content) + if onDelta != nil { + onDelta(ch.Delta.Content) + } + } + if ch.FinishReason != "" { + finishReason = ch.FinishReason + } + } + if chunk.Usage != nil { + promptTok = chunk.Usage.PromptTokens + outputTok = chunk.Usage.CompletionTokens + } + } + if scanErr := scanner.Err(); scanErr != nil { + return nil, fmt.Errorf("llm: stream read: %w", scanErr) + } + return &ChatResponse{ + Content: content.String(), + PromptTokens: promptTok, + OutputTokens: outputTok, + Model: model, + FinishReason: finishReason, + }, nil +} + +func (c *Client) applyHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } +} + +func (c *Client) client() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 60 * time.Second} +} + +// errFromStatus produces an error whose message includes "rate limit", "429", +// or "overloaded" as appropriate so retry.IsRateLimitError treats local 429/503 +// identically to upstream provider rate limits. Any Retry-After header is +// embedded in the error message for retry.ParseRetryAfter to find. +func errFromStatus(resp *http.Response, body []byte) error { + prefix := "" + switch resp.StatusCode { + case http.StatusTooManyRequests: + prefix = fmt.Sprintf("llm: 429 rate limit") + case http.StatusServiceUnavailable: + prefix = "llm: 503 overloaded" + default: + prefix = fmt.Sprintf("llm: http %d", resp.StatusCode) + } + if ra := resp.Header.Get("Retry-After"); ra != "" { + prefix += fmt.Sprintf(" (retry-after: %s)", ra) + } + snippet := strings.TrimSpace(string(body)) + if len(snippet) > 500 { + snippet = snippet[:500] + "..." + } + if snippet != "" { + return fmt.Errorf("%s: %s", prefix, snippet) + } + return errors.New(prefix) +} + +// --- OpenAI wire types --- + +type openAIRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *streamOptions `json:"stream_options,omitempty"` + ResponseFormat *responseFormat `json:"response_format,omitempty"` +} + +type streamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +type responseFormat struct { + Type string `json:"type"` +} + +type openAIResponse struct { + Model string `json:"model"` + Choices []openAIChoice `json:"choices"` + Usage openAIUsage `json:"usage"` +} + +type openAIChoice struct { + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` +} + +type openAIStreamChunk struct { + Model string `json:"model"` + Choices []openAIStreamCh `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` +} + +type openAIStreamCh struct { + Delta openAIDelta `json:"delta"` + FinishReason string `json:"finish_reason"` +} + +type openAIDelta struct { + Content string `json:"content"` +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go new file mode 100644 index 0000000..8257836 --- /dev/null +++ b/internal/llm/client_test.go @@ -0,0 +1,159 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestChat_ParsesCompletion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("missing/wrong bearer header: %q", r.Header.Get("Authorization")) + } + var body openAIRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Model != "test-model" { + t.Errorf("model: want test-model got %q", body.Model) + } + if len(body.Messages) != 1 || body.Messages[0].Content != "hello" { + t.Errorf("messages mismatch: %+v", body.Messages) + } + if body.ResponseFormat == nil || body.ResponseFormat.Type != "json_object" { + t.Errorf("expected response_format json_object, got %+v", body.ResponseFormat) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{ + "model": "test-model", + "choices": [{"message": {"role": "assistant", "content": "world"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 4, "completion_tokens": 7} + }`) + })) + defer srv.Close() + + c := &Client{Endpoint: srv.URL + "/v1", Model: "test-model", APIKey: "test-key"} + resp, err := c.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "hello"}}, + ResponseJSON: true, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "world" { + t.Errorf("content: want world got %q", resp.Content) + } + if resp.PromptTokens != 4 || resp.OutputTokens != 7 { + t.Errorf("tokens mismatch: %+v", resp) + } + if resp.FinishReason != "stop" { + t.Errorf("finish_reason: want stop got %q", resp.FinishReason) + } +} + +func TestChatStream_ParsesSSE(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + chunks := []string{ + `{"model":"test-model","choices":[{"delta":{"content":"Hel"},"finish_reason":""}]}`, + `{"model":"test-model","choices":[{"delta":{"content":"lo, "},"finish_reason":""}]}`, + `{"model":"test-model","choices":[{"delta":{"content":"world"},"finish_reason":"stop"}]}`, + `{"model":"test-model","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":5}}`, + } + for _, c := range chunks { + fmt.Fprintf(w, "data: %s\n\n", c) + if flusher != nil { + flusher.Flush() + } + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + c := &Client{Endpoint: srv.URL + "/v1", Model: "test-model"} + + var deltas []string + resp, err := c.ChatStream(context.Background(), + ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}, + func(d string) { deltas = append(deltas, d) }, + ) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + if got := strings.Join(deltas, ""); got != "Hello, world" { + t.Errorf("aggregated deltas: want %q got %q", "Hello, world", got) + } + if resp.Content != "Hello, world" { + t.Errorf("content: want %q got %q", "Hello, world", resp.Content) + } + if resp.PromptTokens != 3 || resp.OutputTokens != 5 { + t.Errorf("tokens: %+v", resp) + } + if resp.FinishReason != "stop" { + t.Errorf("finish_reason: want stop got %q", resp.FinishReason) + } +} + +func TestChat_RetriesOn429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.Header().Set("Retry-After", "1") + http.Error(w, "slow down", http.StatusTooManyRequests) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{ + "model":"m","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":1,"completion_tokens":1} + }`) + })) + defer srv.Close() + + c := &Client{ + Endpoint: srv.URL + "/v1", + Model: "m", + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := c.Chat(ctx, ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "ok" { + t.Errorf("content: want ok got %q", resp.Content) + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("expected 2 server calls (1 retry), got %d", got) + } +} + +// Sanity: errFromStatus produces a string that retry.IsRateLimitError matches. +func TestErrFromStatus_RateLimitMarker(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"30"}}, + } + body, _ := io.ReadAll(strings.NewReader("limit hit")) + err := errFromStatus(resp, body) + if !strings.Contains(strings.ToLower(err.Error()), "rate limit") { + t.Errorf("error should contain 'rate limit', got: %v", err) + } + if !strings.Contains(err.Error(), "retry-after: 30") { + t.Errorf("error should embed retry-after, got: %v", err) + } +} diff --git a/internal/retry/backoff.go b/internal/retry/backoff.go new file mode 100644 index 0000000..b91abc4 --- /dev/null +++ b/internal/retry/backoff.go @@ -0,0 +1,77 @@ +// Package retry provides exponential-backoff retry helpers used across the +// codebase for rate-limit-aware HTTP/subprocess calls. +package retry + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var retryAfterRe = regexp.MustCompile(`(?i)retry[-_ ]after[:\s]+(\d+)`) + +const maxBackoffDelay = 5 * time.Minute + +// IsRateLimitError returns true if err looks like a transient rate-limit +// (e.g. HTTP 429, "too many requests", "overloaded") that is worth retrying. +func IsRateLimitError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "rate limit") || + strings.Contains(msg, "too many requests") || + strings.Contains(msg, "429") || + strings.Contains(msg, "overloaded") +} + +// ParseRetryAfter extracts a Retry-After duration from an error message. +// Returns 0 if no retry-after value is found. +func ParseRetryAfter(msg string) time.Duration { + m := retryAfterRe.FindStringSubmatch(msg) + if m == nil { + return 0 + } + secs, err := strconv.Atoi(m[1]) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second +} + +// RunWithBackoff calls fn repeatedly on rate-limit errors, using exponential backoff. +// maxRetries is the max number of retry attempts (not counting the initial call). +// baseDelay is the initial backoff duration (doubled each retry). +func RunWithBackoff(ctx context.Context, maxRetries int, baseDelay time.Duration, fn func() error) error { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + lastErr = fn() + if lastErr == nil { + return nil + } + if !IsRateLimitError(lastErr) { + return lastErr + } + if attempt == maxRetries { + break + } + + delay := baseDelay * (1 << attempt) + if delay > maxBackoffDelay { + delay = maxBackoffDelay + } + if ra := ParseRetryAfter(lastErr.Error()); ra > 0 { + delay = ra + } + + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled during rate-limit backoff: %w", ctx.Err()) + case <-time.After(delay): + } + } + return lastErr +} diff --git a/internal/executor/ratelimit_test.go b/internal/retry/backoff_test.go index f45216f..a963fc2 100644 --- a/internal/executor/ratelimit_test.go +++ b/internal/retry/backoff_test.go @@ -1,4 +1,4 @@ -package executor +package retry import ( "context" @@ -8,54 +8,54 @@ import ( "time" ) -// --- isRateLimitError tests --- +// --- IsRateLimitError tests --- func TestIsRateLimitError_RateLimitMessage(t *testing.T) { err := errors.New("claude exited with error: rate limit exceeded") - if !isRateLimitError(err) { + if !IsRateLimitError(err) { t.Error("want true for 'rate limit exceeded', got false") } } func TestIsRateLimitError_TooManyRequests(t *testing.T) { err := errors.New("too many requests to the API") - if !isRateLimitError(err) { + if !IsRateLimitError(err) { t.Error("want true for 'too many requests', got false") } } func TestIsRateLimitError_HTTP429(t *testing.T) { err := errors.New("API returned status 429") - if !isRateLimitError(err) { + if !IsRateLimitError(err) { t.Error("want true for '429', got false") } } func TestIsRateLimitError_Overloaded(t *testing.T) { err := errors.New("API overloaded, please retry later") - if !isRateLimitError(err) { + if !IsRateLimitError(err) { t.Error("want true for 'overloaded', got false") } } func TestIsRateLimitError_NonRateLimitError(t *testing.T) { err := errors.New("claude exited with error: exit status 1") - if isRateLimitError(err) { + if IsRateLimitError(err) { t.Error("want false for non-rate-limit error, got true") } } func TestIsRateLimitError_NilError(t *testing.T) { - if isRateLimitError(nil) { + if IsRateLimitError(nil) { t.Error("want false for nil error, got true") } } -// --- parseRetryAfter tests --- +// --- ParseRetryAfter tests --- func TestParseRetryAfter_RetryAfterSeconds(t *testing.T) { msg := "rate limit exceeded, retry after 30 seconds" - d := parseRetryAfter(msg) + d := ParseRetryAfter(msg) if d != 30*time.Second { t.Errorf("want 30s, got %v", d) } @@ -63,7 +63,7 @@ func TestParseRetryAfter_RetryAfterSeconds(t *testing.T) { func TestParseRetryAfter_RetryAfterHeader(t *testing.T) { msg := "rate_limit_error: retry-after: 60" - d := parseRetryAfter(msg) + d := ParseRetryAfter(msg) if d != 60*time.Second { t.Errorf("want 60s, got %v", d) } @@ -71,13 +71,13 @@ func TestParseRetryAfter_RetryAfterHeader(t *testing.T) { func TestParseRetryAfter_NoRetryInfo(t *testing.T) { msg := "rate limit exceeded" - d := parseRetryAfter(msg) + d := ParseRetryAfter(msg) if d != 0 { t.Errorf("want 0, got %v", d) } } -// --- runWithBackoff tests --- +// --- RunWithBackoff tests --- func TestRunWithBackoff_SuccessOnFirstTry(t *testing.T) { calls := 0 @@ -85,7 +85,7 @@ func TestRunWithBackoff_SuccessOnFirstTry(t *testing.T) { calls++ return nil } - err := runWithBackoff(context.Background(), 3, time.Millisecond, fn) + err := RunWithBackoff(context.Background(), 3, time.Millisecond, fn) if err != nil { t.Errorf("want nil error, got %v", err) } @@ -103,7 +103,7 @@ func TestRunWithBackoff_RetriesOnRateLimit(t *testing.T) { } return nil } - err := runWithBackoff(context.Background(), 3, time.Millisecond, fn) + err := RunWithBackoff(context.Background(), 3, time.Millisecond, fn) if err != nil { t.Errorf("want nil error, got %v", err) } @@ -119,11 +119,10 @@ func TestRunWithBackoff_GivesUpAfterMaxRetries(t *testing.T) { calls++ return rateLimitErr } - err := runWithBackoff(context.Background(), 3, time.Millisecond, fn) + err := RunWithBackoff(context.Background(), 3, time.Millisecond, fn) if err == nil { t.Fatal("want error after max retries, got nil") } - // maxRetries=3: 1 initial call + 3 retries = 4 total calls if calls != 4 { t.Errorf("want 4 calls (1 initial + 3 retries), got %d", calls) } @@ -135,7 +134,7 @@ func TestRunWithBackoff_DoesNotRetryNonRateLimitError(t *testing.T) { calls++ return fmt.Errorf("permission denied") } - err := runWithBackoff(context.Background(), 3, time.Millisecond, fn) + err := RunWithBackoff(context.Background(), 3, time.Millisecond, fn) if err == nil { t.Fatal("want error, got nil") } @@ -150,12 +149,12 @@ func TestRunWithBackoff_ContextCancellation(t *testing.T) { fn := func() error { calls++ - cancel() // cancel immediately after first call + cancel() return fmt.Errorf("rate limit exceeded") } start := time.Now() - err := runWithBackoff(ctx, 3, time.Second, fn) // large delay confirms ctx preempts wait + err := RunWithBackoff(ctx, 3, time.Second, fn) elapsed := time.Since(start) if err == nil { diff --git a/internal/storage/db.go b/internal/storage/db.go index 038480b..c871c77 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -86,6 +86,8 @@ func (s *DB) migrate() error { `ALTER TABLE executions ADD COLUMN changestats_json TEXT`, `ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`, `ALTER TABLE tasks ADD COLUMN elaboration_input TEXT`, + `ALTER TABLE executions ADD COLUMN tokens_in INTEGER`, + `ALTER TABLE executions ADD COLUMN tokens_out INTEGER`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -403,6 +405,11 @@ type Execution struct { Changestats *task.Changestats // stored as JSON; nil if not yet recorded Commits []task.GitCommit // stored as JSON; empty if no commits + // Token usage for non-CLI runners (e.g. LocalRunner). 0 for Claude/Gemini + // CLI runs which report cost in cost_usd instead. + TokensIn int64 + TokensOut int64 + // In-memory only: set when creating a resume execution, not stored in DB. ResumeSessionID string ResumeAnswer string @@ -430,23 +437,23 @@ func (s *DB) CreateExecution(e *Execution) error { commitsJSON = string(b) } _, err := s.db.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, ) return err } // GetExecution retrieves an execution by ID. func (s *DB) GetExecution(id string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE id = ?`, id) return scanExecution(row) } // ListExecutions returns executions for a task. func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { - rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) + rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) if err != nil { return nil, err } @@ -465,7 +472,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { // GetLatestExecution returns the most recent execution for a task. func (s *DB) GetLatestExecution(taskID string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) return scanExecution(row) } @@ -650,11 +657,11 @@ func (s *DB) UpdateExecution(e *Execution) error { _, err := s.db.Exec(` UPDATE executions SET end_time = ?, exit_code = ?, status = ?, cost_usd = ?, error_msg = ?, stdout_path = ?, stderr_path = ?, artifact_dir = ?, session_id = ?, sandbox_dir = ?, - changestats_json = ?, commits_json = ? + changestats_json = ?, commits_json = ?, tokens_in = ?, tokens_out = ? WHERE id = ?`, e.EndTime.UTC(), e.ExitCode, e.Status, e.CostUSD, e.ErrorMsg, e.StdoutPath, e.StderrPath, e.ArtifactDir, e.SessionID, e.SandboxDir, - changestatsJSON, commitsJSON, e.ID, + changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, e.ID, ) return err } @@ -729,13 +736,17 @@ func scanExecution(row scanner) (*Execution, error) { var sandboxDir sql.NullString var changestatsJSON sql.NullString var commitsJSON sql.NullString + var tokensIn sql.NullInt64 + var tokensOut sql.NullInt64 err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status, - &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON) + &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut) if err != nil { return nil, err } e.SessionID = sessionID.String e.SandboxDir = sandboxDir.String + e.TokensIn = tokensIn.Int64 + e.TokensOut = tokensOut.Int64 if changestatsJSON.Valid && changestatsJSON.String != "" { var cs task.Changestats if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil { diff --git a/internal/task/task.go b/internal/task/task.go index b3660d3..fd1dde6 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -40,6 +40,11 @@ type AgentConfig struct { SystemPromptAppend string `yaml:"system_prompt_append" json:"system_prompt_append"` AdditionalArgs []string `yaml:"additional_args" json:"additional_args"` SkipPlanning bool `yaml:"skip_planning" json:"skip_planning"` + + // Local-runner sampling controls. Pointer for Temperature so a 0 value can + // mean "deterministic" rather than "unset, use server default". + Temperature *float64 `yaml:"temperature,omitempty" json:"temperature,omitempty"` + MaxTokens int `yaml:"max_tokens,omitempty" json:"max_tokens,omitempty"` } |
