Add Claude Haiku client for issue compaction (bd-255)

- Implement HaikuClient with Tier 1 and Tier 2 summarization prompts
- Add exponential backoff retry logic for transient errors (network, 429, 5xx)
- API key precedence: env var overrides explicit key parameter
- Comprehensive tests with UTF-8 support and edge case coverage
- Retry logic only retries transient errors, stops on permanent failures
- Clarified retry semantics: maxRetries=3 means 4 total attempts (1 initial + 3 retries)
This commit is contained in:
Steve Yegge
2025-10-15 23:09:33 -07:00
parent 1c5a4a9c70
commit 3e3f46d6d2
5 changed files with 492 additions and 1 deletions

254
internal/compact/haiku.go Normal file
View File

@@ -0,0 +1,254 @@
// Package compact provides AI-powered issue compaction using Claude Haiku.
package compact
import (
"context"
"errors"
"fmt"
"math"
"net"
"os"
"text/template"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/steveyegge/beads/internal/types"
)
const (
defaultModel = "claude-3-5-haiku-20241022"
maxRetries = 3
initialBackoff = 1 * time.Second
)
// HaikuClient wraps the Anthropic API for issue summarization.
type HaikuClient struct {
client anthropic.Client
model anthropic.Model
tier1Template *template.Template
tier2Template *template.Template
maxRetries int
initialBackoff time.Duration
}
// NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey.
func NewHaikuClient(apiKey string) (*HaikuClient, error) {
envKey := os.Getenv("ANTHROPIC_API_KEY")
if envKey != "" {
apiKey = envKey
}
if apiKey == "" {
return nil, fmt.Errorf("API key required: set ANTHROPIC_API_KEY environment variable or provide via config")
}
client := anthropic.NewClient(option.WithAPIKey(apiKey))
tier1Tmpl, err := template.New("tier1").Parse(tier1PromptTemplate)
if err != nil {
return nil, fmt.Errorf("failed to parse tier1 template: %w", err)
}
tier2Tmpl, err := template.New("tier2").Parse(tier2PromptTemplate)
if err != nil {
return nil, fmt.Errorf("failed to parse tier2 template: %w", err)
}
return &HaikuClient{
client: client,
model: defaultModel,
tier1Template: tier1Tmpl,
tier2Template: tier2Tmpl,
maxRetries: maxRetries,
initialBackoff: initialBackoff,
}, nil
}
// SummarizeTier1 creates a structured summary of an issue (Summary, Key Decisions, Resolution).
func (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (string, error) {
prompt, err := h.renderTier1Prompt(issue)
if err != nil {
return "", fmt.Errorf("failed to render prompt: %w", err)
}
return h.callWithRetry(ctx, prompt)
}
// SummarizeTier2 creates an ultra-compressed single-paragraph summary (≤150 words).
func (h *HaikuClient) SummarizeTier2(ctx context.Context, issue *types.Issue) (string, error) {
prompt, err := h.renderTier2Prompt(issue)
if err != nil {
return "", fmt.Errorf("failed to render prompt: %w", err)
}
return h.callWithRetry(ctx, prompt)
}
func (h *HaikuClient) callWithRetry(ctx context.Context, prompt string) (string, error) {
var lastErr error
params := anthropic.MessageNewParams{
Model: h.model,
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
}
for attempt := 0; attempt <= h.maxRetries; attempt++ {
if attempt > 0 {
backoff := h.initialBackoff * time.Duration(math.Pow(2, float64(attempt-1)))
select {
case <-time.After(backoff):
case <-ctx.Done():
return "", ctx.Err()
}
}
message, err := h.client.Messages.New(ctx, params)
if err == nil {
if len(message.Content) > 0 {
content := message.Content[0]
if content.Type == "text" {
return content.Text, nil
}
return "", fmt.Errorf("unexpected response format: not a text block (type=%s)", content.Type)
}
return "", fmt.Errorf("unexpected response format: no content blocks")
}
lastErr = err
if ctx.Err() != nil {
return "", ctx.Err()
}
if !isRetryable(err) {
return "", fmt.Errorf("non-retryable error: %w", err)
}
}
return "", fmt.Errorf("failed after %d retries: %w", h.maxRetries+1, lastErr)
}
func isRetryable(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var netErr net.Error
if errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) {
return true
}
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
statusCode := apiErr.StatusCode
if statusCode == 429 || statusCode >= 500 {
return true
}
return false
}
return false
}
type tier1Data struct {
Title string
Description string
Design string
AcceptanceCriteria string
Notes string
}
func (h *HaikuClient) renderTier1Prompt(issue *types.Issue) (string, error) {
var buf []byte
w := &bytesWriter{buf: buf}
data := tier1Data{
Title: issue.Title,
Description: issue.Description,
Design: issue.Design,
AcceptanceCriteria: issue.AcceptanceCriteria,
Notes: issue.Notes,
}
if err := h.tier1Template.Execute(w, data); err != nil {
return "", err
}
return string(w.buf), nil
}
type tier2Data struct {
Title string
CurrentDescription string
}
func (h *HaikuClient) renderTier2Prompt(issue *types.Issue) (string, error) {
var buf []byte
w := &bytesWriter{buf: buf}
data := tier2Data{
Title: issue.Title,
CurrentDescription: issue.Description,
}
if err := h.tier2Template.Execute(w, data); err != nil {
return "", err
}
return string(w.buf), nil
}
type bytesWriter struct {
buf []byte
}
func (w *bytesWriter) Write(p []byte) (n int, err error) {
w.buf = append(w.buf, p...)
return len(p), nil
}
const tier1PromptTemplate = `You are summarizing a closed software issue for long-term storage. Compress the following issue into a concise summary that preserves key technical decisions and outcomes.
**Title:** {{.Title}}
**Description:**
{{.Description}}
{{if .Design}}**Design:**
{{.Design}}
{{end}}
{{if .AcceptanceCriteria}}**Acceptance Criteria:**
{{.AcceptanceCriteria}}
{{end}}
{{if .Notes}}**Notes:**
{{.Notes}}
{{end}}
Provide a summary in this exact format:
**Summary:** [2-3 sentences covering what was done and why]
**Key Decisions:** [Bullet points of important technical choices or design decisions]
**Resolution:** [Final outcome and any lasting impact]`
const tier2PromptTemplate = `You are performing ultra-compression on a closed software issue. The issue has already been summarized once. Your task is to create a single concise paragraph (≤150 words) that captures the essence.
**Title:** {{.Title}}
**Current Summary:**
{{.CurrentDescription}}
Provide a single paragraph that covers:
- What was built/fixed
- Why it mattered
- Any lasting impact or decisions
Keep it under 150 words while retaining the most important context.`

