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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ PR_AF_AI_INITIAL_BACKOFF_SECONDS=2.0
PR_AF_AI_MAX_BACKOFF_SECONDS=8.0
PR_AF_OPENCODE_BIN=opencode
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.
AGENTFIELD_HARNESS_IDLE_SECONDS=360

# Alternate LLM provider keys (forwarded to harness when set)
ANTHROPIC_API_KEY=
Expand All @@ -33,7 +36,7 @@ GOOGLE_API_KEY=

# --- Review budget / repo paths ---
PR_AF_MAX_COST_USD=2.0
PR_AF_MAX_DURATION_SECONDS=300
PR_AF_MAX_DURATION_SECONDS=3600
PR_AF_WORKDIR=/workspaces
# Defaults to cwd when unset
PR_AF_REPO_PATH=
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,21 @@ Poll for results:
curl http://localhost:8080/api/v1/executions/<execution_id>
```

### Configuration (environment variables)

The key knobs (see `.env.example` for the full list):

| Variable | Purpose |
|-----------------------------|----------------------------------------------------------------|
| `OPENROUTER_API_KEY` | LLM provider key (OpenRouter) — required |
| `GH_TOKEN` | GitHub token (`repo` scope) for reading PRs and posting reviews |
| `PR_AF_PROVIDER` | Harness provider (default `opencode`) |
| `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) |
| `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 |
| `PR_AF_WORKDIR` | Where PR checkouts live (default `/workspaces`); each PR gets its own `<repo>-pr<N>` workspace |

## GitHub Actions Integration

The easiest way to use PR-AF is to drop it into your GitHub Actions. It requires **zero configuration** and runs securely using GitHub's built-in `GITHUB_TOKEN`.
Expand Down
8 changes: 7 additions & 1 deletion agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ user_environment:
default: "2.0"
- name: PR_AF_MAX_DURATION_SECONDS
description: Per-run wall-clock ceiling (seconds)
default: "300"
default: "3600"
# Harness CLIs in JSON mode emit events only at completion boundaries, so a
# long single completion looks silent; the old 120s SDK default killed
# healthy runs.
- name: AGENTFIELD_HARNESS_IDLE_SECONDS
description: harness no-output watchdog window (seconds)
default: "360"
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ class BudgetConfig(BaseModel):
"""All behavioral tuning in one place."""
# Global caps
max_cost_usd: float = 2.0
max_duration_seconds: int = 300 # 5 minutes
max_duration_seconds: int = 3600 # 60 minutes — real reviews measure 60-70 min

# Phase-level
max_concurrent_reviewers: int = 8
Expand Down
2 changes: 1 addition & 1 deletion docs/DX.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ depth: auto # auto | quick | standard | deep
# Budget
budget:
max_cost_usd: 2.00
max_duration_seconds: 300
max_duration_seconds: 3600
model_tier: standard # budget | standard | premium

# Ignore patterns (glob)
Expand Down
44 changes: 40 additions & 4 deletions go/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# PR-AF — Go node

A Go port of the PR-AF agentic code-review node. It registers the same reasoner
surface under the same names as the Python node and exposes a byte-compatible
HTTP API, so the control-plane DAG UI renders identically. The Python package
under `src/pr_af/` is untouched; this port lives entirely under `go/`.
surface under the same names as the Python node, exposes a byte-compatible
HTTP API, and reports every pipeline phase as its own tracked execution, so the
control-plane DAG UI renders the same multi-node orchestration graph as the
Python node (see [Pipeline DAG on the control plane](#pipeline-dag-on-the-control-plane)).
The Python package under `src/pr_af/` is untouched; this port lives entirely
under `go/`.

One binary:

Expand All @@ -28,6 +31,38 @@ curl -X POST http://localhost:8080/api/v1/execute/async/pr-af-go.review \

`NODE_ID` / `PORT` still override the defaults if you want a different id/port.

## Pipeline DAG on the control plane

The Python node's review is not one execution — every phase
(`intake_phase`, `anatomy_phase`, the three `meta_*` selectors, each parallel
`review_dimension`, `adversary_phase`, `evidence_verifier`, the obligation and
compound reasoners, `coverage_gate`) runs as a **tracked child execution** of
the parent `review`, and the control-plane UI renders the run as that DAG.
Python gets this from its `@router.reasoner()` wrapper, which routes direct
in-process calls through workflow instrumentation.

The Go port reproduces the same graph through the SDK's `Agent.CallLocal`:
the orchestrator's phase seams (`orch.callLocalSeams`) invoke each phase under
its registered reasoner name instead of calling the function directly. Each
call builds a child execution context from the incoming request's context and
emits `running` / `succeeded` / `failed` workflow events to the control plane,
which mirrors them into the executions table — one DAG node per phase, parented
under the `review` execution, same shape as Python.

Mechanics worth knowing:

- **Same code path either way.** A CallLocal-routed phase goes through the
registered handler (`afx.Bind` into the typed input → the same `reasoners.*`
function with the same deps). `afx.ToMap` keeps nested values typed so
custom marshalers (notably `OrderedPatches`' insertion-ordered
`diff_patches`) survive the round trip byte-for-byte.
- **Best-effort reporting.** Workflow events are fire-and-forget: an
unreachable control plane logs a warning and the review proceeds — the DAG
is observability, never a failure mode.
- **Stubs opt out.** `orch.Deps.Local` is nil in unit tests and stub
harnesses, which keeps the plain direct-call seams; production wiring
(`node.BuildAgent`) always points it at the live agent.

## Depending on the AgentField Go SDK

Unlike the SWE-AF Go port, this module depends on the AgentField Go SDK
Expand Down Expand Up @@ -123,7 +158,8 @@ The node is configured entirely through the environment.
| `PR_AF_PROVIDER` | Harness provider (default `opencode`) |
| `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) |
| `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 `300`) |
| `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 |
| `HAX_API_KEY` | Optional — enables the HITL review-approval gate when set |

