Skip to content

feat: opt-in Go port of PR-AF on the AgentField Go SDK#53

Open
AbirAbbas wants to merge 18 commits into
mainfrom
feat/go-port
Open

feat: opt-in Go port of PR-AF on the AgentField Go SDK#53
AbirAbbas wants to merge 18 commits into
mainfrom
feat/go-port

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

Complete 1:1 port of PR-AF from Python to Go using the AgentField Go SDK, mirroring the SWE-AF port (Agent-Field/SWE-AF#94). The Go node is an opt-in sibling: it registers as pr-af-go (:8007) so both stacks run against one control plane simultaneously. The Python implementation stays the default and is byte-untouched — every diff lives under go/, plus docker-compose.go.yml, one root-README section, and a new Go CI job.

Callers opt in by targeting the -go reasoner path, e.g. POST /api/v1/execute/async/pr-af-go.review; NODE_ID/PORT env overrides remain available.

Install story

  • Default (unchanged): af install <repo> installs the Python node from the root manifest.
  • Opt-in Go: af install <repo> --path go installs the Go node from go/agentfield-package.yaml (name: pr-af-go, language: go) — the control plane compiles bin/pr-af at install time. Requires the --path selector + Go-package support from feat: af install --path selector + Go SDK harness fixes agentfield#750. Verified end-to-end against an af binary built from that branch: manifest resolution from the go/ subtree, install-time compile, registration as pr-af-go, clean uninstall.
  • Docker/compose: docker build -f go/Dockerfile ., or docker compose -f docker-compose.go.yml up -d as an add-on to the running Python stack (joins its network/volume as external references). Works independently of #750.

The committed go.mod pins the SDK by real pseudo-version (sdk/go v0.0.0-20260710152001-45311737c298, the #750 head) with no replace directive — it resolves from the module proxy, which is what makes the af install --path go build work without vendoring. Bump to the merged-main SHA once #750 lands.

Parity contract honored

  • Registration surface: all 17 reasoners under their exact Python names — review (untagged, with input schema) + 16 tagged ["review","pr"]. Asserted by a functional test against a live control plane.
  • No app.call: like Python, the 16 internal reasoners run in-process (goroutines + errgroup/semaphore reproducing asyncio.gather order-preservation and the streaming reviewer→layer queue); only review is externally driven.
  • Result JSON: snake_case model_dump() key sets reproduced per reasoner (pydantic non-zero defaults seeded via UnmarshalJSON); the hax HITL payload stays the one camelCase surface, with severity re-normalized at that boundary.
  • Both divergent severity maps ported exactly (canonical schemas map vs scoring's private map that maps medium→important, low→suggestion).
  • Prompts byte-verbatim: 25 builders, 51 golden fixtures, and — unlike the SWE-AF port — the Python fixture generator is committed (go/scripts/gen_golden.py) so goldens are reproducible from the repo.
  • Deliberate quirks preserved: the code-level model default stays minimax/minimax-m2.5 while deploy artifacts default to openrouter/moonshotai/kimi-k2.5 (env wins); the $ cost ceiling stays inert (only the wall-clock cap is live); Python round() banker's-rounding reproduced in scoring and the summary Markdown is byte-exact vs _format_summary (golden generated from the running Python).
  • HITL: SDK-native Agent.Pause (webhook-resumed) + direct hax REST client; the review gate never raises — every failure path returns a reject decision with the exact Python instruction strings.

Test plan

  • go build ./... && go vet ./... && go test ./... — whole module green (16 packages with tests; orchestrator + evidence also -race-clean); gofmt -l empty.
  • Python suite untouched and green: zero Python-source diffs vs main, ruff check clean, pytest 69/69.
  • Both Docker images build (root Python image and go/Dockerfile).
  • Functional suite (go test -tags functional ./test/functional/) against a real dockerized control plane: health, exact 17-name registration parity + tags, fail-fast error shape. 3/3 PASS, stack torn down cleanly.
  • Zero-GitHub-writes e2e (go/test/e2e/run.sh): full review of a fixture repo through a mock opencode shim against a real control plane — succeeded with 4 findings, all phases fired (anatomy → meta ×3 → review dimensions → evidence → adversary → compound/obligations → synthesis → output). The four .ai() gate calls (intake/coverage/merge-gate/polish) hit OpenRouter for real — cheap, but the harness needs OPENROUTER_API_KEY (documented in the script header; skips cleanly without it).
  • af install <repo> --path go verified with a #750-built af (see Install story).

Known accepted differences (documented in code)

  • A few nondeterministic Python behaviors were made deterministic where Python's own output is unstable across processes (set-iteration tie-breaks in cluster primary_language and compound-cluster grouping); JSON whole-value floats render 0 instead of 0.0 (numerically identical; the Markdown path uses Python-parity formatting).
  • Shared harness output file race (present in BOTH implementations): parallel harness calls that share a Cwd share one <cwd>/.agentfield_output.json (both SDKs use the same deterministic path), so concurrent same-repo harness calls can race on write/cleanup. The port reproduces Python's behavior bug-for-bug per the parity contract; a proper fix (per-call isolation via ProjectDir/unique cwd) is a follow-up that should land in Python and Go together.

Docs

go/README.md covers build/run/compose/install; the root README gains one "Go implementation (opt-in)" section positioning Python as the default.


🤖 Generated with Claude Code

AbirAbbas and others added 18 commits July 10, 2026 12:22
Module github.com/Agent-Field/pr-af/go pinned to the AgentField Go SDK
at agentfield#750's head via a real pseudo-version require (no replace,
so 'af install --path go' can build the package tree as copied).
afx.Bind and the fatal-error classifier are verbatim from the SWE-AF Go
port; harnessx.Run[T] is copy-adapted with the scout-credential
injection removed (PR-AF has no credential store).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
go/Dockerfile (multi-stage, repo-root context, plain 'go mod download' —
the SDK resolves from the module proxy so no clone stage), byte-copied
docker-entrypoint.sh (opencode.json generation from PR_AF_MODEL),
docker-compose.go.yml as an add-on joining the Python stack's external
network/volume, go/agentfield-package.yaml (name pr-af-go, language go)
for 'af install <repo> --path go', READMEs positioning Python as the
default and pr-af-go (:8007) as opt-in, and a Go CI job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pydantic-parity structs with snake_case tags, pointer optionals, and
UnmarshalJSON default-seeding (seed-then-alias idiom); the canonical
severity alias map from schemas/severity.py with coercing unmarshal.
Config mirrors config.py's numeric tables with call-time env reads and
the _resolve_budget_caps cascade (explicit arg > env > 2.0/300); the
code-level model default stays minimax/minimax-m2.5 while deploy
artifacts default to kimi — env wins, matching Python exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Direct port of hitl/client.py: sender manifest always emitted
(displayName camelCase, call-time env resolution with NODE_ID
fallback), 120s hard timeout on create_request, publicKey omitted so
values round-trip in plaintext, and the AGENTFIELD_PUBLIC_URL >
agent-server > AGENTFIELD_SERVER webhook-URL precedence. Pause goes
through the SDK-native Agent.Pause via a one-method Pauser seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports scoring.py: score_findings with positional f_%03d ids and stable
descending sort, deduplicate_exact, determine_review_event, and the
adversary missed-trap synthesis. Keeps scoring's own severity alias map
(medium->important, low->suggestion) deliberately divergent from the
canonical schemas map, as in Python. pyRound reproduces CPython
round-half-to-even via FormatFloat (0.0625 -> 0.062, where math.Round
would give 0.063).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports diff_engine.py verbatim, with parity verified by running 15
fixture diffs through the real Python module and baking the exact
model_dump() output into the Go assertions. Preserves Python quirks:
renames surface as modified (files_renamed only set on the GitHub API
path), pure-rename/deleted/binary edge cases yield empty paths, and
str.splitlines() semantics via a dedicated helper. The one documented
divergence is a deterministic tie-break in primaryLanguage where
Python's own answer is set-iteration-order nondeterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports github/client.py: PR URL parsing, FetchPR with 4-attempt retry
(2s*(attempt+1) backoff on transport/5xx/403/429, fail-fast otherwise),
files/commits pagination at per_page=100, diff via the v3.diff Accept
header, PostReview with path/line comment filtering, and a typed
APIError so the orchestrator can detect the 422 own-PR case. App auth
mints an RS256 JWT (iat=now-60, exp=now+600) and caches installation
tokens with a 5-minute early refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports evidence.py (grep-backed caller/import extraction with
byte-identical grep invocations, order-preserving semaphore-of-10
fan-out, dimension packs, path/hunk helpers) and blast_radius.py
(deliberately Python-import-limited dependency graph — relative
imports stay unresolved and 'from pkg import mod' resolves the
package, as upstream). Golden values captured by running the real
Python implementation against the same fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All 25 LLM prompt builders (16 reasoners + merge gate + polish +
coverage-gap) ported with byte-for-byte golden tests over 51 fixtures.
pyjson reproduces json.dumps separators, insertion-ordered keys,
ensure_ascii escapes, and Python float repr; pyRepr covers
single-quoted list interpolation. scripts/gen_golden.py is committed
so fixtures are reproducible from the repo — the generator documents
its invocation and is deterministic across runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports merge_gate.py and polish.py: per-finding parallel .ai() calls
with order-preserving pre-indexed fan-out (Python's gather without
return_exceptions is safe here because each worker catches
internally), verdict parsing tolerant of fences/prose/garbage with the
exact 'gate parse error'/'gate non-object'/'gate error' fallbacks,
rune-accurate 400-char reason truncation, and blocking=false on every
failure path. Polish keeps the original comment on any failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports hitl/review_gate.py: the pr-af-review-v1 hax payload (the
system's one camelCase surface — per-finding entries re-normalize
severity at the boundary so a stray 'high' cannot re-trigger the
zod-enum 422), decision parsing (post honors findings_to_post subset,
rerun carries instructions, expired/error/rejected map to reject), and
RequestReviewApproval which never raises — payload/create/pause
failures return reject decisions with the exact Python instruction
strings. Intent cleaning is rune-aware for non-ASCII PR bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports reasoners/harnesses.py: typed inputs with seeded defaults,
private harness-result structs, per-reasoner parse-failure fallbacks
(intake's literal {}, planning's minimal plan, keep-all gates), lens
forcing on the meta selectors, adversary skepticism escalation, and
the .pr-af-context file writers. Prompt assembly verified byte-equal
against the running Python reasoners with a mocked harness. Adds
prompts.NewOMap/PyJSON exports the builders were missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports orchestrator.py: the streaming reviewer/layer pipeline (channel
port of the asyncio.Queue path — the layer starts consuming before all
reviewers finish), order-preserving fan-outs for positional IDs,
batched adversary, coverage/consistency parallel phases, compound
analysis with deterministic cluster selection, synthesis, byte-exact
summary Markdown (banker's rounding + Python float rendering, golden
generated from the real _format_summary), the HITL revision loop, the
422 own-PR downgrade, repo resolution/checkout with verbatim git error
strings, and the inert cost / live wall-clock budget gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BuildAgent reads env into agent.Config (node_id pr-af-go, port 8007,
AGENT_CALLBACK_URL as PublicURL); Serve binds the listener before
Initialize so the control plane's post-register health check reaches a
live server, and routes /webhook/github (HMAC verify, ping, bot-mention
and PR gates, async re-fire at the CP) ahead of the SDK handler. The
review handler reproduces both max_review_depth clamps and the exact
Python error mapping: ErrBadInput -> 400 with the raw message, anything
else -> 'Review pipeline failed' note + 500 with the 'review execution
failed: ' prefix. Also unwraps computeRepoDiff's bad-input error to the
raw stderr message so the 400 body matches Python on the repo-diff path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Functional (-tags functional) suite boots the compose stack on
collision-free host ports and asserts, against the live control plane,
the exact 17-name registration surface with tags (via the discovery
capabilities endpoint — the nodes/{id} route the design assumed does
not exist) and the fail-fast error shape of review with a nonexistent
repo_path. test/mockcli is a deterministic opencode drop-in that
speaks the SDK's JSON-stream + output-file protocol; test/e2e/run.sh
drives a full review of a fixture repo through it (the four .ai()
gates still need a real OPENROUTER_API_KEY — documented, skip-clean
without it). Executed here: functional 3/3 PASS with real docker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK runner validates mock output against the invopop schema
(pointer fields are required and non-nullable), sends standalone
retry prompts, and names the output file relatively when Cwd is
empty — the shim now satisfies all three (never writing on retry or
unknown prompts) and run.sh polls the discovery endpoint that actually
exists. Executed result: full review of the fixture repo succeeds with
4 findings, all phases fired, zero GitHub writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text

Malformed numeric env values now fail loud like Python instead of
silently defaulting: PR_AF_MAX_COST_USD/PR_AF_MAX_DURATION_SECONDS
surface as HTTP 400 with Python's ValueError message shape (request
path), and PR_AF_MAX_TURNS-class variables fail BuildAgent at boot
(Python raises at module import). HITL metadata now carries the real
executionId via the SDK's exported ExecutionContextFrom accessor.
make docker-build uses the repo root as its context, matching what
the Dockerfile COPYs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned SDK strictly validates harness output against the schema
map (the prior doc comment predated that change), and invopop's
reflection marked every field required, pointers non-nullable, and
extra keys forbidden — so Python-valid model output (omitted defaulted
fields, suggestion:null, extra keys) was rejected and retried. The 13
harness destination types now use embedded schemas generated from the
real pydantic models by the committed gen_schemas.py (invopop remains
only as a fallback for unregistered types). The one deviation from raw
model_json_schema() is deliberate: the Severity enum is stripped to
plain string because pydantic's BeforeValidator coerces synonyms
rather than rejecting them, and Go normalizes on unmarshal the same
way. Drift tests cross-check every fixture against the Go structs'
json tags, and a two-way proof test shows the old schema rejecting —
and the new schema accepting — the same Python-valid document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas

AbirAbbas commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all four review findings (commits b35c96d and 4b5f828):

High — harness schemas stricter than Python: fixed in 4b5f828. Confirmed root cause: the invopop-reflected schemas (all fields required, pointers non-nullable, additionalProperties:false) predate the pinned SDK's strict JSON-Schema validation, so Python-valid output (omitted defaulted fields, "suggestion": null, extra keys) was rejected and retried. The 13 harness destination types now use embedded schemas generated from the real pydantic models by a committed generator (go/scripts/gen_schemas.py), with drift tests cross-checking every fixture against the Go structs' json tags, and a two-way behavioral proof test: the old invopop schema rejects — and the new pydantic schema accepts — the same Python-valid document. One deliberate deviation from raw model_json_schema(): the Severity enum is stripped to plain string, because pydantic's BeforeValidator coerces synonyms (highimportant) rather than rejecting them — emitting the enum would have been stricter than Python's actual runtime and would re-create the swallowed-review incident severity.py documents. Side effect: the SDK's OUTPUT REQUIREMENTS prompt suffix now embeds the pydantic schema, structurally matching Python's. Remaining honest gap (inherent, unchanged by this fix): pydantic's type coercion ("0.7"0.7) is more lenient than JSON-Schema validation; applies equally to the old schemas.

Medium — make docker-build context: fixed in b35c96d. The target now builds with the repo root as context (docker build -f Dockerfile -t $(IMAGE) ..), matching the Dockerfile's COPY go/... and the compose file.

Medium-low — HITL executionId: fixed in b35c96d. agent.ExecutionContextFrom is indeed exported at the pinned ref — the port comment was wrong. hitlMetadata now emits the real execution id (empty only when the ctx carries no execution, matching Python's app.ctx is None fallback).

Low — malformed numeric env: fixed in b35c96d. Now fails loud like Python instead of silently defaulting: request-path vars (PR_AF_MAX_COST_USD/PR_AF_MAX_DURATION_SECONDS) → HTTP 400 with Python's ValueError message shape; boot-path vars (PR_AF_MAX_TURNS etc.) → BuildAgent error (Python raises at module import).

All gates re-run green after the fixes: go build/vet/test ./... (16 packages), gofmt clean, generator idempotent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant