diff --git a/.env.example b/.env.example index 6832886..ea74b3a 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ PR_AF_AI_MAX_RETRIES=3 PR_AF_AI_INITIAL_BACKOFF_SECONDS=2.0 PR_AF_AI_MAX_BACKOFF_SECONDS=8.0 PR_AF_OPENCODE_BIN=opencode +# Optional provider-agnostic harness executable override (leave unset to use provider defaults) +# PR_AF_HARNESS_BIN= PR_AF_OPENCODE_SERVER= # Harness no-output watchdog window (seconds). Harness CLIs in JSON mode emit # events only at completion boundaries, so long single completions look silent. diff --git a/docker-compose.go.yml b/docker-compose.go.yml index b282d61..cc93331 100644 --- a/docker-compose.go.yml +++ b/docker-compose.go.yml @@ -42,6 +42,8 @@ services: - AGENT_CALLBACK_URL=http://pr-af-go:8007 - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} + # Optional generic executable override; leave unset for provider defaults. + - PR_AF_HARNESS_BIN - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - GH_TOKEN=${GH_TOKEN:-} - XDG_DATA_HOME=/home/praf/.local/share diff --git a/go/Dockerfile b/go/Dockerfile index 5b95646..5e8ea81 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -39,6 +39,8 @@ FROM debian:bookworm-slim AS runtime ARG OPENCODE_VERSION=1.17.15 +# PR_AF_HARNESS_BIN remains unset by default; set it only to override every +# provider's executable path. ENV AGENTFIELD_SERVER=http://agentfield:8080 \ PR_AF_PROVIDER=opencode \ PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \ diff --git a/go/README.md b/go/README.md index cfc347c..c8c42f5 100644 --- a/go/README.md +++ b/go/README.md @@ -157,6 +157,7 @@ The node is configured entirely through the environment. | `PORT` | Listen port (default `8007`) | | `PR_AF_PROVIDER` | Harness provider (default `opencode`) | | `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) | +| `PR_AF_HARNESS_BIN` | Optional harness executable override for every provider; unset uses provider defaults | | `PR_AF_MAX_COST_USD` | Per-run cost ceiling in USD (default `2.0`) | | `PR_AF_MAX_DURATION_SECONDS`| Per-run wall-clock ceiling in seconds (default `3600`) | | `AGENTFIELD_HARNESS_IDLE_SECONDS` | Harness no-output watchdog window in seconds (default `360`) — harness CLIs in JSON mode emit events only at completion boundaries, so long single completions look silent | diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml index 31c4533..2b08c2e 100644 --- a/go/agentfield-package.yaml +++ b/go/agentfield-package.yaml @@ -38,6 +38,8 @@ user_environment: - name: PR_AF_MODEL description: harness model default: openrouter/moonshotai/kimi-k2.5 + - name: PR_AF_HARNESS_BIN + description: optional executable override for every harness provider (unset uses provider defaults) - name: PR_AF_MAX_COST_USD description: per-run cost ceiling default: "2.0" diff --git a/go/cmd/pr-af/main.go b/go/cmd/pr-af/main.go index 343864c..4863216 100644 --- a/go/cmd/pr-af/main.go +++ b/go/cmd/pr-af/main.go @@ -16,6 +16,7 @@ // PORT listen port (default 8007) // PR_AF_PROVIDER harness provider (default opencode) // PR_AF_MODEL harness model (env wins over the code default) +// PR_AF_HARNESS_BIN optional executable override for every harness provider // OPENROUTER_API_KEY LLM key — required for the .ai() gates; AIConfig is only // attached when set (SDK rejects an empty key) // GH_TOKEN GitHub token for FetchPR/clone/PostReview diff --git a/go/internal/blastradius/blastradius.go b/go/internal/blastradius/blastradius.go index 1aa627a..2412f14 100644 --- a/go/internal/blastradius/blastradius.go +++ b/go/internal/blastradius/blastradius.go @@ -135,7 +135,9 @@ func buildPythonModuleMap(pyFiles []string, repoPath string) map[string]string { mapping := make(map[string]string, len(pyFiles)) for _, pyFile := range pyFiles { rel := relPathOf(repoPath, pyFile) - module := strings.ReplaceAll(rel, string(os.PathSeparator), ".") + // Repository-relative paths are part of the cross-platform data contract; + // normalize before turning a path into a Python module name. + module := strings.ReplaceAll(filepath.ToSlash(rel), "/", ".") module = strings.TrimSuffix(module, ".py") module = strings.TrimSuffix(module, ".__init__") mapping[module] = rel @@ -153,13 +155,14 @@ func extractPythonImports(content string) []string { return modules } -// relPathOf mirrors os.path.relpath(file, repoPath) with the OS separator. +// relPathOf mirrors os.path.relpath(file, repoPath) using repository-standard +// slash separators, independent of the host OS. func relPathOf(repoPath, file string) string { rel, err := filepath.Rel(repoPath, file) if err != nil { return file } - return rel + return filepath.ToSlash(rel) } // containsSkipToken reports whether any skip token is a substring of relRoot, diff --git a/go/internal/blastradius/blastradius_test.go b/go/internal/blastradius/blastradius_test.go index c7cfce2..7396012 100644 --- a/go/internal/blastradius/blastradius_test.go +++ b/go/internal/blastradius/blastradius_test.go @@ -162,8 +162,8 @@ func TestBuildPythonModuleMap(t *testing.T) { } got := buildPythonModuleMap(files, repo) want := map[string]string{ - "a.b": filepath.Join("a", "b.py"), - "pkg": filepath.Join("pkg", "__init__.py"), + "a.b": "a/b.py", + "pkg": "pkg/__init__.py", "top": "top.py", } if !reflect.DeepEqual(got, want) { diff --git a/go/internal/config/ai.go b/go/internal/config/ai.go index 39855d5..7c53a45 100644 --- a/go/internal/config/ai.go +++ b/go/internal/config/ai.go @@ -26,6 +26,7 @@ type AIIntegrationConfig struct { InitialBackoffSeconds float64 `json:"initial_backoff_seconds"` MaxBackoffSeconds float64 `json:"max_backoff_seconds"` OpencodeBin string `json:"opencode_bin"` + HarnessBin string `json:"harness_bin"` OpencodeServer *string `json:"opencode_server"` } @@ -61,7 +62,9 @@ func AIConfigFromEnv() (AIIntegrationConfig, error) { InitialBackoffSeconds: initialBackoff, MaxBackoffSeconds: maxBackoff, OpencodeBin: strEnv("PR_AF_OPENCODE_BIN", "opencode"), - OpencodeServer: lookupPtr("PR_AF_OPENCODE_SERVER"), + // An empty generic binary override deliberately means no override. + HarnessBin: strEnv("PR_AF_HARNESS_BIN", ""), + OpencodeServer: lookupPtr("PR_AF_OPENCODE_SERVER"), }, nil } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index d710378..fbbbe99 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -17,7 +17,7 @@ var configEnvKeys = []string{ "PR_AF_PROVIDER", "PR_AF_MODEL", "PR_AF_AI_MODEL", "PR_AF_MAX_TURNS", "PR_AF_AI_MAX_RETRIES", "PR_AF_AI_INITIAL_BACKOFF_SECONDS", "PR_AF_AI_MAX_BACKOFF_SECONDS", - "PR_AF_OPENCODE_BIN", "PR_AF_OPENCODE_SERVER", + "PR_AF_OPENCODE_BIN", "PR_AF_HARNESS_BIN", "PR_AF_OPENCODE_SERVER", "PR_AF_MAX_COST_USD", "PR_AF_MAX_DURATION_SECONDS", "PR_AF_EVIDENCE_PACK", "PR_AF_POSTWORTHINESS_GATE", "HAX_API_KEY", "AGENTFIELD_APPROVAL_USER_ID", @@ -154,6 +154,9 @@ func TestAIConfigFromEnvDefaults(t *testing.T) { if c.OpencodeBin != "opencode" { t.Errorf("OpencodeBin = %q, want opencode", c.OpencodeBin) } + if c.HarnessBin != "" { + t.Errorf("HarnessBin = %q, want empty when unset", c.HarnessBin) + } if c.OpencodeServer != nil { t.Errorf("OpencodeServer = %v, want nil", *c.OpencodeServer) } @@ -168,6 +171,7 @@ func TestAIConfigFromEnvOverrides(t *testing.T) { t.Setenv("PR_AF_AI_INITIAL_BACKOFF_SECONDS", "1.5") t.Setenv("PR_AF_AI_MAX_BACKOFF_SECONDS", "16") t.Setenv("PR_AF_OPENCODE_BIN", "/usr/bin/opencode") + t.Setenv("PR_AF_HARNESS_BIN", "C:/bin/provider-custom") t.Setenv("PR_AF_OPENCODE_SERVER", "http://localhost:9000") c := mustAIConfig(t) @@ -190,6 +194,9 @@ func TestAIConfigFromEnvOverrides(t *testing.T) { if c.OpencodeServer == nil || *c.OpencodeServer != "http://localhost:9000" { t.Errorf("OpencodeServer = %v", c.OpencodeServer) } + if c.HarnessBin != "C:/bin/provider-custom" { + t.Errorf("HarnessBin = %q, want exact configured value", c.HarnessBin) + } // PR_AF_AI_MODEL, when set, wins over PR_AF_MODEL for AIModel only. t.Setenv("PR_AF_AI_MODEL", "anthropic/claude-x") @@ -202,6 +209,14 @@ func TestAIConfigFromEnvOverrides(t *testing.T) { } } +func TestAIConfigFromEnvEmptyHarnessBinIsNoOverride(t *testing.T) { + clearConfigEnv(t) + t.Setenv("PR_AF_HARNESS_BIN", "") + if got := mustAIConfig(t).HarnessBin; got != "" { + t.Errorf("HarnessBin = %q, want empty for explicit empty environment value", got) + } +} + func TestProviderEnv(t *testing.T) { clearConfigEnv(t) xdg := t.TempDir() diff --git a/go/internal/evidence/evidence.go b/go/internal/evidence/evidence.go index 4347fa9..5fe2537 100644 --- a/go/internal/evidence/evidence.go +++ b/go/internal/evidence/evidence.go @@ -272,7 +272,7 @@ func findFunctionCallers(ctx context.Context, repoPath, functionName, excludeFil args := append([]string{"-RInE", pattern, "."}, skipDirGrepArgs...) stdout, ran := runGrep(ctx, repoPath, args) if !ran { - return []string{} + stdout = fallbackFunctionGrep(repoPath, pattern) } normalizedExclude := normalizeRelativePath(repoPath, excludeFile) @@ -444,7 +444,11 @@ func buildImportContext(ctx context.Context, repoPath, filePath string) string { if moduleName != "" { regex := `^\s*(?:from\s+` + reEscape(moduleName) + `\b|import\s+` + reEscape(moduleName) + `\b)` args := append([]string{"-RIlE", regex, ".", "--include=*.py"}, skipDirGrepArgs...) - if stdout, ran := runGrep(ctx, repoPath, args); ran { + stdout, ran := runGrep(ctx, repoPath, args) + if !ran { + stdout = fallbackImportGrep(repoPath, regex) + } + { for _, rawPath := range splitLines(stdout) { rel := normalizeRelativePath(repoPath, rawPath) if rel != normalized { @@ -790,6 +794,75 @@ func runGrep(ctx context.Context, repoPath string, args []string) (string, bool) return out.String(), true } +// The production implementation follows Python by using grep when available. +// Windows development environments commonly lack it, so retain equivalent +// caller/import discovery with a small filesystem fallback. +func fallbackFunctionGrep(repoPath, pattern string) string { + re, err := regexp.Compile(pattern) + if err != nil { + return "" + } + var matches []string + forEachRepoFile(repoPath, func(rel, abs string) { + data, err := os.ReadFile(abs) + if err != nil { + return + } + for lineNo, line := range splitLines(string(data)) { + if re.MatchString(line) { + matches = append(matches, rel+":"+strconv.Itoa(lineNo+1)+":"+line) + } + } + }) + return strings.Join(matches, "\n") +} + +func fallbackImportGrep(repoPath, pattern string) string { + re, err := regexp.Compile(pattern) + if err != nil { + return "" + } + var matches []string + forEachRepoFile(repoPath, func(rel, abs string) { + if !strings.HasSuffix(rel, ".py") { + return + } + data, err := os.ReadFile(abs) + if err == nil && re.Match(data) { + matches = append(matches, rel) + } + }) + return strings.Join(matches, "\n") +} + +func forEachRepoFile(repoPath string, visit func(rel, abs string)) { + _ = filepath.WalkDir(repoPath, func(abs string, d os.DirEntry, err error) error { + if err != nil || d == nil { + return nil + } + if d.IsDir() { + if abs != repoPath && isSkippedGrepDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + rel, err := filepath.Rel(repoPath, abs) + if err == nil { + visit(filepath.ToSlash(rel), abs) + } + return nil + }) +} + +func isSkippedGrepDir(name string) bool { + for _, skipped := range []string{".git", "node_modules", "__pycache__", ".venv", "vendor", "venv"} { + if name == skipped { + return true + } + } + return false +} + // readFileLines ports _read_file_lines (minus the perf cache): the file split // into lines WITH their terminators, or nil on read error. func readFileLines(absPath string) []string { diff --git a/go/internal/github/client.go b/go/internal/github/client.go index 6966363..b9547d4 100644 --- a/go/internal/github/client.go +++ b/go/internal/github/client.go @@ -53,6 +53,8 @@ import ( const defaultBaseURL = "https://api.github.com" +const postReviewMaxAttempts = 3 + var prURLRe = regexp.MustCompile(`^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)`) // APIError is returned for any GitHub response with status >= 400. It carries @@ -506,15 +508,41 @@ func (c *client) PostReview(ctx context.Context, owner, repo string, number int, if err != nil { return nil, err } - body, err := c.request(ctx, http.MethodPost, - fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number), - authHeaders, payload, 60*time.Second) - if err != nil { - return nil, err + endpoint := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number) + + var lastErr error + for attempt := 0; attempt < postReviewMaxAttempts; attempt++ { + body, err := c.request(ctx, http.MethodPost, endpoint, authHeaders, payload, 60*time.Second) + if err == nil { + var result map[string]any + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return result, nil + } + if !isRetryablePostReviewError(err) { + return nil, err + } + + lastErr = err + if attempt == postReviewMaxAttempts-1 { + break + } + if err := c.sleep(ctx, time.Duration(1<<(attempt+1))*time.Second); err != nil { + return nil, err + } } - var result map[string]any - if err := json.Unmarshal(body, &result); err != nil { - return nil, err + return nil, lastErr +} + +// isRetryablePostReviewError reports whether a review creation failure is +// transient. Client errors are deliberately excluded so the orchestrator can +// retain ownership of its semantic 422 own-PR fallback. +func isRetryablePostReviewError(err error) bool { + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode >= 500 && apiErr.StatusCode <= 599 } - return result, nil + var transportErr *transportError + return errors.As(err, &transportErr) } diff --git a/go/internal/github/client_test.go b/go/internal/github/client_test.go index f77b49b..ed8d059 100644 --- a/go/internal/github/client_test.go +++ b/go/internal/github/client_test.go @@ -1,6 +1,7 @@ package github import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -400,7 +401,10 @@ type postRec struct { mu sync.Mutex auth string body []byte + bodies [][]byte + calls int status int + statusFn func(call int) int respBody string } @@ -412,7 +416,13 @@ func newPostServer(t *testing.T, rec *postRec) *httptest.Server { rec.mu.Lock() rec.auth = r.Header.Get("Authorization") rec.body = b + rec.bodies = append(rec.bodies, append([]byte(nil), b...)) + rec.calls++ + call := rec.calls status := rec.status + if rec.statusFn != nil { + status = rec.statusFn(call) + } respBody := rec.respBody rec.mu.Unlock() if status >= 400 { @@ -420,7 +430,10 @@ func newPostServer(t *testing.T, rec *postRec) *httptest.Server { _, _ = io.WriteString(w, respBody) return } - _, _ = io.WriteString(w, `{"id":42,"state":"COMMENTED"}`) + if respBody == "" { + respBody = `{"id":42,"state":"COMMENTED"}` + } + _, _ = io.WriteString(w, respBody) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) @@ -566,6 +579,221 @@ func TestPostReview_422TypedError(t *testing.T) { } } +func TestPostReview_RetriesTransientServerFailure(t *testing.T) { + rec := &postRec{statusFn: func(call int) int { + if call == 1 { + return http.StatusGatewayTimeout + } + return http.StatusOK + }} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + var delays []time.Duration + c.sleepFn = func(_ context.Context, d time.Duration) error { + delays = append(delays, d) + return nil + } + + result, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{Body: "b", Event: "COMMENT"}, "sha") + if err != nil { + t.Fatalf("PostReview: %v", err) + } + if got, _ := result["id"].(float64); got != 42 { + t.Errorf("result id = %v, want 42", result["id"]) + } + rec.mu.Lock() + calls := rec.calls + bodies := append([][]byte(nil), rec.bodies...) + rec.mu.Unlock() + if calls != 2 { + t.Errorf("POST calls = %d, want 2", calls) + } + if len(bodies) != 2 || !bytes.Equal(bodies[0], bodies[1]) { + t.Errorf("retry payloads = %q, want two identical payloads", bodies) + } + if want := []time.Duration{2 * time.Second}; !equalDurations(delays, want) { + t.Errorf("backoff delays = %v, want %v", delays, want) + } +} + +func TestPostReview_RetriesExhausted(t *testing.T) { + rec := &postRec{status: http.StatusInternalServerError, respBody: `{"message":"boom"}`} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + var delays []time.Duration + c.sleepFn = func(_ context.Context, d time.Duration) error { + delays = append(delays, d) + return nil + } + + _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, "") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("error = %v, want final *APIError 500", err) + } + rec.mu.Lock() + calls := rec.calls + rec.mu.Unlock() + if calls != postReviewMaxAttempts { + t.Errorf("POST calls = %d, want %d", calls, postReviewMaxAttempts) + } + if want := []time.Duration{2 * time.Second, 4 * time.Second}; !equalDurations(delays, want) { + t.Errorf("backoff delays = %v, want %v", delays, want) + } +} + +func TestPostReview_DoesNotRetry422(t *testing.T) { + rec := &postRec{status: http.StatusUnprocessableEntity, respBody: `{"message":"unprocessable"}`} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + var delays []time.Duration + c.sleepFn = func(_ context.Context, d time.Duration) error { + delays = append(delays, d) + return nil + } + + _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, "") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("error = %v, want *APIError 422", err) + } + rec.mu.Lock() + calls := rec.calls + rec.mu.Unlock() + if calls != 1 || len(delays) != 0 { + t.Errorf("calls/delays = %d/%v, want 1/none", calls, delays) + } +} + +func TestPostReview_DoesNotRetryOtherClientErrors(t *testing.T) { + for _, status := range []int{http.StatusForbidden, http.StatusTooManyRequests} { + t.Run(http.StatusText(status), func(t *testing.T) { + rec := &postRec{status: status} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + var sleeps int + c.sleepFn = func(context.Context, time.Duration) error { + sleeps++ + return nil + } + + _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, "") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != status { + t.Fatalf("error = %v, want *APIError %d", err, status) + } + rec.mu.Lock() + calls := rec.calls + rec.mu.Unlock() + if calls != 1 || sleeps != 0 { + t.Errorf("calls/sleeps = %d/%d, want 1/0", calls, sleeps) + } + }) + } +} + +func TestIsRetryablePostReviewError_StatusBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + status int + want bool + }{ + {name: "below server errors", status: http.StatusRequestTimeout, want: false}, + {name: "first server error", status: http.StatusInternalServerError, want: true}, + {name: "last server error", status: 599, want: true}, + {name: "above server errors", status: 600, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := &APIError{StatusCode: tc.status} + if got := isRetryablePostReviewError(err); got != tc.want { + t.Errorf("isRetryablePostReviewError(status %d) = %t, want %t", tc.status, got, tc.want) + } + }) + } + + if !isRetryablePostReviewError(&transportError{err: errors.New("connection reset")}) { + t.Error("transport error should be retryable") + } + if isRetryablePostReviewError(errors.New("payload marshal failure")) { + t.Error("ordinary local error should not be retryable") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestPostReview_RetriesTransportFailure(t *testing.T) { + c := testClient("http://github.test", "test-token") + var calls int + c.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return nil, errors.New("temporary network failure") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":42}`)), + }, nil + })} + var delays []time.Duration + c.sleepFn = func(_ context.Context, d time.Duration) error { + delays = append(delays, d) + return nil + } + + if _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, ""); err != nil { + t.Fatalf("PostReview: %v", err) + } + if calls != 2 { + t.Errorf("transport calls = %d, want 2", calls) + } + if want := []time.Duration{2 * time.Second}; !equalDurations(delays, want) { + t.Errorf("backoff delays = %v, want %v", delays, want) + } +} + +func TestPostReview_StopsWhenBackoffCanceled(t *testing.T) { + rec := &postRec{status: http.StatusBadGateway} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + c.sleepFn = func(context.Context, time.Duration) error { return context.Canceled } + + _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, "") + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + rec.mu.Lock() + calls := rec.calls + rec.mu.Unlock() + if calls != 1 { + t.Errorf("POST calls = %d, want 1 after canceled backoff", calls) + } +} + +func TestPostReview_MalformedSuccessResponseDoesNotRetry(t *testing.T) { + rec := &postRec{respBody: "not-json"} + srv := newPostServer(t, rec) + c := testClient(srv.URL, "test-token") + var sleeps int + c.sleepFn = func(context.Context, time.Duration) error { + sleeps++ + return nil + } + + _, err := c.PostReview(context.Background(), "o", "r", 7, schemas.GitHubReview{}, "") + if err == nil { + t.Fatal("expected JSON decode error") + } + rec.mu.Lock() + calls := rec.calls + rec.mu.Unlock() + if calls != 1 || sleeps != 0 { + t.Errorf("calls/sleeps = %d/%d, want 1/0", calls, sleeps) + } +} + // ---- App-JWT --------------------------------------------------------------- func TestGenerateAppJWT_Claims(t *testing.T) { @@ -795,3 +1023,15 @@ func equalStrings(a, b []string) bool { } return true } + +func equalDurations(a, b []time.Duration) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go/internal/node/node.go b/go/internal/node/node.go index c8ab815..b415383 100644 --- a/go/internal/node/node.go +++ b/go/internal/node/node.go @@ -98,6 +98,30 @@ func defaultRunReview(ctx context.Context, deps orch.Deps, in schemas.ReviewInpu return orch.New(deps, in, cfg).Run(ctx) } +// harnessConfig maps the resolved AI integration configuration to the SDK +// harness configuration. An empty BinPath lets the SDK select the configured +// provider's default executable. +func harnessConfig(c config.AIIntegrationConfig) *agent.HarnessConfig { + return &agent.HarnessConfig{ + Provider: c.Provider, + Model: c.HarnessModel, + MaxTurns: c.MaxTurns, + PermissionMode: "auto", + Env: c.ProviderEnv(), + BinPath: resolvedHarnessBin(c), + } +} + +func resolvedHarnessBin(c config.AIIntegrationConfig) string { + if c.HarnessBin != "" { + return c.HarnessBin + } + if c.Provider == "opencode" { + return c.OpencodeBin + } + return "" +} + // BuildAgent constructs the PR-AF agent from the environment exactly as the // Python entry point does (app.py:26-50): // @@ -140,14 +164,7 @@ func BuildAgent(defaultNodeID, defaultPort, description string) (*Node, error) { ListenAddress: ":" + port, PublicURL: os.Getenv("AGENT_CALLBACK_URL"), CLIConfig: &agent.CLIConfig{AppDescription: description}, - HarnessConfig: &agent.HarnessConfig{ - Provider: aiConf.Provider, - Model: aiConf.HarnessModel, - MaxTurns: aiConf.MaxTurns, - PermissionMode: "auto", - Env: aiConf.ProviderEnv(), - BinPath: aiConf.OpencodeBin, - }, + HarnessConfig: harnessConfig(aiConf), } if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" { // Python's .ai() path runs through LiteLLM, which CONSUMES a leading diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go index 3e65405..c280a3b 100644 --- a/go/internal/node/node_test.go +++ b/go/internal/node/node_test.go @@ -1,9 +1,53 @@ package node import ( + "reflect" "testing" + + "github.com/Agent-Field/pr-af/go/internal/config" ) +func TestHarnessConfigProviderAwareBinary(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + cases := []struct { + name string + conf config.AIIntegrationConfig + want string + }{ + {"codex uses SDK default", config.AIIntegrationConfig{Provider: "codex", OpencodeBin: "C:/bin/opencode-custom"}, ""}, + {"opencode uses configured binary", config.AIIntegrationConfig{Provider: "opencode", OpencodeBin: "C:/bin/opencode-custom"}, "C:/bin/opencode-custom"}, + {"generic override wins", config.AIIntegrationConfig{Provider: "codex", OpencodeBin: "C:/bin/opencode-custom", HarnessBin: "C:/bin/provider-custom"}, "C:/bin/provider-custom"}, + {"generic override wins for opencode", config.AIIntegrationConfig{Provider: "opencode", OpencodeBin: "C:/bin/opencode-custom", HarnessBin: "C:/bin/provider-custom"}, "C:/bin/provider-custom"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := harnessConfig(tc.conf).BinPath; got != tc.want { + t.Errorf("BinPath = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHarnessConfigPreservesExistingFields(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + for _, key := range []string{"OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GH_TOKEN"} { + t.Setenv(key, "") + } + t.Setenv("OPENAI_API_KEY", "openai-key") + conf := config.AIIntegrationConfig{ + Provider: "opencode", HarnessModel: "test-model", MaxTurns: 17, + OpencodeBin: "C:/bin/opencode-custom", + } + got := harnessConfig(conf) + if got.Provider != conf.Provider || got.Model != conf.HarnessModel || got.MaxTurns != conf.MaxTurns || got.PermissionMode != "auto" || got.BinPath != conf.OpencodeBin { + t.Errorf("harnessConfig fields = %+v", got) + } + if want := map[string]string{"OPENAI_API_KEY": "openai-key", "XDG_DATA_HOME": xdg}; !reflect.DeepEqual(got.Env, want) { + t.Errorf("Env = %#v, want %#v", got.Env, want) + } +} + // TestBuildAgentFromEnv is the main.go smoke: BuildAgent resolves node identity // from the environment (with the pr-af-go / 8007 defaults), constructs the agent // without a control plane or LLM key, and RegisterAll wires the full surface. diff --git a/go/internal/orch/degradation_test.go b/go/internal/orch/degradation_test.go new file mode 100644 index 0000000..2713130 --- /dev/null +++ b/go/internal/orch/degradation_test.go @@ -0,0 +1,104 @@ +package orch + +import ( + "context" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/reasoners" + "github.com/Agent-Field/pr-af/go/internal/schemas" +) + +func degradationOrchestrator(t *testing.T) *Orchestrator { + t.Helper() + cfg := config.DefaultReviewConfig() + cfg.Comments.PolishEnabled = false + o := New(Deps{App: &fakeApp{}}, schemas.ReviewInput{DryRun: true}, cfg) + o.prData = &schemas.GitHubPRData{} + o.runIntakeFn = func(context.Context) (schemas.IntakeResult, error) { + return schemas.IntakeResult{PrSummary: "summary", PrType: "feature", Complexity: "low"}, nil + } + o.runAnatomyFn = func(context.Context, schemas.IntakeResult) (schemas.AnatomyResult, error) { + return schemas.AnatomyResult{}, nil + } + o.resolveDepthFn = func(schemas.IntakeResult) string { return "standard" } + o.cleanupFn = func() {} + return o +} + +func degradationPlan(n int) schemas.ReviewPlan { + dims := make([]schemas.ReviewDimension, n) + for i := range dims { + dims[i] = schemas.ReviewDimension{ID: string(rune('a' + i)), Name: "dimension", ReviewPrompt: "review", TargetFiles: []string{"a.go"}} + } + return schemas.ReviewPlan{Dimensions: dims} +} + +func TestRunFailsBeforeOutputWhenAllDimensionsFailSchemaParsing(t *testing.T) { + o := degradationOrchestrator(t) + var outputCalls atomic.Int32 + o.rfns.reviewDim = func(context.Context, reasoners.Deps, reasoners.ReviewDimensionInput) (map[string]any, error) { + return map[string]any{"schema_parse_failed": true}, nil + } + o.runReviewPhasesFn = func(ctx context.Context, _ schemas.IntakeResult, _ schemas.AnatomyResult, _, feedback string) (schemas.ReviewPlan, []schemas.ScoredFinding, error) { + plan := degradationPlan(2) + _, err := o.collectParallelReview(ctx, plan, feedback) + return plan, nil, err + } + o.generateOutputFn = func(context.Context, []schemas.ScoredFinding, schemas.IntakeResult, schemas.AnatomyResult, schemas.ReviewPlan, bool) (schemas.ReviewResult, error) { + outputCalls.Add(1) + return schemas.ReviewResult{}, nil + } + + _, err := o.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "all review dimensions failed schema parsing (failed dimensions: 2)") { + t.Fatalf("Run error = %v", err) + } + if outputCalls.Load() != 0 { + t.Fatalf("generateOutput calls = %d, want 0", outputCalls.Load()) + } +} + +func TestMixedAndCleanDimensionsExposeOnlyActualDegradation(t *testing.T) { + for _, tc := range []struct { + name string + failed int + want string + }{ + {name: "mixed", failed: 1, want: "> Degraded dimensions: 1"}, + {name: "clean empty", failed: 0, want: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + o := degradationOrchestrator(t) + var calls atomic.Int32 + o.rfns.reviewDim = func(context.Context, reasoners.Deps, reasoners.ReviewDimensionInput) (map[string]any, error) { + n := calls.Add(1) + return map[string]any{ + "schema_parse_failed": n <= int32(tc.failed), + "findings": []any{}, + "sub_reviews": []any{}, + }, nil + } + o.resetDimensionStats() + plan := degradationPlan(3) + if _, err := o.collectParallelReview(context.Background(), plan, ""); err != nil { + t.Fatal(err) + } + result, err := o.generateOutput(context.Background(), nil, schemas.IntakeResult{PrSummary: "summary"}, schemas.AnatomyResult{}, plan, false) + if err != nil { + t.Fatal(err) + } + if result.Metadata.DegradedDimensions != tc.failed { + t.Fatalf("degraded_dimensions = %d, want %d", result.Metadata.DegradedDimensions, tc.failed) + } + if tc.want != "" && !strings.Contains(result.Review.Body, tc.want) { + t.Fatalf("review body missing %q", tc.want) + } + if tc.want == "" && strings.Contains(result.Review.Body, "Degraded dimensions:") { + t.Fatalf("clean body unexpectedly degraded: %s", result.Review.Body) + } + }) + } +} diff --git a/go/internal/orch/orchestrator.go b/go/internal/orch/orchestrator.go index de68cce..fba5a38 100644 --- a/go/internal/orch/orchestrator.go +++ b/go/internal/orch/orchestrator.go @@ -126,12 +126,15 @@ type Orchestrator struct { reviewID string startedAt time.Time - mu sync.Mutex // guards the counters below (mutated from fan-out goroutines) - totalCostUSD float64 - costBreakdown map[string]float64 - agentInvocations int - budgetExhausted bool - durationCapTripped bool // the wall-clock cap (not the cost cap) exhausted the budget + mu sync.Mutex // guards the counters below (mutated from fan-out goroutines) + totalCostUSD float64 + costBreakdown map[string]float64 + agentInvocations int + budgetExhausted bool + durationCapTripped bool // the wall-clock cap (not the cost cap) exhausted the budget + reviewDimensionsAttempted int + reviewDimensionsParseable int + degradedDimensions int // Single-threaded-written state (set before/after fan-outs, read after joins). prData *schemas.GitHubPRData @@ -170,6 +173,71 @@ type Orchestrator struct { rfns reasonerSeams } +type dimensionParseStats struct { + mu sync.Mutex + attempted int + parseable int + failed int +} + +type dimensionParseSnapshot struct { + Attempted int + Parseable int + Failed int +} + +func (s *dimensionParseStats) recordAttempt() { + s.mu.Lock() + s.attempted++ + s.mu.Unlock() +} + +func (s *dimensionParseStats) recordResult(schemaParseFailed bool) { + s.mu.Lock() + if schemaParseFailed { + s.failed++ + } else { + s.parseable++ + } + s.mu.Unlock() +} + +func (s *dimensionParseStats) snapshot() dimensionParseSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return dimensionParseSnapshot{Attempted: s.attempted, Parseable: s.parseable, Failed: s.failed} +} + +func (o *Orchestrator) resetDimensionStats() { + o.mu.Lock() + o.reviewDimensionsAttempted = 0 + o.reviewDimensionsParseable = 0 + o.degradedDimensions = 0 + o.mu.Unlock() +} + +func (o *Orchestrator) recordDimensionAttempt() { + o.mu.Lock() + o.reviewDimensionsAttempted++ + o.mu.Unlock() +} + +func (o *Orchestrator) recordDimensionResult(schemaParseFailed bool) { + o.mu.Lock() + if schemaParseFailed { + o.degradedDimensions++ + } else { + o.reviewDimensionsParseable++ + } + o.mu.Unlock() +} + +func (o *Orchestrator) dimensionStats() dimensionParseSnapshot { + o.mu.Lock() + defer o.mu.Unlock() + return dimensionParseSnapshot{Attempted: o.reviewDimensionsAttempted, Parseable: o.reviewDimensionsParseable, Failed: o.degradedDimensions} +} + // reasonerSeams bundles the reasoner entry points the pipeline invokes so tests // can inject latency/instrumentation without a live harness. type reasonerSeams struct { @@ -339,6 +407,7 @@ func (o *Orchestrator) Run(ctx context.Context) (schemas.ReviewResult, error) { reviewerFeedback := "" for revisionIter := 0; revisionIter <= maxRevisions; revisionIter++ { + o.resetDimensionStats() plan, scored, err := o.runReviewPhasesFn(ctx, intake, anatomy, reviewDepth, reviewerFeedback) if err != nil { return zero, err diff --git a/go/internal/orch/output.go b/go/internal/orch/output.go index b9f3440..a4e2b4f 100644 --- a/go/internal/orch/output.go +++ b/go/internal/orch/output.go @@ -161,6 +161,7 @@ func (o *Orchestrator) generateOutput( total := o.totalCostUSD exhausted := o.budgetExhausted o.mu.Unlock() + dimensionStats := o.dimensionStats() metadata := schemas.ReviewMetadata{ Intake: intakeMap, Anatomy: anatomyMap, @@ -172,8 +173,9 @@ func (o *Orchestrator) generateOutput( "max_cost_usd": o.config.Budget.MaxCostUSD, "max_duration_seconds": o.config.Budget.MaxDurationSeconds, }, - AgentInvocations: o.invocations(), - PhasesCompleted: append([]string(nil), phaseOrder...), + AgentInvocations: o.invocations(), + PhasesCompleted: append([]string(nil), phaseOrder...), + DegradedDimensions: dimensionStats.Failed, } return schemas.ReviewResult{ @@ -374,6 +376,9 @@ func (o *Orchestrator) formatSummary( emojis["nitpick"], bySeverity["nitpick"]), "", ) + if degraded := o.dimensionStats().Failed; degraded > 0 { + lines = append(lines, fmt.Sprintf("> Degraded dimensions: %d", degraded), "") + } if intake != nil { lines = append(lines, "
", "PR Overview", "", intake.PrSummary, "", "
", "") diff --git a/go/internal/orch/output_test.go b/go/internal/orch/output_test.go index 72e326d..7f21697 100644 --- a/go/internal/orch/output_test.go +++ b/go/internal/orch/output_test.go @@ -11,10 +11,12 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "time" "github.com/Agent-Field/pr-af/go/internal/config" + "github.com/Agent-Field/pr-af/go/internal/github" "github.com/Agent-Field/pr-af/go/internal/schemas" ) @@ -35,6 +37,57 @@ func fixedClockOrch(t *testing.T) *Orchestrator { return o } +type ownPRFallbackGH struct { + reviews []schemas.GitHubReview +} + +func (g *ownPRFallbackGH) ParsePRURL(string) (string, string, int, error) { + return "", "", 0, nil +} + +func (g *ownPRFallbackGH) FetchPR(context.Context, string, string, int) (schemas.GitHubPRData, error) { + return schemas.GitHubPRData{}, nil +} + +func (g *ownPRFallbackGH) PostReview(_ context.Context, _ string, _ string, _ int, review schemas.GitHubReview, _ string) (map[string]any, error) { + g.reviews = append(g.reviews, review) + if len(g.reviews) == 1 { + return nil, &github.APIError{StatusCode: 422, Body: "review cannot be submitted on your own pull request"} + } + return map[string]any{"id": 42}, nil +} + +func TestPostReview_OwnPR422FallsBackToComment(t *testing.T) { + gh := &ownPRFallbackGH{} + o := New(Deps{App: &fakeApp{}, GH: gh}, schemas.ReviewInput{}, config.DefaultReviewConfig()) + o.prData = &schemas.GitHubPRData{Owner: "o", Repo: "r", Number: 7, HeadSHA: "sha"} + comments := []schemas.GitHubComment{{Path: "a.go", Line: 3, Body: "comment"}} + + o.postReview(context.Background(), schemas.GitHubReview{Body: "initial", Event: "REQUEST_CHANGES"}, "fallback", comments) + + if len(gh.reviews) != 2 { + t.Fatalf("PostReview calls = %d, want 2", len(gh.reviews)) + } + if gh.reviews[0].Event != "REQUEST_CHANGES" { + t.Errorf("initial event = %q, want REQUEST_CHANGES", gh.reviews[0].Event) + } + if got := gh.reviews[1]; got.Event != "COMMENT" || got.Body != "fallback" || !equalComments(got.Comments, comments) { + t.Errorf("fallback review = %#v, want COMMENT with fallback body and comments", got) + } +} + +func equalComments(a, b []schemas.GitHubComment) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func TestSummaryGoldenByteExact(t *testing.T) { o := fixedClockOrch(t) findings := []schemas.ScoredFinding{ @@ -77,8 +130,9 @@ func TestSummaryGoldenByteExact(t *testing.T) { if err != nil { t.Fatal(err) } - if got != string(want) { - reportFirstDiff(t, string(want), got) + wantSummary := strings.ReplaceAll(string(want), "\r\n", "\n") + if got != wantSummary { + reportFirstDiff(t, wantSummary, got) } } diff --git a/go/internal/orch/phases.go b/go/internal/orch/phases.go index 529c303..d0bbcbe 100644 --- a/go/internal/orch/phases.go +++ b/go/internal/orch/phases.go @@ -10,6 +10,7 @@ package orch import ( "context" + "fmt" "os" "path/filepath" "sort" @@ -385,8 +386,9 @@ func dedupCrossMeta(dimensions []schemas.ReviewDimension) []schemas.ReviewDimens func (o *Orchestrator) collectParallelReview(ctx context.Context, plan schemas.ReviewPlan, feedback string) ([]schemas.ReviewFinding, error) { ch := make(chan []schemas.ReviewFinding) errc := make(chan error, 1) + stats := &dimensionParseStats{} go func() { - err := o.runParallelReview(ctx, plan, ch, 0, feedback) + err := o.runParallelReview(ctx, plan, ch, 0, feedback, stats) close(ch) errc <- err }() @@ -394,7 +396,20 @@ func (o *Orchestrator) collectParallelReview(ctx context.Context, plan schemas.R for batch := range ch { out = append(out, batch...) } - return out, <-errc + if err := <-errc; err != nil { + return nil, err + } + if err := allDimensionsSchemaFailed(stats.snapshot()); err != nil { + return nil, err + } + return out, nil +} + +func allDimensionsSchemaFailed(stats dimensionParseSnapshot) error { + if stats.Attempted > 0 && stats.Parseable == 0 && stats.Failed > 0 { + return fmt.Errorf("all review dimensions failed schema parsing (failed dimensions: %d)", stats.Failed) + } + return nil } // runParallelReview ports _run_parallel_review. It fans out one reviewer per @@ -407,6 +422,7 @@ func (o *Orchestrator) runParallelReview( ch chan<- []schemas.ReviewFinding, currentDepth int, feedback string, + stats *dimensionParseStats, ) error { maxDepth := o.config.Budget.MaxReviewDepth sem := semaphore.NewWeighted(int64(o.config.Budget.MaxConcurrentReviewers)) @@ -457,6 +473,8 @@ func (o *Orchestrator) runParallelReview( patchArg = dimPatches } + stats.recordAttempt() + o.recordDimensionAttempt() resultRaw, err := o.rfns.reviewDim(gctx, o.reasonerDeps(), reasoners.ReviewDimensionInput{ ReviewPrompt: dim.ReviewPrompt, TargetFiles: dim.TargetFiles, @@ -477,6 +495,17 @@ func (o *Orchestrator) runParallelReview( } o.incInvocations(1) o.registerCost("review", resultRaw) + schemaParseFailed := getBoolDefault(unwrap(resultRaw), "schema_parse_failed", false) + stats.recordResult(schemaParseFailed) + o.recordDimensionResult(schemaParseFailed) + if schemaParseFailed { + select { + case ch <- []schemas.ReviewFinding{}: + case <-gctx.Done(): + return gctx.Err() + } + return nil + } findings := o.extractFindings(resultRaw, dim) select { @@ -579,7 +608,11 @@ func (o *Orchestrator) streamReviewLayer( g, gctx := errgroup.WithContext(ctx) g.Go(func() error { defer close(ch) - return o.runParallelReview(gctx, plan, ch, 0, reviewerFeedback) + stats := &dimensionParseStats{} + if err := o.runParallelReview(gctx, plan, ch, 0, reviewerFeedback, stats); err != nil { + return err + } + return allDimensionsSchemaFailed(stats.snapshot()) }) g.Go(func() error { var e error diff --git a/go/internal/orch/resolve_test.go b/go/internal/orch/resolve_test.go index 940d63b..e9bde72 100644 --- a/go/internal/orch/resolve_test.go +++ b/go/internal/orch/resolve_test.go @@ -228,5 +228,6 @@ func readFile(t *testing.T, path string) string { if err != nil { t.Fatal(err) } - return string(b) + // Git may apply core.autocrlf in temporary test repositories on Windows. + return strings.ReplaceAll(string(b), "\r\n", "\n") } diff --git a/go/internal/orch/testdata/result_keys.json b/go/internal/orch/testdata/result_keys.json index ad3524c..3a757ea 100644 --- a/go/internal/orch/testdata/result_keys.json +++ b/go/internal/orch/testdata/result_keys.json @@ -59,6 +59,7 @@ "agent_invocations", "anatomy", "budget", + "degraded_dimensions", "intake", "phases_completed", "plan" diff --git a/go/internal/prompts/adversary.go b/go/internal/prompts/adversary.go index 10e3f7d..16dca09 100644 --- a/go/internal/prompts/adversary.go +++ b/go/internal/prompts/adversary.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "unicode/utf8" "github.com/Agent-Field/pr-af/go/internal/schemas" @@ -95,7 +94,7 @@ func AdversaryPrompt(findings []schemas.ReviewFinding, aiGeneratedConfidence flo var findingsRef string if utf8.RuneCountInString(summary) > 10000 && repoPath != "" { - fp := filepath.Join(repoPath, ".pr-af-context", "adversary_findings.json") + fp := contextPath(repoPath, ".pr-af-context", "adversary_findings.json") findingsRef = "Full findings with ground-truth evidence written to: " + fp + "\n" + "Read this file for complete finding details and code evidence." } else { diff --git a/go/internal/prompts/compound.go b/go/internal/prompts/compound.go index 4162fc2..7ab44d9 100644 --- a/go/internal/prompts/compound.go +++ b/go/internal/prompts/compound.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "strconv" "strings" "unicode/utf8" @@ -80,7 +79,7 @@ func CompoundFinderPrompt(findings []schemas.ReviewFinding, repoPath string, evi var findingsRef string if utf8.RuneCountInString(summary) > 10000 && repoPath != "" { - fp := filepath.Join(repoPath, ".pr-af-context", "compound_cluster_findings.json") + fp := contextPath(repoPath, ".pr-af-context", "compound_cluster_findings.json") findingsRef = "Cluster findings and evidence written to: " + fp + "\nRead this file for complete compound-analysis context." } else { diff --git a/go/internal/prompts/deepen.go b/go/internal/prompts/deepen.go index 44a33a8..f112e95 100644 --- a/go/internal/prompts/deepen.go +++ b/go/internal/prompts/deepen.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "strings" "unicode/utf8" ) @@ -48,7 +47,7 @@ func renderPatchesText(patches []StrPair) string { func diffRef(patches []StrPair, repoPath, fileName string) string { text := renderPatchesText(patches) if utf8.RuneCountInString(text) > 9000 && repoPath != "" { - fp := filepath.Join(repoPath, ".pr-af-context", fileName) + fp := contextPath(repoPath, ".pr-af-context", fileName) return "Changed-code diffs written to: " + fp + "\nRead it for the full set of hunks." } return "## Changed code (diffs)\n\n" + text diff --git a/go/internal/prompts/golden_test.go b/go/internal/prompts/golden_test.go index 2f8ef8e..48fb7ab 100644 --- a/go/internal/prompts/golden_test.go +++ b/go/internal/prompts/golden_test.go @@ -3,6 +3,7 @@ package prompts import ( "embed" "fmt" + "strings" "testing" ) @@ -17,7 +18,10 @@ func golden(t *testing.T, name string) string { if err != nil { t.Fatalf("read fixture %s: %v", name, err) } - return string(b) + // Fixtures are checked out with CRLF on Windows when core.autocrlf is set, + // while prompt builders deliberately produce LF. Golden content is textual, + // so normalize the checkout representation before comparing it. + return strings.ReplaceAll(string(b), "\r\n", "\n") } // assertGolden compares got against the named fixture byte-for-byte and, on diff --git a/go/internal/prompts/helpers.go b/go/internal/prompts/helpers.go index 5c076a2..0f12652 100644 --- a/go/internal/prompts/helpers.go +++ b/go/internal/prompts/helpers.go @@ -21,11 +21,20 @@ package prompts import ( "math" + "path" "reflect" "strconv" "strings" ) +// contextPath renders repository-relative context references with Python's +// slash separator on every host. These paths are prompt text, not filesystem +// operations; writers still use filepath.Join at their call sites. +func contextPath(repoPath string, elems ...string) string { + parts := append([]string{strings.ReplaceAll(repoPath, "\\", "/")}, elems...) + return path.Join(parts...) +} + // --------------------------------------------------------------------------- // Python-parity scalar/list formatting. // diff --git a/go/internal/prompts/meta.go b/go/internal/prompts/meta.go index 928105a..ae8685f 100644 --- a/go/internal/prompts/meta.go +++ b/go/internal/prompts/meta.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "unicode/utf8" "github.com/Agent-Field/pr-af/go/internal/schemas" @@ -62,7 +61,7 @@ func MetaContext(intake schemas.IntakeResult, anatomy schemas.AnatomyResult, dif // len() in Python counts code points, so we use RuneCountInString. func metaContextRef(lens, context, repoPath string) string { if repoPath != "" && utf8.RuneCountInString(context) > 8000 { - fp := filepath.Join(repoPath, ".pr-af-context", "meta_"+lens+"_context.json") + fp := contextPath(repoPath, ".pr-af-context", "meta_"+lens+"_context.json") return "\n\nFull analysis context written to: " + fp + "\nRead this file for complete PR context including diff patches." } diff --git a/go/internal/prompts/reviewdim.go b/go/internal/prompts/reviewdim.go index be0379f..ae757b5 100644 --- a/go/internal/prompts/reviewdim.go +++ b/go/internal/prompts/reviewdim.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "strconv" "strings" "unicode/utf8" @@ -77,7 +76,7 @@ func ReviewDimensionPrompt(o ReviewDimensionOptions) string { if len(parts) > 0 { patchesText := strings.Join(parts, "\n\n") if o.RepoPath != "" && utf8.RuneCountInString(patchesText) > 6000 { - pf := filepath.Join(o.RepoPath, ".pr-af-context", "review_dimension_diff_patches.md") + pf := contextPath(o.RepoPath, ".pr-af-context", "review_dimension_diff_patches.md") diffSection = "## Diff Patches for Target Files\n\n" + "Full diff patches written to: " + pf + "\n" + "Read this file for detailed target-file patches.\n\n" @@ -90,7 +89,7 @@ func ReviewDimensionPrompt(o ReviewDimensionOptions) string { primedSection := "" if o.PrimedCode != "" { if o.RepoPath != "" && utf8.RuneCountInString(o.PrimedCode) > 6000 { - pf := filepath.Join(o.RepoPath, ".pr-af-context", "review_dimension_primed_code.md") + pf := contextPath(o.RepoPath, ".pr-af-context", "review_dimension_primed_code.md") primedSection = "## Target-File Code (pre-read for you)\n\n" + "The current content of your target files (with line numbers) and their import " + "context is written to: " + pf + "\nRead that file FIRST — it is the code you would " + diff --git a/go/internal/prompts/verify.go b/go/internal/prompts/verify.go index 40fe976..e47da1a 100644 --- a/go/internal/prompts/verify.go +++ b/go/internal/prompts/verify.go @@ -1,7 +1,6 @@ package prompts import ( - "path/filepath" "unicode/utf8" "github.com/Agent-Field/pr-af/go/internal/schemas" @@ -89,7 +88,7 @@ func EvidenceVerifierPrompt(findings []schemas.ReviewFinding, evidenceMap map[st var findingsRef string if utf8.RuneCountInString(findingsText) > 12000 && repoPath != "" { - fp := filepath.Join(repoPath, ".pr-af-context", "verification_findings.json") + fp := contextPath(repoPath, ".pr-af-context", "verification_findings.json") findingsRef = "Findings with extracted code written to: " + fp + "\n" + "Read this file for the full list of findings and their extracted code context." } else { diff --git a/go/internal/reasoners/ai_schemas.go b/go/internal/reasoners/ai_schemas.go new file mode 100644 index 0000000..4d20221 --- /dev/null +++ b/go/internal/reasoners/ai_schemas.go @@ -0,0 +1,41 @@ +package reasoners + +import "encoding/json" + +// strictAISchemaName identifies a strict structured-output schema owned by a +// reasoner. Keeping these definitions next to their callers avoids relying on +// the SDK's incomplete reflection of slice fields. +type strictAISchemaName string + +const ( + strictAISchemaIntakeGate strictAISchemaName = "IntakeGate" + strictAISchemaCoverageGate strictAISchemaName = "CoverageGate" +) + +// strictAISchemas is the source of truth for the schemas passed to the two +// strict .ai() calls. Its raw JSON values are immutable after initialization. +var strictAISchemas = map[strictAISchemaName]json.RawMessage{ + strictAISchemaIntakeGate: json.RawMessage(`{ + "type": "object", + "properties": { + "pr_type": {"type": "string"}, + "complexity": {"type": "string"}, + "confident": {"type": "boolean"} + }, + "required": ["pr_type", "complexity", "confident"], + "additionalProperties": false + }`), + strictAISchemaCoverageGate: json.RawMessage(`{ + "type": "object", + "properties": { + "fully_covered": {"type": "boolean"}, + "gap_descriptions": { + "type": "array", + "items": {"type": "string"} + }, + "confident": {"type": "boolean"} + }, + "required": ["fully_covered", "gap_descriptions", "confident"], + "additionalProperties": false + }`), +} diff --git a/go/internal/reasoners/contexts_test.go b/go/internal/reasoners/contexts_test.go index 41a35da..16e5334 100644 --- a/go/internal/reasoners/contexts_test.go +++ b/go/internal/reasoners/contexts_test.go @@ -137,7 +137,7 @@ func TestMetaSelectorWritesContextFile(t *testing.T) { if string(b) != want { t.Fatal("context file content diverges from prompts.MetaContext") } - if !strings.Contains(h.gotPrompt, "Full analysis context written to: "+path) { + if !strings.Contains(h.gotPrompt, "Full analysis context written to: "+filepath.ToSlash(path)) { t.Fatal("prompt should reference the context file path") } if strings.Contains(h.gotPrompt, bigPatch) { @@ -179,10 +179,10 @@ func TestReviewDimensionWritesContextFiles(t *testing.T) { if string(pb) != bigPrimed { t.Fatal("primed code file content diverges") } - if !strings.Contains(h.gotPrompt, "Full diff patches written to: "+diffPath) { + if !strings.Contains(h.gotPrompt, "Full diff patches written to: "+filepath.ToSlash(diffPath)) { t.Fatal("prompt should reference the diff patches file") } - if !strings.Contains(h.gotPrompt, "context is written to: "+primedPath) { + if !strings.Contains(h.gotPrompt, "context is written to: "+filepath.ToSlash(primedPath)) { t.Fatal("prompt should reference the primed code file") } } @@ -253,7 +253,7 @@ func TestCompoundFinderWritesContextFile(t *testing.T) { if string(b) != want { t.Fatal("compound context file diverges from the builder summary") } - if !strings.Contains(h.gotPrompt, "Cluster findings and evidence written to: "+path) { + if !strings.Contains(h.gotPrompt, "Cluster findings and evidence written to: "+filepath.ToSlash(path)) { t.Fatal("prompt should reference the context file") } } diff --git a/go/internal/reasoners/coverage.go b/go/internal/reasoners/coverage.go index 790296a..6bd4a18 100644 --- a/go/internal/reasoners/coverage.go +++ b/go/internal/reasoners/coverage.go @@ -18,7 +18,7 @@ func CoverageGate(ctx context.Context, deps Deps, in CoverageGateInput) (map[str prompt := prompts.CoverageGatePrompt(in.Anatomy, in.ReviewedClusters, in.DimensionNamesReviewed) var gate schemas.CoverageGate - if err := aiStructured(ctx, deps.AI, prompt, prompts.CoverageGateSystem, schemas.CoverageGate{}, &gate); err != nil { + if err := aiStructured(ctx, deps.AI, prompt, prompts.CoverageGateSystem, strictAISchemas[strictAISchemaCoverageGate], &gate); err != nil { return nil, err } gate.GapDescriptions = orEmptyStrs(gate.GapDescriptions) diff --git a/go/internal/reasoners/intake.go b/go/internal/reasoners/intake.go index 8d21ac7..a495357 100644 --- a/go/internal/reasoners/intake.go +++ b/go/internal/reasoners/intake.go @@ -26,7 +26,7 @@ func IntakePhase(ctx context.Context, deps Deps, in IntakeInput) (map[string]any pr.Title, pr.Description, pr.Labels, pr.Author, filesChanged, languages, pr.CommitMessages, ) var gate schemas.IntakeGate - if err := aiStructured(ctx, deps.AI, gatePrompt, prompts.IntakeGateSystem, schemas.IntakeGate{}, &gate); err != nil { + if err := aiStructured(ctx, deps.AI, gatePrompt, prompts.IntakeGateSystem, strictAISchemas[strictAISchemaIntakeGate], &gate); err != nil { return nil, err } diff --git a/go/internal/reasoners/reasoners.go b/go/internal/reasoners/reasoners.go index 3fc3866..e773be2 100644 --- a/go/internal/reasoners/reasoners.go +++ b/go/internal/reasoners/reasoners.go @@ -69,9 +69,8 @@ const maxParseRetries = 2 // fresh LLM call, up to maxParseRetries times. Transport/API errors are NOT // retried here (Python retries only "Could not parse structured response"). // Each attempt decodes into a fresh instance so a failed attempt's partial -// fill never bleeds into the next one. schemaSample is a zero value of the -// schema struct (the Go analogue of passing the pydantic class). -func aiStructured(ctx context.Context, caller AICaller, prompt, system string, schemaSample any, dest any) error { +// fill never bleeds into the next one. +func aiStructured(ctx context.Context, caller AICaller, prompt, system string, schema json.RawMessage, dest any) error { if caller == nil { return fmt.Errorf("reasoners: AI seam is nil") } @@ -81,7 +80,7 @@ func aiStructured(ctx context.Context, caller AICaller, prompt, system string, s } var lastErr error for attempt := 0; attempt <= maxParseRetries; attempt++ { - resp, err := caller.AI(ctx, prompt, ai.WithSystem(system), ai.WithSchema(schemaSample)) + resp, err := caller.AI(ctx, prompt, ai.WithSystem(system), ai.WithSchema(schema)) if err != nil { return err } diff --git a/go/internal/reasoners/reasoners_test.go b/go/internal/reasoners/reasoners_test.go index 971507b..5105311 100644 --- a/go/internal/reasoners/reasoners_test.go +++ b/go/internal/reasoners/reasoners_test.go @@ -48,6 +48,7 @@ type fakeAI struct { text string gotPrompt string gotSystem string + gotSchema json.RawMessage calls int } @@ -65,6 +66,9 @@ func (f *fakeAI) AI(_ context.Context, prompt string, opts ...ai.Option) (*ai.Re f.gotSystem = msg.Content[0].Text } } + if req.ResponseFormat != nil && req.ResponseFormat.JSONSchema != nil { + f.gotSchema = append(f.gotSchema[:0], req.ResponseFormat.JSONSchema.Schema...) + } return &ai.Response{ Choices: []ai.Choice{{ Message: ai.Message{ @@ -141,6 +145,9 @@ func TestIntakePhaseConfidentGate(t *testing.T) { if aiSeam.gotSystem != prompts.IntakeGateSystem { t.Fatalf("system prompt = %q", aiSeam.gotSystem) } + if !reflect.DeepEqual(aiSeam.gotSchema, strictAISchemas[strictAISchemaIntakeGate]) { + t.Fatalf("intake schema = %s, want registered schema", aiSeam.gotSchema) + } if out["pr_type"] != "feature" || out["complexity"] != "trivial" { t.Fatalf("gate fields not propagated: %v", out) } @@ -368,7 +375,7 @@ func TestMetaSelectorParseFail(t *testing.T) { // --- review_dimension ------------------------------------------------------------- -var reviewDimKeys = []string{"findings", "sub_reviews", "current_depth"} +var reviewDimKeys = []string{"findings", "sub_reviews", "current_depth", "schema_parse_failed"} var findingDumpKeys = []string{ "dimension_id", "dimension_name", "file_path", "line_start", "line_end", @@ -398,6 +405,9 @@ func TestReviewDimensionHappyPath(t *testing.T) { if out["current_depth"] != 1 { t.Fatalf("current_depth = %v", out["current_depth"]) } + if out["schema_parse_failed"] != false { + t.Fatalf("schema_parse_failed = %v, want false", out["schema_parse_failed"]) + } findings := out["findings"].([]any) if len(findings) != 1 { t.Fatalf("findings = %v", findings) @@ -460,6 +470,9 @@ func TestReviewDimensionParseFail(t *testing.T) { if out["current_depth"] != 0 { t.Fatalf("current_depth = %v", out["current_depth"]) } + if out["schema_parse_failed"] != true { + t.Fatalf("schema_parse_failed = %v, want true", out["schema_parse_failed"]) + } } // --- compound finder / dedup ------------------------------------------------------------- @@ -851,6 +864,9 @@ func TestCoverageGate(t *testing.T) { if aiSeam.gotSystem != prompts.CoverageGateSystem { t.Fatalf("system = %q", aiSeam.gotSystem) } + if !reflect.DeepEqual(aiSeam.gotSchema, strictAISchemas[strictAISchemaCoverageGate]) { + t.Fatalf("coverage schema = %s, want registered schema", aiSeam.gotSchema) + } if !strings.Contains(aiSeam.gotPrompt, "Dimensions already reviewed: Dim A.") { t.Fatalf("prompt = %q", aiSeam.gotPrompt) } @@ -924,7 +940,7 @@ func TestAIStructuredParseToleranceMirrorsPython(t *testing.T) { t.Run("fenced JSON extracted on the first call, no retry", func(t *testing.T) { f := &fakeAISeq{texts: []string{"```json\n{\"pr_type\":\"feature\",\"confident\":true}\n```"}} var g gate - if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err != nil { + if err := aiStructured(context.Background(), f, "p", "s", strictAISchemas[strictAISchemaIntakeGate], &g); err != nil { t.Fatalf("aiStructured: %v", err) } if f.calls != 1 || g.PrType != "feature" || !g.Confident { @@ -935,7 +951,7 @@ func TestAIStructuredParseToleranceMirrorsPython(t *testing.T) { t.Run("malformed first response retries the LLM call", func(t *testing.T) { f := &fakeAISeq{texts: []string{"sorry, no data", `{"pr_type":"fix","confident":false}`}} var g gate - if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err != nil { + if err := aiStructured(context.Background(), f, "p", "s", strictAISchemas[strictAISchemaIntakeGate], &g); err != nil { t.Fatalf("aiStructured: %v", err) } if f.calls != 2 || g.PrType != "fix" { @@ -946,7 +962,7 @@ func TestAIStructuredParseToleranceMirrorsPython(t *testing.T) { t.Run("all attempts malformed -> 3 calls and the Python error string", func(t *testing.T) { f := &fakeAISeq{texts: []string{"garbage"}} var g gate - err := aiStructured(context.Background(), f, "p", "s", gate{}, &g) + err := aiStructured(context.Background(), f, "p", "s", strictAISchemas[strictAISchemaIntakeGate], &g) if err == nil || !strings.HasPrefix(err.Error(), "Could not parse structured response: ") { t.Fatalf("err = %v, want Could-not-parse prefix", err) } @@ -958,7 +974,7 @@ func TestAIStructuredParseToleranceMirrorsPython(t *testing.T) { t.Run("API error is not parse-retried", func(t *testing.T) { f := &fakeAISeq{err: errors.New("boom")} var g gate - if err := aiStructured(context.Background(), f, "p", "s", gate{}, &g); err == nil || err.Error() != "boom" { + if err := aiStructured(context.Background(), f, "p", "s", strictAISchemas[strictAISchemaIntakeGate], &g); err == nil || err.Error() != "boom" { t.Fatalf("err = %v, want boom", err) } if f.calls != 1 { diff --git a/go/internal/reasoners/reviewdim.go b/go/internal/reasoners/reviewdim.go index 972af7d..f61851d 100644 --- a/go/internal/reasoners/reviewdim.go +++ b/go/internal/reasoners/reviewdim.go @@ -66,7 +66,8 @@ func ReviewDimension(ctx context.Context, deps Deps, in ReviewDimensionInput) (m if err != nil { return nil, err } - if res == nil || res.Parsed == nil { + schemaParseFailed := res == nil || res.Parsed == nil + if schemaParseFailed { // Schema parse failed entirely — don't silently report "0 findings", // which is indistinguishable from a clean review. Make it visible. errMsg := "None" @@ -79,6 +80,10 @@ func ReviewDimension(ctx context.Context, deps Deps, in ReviewDimensionInput) (m ) } result := *parsed + if schemaParseFailed { + result.Findings = nil + result.SubReviews = nil + } for i := range result.Findings { result.Findings[i].Tags = orEmptyStrs(result.Findings[i].Tags) } @@ -106,8 +111,9 @@ func ReviewDimension(ctx context.Context, deps Deps, in ReviewDimensionInput) (m return nil, err } return map[string]any{ - "findings": findings, - "sub_reviews": subReviewDicts, - "current_depth": in.CurrentDepth, + "findings": findings, + "sub_reviews": subReviewDicts, + "current_depth": in.CurrentDepth, + "schema_parse_failed": schemaParseFailed, }, nil } diff --git a/go/internal/reasoners/schemas_drift_test.go b/go/internal/reasoners/schemas_drift_test.go index 680b886..69799b0 100644 --- a/go/internal/reasoners/schemas_drift_test.go +++ b/go/internal/reasoners/schemas_drift_test.go @@ -1,8 +1,10 @@ package reasoners import ( + "encoding/json" "reflect" "sort" + "strconv" "strings" "testing" @@ -148,3 +150,65 @@ func TestRootTypeCountMatchesFixtures(t *testing.T) { t.Fatalf("expected 13 registered Run[T] destination fixtures, got %d: %v", len(names), names) } } + +func TestStrictAndRegisteredSchemasDeclareArrayItems(t *testing.T) { + for name, raw := range strictAISchemas { + var schema any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("strict schema %s is invalid JSON: %v", name, err) + } + assertArrayItems(t, string(name), schema) + } + + coverage := map[string]any{} + if err := json.Unmarshal(strictAISchemas[strictAISchemaCoverageGate], &coverage); err != nil { + t.Fatalf("decode coverage schema: %v", err) + } + properties, _ := coverage["properties"].(map[string]any) + gaps, _ := properties["gap_descriptions"].(map[string]any) + if gaps["type"] != "array" { + t.Fatalf("coverage gap_descriptions type = %v, want array", gaps["type"]) + } + items, _ := gaps["items"].(map[string]any) + if items["type"] != "string" { + t.Fatalf("coverage gap_descriptions items type = %v, want string", items["type"]) + } + if coverage["additionalProperties"] != false { + t.Fatalf("coverage additionalProperties = %v, want false", coverage["additionalProperties"]) + } + required, _ := coverage["required"].([]any) + if !reflect.DeepEqual(required, []any{"fully_covered", "gap_descriptions", "confident"}) { + t.Fatalf("coverage required = %v", required) + } + + for rt, fixture := range rootTypes { + schema, ok := harnessx.RegisteredSchema(rt) + if !ok { + t.Fatalf("%s (%s) is not registered", rt.Name(), fixture) + } + assertArrayItems(t, fixture, schema) + } +} + +// assertArrayItems recursively walks schema maps and lists. Any actual array +// schema must declare a non-nil items schema; lists used by composition keywords +// are merely containers and are traversed like every other list. +func assertArrayItems(t *testing.T, path string, node any) { + t.Helper() + switch node := node.(type) { + case map[string]any: + if node["type"] == "array" { + items, ok := node["items"] + if !ok || items == nil { + t.Errorf("%s: array schema has no items definition", path) + } + } + for key, value := range node { + assertArrayItems(t, path+"."+key, value) + } + case []any: + for i, value := range node { + assertArrayItems(t, path+"["+strconv.Itoa(i)+"]", value) + } + } +} diff --git a/go/internal/schemas/output.go b/go/internal/schemas/output.go index e4062d6..0c116b5 100644 --- a/go/internal/schemas/output.go +++ b/go/internal/schemas/output.go @@ -82,10 +82,11 @@ type ReviewResult struct { // intake/anatomy/plan/budget dicts and phases_completed list are collection // defaults in Python — construct them non-nil upstream to marshal as {} / []. type ReviewMetadata struct { - Intake map[string]any `json:"intake"` - Anatomy map[string]any `json:"anatomy"` - Plan map[string]any `json:"plan"` - Budget map[string]any `json:"budget"` - AgentInvocations int `json:"agent_invocations"` - PhasesCompleted []string `json:"phases_completed"` + Intake map[string]any `json:"intake"` + Anatomy map[string]any `json:"anatomy"` + Plan map[string]any `json:"plan"` + Budget map[string]any `json:"budget"` + AgentInvocations int `json:"agent_invocations"` + PhasesCompleted []string `json:"phases_completed"` + DegradedDimensions int `json:"degraded_dimensions"` } diff --git a/go/test/mockcli/state.go b/go/test/mockcli/state.go index af084a4..12b29bd 100644 --- a/go/test/mockcli/state.go +++ b/go/test/mockcli/state.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "syscall" "time" ) @@ -35,11 +34,11 @@ func withLock(dir string, fn func()) { return } defer f.Close() - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + if err := lockFile(f); err != nil { fn() return } - defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + defer unlockFile(f) fn() } diff --git a/go/test/mockcli/state_lock_unix.go b/go/test/mockcli/state_lock_unix.go new file mode 100644 index 0000000..79e8f7d --- /dev/null +++ b/go/test/mockcli/state_lock_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package main + +import ( + "os" + "syscall" +) + +func lockFile(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX) +} + +func unlockFile(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/go/test/mockcli/state_lock_windows.go b/go/test/mockcli/state_lock_windows.go new file mode 100644 index 0000000..45a07f5 --- /dev/null +++ b/go/test/mockcli/state_lock_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "os" + +// Windows has no syscall.Flock. The mock CLI's state files are only test +// instrumentation, so retain its documented best-effort behavior there. +func lockFile(*os.File) error { return nil } + +func unlockFile(*os.File) {}