diff --git a/.env.example b/.env.example index 78dae3e4..20ebd6af 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,13 @@ # --- Required: exactly one LLM provider key --- # -# Uncomment ONE of the options below. Leave the others commented out: any -# non-empty ANTHROPIC_API_KEY — including a leftover placeholder — makes -# SWE-AF pick the claude_code runtime, which is exactly what you don't want -# on an OpenRouter-only deployment. +# Uncomment one provider option. When OpenRouter is present, SWE-AF defaults to +# AForge; with only Anthropic credentials it defaults to Claude Code. # Option A (recommended): OpenRouter — 200+ open and proprietary models # (DeepSeek, Qwen, Llama, MiniMax, GLM, Kimi, …). This is the only secret # needed to get started; GH_TOKEN below is optional. -# With ONLY an OpenRouter key set (no ANTHROPIC_API_KEY, no SWE_DEFAULT_RUNTIME), -# SWE-AF auto-selects the open_code runtime and defaults every role to +# With an OpenRouter key set and no SWE_DEFAULT_RUNTIME, SWE-AF auto-selects +# the aforge runtime and defaults every role to # openrouter/deepseek/deepseek-v4-flash-0731. Override with SWE_DEFAULT_MODEL. # OPENROUTER_API_KEY=sk-or-v1-... @@ -147,18 +145,24 @@ # Default runtime when callers don't pass a `runtime` in the request config. # Lets the deployer pick the runtime once instead of every caller threading -# a config through. Unset = auto: open_code when an OpenRouter key is the -# only provider credential, else claude_code. An invalid value is logged as -# a warning and ignored. Leave this UNSET on an OpenRouter-only deployment — -# auto-select already picks open_code and the deepseek-v4-flash-0731 default. -# SWE_DEFAULT_RUNTIME=claude_code # or: open_code, codex +# a config through. Unset = auto: aforge when an OpenRouter key is available, +# else claude_code. An invalid value is logged as a warning and ignored. +# SWE_DEFAULT_RUNTIME=claude_code # or: aforge, open_code, codex +# +# AFORGE_BIN / AGENTFIELD_AFORGE_COMMAND are honored on the pinned +# agentfield>=0.1.130 SDK (the release carrying agentfield#905): AFORGE_BIN +# overrides the binary otherwise resolved as `aforge` from PATH, and +# AGENTFIELD_AFORGE_COMMAND picks the headless command (`exec`, the default, +# or `do`). +# AFORGE_BIN=/absolute/path/to/aforge +# AGENTFIELD_AFORGE_COMMAND=exec # Default model when callers don't pass `models` in the request config. # Applies to all 16 agent roles for whichever runtime is active. Caller # config (`models.default` or per-role keys) overrides this. Set this on # the deployment to pin a model without code changes — e.g. swap from # deepseek-v4-flash-0731 to a newer release. Empty / unset → use the runtime's -# baked-in defaults (openrouter/deepseek/deepseek-v4-flash-0731 on open_code). +# baked-in defaults (openrouter/deepseek/deepseek-v4-flash-0731 on aforge/open_code). # This is the variable to use for role model selection; AI_MODEL below is # part of the same cascade but is also the direct-LLM fallback, so prefer # this one. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5abd4c0c..463ca49e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest env: # Reproduce go/Dockerfile's sparse SDK clone; keep in sync with its AGENTFIELD_SDK_REF. - AGENTFIELD_SDK_REF: 20955b2637b4708758c328a4f64fe460c7d4b772 + AGENTFIELD_SDK_REF: aba20a9b248d9ee6c74f4e7e688ef0740c542dc9 AGENTFIELD_REPO: https://github.com/Agent-Field/agentfield.git GOWORK: off steps: @@ -72,3 +72,51 @@ jobs: - name: Test (race) working-directory: SWE-AF/go run: go test -race -count=1 ./... + + aforge-fetch-stage: + # SWE-AF had no docker job at all. This one builds JUST the `aforge` fetch + # stage of both Dockerfiles — the part that downloads the released AForge + # CLI and checksum-verifies it — so a bad version/URL/checksum fails here + # instead of on a deploy. It deliberately skips the rest of the image + # (apt, npm, the Go build), which is minutes of work for no extra signal. + # + # The download host is probed first: until agentfield.ai serves + # /downloads/aforge//, the job reports a notice and passes. Flip + # `--target aforge` to a full `docker build .` once the host is live and + # this becomes the image-build gate. + name: AForge fetch stage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Read the AForge coordinates from the Dockerfile + id: coords + run: | + base="$(awk -F= '/^ARG AFORGE_BASE_URL=/{print $2; exit}' Dockerfile)" + version="$(awk -F= '/^ARG AFORGE_VERSION=/{print $2; exit}' Dockerfile)" + test -n "$base" && test -n "$version" + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "AForge $version from $base" + + - name: Probe the download host + id: probe + env: + BASE: ${{ steps.coords.outputs.base }} + VERSION: ${{ steps.coords.outputs.version }} + run: | + if curl -fsS --head --max-time 30 "${BASE}/${VERSION}/checksums.txt" >/dev/null 2>&1; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::${BASE}/${VERSION}/checksums.txt is not reachable yet — skipping the AForge fetch-stage build." + fi + + - name: Build the fetch stage (Python image) + if: steps.probe.outputs.available == 'true' + run: docker build --target aforge -t swe-af-aforge-stage . + + - name: Build the fetch stage (Go image) + if: steps.probe.outputs.available == 'true' + run: docker build --target aforge -f go/Dockerfile -t swe-af-go-aforge-stage . diff --git a/Dockerfile b/Dockerfile index 639efcae..e6d90359 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,45 @@ +# --------------------------------------------------------------------------- +# Stage 1 — aforge: fetch the released AForge CLI from the public download host +# and verify it against the release checksums before it ever enters the image. +# +# The release publishes GZIPPED binaries plus a checksums.txt whose hashes are +# of the DECOMPRESSED binaries, so this stage gunzips first and then rewrites +# the matching checksum line to the local file name before `sha256sum -c`. +# +# Both ARGs are overridable so a mirror / air-gapped registry can be used: +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... +# Changing AFORGE_VERSION also busts this layer's cache (per the docker cache +# rule) — a floating URL alone would keep restoring a stale binary. +# --------------------------------------------------------------------------- +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 + +FROM debian:bookworm-slim AS aforge +ARG AFORGE_BASE_URL +ARG AFORGE_VERSION +# TARGETARCH is populated by BuildKit; the fallback keeps the classic builder +# working on the only architecture this image is published for. +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 -eu; \ + arch="${TARGETARCH:-amd64}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + 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; \ + chmod +x aforge; \ + ./aforge --help > /dev/null; \ + rm -f aforge.gz checksums.txt aforge.sha256 + + +# --------------------------------------------------------------------------- +# Stage 2 — runtime: the SWE-AF Python node. +# --------------------------------------------------------------------------- FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -8,7 +50,7 @@ WORKDIR /app # System deps: git (worktrees, branches), curl (healthcheck), jq (agent bash), # openssh-client (optional SSH git), gh CLI (draft PRs) RUN apt-get update && apt-get install -y --no-install-recommends \ - git curl openssh-client jq nodejs npm && \ + git curl ca-certificates openssh-client jq nodejs npm && \ # Install GitHub CLI curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ @@ -52,6 +94,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Add OpenCode to PATH for non-interactive shells ENV PATH="/root/.opencode/bin:${PATH}" +# AForge CLI (aforge runtime), fetched + checksum-verified in stage 1. +COPY --from=aforge /out/aforge /usr/local/bin/aforge # Tell OpenCode to read its model AND small_model from the deployer's # HARNESS_MODEL env var via {env:...} interpolation. Without this config, @@ -99,7 +143,8 @@ EXPOSE 8003 ENV PORT=8003 \ AGENTFIELD_SERVER=http://control-plane:8080 \ - NODE_ID=swe-planner + NODE_ID=swe-planner \ + AGENTFIELD_AFORGE_COMMAND=exec HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD curl -f http://localhost:${PORT}/health || exit 1 diff --git a/README.md b/README.md index 280d0e69..89553a39 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ New to AgentField? Install the control plane first with `curl -fsSL https://agen One click deploys SWE-AF + AgentField control plane + PostgreSQL. Exactly **one** environment variable is required in Railway — an LLM provider key: -- `OPENROUTER_API_KEY` — **recommended, simplest**. One key, 200+ open and proprietary models. With only this set (no `ANTHROPIC_API_KEY`, no `SWE_DEFAULT_RUNTIME`), SWE-AF auto-selects the `open_code` runtime and defaults every role to `openrouter/deepseek/deepseek-v4-flash-0731` — no further configuration needed. +- `OPENROUTER_API_KEY` — **recommended, simplest**. One key, 200+ open and proprietary models. When present and no runtime is explicitly selected, SWE-AF uses AForge `exec` and defaults every role to `openrouter/deepseek/deepseek-v4-flash-0731`. - *Alternative:* `ANTHROPIC_API_KEY`, or `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token` in [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (uses Pro/Max subscription credits), to run the `claude_code` runtime instead. Optional: @@ -279,6 +279,43 @@ python -m pip install -e ".[dev]" ### 3. Run +#### Harness selection + +The Docker image ships AForge: a dedicated build stage downloads the released +binary from `https://agentfield.ai/downloads/aforge//` and verifies it +against the release `checksums.txt` before it enters the image. Both +coordinates are build args, so a mirror or a different release can be +substituted without editing the Dockerfile: + +```bash +docker build \ + --build-arg AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge \ + --build-arg AFORGE_VERSION=v0.1.0 \ + -t swe-af . +``` + +`AFORGE_VERSION` is part of that layer's cache key — bumping it is what pulls a +newer AForge; a floating URL alone would keep restoring the cached binary. + +A host installation needs `aforge` on `PATH` instead: + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... +export SWE_DEFAULT_RUNTIME=aforge +export SWE_DEFAULT_MODEL=openrouter/deepseek/deepseek-v4-flash-0731 +python -m swe_af +``` + +Set `SWE_DEFAULT_RUNTIME=open_code` for an OpenCode rollback (OpenCode stays +installed in the image), or `claude_code` for Claude. + +> `AFORGE_BIN` and `AGENTFIELD_AFORGE_COMMAND` are honored on the pinned +> `agentfield>=0.1.130` SDK, which carries +> [agentfield#905](https://github.com/Agent-Field/agentfield/pull/905): +> `AFORGE_BIN` overrides the binary otherwise resolved as `aforge` from `PATH`, +> and `AGENTFIELD_AFORGE_COMMAND` picks the headless command (`exec`, the +> default, or `do`) — `aforge exec --json -w --timeout `. + ```bash af # starts AgentField control plane on :8080 python -m swe_af # registers node id "swe-planner" @@ -844,7 +881,7 @@ Pass `config` to `build` or `execute`. Full schema: [`swe_af/execution/schemas.p | Key | Default | Description | | ------------------------- | --------------- | ----------------------------------------------------- | -| `runtime` | `"claude_code"` | Model runtime: `"claude_code"`, `"open_code"`, or `"codex"`. The default also honors the `SWE_DEFAULT_RUNTIME` env var when no `runtime` is passed in `config` — set it on the deployment so callers don't need to plumb a config through. | +| `runtime` | auto | Model runtime: `"aforge"`, `"claude_code"`, `"open_code"`, or `"codex"`. With OpenRouter available the default is `"aforge"`; otherwise it is `"claude_code"`. `SWE_DEFAULT_RUNTIME` overrides it deployment-wide. | | `models` | `null` | Flat role-model map (`default` + role keys below). Without a caller-supplied value, the `SWE_DEFAULT_MODEL` env var is used as the default for all roles — set it on the deployment to pin a model without code changes. Caller `models.default` or per-role keys still win. | | `max_coding_iterations` | `5` | Inner-loop retry budget | | `max_advisor_invocations` | `2` | Middle-loop advisor budget | diff --git a/agentfield-package.yaml b/agentfield-package.yaml index 4417adcb..679fd7e9 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -29,8 +29,8 @@ agent_node: user_environment: require_one_of: - # SWE-AF runs on Claude-compatible APIs or open models via OpenCode. - # Provide one. With only an OpenRouter key it auto-selects the open_code + # SWE-AF runs on Claude-compatible APIs or open models via AForge. + # Provide one. With an OpenRouter key it auto-selects the aforge # runtime and defaults to openrouter/deepseek/deepseek-v4-flash-0731. - id: llm_provider description: an LLM provider key @@ -61,7 +61,10 @@ user_environment: type: secret scope: global - name: SWE_DEFAULT_RUNTIME - description: Coding runtime for every role (claude_code | open_code | codex) + description: Coding runtime for every role (aforge | claude_code | open_code | codex) + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command + default: exec - name: SWE_DEFAULT_MODEL description: Override the model id for every role (e.g. openrouter/deepseek/deepseek-v4-flash-0731) - name: ANTHROPIC_BASE_URL diff --git a/docker-compose.go.yml b/docker-compose.go.yml index 93a411fb..50ebf44b 100644 --- a/docker-compose.go.yml +++ b/docker-compose.go.yml @@ -52,6 +52,7 @@ services: # else claude_code. A baked claude_code fallback here would break # OpenRouter-only deployments. - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} - OPENAI_API_KEY=${OPENAI_API_KEY:-} @@ -111,6 +112,7 @@ services: - OPENCODE_MODEL=${OPENCODE_MODEL:-} # Empty = auto (see swe-agent-go note). - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} # build-db lives in the Python stack; reachable over the shared network. diff --git a/docker-compose.yml b/docker-compose.yml index 1daf1b9c..ee9239fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,10 +40,9 @@ services: - NODE_ID=swe-planner - PORT=8003 - AGENT_CALLBACK_URL=http://swe-agent:8003 - # Empty = auto: open_code when only an OpenRouter key is present, - # else claude_code. A baked claude_code fallback here would break - # OpenRouter-only deployments. + # Empty = auto: aforge when OpenRouter is available, else claude_code. - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} # Provider keys and the GitHub token, so exporting them in the shell @@ -85,9 +84,9 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - OPENCODE_MODEL=${OPENCODE_MODEL:-} - # Empty = auto: open_code when only an OpenRouter key is present, - # else claude_code (see swe-agent note). + # Empty = auto: aforge when OpenRouter is available, else claude_code. - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} + - AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} - DATABASE_URL_TEST=${DATABASE_URL_TEST:-postgres://builder:builder@build-db:5432/buildtest} diff --git a/go/Dockerfile b/go/Dockerfile index 0c9d13f2..0d728000 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -3,15 +3,56 @@ # Build from the SWE-AF repo root so the whole go/ module is in context: # docker build -f go/Dockerfile . # -# The Go module depends on the AgentField Go SDK via a `replace` directive -# (go/go.mod: replace github.com/Agent-Field/agentfield/sdk/go => ../../agentfield/sdk/go). -# The SDK lives in a *sibling* repo that is NOT in this build context, so the -# builder stage clones it at a pinned ref and lays it out so the replace path -# (../../agentfield/sdk/go, relative to /src/SWE-AF/go) resolves to /src/agentfield/sdk/go. +# The Go module requires the AgentField Go SDK by version (go/go.mod: +# github.com/Agent-Field/agentfield/sdk/go v0.1.130) and the builder resolves it +# through the module proxy — there is no `replace` directive, so nothing here +# depends on a sibling checkout. The builder still sparse-clones the SDK at +# AGENTFIELD_SDK_REF: it records the exact commit behind that version in the +# image and fails the build early if the ref ever disappears. # # Cache-busting (per the docker cache rule): the SDK checkout is keyed on the # AGENTFIELD_SDK_REF build arg. Bump the ref (or pass --build-arg) to force a -# re-clone; an unchanged ref restores the cached layer. +# re-clone; an unchanged ref restores the cached layer. Keep it on the same +# release as go/go.mod's require. + +# --------------------------------------------------------------------------- +# Stage 0 — aforge: fetch the released AForge CLI from the public download host +# and verify it against the release checksums before it ever enters the image. +# +# The release publishes GZIPPED binaries plus a checksums.txt whose hashes are +# of the DECOMPRESSED binaries, so this stage gunzips first and then rewrites +# the matching checksum line to the local file name before `sha256sum -c`. +# +# Both ARGs are overridable so a mirror / air-gapped registry can be used: +# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... +# Changing AFORGE_VERSION also busts this layer's cache (per the docker cache +# rule) — a floating URL alone would keep restoring a stale binary. +# --------------------------------------------------------------------------- +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=v0.1.0 + +FROM debian:bookworm-slim AS aforge +ARG AFORGE_BASE_URL +ARG AFORGE_VERSION +# TARGETARCH is populated by BuildKit; the fallback keeps the classic builder +# working on the only architecture this image is published for. +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 -eu; \ + arch="${TARGETARCH:-amd64}"; \ + curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \ + gunzip -c aforge.gz > aforge; \ + 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; \ + chmod +x aforge; \ + ./aforge --help > /dev/null; \ + rm -f aforge.gz checksums.txt aforge.sha256 + # --------------------------------------------------------------------------- # Stage 1 — builder: clone the SDK at a pinned ref, build both static binaries. @@ -20,9 +61,10 @@ # control-plane Go prerequisite (Go 1.23+). FROM golang:1.23-bookworm AS builder -# Pinned AgentField SDK ref. Default = agentfield origin/main HEAD at port time -# (v0.1.107-rc.1). Changing this string invalidates the clone layer below. -ARG AGENTFIELD_SDK_REF=20955b2637b4708758c328a4f64fe460c7d4b772 +# Pinned AgentField SDK ref. Default = the sdk/go/v0.1.130 tag commit, the same +# release go/go.mod requires. Changing this string invalidates the clone layer +# below. +ARG AGENTFIELD_SDK_REF=aba20a9b248d9ee6c74f4e7e688ef0740c542dc9 ARG AGENTFIELD_REPO=https://github.com/Agent-Field/agentfield.git WORKDIR /src @@ -42,7 +84,7 @@ RUN git init -q /src/agentfield && \ # Prime the module cache from go.mod/go.sum before copying sources so dependency # downloads cache independently of source edits. GOWORK=off: no workspace in the -# image, resolution goes through the replace directive. +# image, resolution goes through go.mod's versioned require. ENV GOWORK=off CGO_ENABLED=0 GOOS=linux COPY go/go.mod go/go.sum /src/SWE-AF/go/ WORKDIR /src/SWE-AF/go @@ -67,7 +109,8 @@ ENV DEBIAN_FRONTEND=noninteractive # ca-certificates (HTTPS for gh/opencode/npm), jq (agent bash), openssh-client # (optional SSH git), nodejs+npm (codex + claude-code CLIs), gh CLI (draft PRs), # OpenCode CLI (open_code runtime), Codex CLI (codex runtime), Claude Code CLI -# (claude_code runtime). +# (claude_code runtime). The AForge CLI (aforge runtime) is copied in from +# stage 0 rather than installed here. RUN apt-get update && apt-get install -y --no-install-recommends \ git curl ca-certificates openssh-client jq nodejs npm \ # Python test tooling: repos under build are frequently Python, and both @@ -145,6 +188,9 @@ RUN git config --global user.name "SWE-AF" && \ git config --global user.email "eng@agentfield.ai" && \ gh auth setup-git --hostname github.com --force +# AForge CLI (aforge runtime), fetched + checksum-verified in stage 0. +COPY --from=aforge /out/aforge /usr/local/bin/aforge + # Application binaries (static, from the builder stage). COPY --from=builder /out/swe-planner /usr/local/bin/swe-planner COPY --from=builder /out/swe-fast /usr/local/bin/swe-fast @@ -168,7 +214,8 @@ EXPOSE 8005 # need distinct identities, and the swe-fast service overrides NODE_ID/PORT. ENV PORT=8005 \ AGENTFIELD_SERVER=http://control-plane:8080 \ - NODE_ID=swe-planner + NODE_ID=swe-planner \ + AGENTFIELD_AFORGE_COMMAND=exec HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD curl -f http://localhost:${PORT}/health || exit 1 diff --git a/go/Makefile b/go/Makefile index b41355c3..e5f23561 100644 --- a/go/Makefile +++ b/go/Makefile @@ -39,7 +39,7 @@ run-fast: # AgentField SDK ref are overridable: # make docker-build IMAGE=myrepo/swe-af-go:dev AGENTFIELD_SDK_REF= IMAGE ?= swe-af-go:latest -AGENTFIELD_SDK_REF ?= dfb5c8a37f93f510f3e390bd515afd9154194066 +AGENTFIELD_SDK_REF ?= aba20a9b248d9ee6c74f4e7e688ef0740c542dc9 # Build the multi-stage Go image from the repo root. docker-build: diff --git a/go/README.md b/go/README.md index 139fe0ab..3a90e310 100644 --- a/go/README.md +++ b/go/README.md @@ -37,25 +37,23 @@ anywhere you need different ids or ports. ## Depending on the AgentField Go SDK -There are **no `sdk/go/vX.Y.Z` submodule tags** in the agentfield repo, so a -normal versioned `require` is impossible. The port depends on the SDK -(`github.com/Agent-Field/agentfield/sdk/go`) two ways: - -- **Dev — Go workspace.** A `go.work` at the shared parent of both repos - (`/go.work`) lists `./SWE-AF/go` and `./agentfield/sdk/go`, - so edits to the SDK are picked up live with zero `go.mod` churn. It is not - committed (it spans two repos). With the workspace present, `go build ./...` - just works. -- **CI / Docker — `replace` directive.** `go.mod` carries - `replace github.com/Agent-Field/agentfield/sdk/go => ../../agentfield/sdk/go`. - Any build without the workspace (set `GOWORK=off`, or build where no `go.work` - exists) resolves the SDK through that relative path, which must point at a - sibling checkout of the agentfield repo. The Docker builder clones it there - automatically (see below). - -Migration target: once agentfield publishes `sdk/go/vX.Y.Z` submodule tags, drop -the `replace` and switch to a real `require`. The agentfield repo is treated as -read-only — every SDK gap is worked around app-side. +The agentfield repo now publishes `sdk/go/vX.Y.Z` submodule tags, so `go.mod` +carries a plain versioned require: + +``` +require github.com/Agent-Field/agentfield/sdk/go v0.1.130 +``` + +There is no `replace` directive: CI, Docker and a bare `go build ./...` all +resolve the SDK through the module proxy. `go/Dockerfile` and +`.github/workflows/ci.yml` pin the same release by commit +(`AGENTFIELD_SDK_REF`, the `sdk/go/v0.1.130` tag commit) — bump the require and +those refs together. + +For SDK development a `go.work` at the shared parent of both repos +(`/go.work`, listing `./SWE-AF/go` and `./agentfield/sdk/go`) still +layers a local checkout on top with zero `go.mod` churn. It is not committed +(it spans two repos). Set `GOWORK=off` to build the way CI and Docker do. ## Build & run locally @@ -74,8 +72,8 @@ make run-fast # run the fast-mode node (swe-fast, :8006) `AGENTFIELD_SERVER` (default `http://localhost:8080`). Both nodes read all configuration from the environment at startup (the Go SDK reads no env itself). -To build without the dev workspace (the way CI/Docker do), a sibling agentfield -checkout must exist at `../../agentfield`: +To build the way CI/Docker do — ignoring any `go.work`, resolving the SDK from +the module proxy: ```bash GOWORK=off go build ./... @@ -83,11 +81,13 @@ GOWORK=off go build ./... ## Docker -The image is a multi-stage build. The builder clones the AgentField Go SDK at a -**pinned ref** and lays it out so the `replace` path resolves, then builds both -static binaries; the runtime stage is a slim Debian with the same external CLI -surface the agents shell out to (`git`, `gh`, `jq`, OpenCode, Codex, Claude -Code). +The image is a multi-stage build. A fetch stage downloads the released AForge +CLI and verifies it against the release `checksums.txt` before it enters the +image; the builder resolves the AgentField Go SDK at the version `go.mod` +requires (and clones it at the matching **pinned ref**), then builds both static +binaries; the +runtime stage is a slim Debian with the same external CLI surface the agents +shell out to (`git`, `gh`, `jq`, AForge, OpenCode, Codex, Claude Code). Build the image (context is the **repo root**, so the whole `go/` module is available and the SDK clone can be laid out as a sibling): @@ -110,6 +110,26 @@ SDK**; an unchanged ref restores the cached clone (same rationale as the docker-pip cache-busting rule: the constraint string itself must change to invalidate the layer). +The AForge download is pinned the same way: + +```bash +docker build -f go/Dockerfile \ + --build-arg AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge \ + --build-arg AFORGE_VERSION=v0.1.0 \ + -t swe-af-go:latest . +``` + +`AFORGE_VERSION` is part of the fetch layer's cache key, so bumping it is what +pulls a newer AForge — a floating URL alone would keep restoring the cached +binary. `AFORGE_BASE_URL` exists so a mirror can be substituted. + +> **The `aforge` runtime needs a Go SDK that has the aforge harness provider.** +> That is agentfield#905, first released in `sdk/go/v0.1.130` — the version +> `go/go.mod` requires and the commit `AGENTFIELD_SDK_REF` pins here and in +> `.github/workflows/ci.yml`. Keep the three in sync when bumping; an older SDK +> returns `unknown harness provider: "aforge"` and the node has to be pointed at +> another runtime with `SWE_DEFAULT_RUNTIME`. + ### Compose: opt-in add-on to the Python stack `docker-compose.go.yml` (at the repo root) is an **add-on**, not a standalone @@ -155,9 +175,10 @@ set; the load-bearing ones: | Variable | Purpose | |-----------------------------------------------------------|------------------------------------------------------| | `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` | Claude runtime (`claude_code`) | -| `OPENROUTER_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY`| Open runtimes (`open_code` / `codex`) | +| `OPENROUTER_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY`| Open runtimes (`aforge` / `open_code` / `codex`) | | `GH_TOKEN` | Optional: GitHub PAT (`repo` scope) — needed for private repos and PRs | -| `SWE_DEFAULT_RUNTIME` | `claude_code` \| `open_code` \| `codex` (unset: auto — `open_code` when only an OpenRouter key is present, else `claude_code`) | +| `SWE_DEFAULT_RUNTIME` | `aforge` \| `claude_code` \| `open_code` \| `codex` (unset: auto — `aforge` when an OpenRouter key is available, else `claude_code`) | +| `AGENTFIELD_AFORGE_COMMAND` | AForge headless command — `exec` (default, baked into the image) or `do` | | `SWE_DEFAULT_MODEL` | Default model when the request config omits `models` | | `SWE_CODEX_AUTH_MODE` | `auto` \| `chatgpt` \| `api_key` (codex CLI auth) | | `OPENCODE_ENABLE_EXA` + `EXA_API_KEY` | Optional web search for the open runtime | diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml index ee449506..70299b60 100644 --- a/go/agentfield-package.yaml +++ b/go/agentfield-package.yaml @@ -25,8 +25,8 @@ agent_node: user_environment: require_one_of: # SWE-AF runs on either Claude (Anthropic) or open models via OpenRouter. - # Provide one. With only an OpenRouter key it auto-selects the open_code - # runtime. + # Provide one. With an OpenRouter key it auto-selects the aforge runtime + # and defaults to openrouter/deepseek/deepseek-v4-flash-0731. - id: llm_provider description: an LLM provider key options: @@ -47,7 +47,10 @@ user_environment: type: secret scope: global - name: SWE_DEFAULT_RUNTIME - description: Coding runtime for every role (claude_code | open_code | codex) + description: Coding runtime for every role (aforge | claude_code | open_code | codex) + - name: AGENTFIELD_AFORGE_COMMAND + description: AForge headless command + default: exec - name: SWE_DEFAULT_MODEL description: Override the model id for every role (e.g. openrouter/deepseek/deepseek-v4-flash-0731) - name: SWE_PRO_ENGINE diff --git a/go/go.mod b/go/go.mod index 002908a0..67c8b8ed 100644 --- a/go/go.mod +++ b/go/go.mod @@ -5,7 +5,7 @@ module github.com/Agent-Field/SWE-AF/go go 1.21 require ( - github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260723130821-20955b2637b4 + github.com/Agent-Field/agentfield/sdk/go v0.1.130 github.com/invopop/jsonschema v0.13.0 golang.org/x/sync v0.11.0 ) @@ -19,8 +19,9 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -// The SDK has no sdk/go/vX.Y.Z submodule tags, so it is pinned by -// pseudo-version above — the same commit go/Dockerfile pins via -// AGENTFIELD_SDK_REF. Bump both together. Dev can still layer a local -// checkout on top with the go.work workspace; nothing here depends on a -// sibling checkout anymore, which is what makes `af install …//go` work. +// The SDK now publishes sdk/go/vX.Y.Z submodule tags, so the require above is +// a real version. go/Dockerfile and .github/workflows/ci.yml pin the same +// release by commit via AGENTFIELD_SDK_REF (sdk/go/v0.1.130 is +// aba20a9b248d9ee6c74f4e7e688ef0740c542dc9) — bump them together. Dev can still +// layer a local checkout on top with the go.work workspace; nothing here +// depends on a sibling checkout, which is what makes `af install …//go` work. diff --git a/go/go.sum b/go/go.sum index 43bf171d..0defd2e1 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260723130821-20955b2637b4 h1:OwOEyxRfYD0n2LAmaJIJdfejWpIYgRAf9oq/YA4qfVk= -github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260723130821-20955b2637b4/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/config_test.go b/go/internal/config/config_test.go index 222949bb..3950acd3 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -40,10 +40,11 @@ func TestDefaultRuntime(t *testing.T) { }{ {"no keys -> claude_code", nil, "claude_code"}, {"anthropic -> claude_code", map[string]string{"ANTHROPIC_API_KEY": "sk-ant"}, "claude_code"}, - {"openrouter only -> open_code", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, "open_code"}, - {"both keys -> claude_code", map[string]string{"ANTHROPIC_API_KEY": "sk-ant", "OPENROUTER_API_KEY": "sk-or"}, "claude_code"}, + {"openrouter only -> aforge", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, "aforge"}, + {"both keys -> aforge (OpenRouter wins)", map[string]string{"ANTHROPIC_API_KEY": "sk-ant", "OPENROUTER_API_KEY": "sk-or"}, "aforge"}, {"explicit runtime beats autoselect", map[string]string{"OPENROUTER_API_KEY": "sk-or", "SWE_DEFAULT_RUNTIME": "claude_code"}, "claude_code"}, {"env open_code", map[string]string{"SWE_DEFAULT_RUNTIME": "open_code"}, "open_code"}, + {"env aforge", map[string]string{"SWE_DEFAULT_RUNTIME": "aforge"}, "aforge"}, {"env codex", map[string]string{"SWE_DEFAULT_RUNTIME": "codex"}, "codex"}, {"invalid env -> claude_code", map[string]string{"SWE_DEFAULT_RUNTIME": "bogus_runtime"}, "claude_code"}, {"empty env -> claude_code", map[string]string{"SWE_DEFAULT_RUNTIME": ""}, "claude_code"}, @@ -233,7 +234,7 @@ func TestResolveRuntimeModels_Errors(t *testing.T) { clearProviderEnv(t) if _, err := ResolveRuntimeModels("bad_runtime", nil, nil); err == nil { t.Fatal("expected error for invalid runtime") - } else if err.Error() != "Unsupported runtime 'bad_runtime'. Valid runtimes: claude_code, open_code, codex" { + } else if err.Error() != "Unsupported runtime 'bad_runtime'. Valid runtimes: aforge, claude_code, open_code, codex" { t.Fatalf("runtime error string = %q", err.Error()) } _, err := ResolveRuntimeModels("claude_code", map[string]string{"bad": "opus"}, nil) @@ -370,8 +371,8 @@ func TestBuildConfig_AutoOpenRouterEndToEnd(t *testing.T) { clearProviderEnv(t) t.Setenv("OPENROUTER_API_KEY", "sk-or") cfg := mustLoadBuild(t, nil) - if cfg.Runtime != "open_code" { - t.Fatalf("runtime = %q, want open_code", cfg.Runtime) + if cfg.Runtime != "aforge" { + t.Fatalf("runtime = %q, want aforge", cfg.Runtime) } resolved, err := cfg.ResolvedModels() if err != nil { @@ -711,9 +712,9 @@ func TestDefaultFastRuntime(t *testing.T) { {"open_code", map[string]string{"SWE_DEFAULT_RUNTIME": "open_code"}, true, "open_code"}, {"invalid -> claude_code", map[string]string{"SWE_DEFAULT_RUNTIME": "bogus"}, true, "claude_code"}, // The main path's OpenRouter auto-detect applies to fast builds too. - {"openrouter only -> open_code", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, true, "open_code"}, - {"openrouter + anthropic -> claude_code", map[string]string{ - "OPENROUTER_API_KEY": "sk-or", "ANTHROPIC_API_KEY": "sk-ant"}, true, "claude_code"}, + {"openrouter only -> aforge", map[string]string{"OPENROUTER_API_KEY": "sk-or"}, true, "aforge"}, + {"openrouter + anthropic -> aforge", map[string]string{ + "OPENROUTER_API_KEY": "sk-or", "ANTHROPIC_API_KEY": "sk-ant"}, true, "aforge"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/go/internal/config/fastconfig.go b/go/internal/config/fastconfig.go index 0d26ee4d..97d1d0d8 100644 --- a/go/internal/config/fastconfig.go +++ b/go/internal/config/fastconfig.go @@ -14,8 +14,9 @@ import ( const ( fastClaudeCodeDefault = "haiku" - // Fast mode shares the open_code default with the main path so an - // OpenRouter-only install behaves the same on both nodes. + // Fast mode shares the OpenRouter default with the main path so an + // OpenRouter-only install behaves the same on both nodes. aforge and + // open_code resolve to the same model id. fastOpenCodeDefault = openRouterAutoDefaultModel ) @@ -46,14 +47,14 @@ var fastValidKeys = map[string]struct{}{ } // DefaultFastRuntime ports _default_fast_runtime, honoring SWE_DEFAULT_RUNTIME. -// When unset (or blank), auto-selects open_code if only an OpenRouter key is +// When unset (or blank), auto-selects aforge if only an OpenRouter key is // present — the same detection the main path uses (openRouterOnlyEnv) — else // claude_code. An invalid value falls back to claude_code. func DefaultFastRuntime() string { value := envStripped("SWE_DEFAULT_RUNTIME") if value == "" { if openRouterOnlyEnv() { - return "open_code" + return "aforge" } return "claude_code" } @@ -72,7 +73,7 @@ func fastRuntimeDefault(runtime string) string { return codexDefaultModel() case "claude_code": return fastClaudeCodeDefault - case "open_code": + case "aforge", "open_code": return fastOpenCodeDefault default: return "" diff --git a/go/internal/config/modeltiers_test.go b/go/internal/config/modeltiers_test.go index 346adb60..3c3f9561 100644 --- a/go/internal/config/modeltiers_test.go +++ b/go/internal/config/modeltiers_test.go @@ -41,6 +41,8 @@ func TestModelTiers_NoTierEnvsUnchanged(t *testing.T) { }}, {"open_code base defaults", "open_code", nil, func(string) string { return openCodeBaseModel }}, + {"aforge base defaults", "aforge", nil, + func(string) string { return openCodeBaseModel }}, {"codex base defaults", "codex", map[string]string{"SWE_CODEX_AUTH_MODE": "api_key"}, func(string) string { return "gpt-5.3-codex" }}, } diff --git a/go/internal/config/resolve.go b/go/internal/config/resolve.go index a1ef0eec..f5036c90 100644 --- a/go/internal/config/resolve.go +++ b/go/internal/config/resolve.go @@ -131,21 +131,25 @@ const ( codexAPIKeyModel = "gpt-5.3-codex" // OpenAI API-key auth (api_key mode) codexChatGPTModel = "gpt-5.5" // ChatGPT-account auth (-codex blocked) - // Default model for the open_code runtime — both the auto-selected - // OpenRouter path (see openRouterOnlyEnv) and an explicit - // SWE_DEFAULT_RUNTIME=open_code resolve here, so opting in explicitly + // Default model for the OpenRouter-backed runtimes (aforge, open_code) — + // both the auto-selected OpenRouter path (see openRouterOnlyEnv) and an + // explicit SWE_DEFAULT_RUNTIME resolve here, so opting in explicitly // never silently swaps the model. openRouterAutoDefaultModel = "openrouter/deepseek/deepseek-v4-flash-0731" ) // runtimeBaseModels ports _RUNTIME_BASE_MODELS[runtime] as a fresh copy for the // given runtime, or nil if the runtime is unknown. claude_code is all "sonnet" -// except qa_synthesizer_model="haiku"; open_code is all +// except qa_synthesizer_model="haiku"; aforge and open_code are all // openRouterAutoDefaultModel (v4-flash-0731); codex is all the API-key model // (adjusted for auth mode by ResolveRuntimeModels). func runtimeBaseModels(runtime string) map[string]string { base := make(map[string]string, len(AllModelFields)) switch runtime { + case "aforge": + for _, field := range AllModelFields { + base[field] = openRouterAutoDefaultModel + } case "claude_code": for _, field := range AllModelFields { base[field] = "sonnet" @@ -174,26 +178,28 @@ func envStripped(key string) string { } // openRouterOnlyEnv ports _openrouter_only_env: whether the deployer implicitly -// chose the OpenRouter runtime (no explicit SWE_DEFAULT_RUNTIME, no Anthropic -// key, but an OpenRouter key present). +// chose the OpenRouter runtime (no explicit SWE_DEFAULT_RUNTIME, but an +// OpenRouter key present). An Anthropic key alongside it no longer vetoes the +// choice — OpenRouter wins, and SWE_DEFAULT_RUNTIME=claude_code is the opt-out. func openRouterOnlyEnv() bool { if envStripped("SWE_DEFAULT_RUNTIME") != "" { return false } - if envStripped("ANTHROPIC_API_KEY") != "" { - return false - } return envStripped("OPENROUTER_API_KEY") != "" } // DefaultRuntime ports _default_runtime, honoring SWE_DEFAULT_RUNTIME. -// When unset, auto-selects open_code if only an OpenRouter key is present, +// When unset, auto-selects aforge if only an OpenRouter key is present, // otherwise claude_code. An invalid env value falls back to claude_code. +// +// The aforge default requires an AgentField Go SDK whose harness.BuildProvider +// knows the "aforge" provider (agentfield#905). go.mod pins sdk/go v0.1.130, +// which carries it — see go/README.md § Docker. func DefaultRuntime() string { value := envStripped("SWE_DEFAULT_RUNTIME") if value == "" { if openRouterOnlyEnv() { - return "open_code" + return "aforge" } return "claude_code" } diff --git a/go/internal/fast/build.go b/go/internal/fast/build.go index 8bcd67e2..2df34ce6 100644 --- a/go/internal/fast/build.go +++ b/go/internal/fast/build.go @@ -135,13 +135,16 @@ func repoNameFromURL(url string) string { } // runtimeToProvider ports fast/app.py::_runtime_to_provider — the fast-specific -// runtime→ai_provider map (note: anything not claude_code/codex → "opencode"). +// runtime→ai_provider map (note: anything not claude_code/codex/aforge → +// "opencode"). func runtimeToProvider(runtime string) string { switch runtime { case "claude_code": return "claude" case "codex": return "codex" + case "aforge": + return "aforge" default: return "opencode" } diff --git a/go/internal/fast/build_test.go b/go/internal/fast/build_test.go index 15b12026..751f8383 100644 --- a/go/internal/fast/build_test.go +++ b/go/internal/fast/build_test.go @@ -214,7 +214,7 @@ func TestRepoNameFromURL(t *testing.T) { // Contract: _runtime_to_provider maps runtime strings (fast-specific fallback). func TestRuntimeToProvider(t *testing.T) { - cases := map[string]string{"claude_code": "claude", "open_code": "opencode", "codex": "codex", "other": "opencode"} + cases := map[string]string{"claude_code": "claude", "open_code": "opencode", "aforge": "aforge", "codex": "codex", "other": "opencode"} for runtime, want := range cases { if got := runtimeToProvider(runtime); got != want { t.Errorf("runtimeToProvider(%q) = %q, want %q", runtime, got, want) diff --git a/go/internal/orch/plan.go b/go/internal/orch/plan.go index ffedd4a6..d6c44bef 100644 --- a/go/internal/orch/plan.go +++ b/go/internal/orch/plan.go @@ -62,7 +62,7 @@ func Plan(ctx context.Context, deps *Deps, input map[string]any) (any, error) { } // Resolve provider/model defaults from the environment (docstring parity): - // with only an OPENROUTER_API_KEY present the pipeline runs on open_code with + // with only an OPENROUTER_API_KEY present the pipeline runs on aforge with // the default OpenRouter model; explicit args always win. aiProvider := in.AIProvider if aiProvider == "" { diff --git a/go/internal/orch/plan_test.go b/go/internal/orch/plan_test.go index e66519fd..99d5513e 100644 --- a/go/internal/orch/plan_test.go +++ b/go/internal/orch/plan_test.go @@ -447,8 +447,8 @@ func TestPlanOpenRouterOnlyDefaults(t *testing.T) { if len(pm) != 1 { t.Fatalf("expected 1 PM call, got %d", len(pm)) } - if got := mapStr(pm[0].input, "ai_provider", ""); got != "open_code" { - t.Errorf("ai_provider = %q, want open_code", got) + if got := mapStr(pm[0].input, "ai_provider", ""); got != "aforge" { + t.Errorf("ai_provider = %q, want aforge", got) } if got := mapStr(pm[0].input, "model", ""); got != "openrouter/deepseek/deepseek-v4-flash-0731" { t.Errorf("model = %q, want the OpenRouter auto default", got) diff --git a/go/internal/roles/coding/coding_test.go b/go/internal/roles/coding/coding_test.go index 07ee4afe..744711fb 100644 --- a/go/internal/roles/coding/coding_test.go +++ b/go/internal/roles/coding/coding_test.go @@ -181,7 +181,7 @@ func TestRunCoderDirectCallRuntimeDefaults(t *testing.T) { }); err != nil { t.Fatalf("RunCoder: %v", err) } - if mh.gotOpts.Provider != "opencode" || mh.gotOpts.Model != "openrouter/deepseek/deepseek-v4-flash-0731" { + if mh.gotOpts.Provider != "aforge" || mh.gotOpts.Model != "openrouter/deepseek/deepseek-v4-flash-0731" { t.Fatalf("defaults = provider %q, model %q", mh.gotOpts.Provider, mh.gotOpts.Model) } } diff --git a/go/internal/roles/planning/planning_test.go b/go/internal/roles/planning/planning_test.go index 95f42bcf..b025e796 100644 --- a/go/internal/roles/planning/planning_test.go +++ b/go/internal/roles/planning/planning_test.go @@ -180,7 +180,7 @@ func TestProductManagerDirectCallRuntimeDefaults(t *testing.T) { clearRuntimeEnv(t) t.Setenv("OPENROUTER_API_KEY", "test-key") opts := run(t, map[string]any{}) - if opts.Provider != "opencode" || opts.Model != "openrouter/deepseek/deepseek-v4-flash-0731" { + if opts.Provider != "aforge" || opts.Model != "openrouter/deepseek/deepseek-v4-flash-0731" { t.Fatalf("defaults = provider %q, model %q", opts.Provider, opts.Model) } }) diff --git a/go/internal/runtimex/providers.go b/go/internal/runtimex/providers.go index 1f585a29..cea5231e 100644 --- a/go/internal/runtimex/providers.go +++ b/go/internal/runtimex/providers.go @@ -1,13 +1,13 @@ // Package runtimex is a verbatim port of swe_af/runtime/providers.py: the // shared runtime/provider normalization and mapping utilities. // -// Three canonical runtimes exist (RuntimeValues). Callers pass user-facing -// aliases ("claude", "claude-code", "opencode", ...) which NormalizeRuntimeProvider -// folds to a canonical value. Two separate mappings then translate a canonical -// runtime to the string the harness expects — and they are NOT the same for -// claude_code: the harness *provider* is "claude" while the harness *adapter* -// is "claude-code" (design §4.7, "note the asymmetry"). open_code and codex map -// identically under both. +// Four canonical runtimes exist (RuntimeValues). Callers pass user-facing +// aliases ("claude", "claude-code", "opencode", "aforge-v2", ...) which +// NormalizeRuntimeProvider folds to a canonical value. Two separate mappings +// then translate a canonical runtime to the string the harness expects — and +// they are NOT the same for claude_code: the harness *provider* is "claude" +// while the harness *adapter* is "claude-code" (design §4.7, "note the +// asymmetry"). aforge, open_code and codex map identically under both. package runtimex import ( @@ -16,8 +16,9 @@ import ( ) // RuntimeValues is the tuple of canonical runtime values, ported verbatim from -// Python's RUNTIME_VALUES = ("claude_code", "open_code", "codex"). -var RuntimeValues = [...]string{"claude_code", "open_code", "codex"} +// Python's RUNTIME_VALUES = ("aforge", "claude_code", "open_code", "codex"). +// Order matters: it is joined verbatim into the "Valid runtimes: ..." error. +var RuntimeValues = [...]string{"aforge", "claude_code", "open_code", "codex"} // NormalizeRuntimeProvider normalizes user/runtime aliases to canonical runtime // values. @@ -34,6 +35,8 @@ func NormalizeRuntimeProvider(runtime string) (string, error) { return "claude_code", nil case "open_code", "opencode": return "open_code", nil + case "aforge", "aforge_v2", "aforge-v2": + return "aforge", nil case "codex": return "codex", nil } @@ -44,8 +47,8 @@ func NormalizeRuntimeProvider(runtime string) (string, error) { // value. // // Ports runtime_to_harness_provider: claude_code -> "claude", open_code -> -// "opencode", codex -> "codex". Normalizes first, propagating the normalize -// error for unsupported input. +// "opencode", aforge -> "aforge", codex -> "codex". Normalizes first, +// propagating the normalize error for unsupported input. func RuntimeToHarnessProvider(runtime string) (string, error) { normalized, err := NormalizeRuntimeProvider(runtime) if err != nil { @@ -56,6 +59,8 @@ func RuntimeToHarnessProvider(runtime string) (string, error) { return "claude", nil case "open_code": return "opencode", nil + case "aforge": + return "aforge", nil default: return "codex", nil } @@ -65,9 +70,10 @@ func RuntimeToHarnessProvider(runtime string) (string, error) { // values. // // Ports runtime_to_harness_adapter: claude_code -> "claude-code", open_code -> -// "opencode", codex -> "codex". Differs from RuntimeToHarnessProvider only for -// claude_code ("claude-code" here vs "claude" there). Normalizes first, -// propagating the normalize error for unsupported input. +// "opencode", aforge -> "aforge", codex -> "codex". Differs from +// RuntimeToHarnessProvider only for claude_code ("claude-code" here vs "claude" +// there). Normalizes first, propagating the normalize error for unsupported +// input. func RuntimeToHarnessAdapter(runtime string) (string, error) { normalized, err := NormalizeRuntimeProvider(runtime) if err != nil { @@ -78,6 +84,8 @@ func RuntimeToHarnessAdapter(runtime string) (string, error) { return "claude-code", nil case "open_code": return "opencode", nil + case "aforge": + return "aforge", nil default: return "codex", nil } diff --git a/go/internal/runtimex/providers_test.go b/go/internal/runtimex/providers_test.go index 87c8a4df..d71ef4ba 100644 --- a/go/internal/runtimex/providers_test.go +++ b/go/internal/runtimex/providers_test.go @@ -4,7 +4,7 @@ import "testing" // Contract: RuntimeValues is exactly the Python RUNTIME_VALUES tuple, in order. func TestRuntimeValues(t *testing.T) { - want := [...]string{"claude_code", "open_code", "codex"} + want := [...]string{"aforge", "claude_code", "open_code", "codex"} if RuntimeValues != want { t.Fatalf("RuntimeValues = %v, want %v", RuntimeValues, want) } @@ -13,6 +13,7 @@ func TestRuntimeValues(t *testing.T) { // Contract: aliases fold to canonical runtimes. // - "claude"/"claude-code"/"claude_code" -> "claude_code" // - "opencode"/"open_code" -> "open_code" +// - "aforge"/"aforge_v2"/"aforge-v2" -> "aforge" // - "codex" -> "codex" // - case/whitespace insensitive (trim + lower) func TestNormalizeRuntimeProvider(t *testing.T) { @@ -25,11 +26,15 @@ func TestNormalizeRuntimeProvider(t *testing.T) { {"claude_code", "claude_code"}, {"opencode", "open_code"}, {"open_code", "open_code"}, + {"aforge", "aforge"}, + {"aforge_v2", "aforge"}, + {"aforge-v2", "aforge"}, {"codex", "codex"}, // trim + lowercase normalization {" Claude ", "claude_code"}, {"CLAUDE-CODE", "claude_code"}, {"OpenCode", "open_code"}, + {" AForge ", "aforge"}, {"\tCODEX\n", "codex"}, } for _, c := range cases { @@ -70,7 +75,8 @@ func TestNormalizeRuntimeProviderUnsupported(t *testing.T) { } // Contract: canonical runtime -> harness provider string. -// claude_code -> "claude", open_code -> "opencode", codex -> "codex". +// claude_code -> "claude", open_code -> "opencode", aforge -> "aforge", +// codex -> "codex". func TestRuntimeToHarnessProvider(t *testing.T) { cases := []struct { in string @@ -81,6 +87,8 @@ func TestRuntimeToHarnessProvider(t *testing.T) { {"claude-code", "claude"}, {"open_code", "opencode"}, {"opencode", "opencode"}, + {"aforge", "aforge"}, + {"aforge-v2", "aforge"}, {"codex", "codex"}, } for _, c := range cases { @@ -96,7 +104,8 @@ func TestRuntimeToHarnessProvider(t *testing.T) { } // Contract: canonical runtime -> harness adapter string. -// claude_code -> "claude-code", open_code -> "opencode", codex -> "codex". +// claude_code -> "claude-code", open_code -> "opencode", aforge -> "aforge", +// codex -> "codex". func TestRuntimeToHarnessAdapter(t *testing.T) { cases := []struct { in string @@ -107,6 +116,8 @@ func TestRuntimeToHarnessAdapter(t *testing.T) { {"claude-code", "claude-code"}, {"open_code", "opencode"}, {"opencode", "opencode"}, + {"aforge", "aforge"}, + {"aforge-v2", "aforge"}, {"codex", "codex"}, } for _, c := range cases { @@ -123,7 +134,7 @@ func TestRuntimeToHarnessAdapter(t *testing.T) { // Contract (the asymmetry): provider vs adapter strings differ ONLY for claude. // For every canonical runtime, compare the two mappings; they must match for -// open_code and codex and differ for claude_code. +// aforge, open_code and codex and differ for claude_code. func TestProviderAdapterAsymmetry(t *testing.T) { for _, rt := range RuntimeValues { provider, err := RuntimeToHarnessProvider(rt) diff --git a/pyproject.toml b/pyproject.toml index 24046524..37ac02cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,11 @@ requires-python = ">=3.12" dependencies = [ # >=0.1.96 ships ReasonerFailed, which build() raises so an empty build # reports `failed` (not `succeeded`) with its result preserved (#82 Gap 2). - "agentfield>=0.1.113", + # >=0.1.129 ships the `aforge` harness provider (`aforge exec --json`), + # which the aforge runtime dispatches to. >=0.1.130 is the release carrying + # agentfield#905, so AFORGE_BIN / AGENTFIELD_AFORGE_COMMAND are honored and + # the provider passes an explicit --timeout. + "agentfield>=0.1.130", "pydantic>=2.0", # Compatibility pin: newer SDK builds have surfaced # "Unknown message type: rate_limit_event" during streaming. diff --git a/requirements-docker.txt b/requirements-docker.txt index 1c5ff7db..16e89628 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -2,7 +2,7 @@ # # Same runtime dependencies as requirements.txt. -agentfield>=0.1.111 +agentfield>=0.1.130 pydantic>=2.0 claude-agent-sdk==0.1.20 hax-sdk>=0.2.4 diff --git a/requirements.txt b/requirements.txt index 53da52c2..c0799505 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # # Install: python -m pip install -r requirements.txt -agentfield>=0.1.113 +agentfield>=0.1.130 pydantic>=2.0 claude-agent-sdk==0.1.20 hax-sdk>=0.2.4 diff --git a/swe_af/app.py b/swe_af/app.py index 0a7f548c..f3298f23 100644 --- a/swe_af/app.py +++ b/swe_af/app.py @@ -1454,8 +1454,8 @@ async def plan( ``ai_provider`` and the per-role ``*_model`` arguments default to ``None`` and are resolved from the environment so an OpenRouter-only deployment needs zero - config: with only an ``OPENROUTER_API_KEY`` present, the pipeline runs on the - ``open_code`` runtime with the default OpenRouter model instead of Claude + config: with an ``OPENROUTER_API_KEY`` present, the pipeline runs on the + ``aforge`` runtime with the default OpenRouter model instead of Claude (mirroring ``build``/``execute``, which already auto-select via ``_default_runtime``). Any explicitly passed value always wins. """ diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index 532c056b..aec96ade 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -616,6 +616,9 @@ class QASynthesisResult(BaseModel): _OPENROUTER_AUTO_DEFAULT_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" _RUNTIME_BASE_MODELS: dict[str, dict[str, str]] = { + "aforge": { + **{field: _OPENROUTER_AUTO_DEFAULT_MODEL for field in ALL_MODEL_FIELDS}, + }, "claude_code": { **{field: "sonnet" for field in ALL_MODEL_FIELDS}, "qa_synthesizer_model": "haiku", @@ -656,39 +659,35 @@ def _codex_default_model() -> str: return _CODEX_CHATGPT_MODEL if _codex_uses_chatgpt_auth() else _CODEX_API_KEY_MODEL -def _runtime_to_provider(runtime: str) -> Literal["claude", "opencode", "codex"]: +def _runtime_to_provider(runtime: str) -> Literal["aforge", "claude", "opencode", "codex"]: return runtime_to_harness_provider(runtime) # type: ignore[return-value] def _openrouter_only_env() -> bool: """Whether the deployer implicitly chose the OpenRouter runtime. - True when no explicit ``SWE_DEFAULT_RUNTIME`` is set, no Anthropic key is - present, but an ``OPENROUTER_API_KEY`` is — i.e. the user "went with - OpenRouter" without spelling out a runtime. In that case SWE-AF defaults to - the ``open_code`` runtime and to ``_OPENROUTER_AUTO_DEFAULT_MODEL``. Setting - ``SWE_DEFAULT_RUNTIME`` (to anything) opts out and preserves the explicit + True when no explicit ``SWE_DEFAULT_RUNTIME`` is set and an + ``OPENROUTER_API_KEY`` is present. In that case SWE-AF defaults to AForge; + setting ``SWE_DEFAULT_RUNTIME`` opts out and preserves the explicit runtime's own defaults. """ if os.getenv("SWE_DEFAULT_RUNTIME", "").strip(): return False - if os.getenv("ANTHROPIC_API_KEY", "").strip(): - return False return bool(os.getenv("OPENROUTER_API_KEY", "").strip()) -def _default_runtime() -> Literal["claude_code", "open_code", "codex"]: +def _default_runtime() -> Literal["aforge", "claude_code", "open_code", "codex"]: """Default runtime, honoring the ``SWE_DEFAULT_RUNTIME`` env var. Lets the deployer pick the runtime without every caller having to pass - a config. When ``SWE_DEFAULT_RUNTIME`` is unset, auto-selects ``open_code`` - if only an OpenRouter key is present (see ``_openrouter_only_env``), + a config. When ``SWE_DEFAULT_RUNTIME`` is unset, auto-selects ``aforge`` + if an OpenRouter key is present (see ``_openrouter_only_env``), otherwise ``claude_code``. Logs and falls back to ``claude_code`` when the env value isn't a valid runtime. """ value = os.getenv("SWE_DEFAULT_RUNTIME", "").strip() if not value: - return "open_code" if _openrouter_only_env() else "claude_code" + return "aforge" if _openrouter_only_env() else "claude_code" if value in RUNTIME_VALUES: return value # type: ignore[return-value] logging.getLogger(__name__).warning( @@ -930,7 +929,7 @@ class BuildConfig(BaseModel): model_config = ConfigDict(extra="forbid") - runtime: Literal["claude_code", "open_code", "codex"] = Field(default_factory=_default_runtime) + runtime: Literal["aforge", "claude_code", "open_code", "codex"] = Field(default_factory=_default_runtime) models: dict[str, str] | None = None max_review_iterations: int = 2 @@ -1049,7 +1048,7 @@ def model_post_init(self, __context: Any) -> None: _validate_flat_models(self.models) @property - def ai_provider(self) -> Literal["claude", "opencode", "codex"]: + def ai_provider(self) -> Literal["aforge", "claude", "opencode", "codex"]: return _runtime_to_provider(self.runtime) @property @@ -1239,7 +1238,7 @@ class ExecutionConfig(BaseModel): model_config = ConfigDict(extra="forbid") - runtime: Literal["claude_code", "open_code", "codex"] = Field(default_factory=_default_runtime) + runtime: Literal["aforge", "claude_code", "open_code", "codex"] = Field(default_factory=_default_runtime) models: dict[str, str] | None = None _resolved_models: dict[str, str] = PrivateAttr(default_factory=dict) @@ -1291,7 +1290,7 @@ def _model_for(self, field_name: str) -> str: return self._resolved_models[field_name] @property - def ai_provider(self) -> Literal["claude", "opencode", "codex"]: + def ai_provider(self) -> Literal["aforge", "claude", "opencode", "codex"]: return _runtime_to_provider(self.runtime) @property diff --git a/swe_af/fast/app.py b/swe_af/fast/app.py index 66571b63..e227a2cb 100644 --- a/swe_af/fast/app.py +++ b/swe_af/fast/app.py @@ -59,6 +59,8 @@ def _runtime_to_provider(runtime: str) -> str: return "claude" if runtime == "codex": return "codex" + if runtime == "aforge": + return "aforge" return "opencode" diff --git a/swe_af/fast/schemas.py b/swe_af/fast/schemas.py index 8f0f1b6c..68f84890 100644 --- a/swe_af/fast/schemas.py +++ b/swe_af/fast/schemas.py @@ -20,6 +20,7 @@ _OPEN_CODE_DEFAULT = "openrouter/deepseek/deepseek-v4-flash-0731" _RUNTIME_DEFAULTS: dict[str, str] = { + "aforge": _OPEN_CODE_DEFAULT, "claude_code": _CLAUDE_CODE_DEFAULT, "open_code": _OPEN_CODE_DEFAULT, # codex is resolved dynamically (auth-mode dependent); see _runtime_default(). @@ -110,14 +111,14 @@ class FastVerificationResult(BaseModel): def _default_fast_runtime() -> str: """Default runtime for fast builds, honoring ``SWE_DEFAULT_RUNTIME``. - When unset (or blank), auto-selects ``open_code`` if only an OpenRouter key - is present — the same detection the main path uses — else ``claude_code``. + When unset (or blank), auto-selects ``aforge`` if an OpenRouter key is + present — the same detection the main path uses — else ``claude_code``. """ value = os.getenv("SWE_DEFAULT_RUNTIME", "").strip() if not value: from swe_af.execution.schemas import _openrouter_only_env # noqa: PLC0415 - return "open_code" if _openrouter_only_env() else "claude_code" + return "aforge" if _openrouter_only_env() else "claude_code" return value if value in RUNTIME_VALUES else "claude_code" @@ -126,7 +127,7 @@ class FastBuildConfig(BaseModel): model_config = ConfigDict(extra="forbid") - runtime: Literal["claude_code", "open_code", "codex"] = Field(default_factory=_default_fast_runtime) + runtime: Literal["aforge", "claude_code", "open_code", "codex"] = Field(default_factory=_default_fast_runtime) models: dict[str, str] | None = None max_tasks: int = 10 task_timeout_seconds: int = 300 diff --git a/swe_af/issue/schemas.py b/swe_af/issue/schemas.py index c93f5ac9..2f5831ff 100644 --- a/swe_af/issue/schemas.py +++ b/swe_af/issue/schemas.py @@ -106,7 +106,7 @@ class IssueBuildConfig(BaseModel): model_config = ConfigDict(extra="forbid") - runtime: Literal["claude_code", "open_code", "codex"] = Field( + runtime: Literal["aforge", "claude_code", "open_code", "codex"] = Field( default_factory=_default_runtime ) models: dict[str, str] | None = None diff --git a/swe_af/runtime/providers.py b/swe_af/runtime/providers.py index 8784253e..f6009bb5 100644 --- a/swe_af/runtime/providers.py +++ b/swe_af/runtime/providers.py @@ -2,7 +2,7 @@ from __future__ import annotations -RUNTIME_VALUES = ("claude_code", "open_code", "codex") +RUNTIME_VALUES = ("aforge", "claude_code", "open_code", "codex") def normalize_runtime_provider(runtime: str) -> str: @@ -12,6 +12,8 @@ def normalize_runtime_provider(runtime: str) -> str: return "claude_code" if value in {"open_code", "opencode"}: return "open_code" + if value in {"aforge", "aforge_v2", "aforge-v2"}: + return "aforge" if value == "codex": return "codex" raise ValueError(f"Unsupported runtime provider: {runtime}") @@ -24,6 +26,8 @@ def runtime_to_harness_provider(runtime: str) -> str: return "claude" if normalized == "open_code": return "opencode" + if normalized == "aforge": + return "aforge" return "codex" @@ -34,4 +38,6 @@ def runtime_to_harness_adapter(runtime: str) -> str: return "claude-code" if normalized == "open_code": return "opencode" + if normalized == "aforge": + return "aforge" return "codex" diff --git a/tests/fast/test_docker_config.py b/tests/fast/test_docker_config.py index f56decb0..130dde8c 100644 --- a/tests/fast/test_docker_config.py +++ b/tests/fast/test_docker_config.py @@ -98,9 +98,7 @@ def test_codex_auth_mode_env_in_swe_agent_and_swe_fast(): def test_default_runtime_env_in_swe_agent_and_swe_fast(): - # Empty = auto-select (open_code when only an OpenRouter key is present, - # else claude_code). A baked claude_code fallback here would break - # OpenRouter-only deployments. + # Empty = auto-select (aforge when OpenRouter is present, else claude_code). expected = "SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-}" assert expected in _service_environment("swe-agent") assert expected in _service_environment("swe-fast") diff --git a/tests/fast/test_schemas.py b/tests/fast/test_schemas.py index 1c7c5a15..4bafb2a0 100644 --- a/tests/fast/test_schemas.py +++ b/tests/fast/test_schemas.py @@ -31,14 +31,14 @@ def test_runtime_default(self, monkeypatch) -> None: cfg = FastBuildConfig() assert cfg.runtime == "claude_code" - def test_runtime_auto_selects_open_code_with_only_openrouter_key(self, monkeypatch) -> None: + def test_runtime_auto_selects_aforge_with_openrouter_key(self, monkeypatch) -> None: # Same auto-detect as the main path: an OpenRouter key with no - # Anthropic key and no explicit runtime selects open_code. + # Anthropic key and no explicit runtime selects aforge. monkeypatch.delenv("SWE_DEFAULT_RUNTIME", raising=False) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or") cfg = FastBuildConfig() - assert cfg.runtime == "open_code" + assert cfg.runtime == "aforge" def test_max_tasks_default(self) -> None: cfg = FastBuildConfig() diff --git a/tests/test_model_config.py b/tests/test_model_config.py index a7a38967..e63678e6 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -89,6 +89,11 @@ def test_open_code_defaults(self) -> None: for field in ALL_MODEL_FIELDS: self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash-0731") + def test_aforge_defaults(self) -> None: + resolved = resolve_runtime_models(runtime="aforge", models=None) + for field in ALL_MODEL_FIELDS: + self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash-0731") + def test_models_default_applies_to_all(self) -> None: resolved = resolve_runtime_models( runtime="claude_code", @@ -127,23 +132,31 @@ def test_open_code_runtime_provider(self) -> None: resolved = cfg.resolved_models() self.assertEqual(resolved["coder_model"], "openrouter/deepseek/deepseek-v4-flash-0731") + def test_aforge_runtime_provider(self) -> None: + cfg = BuildConfig(runtime="aforge") + self.assertEqual(cfg.ai_provider, "aforge") + self.assertEqual( + cfg.resolved_models()["coder_model"], + "openrouter/deepseek/deepseek-v4-flash-0731", + ) + class TestOpenRouterAutoSelection(unittest.TestCase): - """When only an OpenRouter key is present (no explicit runtime), SWE-AF - auto-selects the open_code runtime and defaults to DeepSeek.""" + """When an OpenRouter key is present (no explicit runtime), SWE-AF + auto-selects the aforge runtime and defaults to DeepSeek — including when + an Anthropic key is also set.""" - def test_openrouter_only_auto_selects_open_code(self) -> None: + def test_openrouter_auto_selects_aforge(self) -> None: with _provider_env(OPENROUTER_API_KEY="sk-or-x"): - self.assertEqual(_default_runtime(), "open_code") + self.assertEqual(_default_runtime(), "aforge") def test_anthropic_key_keeps_claude_code(self) -> None: with _provider_env(ANTHROPIC_API_KEY="sk-ant"): self.assertEqual(_default_runtime(), "claude_code") - def test_both_keys_keep_claude_code(self) -> None: - # Anthropic present → claude_code even if OpenRouter is also set. + def test_openrouter_wins_when_both_keys_are_present(self) -> None: with _provider_env(ANTHROPIC_API_KEY="sk-ant", OPENROUTER_API_KEY="sk-or"): - self.assertEqual(_default_runtime(), "claude_code") + self.assertEqual(_default_runtime(), "aforge") def test_no_keys_default_claude_code(self) -> None: with _provider_env(): @@ -156,7 +169,7 @@ def test_explicit_runtime_overrides_autoselect(self) -> None: def test_auto_openrouter_defaults_to_deepseek(self) -> None: with _provider_env(OPENROUTER_API_KEY="sk-or"): - resolved = resolve_runtime_models(runtime="open_code", models=None) + resolved = resolve_runtime_models(runtime="aforge", models=None) for field in ALL_MODEL_FIELDS: self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash-0731") @@ -178,7 +191,7 @@ def test_swe_default_model_overrides_auto_deepseek(self) -> None: def test_build_config_auto_openrouter_end_to_end(self) -> None: with _provider_env(OPENROUTER_API_KEY="sk-or"): cfg = BuildConfig() - self.assertEqual(cfg.runtime, "open_code") + self.assertEqual(cfg.runtime, "aforge") resolved = cfg.resolved_models() self.assertEqual(resolved["coder_model"], "openrouter/deepseek/deepseek-v4-flash-0731") diff --git a/tests/test_planner_pipeline.py b/tests/test_planner_pipeline.py index b5c6ae82..bcb1718e 100644 --- a/tests/test_planner_pipeline.py +++ b/tests/test_planner_pipeline.py @@ -372,8 +372,8 @@ def _happy_path_side_effect() -> list: @pytest.mark.asyncio -async def test_plan_openrouter_only_defaults_to_open_code(mock_agent_ai, tmp_path, monkeypatch): - """Only an OpenRouter key present → plan() runs on open_code with the default +async def test_plan_openrouter_defaults_to_aforge(mock_agent_ai, tmp_path, monkeypatch): + """An OpenRouter key present → plan() runs on aforge with the default OpenRouter model, with no ai_provider/model args passed.""" for k in _PROVIDER_ENV_KEYS: monkeypatch.delenv(k, raising=False) @@ -384,7 +384,7 @@ async def test_plan_openrouter_only_defaults_to_open_code(mock_agent_ai, tmp_pat pm_call = mock_agent_ai.call_args_list[0] assert pm_call.args[0].endswith("run_product_manager") - assert pm_call.kwargs["ai_provider"] == "open_code" + assert pm_call.kwargs["ai_provider"] == "aforge" assert pm_call.kwargs["model"] == "openrouter/deepseek/deepseek-v4-flash-0731" @@ -409,7 +409,7 @@ async def test_plan_explicit_args_override_env(mock_agent_ai, tmp_path, monkeypa """Explicit ai_provider/model always win over the env-resolved defaults.""" for k in _PROVIDER_ENV_KEYS: monkeypatch.delenv(k, raising=False) - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") # would otherwise force open_code + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") # would otherwise force aforge mock_agent_ai.side_effect = _happy_path_side_effect() await _run_plan_defaults(str(tmp_path), ai_provider="codex", pm_model="gpt-5") @@ -431,5 +431,5 @@ async def test_plan_swe_default_model_overrides_openrouter_auto(mock_agent_ai, t await _run_plan_defaults(str(tmp_path)) pm_call = mock_agent_ai.call_args_list[0] - assert pm_call.kwargs["ai_provider"] == "open_code" + assert pm_call.kwargs["ai_provider"] == "aforge" assert pm_call.kwargs["model"] == "openrouter/qwen/qwen3-max" diff --git a/tests/test_runtime_aware_model_default.py b/tests/test_runtime_aware_model_default.py index c6fb7166..9771a555 100644 --- a/tests/test_runtime_aware_model_default.py +++ b/tests/test_runtime_aware_model_default.py @@ -180,7 +180,7 @@ def test_omitted_runtime_falls_back_to_env_resolution( ) -> None: """No runtime arg → runtime is resolved from env (historical behavior).""" monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") - # OpenRouter-only env auto-selects open_code, so the auto default applies. + # OpenRouter env auto-selects aforge, which shares this model default. assert _default_planning_model() == _OPENROUTER_AUTO_DEFAULT_MODEL diff --git a/tests/test_runtime_provider_routing.py b/tests/test_runtime_provider_routing.py index a6185096..82eebaf2 100644 --- a/tests/test_runtime_provider_routing.py +++ b/tests/test_runtime_provider_routing.py @@ -7,6 +7,11 @@ def test_runtime_to_harness_adapter_supports_codex() -> None: assert runtime_to_harness_adapter("codex") == "codex" +def test_runtime_to_harness_adapter_supports_aforge_aliases() -> None: + assert runtime_to_harness_adapter("aforge") == "aforge" + assert runtime_to_harness_adapter("aforge-v2") == "aforge" + + def test_execution_agents_source_uses_shared_runtime_adapter() -> None: import inspect import swe_af.reasoners.execution_agents as execution_agents