Files
BountyBoard/services/atomizer/llm.go
etalon d698f70c4f feat: mock atomizer and work-performer services with contract tests (phase 11)
- services/atomizer: standalone Go module implementing §5.1 — Anthropic
  OpenAI-compatible chat completions by default, native /v1/messages when
  LLM_API_STYLE=anthropic, strict-JSON prompting, defensive fence-stripping
  parse with one retry, deterministic equal-split fallback without an API
  key, exact coefficient-sum normalization, bearer auth
- services/work-performer: Node 20 http server implementing §5.2 — single
  concurrency, /work/{jobId}/TASK.md preparation, attachment downloads,
  optional shallow git clone, claude CLI execution with JSON output,
  simulated success when the CLI is unavailable (offline demo), artifact
  endpoint, idempotent HMAC-signed callbacks with retry, best-effort cancel
- compose profile 'mocks': separate builds/ports/tokens, healthchecks,
  ${HOME}/.claude(.json) mounted read-only into the performer
- contract tests (go test -tags=contract) for both services; Makefile
  test-contract target; verified live incl. a real Claude Code job run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:31:39 +02:00

253 lines
8.2 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// llmClient calls Anthropic via the OpenAI-compatible chat-completions
// endpoint (default) or the native Messages API (LLM_API_STYLE=anthropic).
// Plain net/http, no SDK (§9.1).
type llmClient struct {
cfg config
log *slog.Logger
http *http.Client
}
func newLLMClient(cfg config, log *slog.Logger) *llmClient {
return &llmClient{cfg: cfg, log: log, http: &http.Client{Timeout: 110 * time.Second}}
}
const atomizeSystem = `You are an expert software project planner. You split one ticket into small, well-scoped, independently deliverable developer tasks.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"tasks":[{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.25}],"notes":"short advice for the consultant"}
Rules: between MIN and MAX tasks; every effortCoefficient is a number in (0,1]; all effortCoefficients MUST sum to exactly 1.0; titles are concise; descriptions are self-contained instructions; each task has 1-4 testable acceptance criteria.`
const extendSystem = `You are an expert software project planner. You design exactly ONE additional sibling task that extends the given task's functionality per the extension note.
Respond with ONLY a JSON object, no prose and no markdown fences, exactly matching:
{"task":{"title":"...","description":"...","acceptanceCriteria":["..."],"effortCoefficient":0.4},"notes":"short advice"}
Rules: effortCoefficient is the effort RELATIVE to the source task, a number in (0,2].`
func ticketPrompt(req atomizeRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "TICKET: %s\n\nDESCRIPTION:\n%s\n", req.Title, req.Description)
if len(req.AcceptanceCriteria) > 0 {
sb.WriteString("\nACCEPTANCE CRITERIA:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&sb, "- %s\n", c)
}
}
if len(req.Links) > 0 {
fmt.Fprintf(&sb, "\nLINKS: %s\n", strings.Join(req.Links, ", "))
}
for _, a := range req.Attachments {
fmt.Fprintf(&sb, "ATTACHMENT: %s (%s) %s\n", a.Name, a.MimeType, a.URL)
}
return sb.String()
}
func (l *llmClient) atomize(ctx context.Context, req atomizeRequest, minT, maxT int) ([]subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackAtomize(req, minT, maxT), "offline-fallback", "Deterministic split (no ANTHROPIC_API_KEY configured).", nil
}
system := strings.NewReplacer("MIN", fmt.Sprint(minT), "MAX", fmt.Sprint(maxT)).Replace(atomizeSystem)
user := ticketPrompt(req)
if req.SubdivisionNote != "" {
user += "\nCONSULTANT NOTE: " + req.SubdivisionNote
}
var parsed struct {
Tasks []subTask `json:"tasks"`
Notes string `json:"notes"`
}
raw, err := l.completeWithRetry(ctx, system, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if len(parsed.Tasks) == 0 {
return fmt.Errorf("no tasks in response")
}
for _, t := range parsed.Tasks {
if t.Title == "" || t.EffortCoefficient <= 0 {
return fmt.Errorf("invalid task entry")
}
}
return nil
})
if err != nil {
l.log.Warn("llm atomize failed, using fallback", "err", err)
return fallbackAtomize(req, minT, maxT), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic split.", nil
}
_ = raw
return parsed.Tasks, l.cfg.model, parsed.Notes, nil
}
func (l *llmClient) extend(ctx context.Context, req atomizeRequest) (subTask, string, string, error) {
if l.cfg.apiKey == "" {
return fallbackExtend(req), "offline-fallback", "Deterministic extension (no ANTHROPIC_API_KEY configured).", nil
}
user := ticketPrompt(req) + "\nEXTENSION NOTE (required scope): " + req.ExtensionNote
var parsed struct {
Task subTask `json:"task"`
Notes string `json:"notes"`
}
_, err := l.completeWithRetry(ctx, extendSystem, user, func(text string) error {
if err := json.Unmarshal([]byte(stripFences(text)), &parsed); err != nil {
return err
}
if parsed.Task.Title == "" {
return fmt.Errorf("no task in response")
}
return nil
})
if err != nil {
l.log.Warn("llm extend failed, using fallback", "err", err)
return fallbackExtend(req), "offline-fallback",
"LLM unavailable (" + err.Error() + "); deterministic extension.", nil
}
return parsed.Task, l.cfg.model, parsed.Notes, nil
}
// completeWithRetry runs the chat completion and retries ONCE on parse
// failure with a corrective hint (§9.1).
func (l *llmClient) completeWithRetry(ctx context.Context, system, user string,
validate func(string) error) (string, error) {
text, err := l.complete(ctx, system, user)
if err != nil {
return "", err
}
if vErr := validate(text); vErr == nil {
return text, nil
} else {
l.log.Warn("llm response failed validation, retrying once", "err", vErr)
}
text, err = l.complete(ctx, system,
user+"\n\nIMPORTANT: your previous reply was not valid JSON matching the schema. Reply with ONLY the JSON object.")
if err != nil {
return "", err
}
if vErr := validate(text); vErr != nil {
return "", fmt.Errorf("invalid JSON after retry: %w", vErr)
}
return text, nil
}
func (l *llmClient) complete(ctx context.Context, system, user string) (string, error) {
if l.cfg.apiStyle == "anthropic" {
return l.completeNative(ctx, system, user)
}
return l.completeOpenAI(ctx, system, user)
}
// completeOpenAI uses Anthropic's OpenAI-compatible endpoint.
func (l *llmClient) completeOpenAI(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+l.cfg.apiKey)
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode chat completion: %w", err)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return out.Choices[0].Message.Content, nil
}
// completeNative uses the native Anthropic Messages API (flip via
// LLM_API_STYLE=anthropic if the compatibility endpoint misbehaves, §9.1).
func (l *llmClient) completeNative(ctx context.Context, system, user string) (string, error) {
body, _ := json.Marshal(map[string]any{
"model": l.cfg.model,
"max_tokens": 4096,
"system": system,
"messages": []map[string]string{{"role": "user", "content": user}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
l.cfg.baseURL+"/messages", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", l.cfg.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
data, err := l.do(req)
if err != nil {
return "", err
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("decode messages response: %w", err)
}
for _, c := range out.Content {
if c.Type == "text" {
return c.Text, nil
}
}
return "", fmt.Errorf("no text content in response")
}
func (l *llmClient) do(req *http.Request) ([]byte, error) {
resp, err := l.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llm api status %d: %.300s", resp.StatusCode, data)
}
return data, nil
}
// stripFences defensively removes markdown code fences and surrounding prose
// (§9.1) by slicing from the first '{' to the last '}'.
func stripFences(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
return s[i : j+1]
}
}
return s
}