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
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,22 @@ af install https://github.com/Agent-Field/pr-af
af run pr-af
```

`af install` clones the repo, provisions an isolated Python environment, and registers the `pr-af` node with your control plane. On first `af run` you're prompted for the required secrets — `OPENROUTER_API_KEY` and `GH_TOKEN` — which are stored encrypted and reused across every node, so you enter each only once. Then review a PR:
`af install` follows the repository manifest to the maintained Go package and registers it as the `pr-af` node with your control plane. If an older Python `pr-af` is installed, it is replaced in place, retaining the same node id, triggers, and node-scoped secrets. On first `af run` you're prompted for the required secrets — `OPENROUTER_API_KEY` and `GH_TOKEN` — which are stored encrypted and reused across every node, so you enter each only once. Then review a PR:

```bash
af call pr-af.review --in '{"pr_url": "https://github.com/owner/repo/pull/123"}'
```

New to AgentField? Install the control plane first with `curl -fsSL https://agentfield.ai/install.sh | bash`, or use one of the options below.

To install the Python node deliberately, clone this repository and install the
checkout as a local path. Local-path installs do not follow `superseded_by`:

```bash
git clone https://github.com/Agent-Field/pr-af
af install ./pr-af
```

### Deploy with Railway (fastest)

[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/pr-af)
Expand Down Expand Up @@ -335,12 +343,12 @@ When the writer and the reviewer are the same intelligence, the pull request gat

---

## Go implementation (opt-in)
## Go implementation

This repo also ships a Go port of the node under [`go/`](go/README.md). The
**Python implementation is the default** — everything above is unchanged and
still runs as `pr-af` (`:8004`). The Go port registers **separately** as
`pr-af-go` (`:8007`), so both can run against one control plane simultaneously.
Opt in by targeting the `-go` reasoner path, e.g.
`POST /api/v1/execute/async/pr-af-go.review`. Build, run, and Docker/compose
docs live in [`go/README.md`](go/README.md).
The maintained node lives under [`go/`](go/README.md), and installing the bare
repository URL gives you this implementation as `pr-af` on its default port
`8007`. The Python implementation remains available through `python -m
pr_af.app`, the root Docker Compose stack, or the local-path install escape
hatch above. The Go add-on Compose file explicitly uses `pr-af-go` only so both
implementations can run against one control plane during a changeover. Build,
run, and Docker/compose docs live in [`go/README.md`](go/README.md).
15 changes: 15 additions & 0 deletions agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ version: 0.1.0
description: Opens draft pull requests from a task description
author: Agent-Field

# The Go node in go/ is the maintained PR review node: same reasoners, same
# interface, one static binary, no per-node venv to build. Installing this repo
# installs that instead — so `af install https://github.com/Agent-Field/pr-af`
# is the one thing a user has to know, before and after the switch.
#
# go/ declares this same name deliberately, so the switch is a replacement in
# place: same node id, same triggers, node-scoped secrets kept. Only one of the
# two can be installed at a time, which is the point.
#
# This manifest stays here as the redirect, so the Python node is still what
# `python -m pr_af.app` and docker-compose run. The redirect is a git-install
# behaviour only: to install this node deliberately, clone the repo and install
# the checkout as a local path.
superseded_by: https://github.com/Agent-Field/pr-af//go

entrypoint:
start: python -m pr_af.app
healthcheck: /health
Expand Down
4 changes: 2 additions & 2 deletions go/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# the paths below (go/go.mod, go/) resolve. The docker-compose.go.yml add-on
# builds it exactly this way (build.context: ., dockerfile: go/Dockerfile):
#
# docker build -f go/Dockerfile -t pr-af-go:latest .
# docker build -f go/Dockerfile -t pr-af:latest .
#
# Unlike the SWE-AF Go port, the AgentField Go SDK is a REAL versioned require
# resolved from proxy.golang.org — so there is NO sparse SDK clone stage, no
Expand Down Expand Up @@ -45,7 +45,7 @@ ENV AGENTFIELD_SERVER=http://agentfield:8080 \
PR_AF_PROVIDER=opencode \
PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \
PORT=8007 \
NODE_ID=pr-af-go \
NODE_ID=pr-af \
HOME=/home/praf \
PATH=/home/praf/.opencode/bin:${PATH} \
XDG_DATA_HOME=/home/praf/.local/share \
Expand Down
6 changes: 3 additions & 3 deletions go/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ lint:
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || \
echo "golangci-lint not installed; skipping (install: https://golangci-lint.run)"

