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>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user