From a9cd9ada4e6b8e064501ec4f0e94e86865313c4b Mon Sep 17 00:00:00 2001 From: Vadim Ponomarev Date: Fri, 7 Aug 2026 22:04:04 +0300 Subject: [PATCH] fix(go): fall back to the harness when the .ai() gates are unavailable intake_phase and coverage_gate ask the AI seam for a response_format schema. The Go port propagated every failure out of both, so a node whose harness is not OpenRouter-backed failed the entire review with "AI not configured for this agent" (BuildAgent only attaches AIConfig when OPENROUTER_API_KEY is set), and a provider that rejects structured output sank it the same way. Mirror harnesses.py, which wraps both calls: an unusable AI seam leaves the intake gate unconfident so control falls through to the harness classifier already sitting below it, and coverage_gate retries the same prompt through the harness, returning {} when the result fails to parse. Verified end to end with PR_AF_PROVIDER=claude-code and no OpenRouter key: the full pipeline completes instead of failing at the first phase. --- go/internal/reasoners/coverage.go | 20 +++++-- go/internal/reasoners/intake.go | 9 ++- go/internal/reasoners/reasoners_test.go | 74 +++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/go/internal/reasoners/coverage.go b/go/internal/reasoners/coverage.go index 6bd4a18..500afaf 100644 --- a/go/internal/reasoners/coverage.go +++ b/go/internal/reasoners/coverage.go @@ -3,23 +3,35 @@ package reasoners import ( "context" + "github.com/Agent-Field/agentfield/sdk/go/harness" + + "github.com/Agent-Field/pr-af/go/internal/harnessx" "github.com/Agent-Field/pr-af/go/internal/prompts" "github.com/Agent-Field/pr-af/go/internal/schemas" ) // CoverageGate ports coverage_gate: one .ai() call that decides whether the -// review dimensions covered every change cluster. +// review dimensions covered every change cluster, with a .harness() retry of +// the same prompt when the AI seam is unavailable or refuses the structured +// request (harnesses.py coverage_gate wraps its .ai() call the same way). // // Output keys (§B.2): fully_covered, gap_descriptions, confident. Absent // response keys land on the pydantic defaults through CoverageGate's seeded -// UnmarshalJSON (confident=true, gap_descriptions=[]); an AI transport or -// decode error propagates exactly as Python lets the exception escape. +// UnmarshalJSON (confident=true, gap_descriptions=[]); a harness fallback that +// fails to parse returns {} (Python returns a literal empty dict). func CoverageGate(ctx context.Context, deps Deps, in CoverageGateInput) (map[string]any, error) { prompt := prompts.CoverageGatePrompt(in.Anatomy, in.ReviewedClusters, in.DimensionNamesReviewed) var gate schemas.CoverageGate if err := aiStructured(ctx, deps.AI, prompt, prompts.CoverageGateSystem, strictAISchemas[strictAISchemaCoverageGate], &gate); err != nil { - return nil, err + parsed, res, harnessErr := harnessx.Run[schemas.CoverageGate](ctx, deps.Harness, prompt, harness.Options{}) + if harnessErr != nil { + return nil, harnessErr + } + if res == nil || res.Parsed == nil { + return map[string]any{}, nil + } + gate = *parsed } gate.GapDescriptions = orEmptyStrs(gate.GapDescriptions) return dumpMap(gate) diff --git a/go/internal/reasoners/intake.go b/go/internal/reasoners/intake.go index a495357..7cd120b 100644 --- a/go/internal/reasoners/intake.go +++ b/go/internal/reasoners/intake.go @@ -25,9 +25,16 @@ func IntakePhase(ctx context.Context, deps Deps, in IntakeInput) (map[string]any gatePrompt := prompts.IntakeGatePrompt( pr.Title, pr.Description, pr.Labels, pr.Author, filesChanged, languages, pr.CommitMessages, ) + // Python wraps this .ai() call in try/except and leaves gate_result None on + // any failure (harnesses.py intake_phase): providers reached over an + // OpenAI-compatible endpoint may not support the structured-output request + // .ai() issues, and a node configured for a non-OpenRouter harness has no + // AI seam at all. Either way the classification is not worth sinking the + // review for — a zero IntakeGate is not confident, so control falls through + // to the harness classifier below exactly as Python's does. var gate schemas.IntakeGate if err := aiStructured(ctx, deps.AI, gatePrompt, prompts.IntakeGateSystem, strictAISchemas[strictAISchemaIntakeGate], &gate); err != nil { - return nil, err + gate = schemas.IntakeGate{} } if gate.Confident { diff --git a/go/internal/reasoners/reasoners_test.go b/go/internal/reasoners/reasoners_test.go index 5105311..23d977a 100644 --- a/go/internal/reasoners/reasoners_test.go +++ b/go/internal/reasoners/reasoners_test.go @@ -205,6 +205,42 @@ func TestIntakePhaseFallbackParsed(t *testing.T) { } } +// Contract: an unusable AI seam escalates to the harness instead of sinking +// the review — the gate is treated as unconfident (Python's except -> None). +// Covers both a node with no AI configured at all (nil seam, e.g. a +// claude-code harness with no OpenRouter key) and a provider that rejects the +// structured-output request. +func TestIntakePhaseAIUnavailableFallsBackToHarness(t *testing.T) { + payload := `{ + "pr_type":"refactor","complexity":"standard","languages":["go"], + "areas_touched":["api"],"risk_signals":[],"ai_generated":0.1, + "review_depth":"standard","pr_summary":"s"}` + + for name, seam := range map[string]AICaller{ + "no AI seam configured": nil, + "structured output rejected": &fakeAISeq{ + err: errors.New("response_format is not supported"), + }, + } { + t.Run(name, func(t *testing.T) { + h := &mockHarness{payload: payload} + out, err := IntakePhase(context.Background(), Deps{Harness: h, AI: seam}, IntakeInput{ + PRData: fixturePR(), Depth: "standard", + }) + if err != nil { + t.Fatal(err) + } + if h.calls != 1 { + t.Fatalf("harness calls = %d, want 1", h.calls) + } + wantKeys(t, out, intakeKeys...) + if out["pr_type"] != "refactor" { + t.Fatalf("pr_type = %v", out["pr_type"]) + } + }) + } +} + // Contract: fallback parse failure returns Python's literal empty dict. func TestIntakePhaseFallbackParseFailReturnsEmpty(t *testing.T) { aiSeam := &fakeAI{text: `{"pr_type":"","complexity":"","confident":false}`} @@ -885,6 +921,44 @@ func TestCoverageGate(t *testing.T) { } } +// Contract: an unusable AI seam retries the SAME coverage prompt through the +// harness, and a harness result that fails to parse yields Python's literal +// empty dict. +func TestCoverageGateAIUnavailableFallsBackToHarness(t *testing.T) { + in := CoverageGateInput{ + Anatomy: schemas.AnatomyResult{ + Clusters: []schemas.ChangeCluster{{ID: "cluster_0", Name: "root", Files: []string{"a.go"}}}, + }, + ReviewedClusters: []string{"cluster_0"}, + DimensionNamesReviewed: []string{"Dim A"}, + } + + h := &mockHarness{payload: `{"fully_covered":true,"gap_descriptions":[],"confident":true}`} + out, err := CoverageGate(context.Background(), Deps{Harness: h, AI: nil}, in) + if err != nil { + t.Fatal(err) + } + if h.calls != 1 { + t.Fatalf("harness calls = %d, want 1", h.calls) + } + if !strings.Contains(h.gotPrompt, "Dimensions already reviewed: Dim A.") { + t.Fatalf("harness prompt = %q, want the coverage prompt", h.gotPrompt) + } + wantKeys(t, out, "fully_covered", "gap_descriptions", "confident") + if out["fully_covered"] != true { + t.Fatalf("got %v", out) + } + + h = &mockHarness{parseFail: true} + out, err = CoverageGate(context.Background(), Deps{Harness: h, AI: nil}, in) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("want {}, got %v", out) + } +} + // --- error propagation ------------------------------------------------------------- type erroringHarness struct{}