Note: the code default model is `minimax/minimax-m2.5`, while the Docker image /
Expand Down
8 changes: 7 additions & 1 deletion go/agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ user_environment:
default: "2.0"
- name: PR_AF_MAX_DURATION_SECONDS
description: per-run wall-clock ceiling
default: "300"
default: "3600"
# Harness CLIs in JSON mode emit events only at completion boundaries, so a
# long single completion looks silent; the old 120s SDK default killed
# healthy runs.
- name: AGENTFIELD_HARNESS_IDLE_SECONDS
description: harness no-output watchdog window (seconds)
default: "360"
2 changes: 1 addition & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module github.com/Agent-Field/pr-af/go
go 1.21

require (
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260713163335-795bdd57b4de
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260714191100-2cc5fe2adcf4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/invopop/jsonschema v0.13.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
Expand Down
4 changes: 2 additions & 2 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260713163335-795bdd57b4de h1:GYwOpQQheZ53FgmPta3PJ2CKQjATnPIMwAvNsBA7vF4=
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260713163335-795bdd57b4de/go.mod h1:08VZk14uw4GJH6a34psHkuLu+DcRr197Zi0IGmLlfrM=
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260714191100-2cc5fe2adcf4 h1:B3uCMLZSa2rsRDRGuecBIXCgkYqBuScSK4CXKPDaCgk=
github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260714191100-2cc5fe2adcf4/go.mod h1:08VZk14uw4GJH6a34psHkuLu+DcRr197Zi0IGmLlfrM=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
Expand Down
66 changes: 66 additions & 0 deletions go/internal/afx/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package afx
import (
"encoding/json"
"fmt"
"reflect"
"strings"
)

// Bind decodes a reasoner's untyped input map into a typed value T.
Expand Down Expand Up @@ -33,3 +35,67 @@ func Bind[T any](input map[string]any) (T, error) {
}
return out, nil
}