View File

@@ -0,0 +1,220 @@
package compact
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestNewHaikuClient_RequiresAPIKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
_, err := NewHaikuClient("")
if err == nil {
t.Fatal("expected error when API key is missing")
}
if !strings.Contains(err.Error(), "API key required") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestNewHaikuClient_EnvVarUsedWhenNoExplicitKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
client, err := NewHaikuClient("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Fatal("expected non-nil client")
}
}
func TestNewHaikuClient_EnvVarOverridesExplicitKey(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "test-key-from-env")
client, err := NewHaikuClient("test-key-explicit")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Fatal("expected non-nil client")
}
}
func TestRenderTier1Prompt(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Fix authentication bug",
Description: "Users can't log in with OAuth",
Design: "Add error handling to OAuth flow",
AcceptanceCriteria: "Users can log in successfully",
Notes: "Related to issue bd-2",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "Fix authentication bug") {
t.Error("prompt should contain title")
}
if !strings.Contains(prompt, "Users can't log in with OAuth") {
t.Error("prompt should contain description")
}
if !strings.Contains(prompt, "Add error handling to OAuth flow") {
t.Error("prompt should contain design")
}
if !strings.Contains(prompt, "Users can log in successfully") {
t.Error("prompt should contain acceptance criteria")
}
if !strings.Contains(prompt, "Related to issue bd-2") {
t.Error("prompt should contain notes")
}
if !strings.Contains(prompt, "**Summary:**") {
t.Error("prompt should contain format instructions")
}
}
func TestRenderTier2Prompt(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Fix authentication bug",
Description: "**Summary:** Fixed OAuth login flow by adding proper error handling.",
Status: types.StatusClosed,
}
prompt, err := client.renderTier2Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "Fix authentication bug") {
t.Error("prompt should contain title")
}
if !strings.Contains(prompt, "Fixed OAuth login flow") {
t.Error("prompt should contain current description")
}
if !strings.Contains(prompt, "150 words") {
t.Error("prompt should contain word limit")
}
if !strings.Contains(prompt, "ultra-compression") {
t.Error("prompt should indicate ultra-compression")
}
}
func TestRenderTier1Prompt_HandlesEmptyFields(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Simple task",
Description: "Just a simple task",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "Simple task") {
t.Error("prompt should contain title")
}
if !strings.Contains(prompt, "Just a simple task") {
t.Error("prompt should contain description")
}
}
func TestRenderTier1Prompt_UTF8(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
issue := &types.Issue{
ID: "bd-1",
Title: "Fix bug with émojis 🎉",
Description: "Handle UTF-8: café, 日本語, emoji 🚀",
Status: types.StatusClosed,
}
prompt, err := client.renderTier1Prompt(issue)
if err != nil {
t.Fatalf("failed to render prompt: %v", err)
}
if !strings.Contains(prompt, "🎉") {
t.Error("prompt should preserve emoji in title")
}
if !strings.Contains(prompt, "café") {
t.Error("prompt should preserve accented characters")
}
if !strings.Contains(prompt, "日本語") {
t.Error("prompt should preserve unicode characters")
}
if !strings.Contains(prompt, "🚀") {
t.Error("prompt should preserve emoji in description")
}
}
func TestCallWithRetry_ContextCancellation(t *testing.T) {
client, err := NewHaikuClient("test-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
client.initialBackoff = 100 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.callWithRetry(ctx, "test prompt")
if err == nil {
t.Fatal("expected error when context is cancelled")
}
if err != context.Canceled {
t.Errorf("expected context.Canceled error, got: %v", err)
}
}
func TestIsRetryable(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"context canceled", context.Canceled, false},
{"context deadline exceeded", context.DeadlineExceeded, false},
{"generic error", errors.New("some error"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isRetryable(tt.err)
if got != tt.expected {
t.Errorf("isRetryable(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}