# Run the PR-AF node (pr-af-go, default port 8007).
# Run the PR-AF node (pr-af, default port 8007).
run:
go run ./cmd/pr-af

Expand All @@ -42,8 +42,8 @@ run:
# a real require pulled by `go mod download`, so there is no SDK clone stage and
# no build-arg. The compose add-on (docker-compose.go.yml) lives at the repo
# root and joins the Python pr-af stack's external network + workspaces volume.
# Override the image tag with: make docker-build IMAGE=myrepo/pr-af-go:dev
IMAGE ?= pr-af-go:latest
# Override the image tag with: make docker-build IMAGE=myrepo/pr-af:dev
IMAGE ?= pr-af:latest

# Build the multi-stage Go image. The Dockerfile expects the REPO ROOT as the
# build context (it COPYs go/go.mod, go/ ...), so the context is the parent dir.
Expand Down
47 changes: 22 additions & 25 deletions go/README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
# PR-AF — Go node

A Go port of the PR-AF agentic code-review node. It registers the same reasoner
A Go implementation of the PR-AF agentic code-review node. It registers the same reasoner
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
The Python package under `src/pr_af/` is untouched; this implementation lives entirely
under `go/`.

One binary:

| Binary | Node ID | Default port | Role |
|----------|------------|--------------|----------------------------------------|
| `pr-af` | `pr-af-go` | `8007` | Full review pipeline (intake → review) |
| `pr-af` | `pr-af` | `8007` | Full review pipeline (intake → review) |

Module path: `github.com/Agent-Field/pr-af/go`.

## Opt-in alongside Python
## Install

The Python node is the **default**: `pr-af` on `:8004`, unchanged. This Go port
registers **separately** under a distinct identity — `pr-af-go` on `:8007` — so
both nodes can run against **one** control plane at the same time. Nothing is
replaced; callers **opt in** by targeting the `-go` reasoner path, e.g.
Installing the bare repository URL follows the root manifest's redirect to this
package and registers the Go node as `pr-af` on `:8007`:

```bash
curl -X POST http://localhost:8080/api/v1/execute/async/pr-af-go.review \
-H 'Content-Type: application/json' \
-d '{"input":{"pr_url":"https://github.com/owner/repo/pull/123"}}'
af install https://github.com/Agent-Field/pr-af
af run pr-af
af call pr-af.review --in '{"pr_url":"https://github.com/owner/repo/pull/123"}'
```

`NODE_ID` / `PORT` still override the defaults if you want a different id/port.
To install the Python node deliberately, clone the repository and run `af
install ./pr-af`; local-path installs do not follow the redirect. `NODE_ID` /
`PORT` still override the Go defaults if you want a different id/port.

## Pipeline DAG on the control plane