// ToMap is Bind's inverse: it renders a typed input struct as the
// map[string]any shape the SDK's reasoner handlers (and Agent.CallLocal)
// accept. Top-level exported fields become map entries keyed by their json
// tag, and the field VALUES stay typed — deliberately NOT a marshal→unmarshal
// round trip, which would decode nested values into plain Go maps and lose
// what their custom marshalers encode. The concrete casualty would be
// reasoners.OrderedPatches: its object-order MarshalJSON preserves Python's
// dict insertion order, but flattened into a map[string]any it re-marshals
// with sorted keys and the meta/obligation prompts would render patches in
// the wrong order. Keeping values typed lets Bind on the handler side (and
// the SDK's workflow-event emitter) re-marshal them through the same custom
// marshalers, so ToMap→Bind is lossless.
//
// The reasoner input structs are flat, fully json-tagged, and carry no
// omitempty (every key is emitted, so Bind-side default seeding never
// overrides a deliberately zero field); ToMap ignores omitempty accordingly.
// Anonymous embedded structs without their own json tag are flattened the way
// encoding/json flattens them.
func ToMap(v any) (map[string]any, error) {
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil, fmt.Errorf("afx.ToMap: nil %T", v)
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return nil, fmt.Errorf("afx.ToMap: %T is not a struct", v)
}
out := make(map[string]any, rv.NumField())
fillMap(out, rv)
return out, nil
}

// fillMap writes rv's fields into out, recursing through untagged anonymous
// struct fields (encoding/json flattening).
func fillMap(out map[string]any, rv reflect.Value) {
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if !f.IsExported() {
continue
}
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "-" {
continue
}
if name == "" {
if f.Anonymous {
fv := rv.Field(i)
for fv.Kind() == reflect.Pointer && !fv.IsNil() {
fv = fv.Elem()
}
if fv.Kind() == reflect.Struct {
fillMap(out, fv)
continue
}
}
name = f.Name
}
out[name] = rv.Field(i).Interface()
}
}
83 changes: 83 additions & 0 deletions go/internal/afx/bind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,86 @@ func TestBind_TypeMismatch(t *testing.T) {
t.Fatalf("Bind = nil error, want unmarshal error for string into int")
}
}

// orderedVal is a marshaler-carrying type standing in for
// reasoners.OrderedPatches: MarshalJSON controls its bytes. ToMap must keep
// values typed so this marshaler still runs when the map is re-marshaled —
// the property that preserves diff_patches insertion order through the
// CallLocal round trip.
type orderedVal struct{ order []string }

func (o orderedVal) MarshalJSON() ([]byte, error) {
out := "{"
for i, k := range o.order {
if i > 0 {
out += ","
}
b, _ := json.Marshal(k)
out += string(b) + ":1"
}
return []byte(out + "}"), nil
}

// TestToMap_KeepsValuesTyped proves ToMap does not flatten field values into
// plain maps: the custom marshaler's byte order survives a re-marshal of the
// produced map.
func TestToMap_KeepsValuesTyped(t *testing.T) {
type in struct {
Patches orderedVal `json:"diff_patches"`
Depth string `json:"depth"`
}
m, err := ToMap(in{Patches: orderedVal{order: []string{"z.go", "a.go"}}, Depth: "quick"})
if err != nil {
t.Fatalf("ToMap: %v", err)
}
if m["depth"] != "quick" {
t.Errorf("depth = %v, want quick", m["depth"])
}
b, err := json.Marshal(m["diff_patches"])
if err != nil {
t.Fatalf("re-marshal diff_patches: %v", err)
}
if string(b) != `{"z.go":1,"a.go":1}` {
t.Errorf("diff_patches bytes = %s, want insertion order preserved", b)
}
}

