Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions go/internal/reasoners/coverage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion go/internal/reasoners/intake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 74 additions & 0 deletions go/internal/reasoners/reasoners_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
Expand Down Expand Up @@ -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{}
Expand Down
Loading