Expand Down Expand Up @@ -93,7 +93,7 @@ make vet # go vet ./...
make test # go test ./...
make check # vet + test
make fmt # gofmt -w .
make run # run the node (pr-af-go, :8007)
make run # run the node (pr-af, :8007)
```

`make run` needs a control plane reachable at `AGENTFIELD_SERVER` (default
Expand All @@ -113,7 +113,7 @@ The build context is the **repo root** so the `go/` module is in context:

```bash
# from the repo root
docker build -f go/Dockerfile -t pr-af-go:latest .
docker build -f go/Dockerfile -t pr-af:latest .
```

### Compose: opt-in add-on to the Python stack
Expand Down Expand Up @@ -153,7 +153,7 @@ The node is configured entirely through the environment.
| `GH_TOKEN` | GitHub token (`repo` scope) for reading PRs and posting reviews |
| `AGENTFIELD_SERVER` | Control-plane URL (default `http://localhost:8080`) |
| `AGENTFIELD_API_KEY` | Control-plane API key (if the CP has auth enabled) |
| `NODE_ID` | Node ID (default `pr-af-go`) |
| `NODE_ID` | Node ID (default `pr-af`) |
| `PORT` | Listen port (default `8007`) |
| `PR_AF_PROVIDER` | Harness provider (default `opencode`) |
| `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) |
Expand All @@ -167,18 +167,15 @@ Note: the code default model is `minimax/minimax-m2.5`, while the Docker image /
compose / manifest set `PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5`. The env
var always wins; both defaults are intentional (they mirror the Python node).

## Deployment: `af install --path go`
## Deployment: `af install`

Because the SDK is a committed real `require` (no out-of-tree `replace` for the
installer to reject), the Go node **can** be installed via the AgentField
package installer, pointing it at the `go/` subdirectory:
Because the root package redirects git installs here, install the Go node with
the repository's bare URL:

```bash
af install https://github.com/Agent-Field/pr-af --path go
af install https://github.com/Agent-Field/pr-af
```

This reads `go/agentfield-package.yaml` (node id `pr-af-go`, default port
`8007`) and builds `./cmd/pr-af`. The `--path` subdirectory selector requires
**agentfield ≥ v0.1.108** (the installer's `--path` support, merged in
agentfield#750); on an older control plane, prefer the Docker image / compose /
binary path above.
This resolves to `go/agentfield-package.yaml` (node id `pr-af`, default port
`8007`) and builds `./cmd/pr-af`. A prior Python `pr-af` installation is
replaced in place, retaining its node id, triggers, and node-scoped secrets.
14 changes: 11 additions & 3 deletions go/agentfield-package.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
config_version: v1
name: pr-af-go # MUST differ from root "pr-af" (installer registry is keyed by name)
# This is THE PR-AF node. It deliberately shares the root manifest's name: the
# root declares `superseded_by` pointing here, so installing this repo installs
# this package, and a user who already has the Python pr-af gets it replaced in
# place — same name, same node id, same triggers, secrets kept. Installing the
# root as a local path (the documented escape hatch) is the one way to get the
# Python node, and it necessarily takes this name over.
name: pr-af
version: 0.1.0
description: AI-Native Pull Request Review Agent (Go port)
description: AI-Native Pull Request Review Agent
author: Agent-Field
language: go # explicit (also auto-detected from go/go.mod)

Expand All @@ -11,7 +17,9 @@ entrypoint:
healthcheck: /health

agent_node:
node_id: pr-af-go
node_id: pr-af
# 8007 rather than the Python node's 8004: during the changeover both may be
# running, and triggers resolve by node id, not port.
default_port: 8007

user_environment:
Expand Down
9 changes: 4 additions & 5 deletions go/cmd/pr-af/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
// surface (design §B.1), and serves the SDK handler plus the custom
// /webhook/github route until SIGINT/SIGTERM.
//
// Defaults: NODE_ID "pr-af-go", PORT 8007 — a distinct identity from the Python
// pr-af node (:8004) so the Go port runs as an opt-in sibling alongside Python
// against one control plane. NODE_ID / PORT env vars override.
// Defaults: NODE_ID "pr-af", PORT 8007. NODE_ID / PORT env vars override;
// docker-compose.go.yml does so when both implementations coexist.
//
// Boot env (T4.3 e2e / production):
//
// AGENTFIELD_SERVER control-plane base URL (default http://localhost:8080)
// AGENTFIELD_API_KEY control-plane bearer token
// AGENT_CALLBACK_URL base URL the CP uses to reach this node (else localhost)
// NODE_ID node id (default pr-af-go)
// NODE_ID node id (default pr-af)
// PORT listen port (default 8007)
// PR_AF_PROVIDER harness provider (default opencode)
// PR_AF_MODEL harness model (env wins over the code default)
Expand All @@ -33,7 +32,7 @@ import (

func main() {
n, err := node.BuildAgent(
"pr-af-go",
"pr-af",
"8007",
"AI-Native Pull Request Review Agent",
)
Expand Down
6 changes: 3 additions & 3 deletions go/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
// The port turns a pull request (or raw diff / local repo) into a structured,
// multi-dimensional code review — the same reasoner names, control-plane
// surface, and HTTP API shapes as the Python pr_af package. It registers as an
// OPT-IN sibling node ("pr-af-go", default port 8007) so it can run against one
// control plane alongside the untouched Python "pr-af" node.
// maintained node ("pr-af", default port 8007). NODE_ID can be overridden when
// it runs alongside the untouched Python node.
//
// Layout (design §A):
//
// cmd/pr-af node entry point (node id "pr-af-go")
// cmd/pr-af node entry point (node id "pr-af")
// internal/afx small AgentField SDK ergonomics (typed input binding)
// internal/fatal non-retryable harness-error classification
// internal/harnessx the single generic Run[T] harness choke-point + schema cache
Expand Down
12 changes: 6 additions & 6 deletions go/internal/hitl/hax_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func ptr(s string) *string { return &s }
func TestCreateRequestWireBody(t *testing.T) {
clearEnv(t, haxEnvKeys...)
// Deterministic sender: display name explicit, key falls back to it.
setEnv(t, "HAX_SENDER_NAME", "pr-af-go")
setEnv(t, "HAX_SENDER_NAME", "pr-af")

var (
gotMethod string
Expand Down Expand Up @@ -152,7 +152,7 @@ func TestCreateRequestWireBody(t *testing.T) {
if err := json.Unmarshal(gotBody["sender"], &sender); err != nil {
t.Fatalf("decode sender: %v", err)
}
wantSender := map[string]any{"key": "pr-af-go", "displayName": "pr-af-go"}
wantSender := map[string]any{"key": "pr-af", "displayName": "pr-af"}
if !reflect.DeepEqual(sender, wantSender) {
t.Errorf("sender = %v, want %v", sender, wantSender)
}
Expand All @@ -162,7 +162,7 @@ func TestCreateRequestWireBody(t *testing.T) {
// unset — only type, payload and the always-present sender remain.
func TestCreateRequestMinimalBody(t *testing.T) {
clearEnv(t, haxEnvKeys...)
setEnv(t, "NODE_ID", "pr-af-go") // sender falls back to NODE_ID
setEnv(t, "NODE_ID", "pr-af") // sender falls back to NODE_ID

var gotBody map[string]json.RawMessage
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -300,10 +300,10 @@ func TestResolveSender(t *testing.T) {
wantKey, wantName string
}{
{name: "all unset -> pr-af/pr-af", wantKey: "pr-af", wantName: "pr-af"},
{name: "NODE_ID drives both (design case)", nodeID: ptr("pr-af-go"), wantKey: "pr-af-go", wantName: "pr-af-go"},
{name: "HAX_SENDER_NAME wins over NODE_ID", senderName: ptr("custom"), nodeID: ptr("pr-af-go"), wantKey: "custom", wantName: "custom"},
{name: "NODE_ID drives both (design case)", nodeID: ptr("pr-af"), wantKey: "pr-af", wantName: "pr-af"},
{name: "HAX_SENDER_NAME wins over NODE_ID", senderName: ptr("custom"), nodeID: ptr("pr-af"), wantKey: "custom", wantName: "custom"},
{name: "explicit key overrides name-derived key", senderName: ptr("disp"), senderKey: ptr("k-explicit"), wantKey: "k-explicit", wantName: "disp"},
{name: "empty HAX_SENDER_NAME falls through to NODE_ID", senderName: ptr(""), nodeID: ptr("pr-af-go"), wantKey: "pr-af-go", wantName: "pr-af-go"},
{name: "empty HAX_SENDER_NAME falls through to NODE_ID", senderName: ptr(""), nodeID: ptr("pr-af"), wantKey: "pr-af", wantName: "pr-af"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions go/internal/node/calllocal_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestReviewHandlerForwardsLocalCaller(t *testing.T) {

var got orch.Deps
n := &Node{
NodeID: "pr-af-go",
NodeID: "pr-af",
reviewApp: &fakeApp{},
localCaller: sentinel,
runReview: func(_ context.Context, deps orch.Deps, _ schemas.ReviewInput, _ config.ReviewConfig) (schemas.ReviewResult, error) {
Expand All @@ -51,7 +51,7 @@ func TestBuildAgentWiresLocalCallerToApp(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("OPENROUTER_API_KEY", "")

n, err := BuildAgent("pr-af-go", "8007", "desc")
n, err := BuildAgent("pr-af", "8007", "desc")
if err != nil {
t.Fatalf("BuildAgent: %v", err)
}
Expand Down
5 changes: 2 additions & 3 deletions go/internal/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type Node struct {
// field at it and Serve mounts App.Handler() as the fallback route.
App *agent.Agent

// NodeID is the resolved node id (NODE_ID env, or the pr-af-go default).
// NodeID is the resolved node id (NODE_ID env, or the pr-af default).
NodeID string

// AgentFieldServer is the control-plane base URL (AGENTFIELD_SERVER). The
Expand Down Expand Up @@ -125,8 +125,7 @@ func resolvedHarnessBin(c config.AIIntegrationConfig) string {
// BuildAgent constructs the PR-AF agent from the environment exactly as the
// Python entry point does (app.py:26-50):
//
// - NODE_ID default "pr-af-go" (opt-in sibling; distinct from the
// Python "pr-af" node so both run against one control plane).
// - NODE_ID default "pr-af" (the maintained package identity).
// - AGENTFIELD_SERVER default "http://localhost:8080".
// - AGENTFIELD_API_KEY -> Config.Token (control-plane bearer).
// - PORT default "8007" -> ListenAddress ":8007".
Expand Down
Loading
Loading