diff --git a/.env.example b/.env.example index 5e59cda..91eb736 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,10 @@ AGENTFIELD_PUBLIC_URL= NODE_ID=pr-af # --- AI / harness config --- -PR_AF_PROVIDER=opencode +# AForge exec is the default. Set PR_AF_PROVIDER=opencode to roll back. +PR_AF_PROVIDER=aforge +# Read by the Go node's SDK adapter; the pinned Python SDK always runs `exec`. +AGENTFIELD_AFORGE_COMMAND=exec PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 # Falls back to PR_AF_MODEL when unset # PR_AF_AI_MODEL= @@ -24,6 +27,7 @@ PR_AF_AI_MAX_RETRIES=3 PR_AF_AI_INITIAL_BACKOFF_SECONDS=2.0 PR_AF_AI_MAX_BACKOFF_SECONDS=8.0 PR_AF_OPENCODE_BIN=opencode +PR_AF_AFORGE_BIN=aforge # Optional provider-agnostic harness executable override (leave unset to use provider defaults) # PR_AF_HARNESS_BIN= PR_AF_OPENCODE_SERVER= diff --git a/Dockerfile b/Dockerfile index 7bfa1a1..f497dcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,36 @@ +# --------------------------------------------------------------------------- +# Stage 0 — aforge: fetch the released AForge CLI from the public download host +# and verify it against the release checksums (which hash the DECOMPRESSED +# binaries). Both ARGs are overridable so CI or a local mirror can serve the +# assets from somewhere else: +# +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... . +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS aforge + +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /out +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + rm -f aforge.gz; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \ + grep " aforge-linux-${arch}$" checksums.txt | sed 's/ aforge-linux-.*/ aforge/' > aforge.sha256; \ + test -s aforge.sha256; \ + sha256sum -c aforge.sha256; \ + rm -f checksums.txt aforge.sha256; \ + chmod +x aforge + + FROM python:3.11-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -14,7 +47,7 @@ COPY pyproject.toml README.md ./ COPY src/ src/ RUN pip install --no-cache-dir --prefix=/install \ - "agentfield==0.1.126" \ + "agentfield>=0.1.130" \ "hax-sdk>=0.2.4" \ "pydantic>=2.0" \ "httpx>=0.27" \ @@ -32,7 +65,8 @@ ARG OPENCODE_VERSION=1.17.15 ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ AGENTFIELD_SERVER=http://agentfield:8080 \ - PR_AF_PROVIDER=opencode \ + PR_AF_PROVIDER=aforge \ + AGENTFIELD_AFORGE_COMMAND=exec \ PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \ PORT=8004 \ HOME=/home/praf \ @@ -55,6 +89,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ rm -rf /var/lib/apt/lists/* COPY --from=builder /install /usr/local +COPY --from=aforge /out/aforge /usr/local/bin/aforge COPY src/ /app/src/ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh diff --git a/README.md b/README.md index d88dff2..5876434 100644 --- a/README.md +++ b/README.md @@ -263,13 +263,26 @@ The key knobs (see `.env.example` for the full list): |-----------------------------|----------------------------------------------------------------| | `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_PROVIDER` | Harness provider (default `aforge`; use `opencode` to roll back) | +| `AGENTFIELD_AFORGE_COMMAND` | AForge headless command (default `exec`) — read by the Go node's SDK adapter; the pinned Python SDK always runs `exec` | +| `PR_AF_AFORGE_BIN` | Path to an aforge-v2 binary (default `aforge`) | +| `PR_AF_HARNESS_BIN` | Provider-agnostic executable override | | `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 `-pr` workspace | +Both Docker images ship the released AForge CLI (fetched and checksum-verified +at build time from `https://agentfield.ai/downloads/aforge`) and run `exec` by +default. The Python node resolves the binary from `PR_AF_AFORGE_BIN` (or +`PR_AF_HARNESS_BIN`); the maintained Go node resolves it from +`PR_AF_HARNESS_BIN`. OpenCode stays installed in both images, so +`PR_AF_PROVIDER=opencode` is a configuration-only rollback — no rebuild. + +The image's AForge version is pinned by the `AFORGE_VERSION` build arg; +`AFORGE_BASE_URL` points the fetch at a different host when needed. + ## 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`. diff --git a/agentfield-package.yaml b/agentfield-package.yaml index 88f20b5..d838577 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -49,8 +49,13 @@ user_environment: type: secret scope: global - name: PR_AF_PROVIDER - description: Coding-agent harness provider - default: opencode + description: Coding-agent harness provider (aforge | claude-code | codex | gemini | opencode) + default: aforge + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command the SDK runs (exec | do); default exec on agentfield>=0.1.130 + default: exec + - name: PR_AF_AFORGE_BIN + description: Optional path to the aforge-v2 binary (defaults to aforge on PATH) - name: PR_AF_MODEL description: Model the harness uses default: openrouter/moonshotai/kimi-k2.5 diff --git a/docker-compose.go.yml b/docker-compose.go.yml index cc93331..35baa56 100644 --- a/docker-compose.go.yml +++ b/docker-compose.go.yml @@ -40,7 +40,8 @@ services: - NODE_ID=pr-af-go - PORT=8007 - AGENT_CALLBACK_URL=http://pr-af-go:8007 - - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} + - PR_AF_PROVIDER=${PR_AF_PROVIDER:-aforge} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} # Optional generic executable override; leave unset for provider defaults. - PR_AF_HARNESS_BIN diff --git a/docker-compose.yml b/docker-compose.yml index 92ef035..3e4bd78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,8 @@ services: - AGENTFIELD_SERVER=http://agentfield:8080 - AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-} - AGENT_CALLBACK_URL=http://pr-af:8004 - - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} + - PR_AF_PROVIDER=${PR_AF_PROVIDER:-aforge} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - GH_TOKEN=${GH_TOKEN:-} diff --git a/go/Dockerfile b/go/Dockerfile index deac3d7..4be6bf4 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -11,9 +11,42 @@ # GOWORK=off, and no `replace` dance. `go mod download` pulls the SDK and every # other dependency straight from the module proxy, cache-keyed on go.mod/go.sum. +# --------------------------------------------------------------------------- +# Stage 0 — aforge: fetch the released AForge CLI from the public download host +# and verify it against the release checksums (which hash the DECOMPRESSED +# binaries). Both ARGs are overridable so CI or a local mirror can serve the +# assets from somewhere else: +# +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... . +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim AS aforge + +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 +ARG TARGETARCH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /out +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + rm -f aforge.gz; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \ + grep " aforge-linux-${arch}$" checksums.txt | sed 's/ aforge-linux-.*/ aforge/' > aforge.sha256; \ + test -s aforge.sha256; \ + sha256sum -c aforge.sha256; \ + rm -f checksums.txt aforge.sha256; \ + chmod +x aforge + + # --------------------------------------------------------------------------- # Stage 1 — builder: fetch modules from the proxy, build the static binary. -# golang 1.23 satisfies go.mod's `go 1.21` directive. +# Go 1.23 satisfies go.mod's `go 1.21` directive. # --------------------------------------------------------------------------- FROM golang:1.23-bookworm AS builder @@ -42,7 +75,8 @@ ARG OPENCODE_VERSION=1.17.15 # PR_AF_HARNESS_BIN remains unset by default; set it only to override every # provider's executable path. ENV AGENTFIELD_SERVER=http://agentfield:8080 \ - PR_AF_PROVIDER=opencode \ + PR_AF_PROVIDER=aforge \ + AGENTFIELD_AFORGE_COMMAND=exec \ PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \ PORT=8007 \ NODE_ID=pr-af \ @@ -67,10 +101,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ rm -rf /var/lib/apt/lists/* COPY --from=builder /out/pr-af /usr/local/bin/pr-af +COPY --from=aforge /out/aforge /usr/local/bin/aforge COPY go/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh USER praf +# Cwd must be writable by praf: the AgentField Go SDK creates its schema +# output dir under the process cwd when a harness call carries no Cwd +# (planning/coverage/dedup/worthiness/intake-fallback do), and `/` is +# root-owned. /workspaces is praf-owned and is PR_AF_WORKDIR already. +WORKDIR /workspaces EXPOSE 8007 diff --git a/go/README.md b/go/README.md index 854716d..3791626 100644 --- a/go/README.md +++ b/go/README.md @@ -155,18 +155,24 @@ The node is configured entirely through the environment. | `AGENTFIELD_API_KEY` | Control-plane API key (if the CP has auth enabled) | | `NODE_ID` | Node ID (default `pr-af`) | | `PORT` | Listen port (default `8007`) | -| `PR_AF_PROVIDER` | Harness provider (default `opencode`) | +| `PR_AF_PROVIDER` | Harness provider (default `aforge`; use `opencode` to roll back) | +| `AGENTFIELD_AFORGE_COMMAND` | AForge headless command — `exec` (default) or `do` | | `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) | | `PR_AF_LABEL` | Pull-request label that triggers a webhook review (default `pr-af`) | | `PR_AF_MAX_CONCURRENT_REVIEWERS` | Optional webhook review concurrency cap (minimum `1`) | | `PR_AF_MAX_REVIEW_DEPTH` | Optional webhook sub-review depth cap (minimum `0`) | | `PR_AF_MAX_COVERAGE_ITERATIONS` | Optional webhook coverage iteration cap (minimum `1`) | -| `PR_AF_HARNESS_BIN` | Optional harness executable override for every provider; unset uses provider defaults | +| `PR_AF_HARNESS_BIN` | Optional harness executable override for every provider (point it at an `aforge` binary outside `PATH`); unset uses provider defaults | | `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 | | `HAX_API_KEY` | Optional — enables the HITL review-approval gate when set | +The image ships the released AForge CLI (fetched and checksum-verified at build +time from `https://agentfield.ai/downloads/aforge`, pinned by the +`AFORGE_VERSION` build arg) and runs `exec` by default. OpenCode remains +installed and can be selected with `PR_AF_PROVIDER=opencode` without rebuilding. + 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). diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml index ed0b8de..5e2d7b3 100644 --- a/go/agentfield-package.yaml +++ b/go/agentfield-package.yaml @@ -41,8 +41,11 @@ user_environment: type: secret scope: global - name: PR_AF_PROVIDER - description: harness provider - default: opencode + description: harness provider (aforge | claude-code | codex | gemini | opencode) + default: aforge + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command (exec | do) + default: exec - name: PR_AF_MODEL description: harness model default: openrouter/moonshotai/kimi-k2.5 @@ -56,7 +59,7 @@ user_environment: - name: PR_AF_MAX_COVERAGE_ITERATIONS description: optional coverage iteration cap for webhook-triggered reviews (minimum 1) - name: PR_AF_HARNESS_BIN - description: optional executable override for every harness provider (unset uses provider defaults) + description: optional executable override for every harness provider (set to an aforge-v2 binary with PR_AF_PROVIDER=aforge) - name: PR_AF_MAX_COST_USD description: per-run cost ceiling default: "2.0" diff --git a/go/cmd/pr-af/main.go b/go/cmd/pr-af/main.go index f350cf2..2474060 100644 --- a/go/cmd/pr-af/main.go +++ b/go/cmd/pr-af/main.go @@ -13,7 +13,7 @@ // AGENT_CALLBACK_URL base URL the CP uses to reach this node (else localhost) // NODE_ID node id (default pr-af) // PORT listen port (default 8007) -// PR_AF_PROVIDER harness provider (default opencode) +// PR_AF_PROVIDER harness provider (default aforge; accepts opencode rollback) // PR_AF_MODEL harness model (env wins over the code default) // PR_AF_HARNESS_BIN optional executable override for every harness provider // OPENROUTER_API_KEY LLM key — required for the .ai() gates; AIConfig is only diff --git a/go/go.mod b/go/go.mod index 339de0a..2ed05e1 100644 --- a/go/go.mod +++ b/go/go.mod @@ -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-20260714191100-2cc5fe2adcf4 + github.com/Agent-Field/agentfield/sdk/go v0.1.130 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 diff --git a/go/go.sum b/go/go.sum index 92df3d9..f99fd26 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -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/Agent-Field/agentfield/sdk/go v0.1.130 h1:k6ATecElqx54AUGzmFnbJy/BrFY+2UhPV7VM3X8ByTw= +github.com/Agent-Field/agentfield/sdk/go v0.1.130/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= diff --git a/go/internal/config/ai.go b/go/internal/config/ai.go index 7c53a45..e84a804 100644 --- a/go/internal/config/ai.go +++ b/go/internal/config/ai.go @@ -53,7 +53,7 @@ func AIConfigFromEnv() (AIIntegrationConfig, error) { return AIIntegrationConfig{}, err } return AIIntegrationConfig{ - Provider: strEnv("PR_AF_PROVIDER", "opencode"), + Provider: strEnv("PR_AF_PROVIDER", "aforge"), HarnessModel: strEnv("PR_AF_MODEL", "minimax/minimax-m2.5"), // AI_MODEL falls back to PR_AF_MODEL, then to the code default. AIModel: strEnv("PR_AF_AI_MODEL", strEnv("PR_AF_MODEL", "minimax/minimax-m2.5")), @@ -90,6 +90,7 @@ func (c AIIntegrationConfig) ProviderEnv() map[string]string { } _ = os.MkdirAll(xdg, 0o755) env["XDG_DATA_HOME"] = xdg + env["AGENTFIELD_AFORGE_COMMAND"] = strEnv("AGENTFIELD_AFORGE_COMMAND", "exec") return env } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index fbbbe99..80afbfe 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -134,8 +134,8 @@ func TestResolveBudgetCapsCascade(t *testing.T) { func TestAIConfigFromEnvDefaults(t *testing.T) { clearConfigEnv(t) c := mustAIConfig(t) - if c.Provider != "opencode" { - t.Errorf("Provider = %q, want opencode", c.Provider) + if c.Provider != "aforge" { + t.Errorf("Provider = %q, want aforge", c.Provider) } // The CODE default is minimax — NOT the manifest's kimi default. env wins, // but with no env this must be minimax (design §B.6). @@ -238,6 +238,9 @@ func TestProviderEnv(t *testing.T) { if env["XDG_DATA_HOME"] != xdg { t.Errorf("XDG_DATA_HOME = %q, want %q", env["XDG_DATA_HOME"], xdg) } + if env["AGENTFIELD_AFORGE_COMMAND"] != "exec" { + t.Errorf("AGENTFIELD_AFORGE_COMMAND = %q, want exec", env["AGENTFIELD_AFORGE_COMMAND"]) + } // With XDG_DATA_HOME unset, ProviderEnv falls back to a tmp dir and creates // it. diff --git a/go/internal/harnessx/run.go b/go/internal/harnessx/run.go index cf0397b..6a25354 100644 --- a/go/internal/harnessx/run.go +++ b/go/internal/harnessx/run.go @@ -80,7 +80,7 @@ func seedDefaults[T any]() T { // router.harness (system_prompt, schema, model, provider, tools, cwd, max_turns, // permission_mode). type RoleOptions struct { - // Provider is the harness ADAPTER string, e.g. "opencode" (PR-AF's default), + // Provider is the harness ADAPTER string, e.g. "aforge" (PR-AF's default) or "opencode", // "claude-code", "codex". Provider string diff --git a/go/internal/node/node.go b/go/internal/node/node.go index d4dcdc7..a88ff92 100644 --- a/go/internal/node/node.go +++ b/go/internal/node/node.go @@ -139,7 +139,7 @@ func resolvedHarnessBin(c config.AIIntegrationConfig) string { // - PORT default "8007" -> ListenAddress ":8007". // - AGENT_CALLBACK_URL -> Config.PublicURL — the base URL the CP uses to reach // this node; unset falls back to the SDK's http://localhost:. -// - HarnessConfig / AIConfig — the harness (opencode) + LLM credentials the +// - HarnessConfig / AIConfig — the selected harness + LLM credentials the // reasoners rely on. Every reasoner calls the harness with only Cwd set, so // the agent's default HarnessConfig Provider/Model must be present, and the // two .ai() gates (intake/coverage) need AIConfig. Mirrors app.py's diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go index 0b3fe4c..83aaa6b 100644 --- a/go/internal/node/node_test.go +++ b/go/internal/node/node_test.go @@ -16,6 +16,8 @@ func TestHarnessConfigProviderAwareBinary(t *testing.T) { }{ {"codex uses SDK default", config.AIIntegrationConfig{Provider: "codex", OpencodeBin: "C:/bin/opencode-custom"}, ""}, {"opencode uses configured binary", config.AIIntegrationConfig{Provider: "opencode", OpencodeBin: "C:/bin/opencode-custom"}, "C:/bin/opencode-custom"}, + {"aforge uses SDK default", config.AIIntegrationConfig{Provider: "aforge", OpencodeBin: "C:/bin/opencode-custom"}, ""}, + {"generic override selects aforge", config.AIIntegrationConfig{Provider: "aforge", HarnessBin: "C:/bin/aforge-custom"}, "C:/bin/aforge-custom"}, {"generic override wins", config.AIIntegrationConfig{Provider: "codex", OpencodeBin: "C:/bin/opencode-custom", HarnessBin: "C:/bin/provider-custom"}, "C:/bin/provider-custom"}, {"generic override wins for opencode", config.AIIntegrationConfig{Provider: "opencode", OpencodeBin: "C:/bin/opencode-custom", HarnessBin: "C:/bin/provider-custom"}, "C:/bin/provider-custom"}, } @@ -43,7 +45,11 @@ func TestHarnessConfigPreservesExistingFields(t *testing.T) { if got.Provider != conf.Provider || got.Model != conf.HarnessModel || got.MaxTurns != conf.MaxTurns || got.PermissionMode != "auto" || got.BinPath != conf.OpencodeBin { t.Errorf("harnessConfig fields = %+v", got) } - if want := map[string]string{"OPENAI_API_KEY": "openai-key", "XDG_DATA_HOME": xdg}; !reflect.DeepEqual(got.Env, want) { + if want := map[string]string{ + "OPENAI_API_KEY": "openai-key", + "XDG_DATA_HOME": xdg, + "AGENTFIELD_AFORGE_COMMAND": "exec", + }; !reflect.DeepEqual(got.Env, want) { t.Errorf("Env = %#v, want %#v", got.Env, want) } } diff --git a/go/test/functional/compose.functional.yml b/go/test/functional/compose.functional.yml index 2b82a13..f191163 100644 --- a/go/test/functional/compose.functional.yml +++ b/go/test/functional/compose.functional.yml @@ -49,7 +49,8 @@ services: - NODE_ID=pr-af - PORT=8007 - AGENT_CALLBACK_URL=http://pr-af:8007 - - PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode} + - PR_AF_PROVIDER=${PR_AF_PROVIDER:-aforge} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - GH_TOKEN=${GH_TOKEN:-} diff --git a/pyproject.toml b/pyproject.toml index d9c5449..10d02be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "Apache-2.0" requires-python = ">=3.11" authors = [{ name = "AgentField", email = "hello@agentfield.dev" }] dependencies = [ - "agentfield>=0.1.84", + "agentfield>=0.1.130", "hax-sdk>=0.2.4", "pydantic>=2.0", "httpx>=0.27", diff --git a/src/pr_af/app.py b/src/pr_af/app.py index 87e736b..f58a8bb 100644 --- a/src/pr_af/app.py +++ b/src/pr_af/app.py @@ -43,7 +43,8 @@ model=_ai_config.harness_model, max_turns=_ai_config.max_turns, env=_ai_config.provider_env(), - opencode_bin=_ai_config.opencode_bin, + opencode_bin=_ai_config.harness_bin or _ai_config.opencode_bin, + aforge_bin=_ai_config.harness_bin or _ai_config.aforge_bin, permission_mode="auto", ), ai_config=AIConfig( diff --git a/src/pr_af/config.py b/src/pr_af/config.py index 90991f8..87bbd35 100644 --- a/src/pr_af/config.py +++ b/src/pr_af/config.py @@ -313,7 +313,7 @@ def from_yaml(cls, path: str) -> ReviewConfig: class AIIntegrationConfig(BaseModel): - provider: str = Field(default_factory=lambda: os.getenv("PR_AF_PROVIDER", "opencode")) + provider: str = Field(default_factory=lambda: os.getenv("PR_AF_PROVIDER", "aforge")) harness_model: str = Field( default_factory=lambda: os.getenv("PR_AF_MODEL", "minimax/minimax-m2.5") ) @@ -330,6 +330,13 @@ class AIIntegrationConfig(BaseModel): ) max_backoff_seconds: float = Field(default_factory=lambda: float(os.getenv("PR_AF_AI_MAX_BACKOFF_SECONDS", "8.0"))) opencode_bin: str = Field(default_factory=lambda: os.getenv("PR_AF_OPENCODE_BIN", "opencode")) + aforge_bin: str = Field( + default_factory=lambda: os.getenv( + "PR_AF_AFORGE_BIN", + os.getenv("AFORGE_BIN", "aforge"), + ) + ) + harness_bin: str = Field(default_factory=lambda: os.getenv("PR_AF_HARNESS_BIN", "")) opencode_server: str | None = Field(default_factory=lambda: os.getenv("PR_AF_OPENCODE_SERVER")) @classmethod @@ -345,6 +352,7 @@ def provider_env(self) -> dict[str, str]: "GH_TOKEN", ) env: dict[str, str] = {key: value for key in env_keys if (value := os.getenv(key))} + env["AGENTFIELD_AFORGE_COMMAND"] = os.getenv("AGENTFIELD_AFORGE_COMMAND", "exec") xdg = os.getenv("XDG_DATA_HOME") or os.path.join(tempfile.gettempdir(), "opencode-shared-data") os.makedirs(xdg, exist_ok=True) env["XDG_DATA_HOME"] = xdg diff --git a/tests/test_aforge_config.py b/tests/test_aforge_config.py new file mode 100644 index 0000000..ae0fde4 --- /dev/null +++ b/tests/test_aforge_config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pr_af.config import AIIntegrationConfig + + +def test_aforge_provider_and_binary_overrides(monkeypatch) -> None: + monkeypatch.setenv("PR_AF_PROVIDER", "aforge") + monkeypatch.setenv("PR_AF_AFORGE_BIN", "/opt/aforge") + + config = AIIntegrationConfig.from_env() + + assert config.provider == "aforge" + assert config.aforge_bin == "/opt/aforge" + assert config.harness_bin == "" + + +def test_generic_harness_binary_is_available_to_all_providers(monkeypatch) -> None: + monkeypatch.setenv("PR_AF_HARNESS_BIN", "/opt/harness") + + config = AIIntegrationConfig.from_env() + + assert config.harness_bin == "/opt/harness" + + +def test_aforge_exec_is_the_default(monkeypatch) -> None: + monkeypatch.delenv("PR_AF_PROVIDER", raising=False) + monkeypatch.delenv("AGENTFIELD_AFORGE_COMMAND", raising=False) + + config = AIIntegrationConfig.from_env() + + assert config.provider == "aforge" + assert config.provider_env()["AGENTFIELD_AFORGE_COMMAND"] == "exec" + + +def test_opencode_remains_an_explicit_rollback(monkeypatch) -> None: + monkeypatch.setenv("PR_AF_PROVIDER", "opencode") + + assert AIIntegrationConfig.from_env().provider == "opencode"