// TestToMap_TagHandling covers tag name selection, json:"-" skipping,
// unexported skipping, untagged-field fallback to the Go name, and anonymous
// embedded flattening.
func TestToMap_TagHandling(t *testing.T) {
type Embedded struct {
Inner string `json:"inner"`
}
type in struct {
Embedded
Named string `json:"named_key"`
Skipped string `json:"-"`
Bare string
hidden string //nolint:unused // proves unexported fields are skipped
}
m, err := ToMap(&in{Embedded: Embedded{Inner: "i"}, Named: "n", Skipped: "s", Bare: "b"})
if err != nil {
t.Fatalf("ToMap: %v", err)
}
want := map[string]any{"inner": "i", "named_key": "n", "Bare": "b"}
if len(m) != len(want) {
t.Fatalf("map = %#v, want %#v", m, want)
}
for k, v := range want {
if m[k] != v {
t.Errorf("m[%q] = %v, want %v", k, m[k], v)
}
}
}

// TestToMap_Errors covers the non-struct and nil-pointer error paths.
func TestToMap_Errors(t *testing.T) {
if _, err := ToMap([]string{"not", "a", "struct"}); err == nil {
t.Error("ToMap(slice) = nil error, want non-struct error")
}
var p *struct{}
if _, err := ToMap(p); err == nil {
t.Error("ToMap(nil pointer) = nil error, want nil error message")
}
}
22 changes: 12 additions & 10 deletions go/internal/config/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import "strings"

// BudgetConfig ports config.py BudgetConfig — global and per-phase budget caps.
// Numeric defaults are the §D "Config numeric tables" verbatim. Note
// max_duration_seconds defaults to 1800 here but is always overridden per call
// to the resolved value (300 by default) via ReviewConfig.FromInput.
// max_duration_seconds is always overridden per call to the resolved value
// (3600 by default) via ReviewConfig.FromInput.
type BudgetConfig struct {
MaxCostUSD float64 `json:"max_cost_usd"`
MaxDurationSeconds int `json:"max_duration_seconds"`
Expand Down Expand Up @@ -49,7 +49,7 @@ func DefaultPhaseBudgets() map[string]float64 {
func DefaultBudgetConfig() BudgetConfig {
return BudgetConfig{
MaxCostUSD: 2.0,
MaxDurationSeconds: 1800,
MaxDurationSeconds: 3600,
PhaseBudgets: DefaultPhaseBudgets(),
MaxConcurrentReviewers: 8,
MaxReferenceFollowsPerReviewer: 3,
Expand All @@ -72,11 +72,13 @@ func evidencePackDefault() bool {
}
}

// ResolveBudgetCaps ports app.py:62-77 _resolve_budget_caps. An explicit
// argument always wins; when nil, the PR_AF_MAX_COST_USD / PR_AF_MAX_DURATION_
// SECONDS env vars are consulted; finally the historical defaults 2.0 / 300.
// A malformed env value is an error (Python's float()/int() raises inside
// review(), where the ValueError class maps to HTTP 400).
// ResolveBudgetCaps ports app.py _resolve_budget_caps. An explicit argument
// always wins; when nil, the PR_AF_MAX_COST_USD / PR_AF_MAX_DURATION_SECONDS
// env vars are consulted; finally the defaults 2.0 / 3600 (real reviews
// measure 60-70 minutes — the historical 300s default killed every fresh
// install mid-pipeline). A malformed env value is an error (Python's
// float()/int() raises inside review(), where the ValueError class maps to
// HTTP 400).
func ResolveBudgetCaps(maxCostUSD *float64, maxDurationSeconds *int) (float64, int, error) {
cost := 2.0
if maxCostUSD != nil {
Expand All @@ -88,12 +90,12 @@ func ResolveBudgetCaps(maxCostUSD *float64, maxDurationSeconds *int) (float64, i
}
}

dur := 300
dur := 3600
if maxDurationSeconds != nil {
dur = *maxDurationSeconds
} else {
var err error
if dur, err = intEnv("PR_AF_MAX_DURATION_SECONDS", 300); err != nil {
if dur, err = intEnv("PR_AF_MAX_DURATION_SECONDS", 3600); err != nil {
return 0, 0, err
}
}
Expand Down
Loading
Loading