diff --git a/.env.example b/.env.example index 1a5de276..144c412e 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,26 @@ -# --- Required (one of these) --- +# --- 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. + +# 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 +# openrouter/deepseek/deepseek-v4-flash. Override with SWE_DEFAULT_MODEL. +# OPENROUTER_API_KEY=sk-or-v1-... -# Option A: Anthropic API key — used by claude-agent-sdk for all coding agents -ANTHROPIC_API_KEY=sk-ant-api03-... +# Option B: Anthropic API key — used by claude-agent-sdk for all coding agents +# ANTHROPIC_API_KEY=sk-ant-api03-... -# Option B: Claude Code subscription OAuth token (run `claude setup-token` to get it) +# Option C: Claude Code subscription OAuth token (run `claude setup-token` to get it) # Uses your Pro/Max subscription credits instead of API billing # CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... -# Option C: Open-source models (DeepSeek, Qwen, Llama, MiniMax, etc.) -# Access 200+ open-source and proprietary models via OpenRouter, OpenAI, or Google -# Configure one or more API keys: - -# OpenRouter (recommended - 200+ models including DeepSeek, Qwen, Llama, MiniMax) -# OPENROUTER_API_KEY=sk-or-v1-... -# With ONLY an OpenRouter key set (no ANTHROPIC_API_KEY, no SWE_DEFAULT_RUNTIME), -# SWE-AF auto-selects the open_code runtime and defaults to -# openrouter/deepseek/deepseek-v4-flash. Override the model with SWE_DEFAULT_MODEL. +# --- Optional: additional providers --- # OpenAI API-platform billing for OpenAI models and Codex api_key mode. # OPENAI_API_KEY=sk-... @@ -52,6 +57,10 @@ ANTHROPIC_API_KEY=sk-ant-api03-... # AI_MODEL overrides the default model for the direct-LLM path (default gpt-4o). # The coding loop passes the qa_synthesizer role model (default "haiku"), which # WithModel overrides per call, so AI_MODEL is only the fallback default. +# WARNING: AI_MODEL is ALSO the second step of the role-model cascade +# (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL), so setting it here +# repoints every agent role too. To pick a model for the roles, set +# SWE_DEFAULT_MODEL instead and leave this one unset. # AI_MODEL=anthropic/claude-haiku-4.5 # --- Optional: Web search (open runtime) --- @@ -71,8 +80,11 @@ ANTHROPIC_API_KEY=sk-ant-api03-... # --- Optional: GitHub integration --- # GitHub personal access token (repo scope) — for draft PR creation -# Only needed if using repo_url + enable_github_pr -GH_TOKEN=ghp_... +# Optional: builds on local and public repos run without it. Needed to clone +# private repos, push branches, and open pull requests. Leave commented out +# unless you have a real token — a placeholder value is worse than none, +# because git and gh will try to authenticate with it and fail. +# GH_TOKEN=ghp_... # --- Optional: Build-time test database --- @@ -112,17 +124,22 @@ GH_TOKEN=ghp_... # 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. Falls back to claude_code if unset; an invalid value is -# logged as a warning and ignored. +# 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 default. # SWE_DEFAULT_RUNTIME=claude_code # or: open_code, codex # 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 -# minimax-m2.5 to a newer release. Empty / unset → use the runtime's -# baked-in defaults. -# SWE_DEFAULT_MODEL=openrouter/minimax/minimax-m2.6 +# deepseek-v4-flash to a newer release. Empty / unset → use the runtime's +# baked-in defaults (openrouter/deepseek/deepseek-v4-flash on 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. +# SWE_DEFAULT_MODEL=openrouter/deepseek/deepseek-v4-flash # Per-tier models. Each of the 17 agent roles belongs to one of three tiers: # high = planning-heavy reasoning (pm, architect, tech_lead, replan) @@ -168,6 +185,7 @@ GH_TOKEN=ghp_... # {"runtime": "codex", "models": {"default": "gpt-5.3-codex"}} # Available open runtime model IDs (format: provider/model-name): +# openrouter/deepseek/deepseek-v4-flash # the open_code default # deepseek/deepseek-chat # DeepSeek via OpenRouter # minimax/minimax-m2.5 # MiniMax M2.5 via OpenRouter # qwen/qwen-2.5-72b-instruct # Qwen via OpenRouter @@ -175,3 +193,35 @@ GH_TOKEN=ghp_... # anthropic/claude-sonnet-4 # Claude via Anthropic # zhipuai-coding-plan/glm-4.7 # GLM-4.7 via Z.AI direct (set ZHIPU_API_KEY) # openrouter/z-ai/glm-5 # GLM-5 via OpenRouter (set OPENROUTER_API_KEY) + +# --- High-performance coding engine (Go node, ON by default) --- +# +# The Go node ships prebuilt coding engines — one per supported platform, at +# go/bin/swe-pro-darwin-arm64 and go/bin/swe-pro-linux-amd64 — that take over +# per-issue coding work. `af install` turns this on for you (the manifest +# defaults it to 1): the engine registers as its own node on the control plane +# and builds route their coding through it. Set it to 0 to use the classic +# coding loop instead. If the binary is missing, the node logs a warning and +# keeps using the classic loop. +# SWE_PRO_ENGINE=0 + +# Engine sidecar identity and reachability. In containers, set the public URL +# so the control plane can reach the engine's callback (mirrors +# AGENT_CALLBACK_URL on the SWE-AF nodes); the engine otherwise advertises +# http://localhost:8801, which a control-plane container cannot reach. +# SWE_PRO_NODE_ID=swe-pro +# SWE_PRO_PORT=8801 +# SWE_PRO_PUBLIC_URL=http://swe-agent-go:8801 +# Only needed to point at an engine build outside the shipped layout: the node +# already finds /usr/local/bin/swe-pro (Docker) or the swe-pro-- +# binary next to itself (an `af install` checkout). +# SWE_PRO_BIN=/usr/local/bin/swe-pro + +# Engine model pools and reasoning effort. Unset keeps the engine's own +# defaults (all OpenRouter ids, so an OpenRouter key is all it needs). +# SWE_PRO_MODELS_HIGH=openrouter/deepseek/deepseek-v4-pro +# SWE_PRO_MODELS_LOW=openrouter/deepseek/deepseek-v4-flash +# SWE_PRO_VARIANT=low +# Per-run USD ceiling. Unset = no per-run cap; set this on shared or +# unattended deployments so a wide issue DAG cannot run away. +# SWE_PRO_MAX_COST=5 diff --git a/README.md b/README.md index af7d3ae4..0a8d1340 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ af install https://github.com/Agent-Field/SWE-AF af run swe-planner ``` -`af install` clones the repo, provisions an isolated Python environment, and registers the `swe-planner` node with your control plane. On first `af run` you're prompted for the required secrets — an LLM provider key (`ANTHROPIC_API_KEY` **or** `OPENROUTER_API_KEY`) plus `GH_TOKEN` — which are stored encrypted and reused across every node, so you enter each only once. Then kick off a build: +`af install` clones the repo, provisions an isolated Python environment, and registers the `swe-planner` node with your control plane. On first `af run` you're prompted for the one required secret — an LLM provider key (`ANTHROPIC_API_KEY` **or** `OPENROUTER_API_KEY`) — which is stored encrypted and reused across every node, so you enter it only once. (Add `GH_TOKEN` when you want builds to clone private repos and open pull requests.) Then kick off a build: ```bash af call swe-planner.build --in '{"goal": "Add JWT auth", "repo_url": "https://github.com/user/my-repo"}' @@ -244,10 +244,14 @@ New to AgentField? Install the control plane first with `curl -fsSL https://agen [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/swe-af) -One click deploys SWE-AF + AgentField control plane + PostgreSQL. Set two environment variables in Railway: +One click deploys SWE-AF + AgentField control plane + PostgreSQL. Exactly **one** environment variable is required in Railway — an LLM provider key: -- `CLAUDE_CODE_OAUTH_TOKEN` — run `claude setup-token` in [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (uses Pro/Max subscription credits) -- `GH_TOKEN` — GitHub personal access token with `repo` scope for PR creation +- `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` — no further configuration needed. +- *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: + +- `GH_TOKEN` — GitHub personal access token with `repo` scope. Needed only to clone **private** repos, push branches, and open pull requests; builds against public repos work without it. Once deployed, trigger a build: @@ -509,16 +513,22 @@ Benchmark assets, logs, evaluator, and generated projects live in [`examples/age ```bash cp .env.example .env -# Add your API key: ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY -# Optionally add GH_TOKEN for PR workflow +# Uncomment exactly ONE provider key: OPENROUTER_API_KEY (recommended), +# ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_API_KEY, or GOOGLE_API_KEY +# Optionally add GH_TOKEN (private-repo clones, pushing branches, opening PRs) docker compose up -d ``` +> `.env.example` ships with **every** provider key commented out — uncomment +> exactly one. In particular, don't leave a placeholder `ANTHROPIC_API_KEY` +> value in place: any non-empty value forces the `claude_code` runtime and +> breaks an OpenRouter-only setup. + Submit a build: ```bash -# Default (Claude) +# Default runtime (auto-selected from whichever provider key is in .env) curl -X POST http://localhost:8080/api/v1/execute/async/swe-planner.build \ -H "Content-Type: application/json" \ -d @- <<'JSON' @@ -600,7 +610,9 @@ JSON Requirements: -- `GH_TOKEN` in `.env` with `repo` scope +- `GH_TOKEN` in `.env` with `repo` scope — required for *this* workflow, since + it clones private repos, pushes the branch, and opens the PR. Builds that + stay local (`repo_path`) or target a public repo don't need it. - Repo access for that token ### Post-PR CI gate @@ -735,8 +747,8 @@ Notes for main-harness authors: - Cap your fan-out: each delegation is a paid multi-agent run. A handful of concurrent issues per repo is the sweet spot — the node also bounds its own concurrency. -- Available identically on `swe-fast.implement_issue` and, in the Go port, on - `swe-planner-go` / `swe-fast-go`. +- Available identically on `swe-fast.implement_issue`, and on the Go + implementation under those same node ids. A ready-made Claude Code skill for this flow ships in [`.claude/skills/delegate-issue/`](.claude/skills/delegate-issue/SKILL.md). @@ -920,17 +932,33 @@ make clean-examples --- -## Go implementation (opt-in) +## Go implementation -This repo also ships a Go port of the node under [`go/`](go/README.md). The -**Python implementation is the default** — everything above is unchanged and -still runs as `swe-planner` (`:8003`) / `swe-fast` (`:8004`). The Go port -registers **separately** as `swe-planner-go` (`:8005`) and `swe-fast-go` -(`:8006`), so both stacks can run against one control plane simultaneously. -Opt in by targeting the `-go` reasoner path (e.g. -`POST /api/v1/execute/async/swe-planner-go.build`). See +The node under [`go/`](go/README.md) is what `af install` gives you, and it +registers under the same ids as everything above — `swe-planner` and +`swe-fast` — so no trigger, reasoner name, or API shape changes with it. The +repo-root manifest declares itself `superseded_by` `//go`, so +`af install https://github.com/Agent-Field/SWE-AF` lands there and replaces an +existing Python install in place, keeping its node-scoped secrets. + +The Python implementation is unchanged and still what `python -m swe_af` and +the compose stack in `docker-compose.yml` run. Because the two now answer to +the same node ids, running both against one control plane needs an explicit +`NODE_ID` on one of them — `docker-compose.go.yml` does that. See [`go/README.md`](go/README.md) for build, run, and Docker instructions. +### Coding engine (opt-in preview) + +The Go node ships a prebuilt high-performance coding engine next to the +classic coding loop. It is **inert by default** — nothing changes unless you +set `SWE_PRO_ENGINE=1`. With the flag set, builds route per-issue coding +through the engine; unset it and the node returns to the classic +coder → reviewer/QA loop. If the binary isn't present, the node logs a +warning and keeps using the classic loop, so the flag is safe to leave on. +Tuning knobs (`SWE_PRO_VARIANT`, `SWE_PRO_MAX_COST`, `SWE_PRO_PUBLIC_URL`) +and the full env surface are documented in +[`go/docs/pro-engine.md`](go/docs/pro-engine.md). + --- ### Also built on AgentField diff --git a/agentfield-package.yaml b/agentfield-package.yaml index 1a393a2f..ee439b92 100644 --- a/agentfield-package.yaml +++ b/agentfield-package.yaml @@ -4,6 +4,21 @@ version: 0.1.0 description: SWE planning/execution agent node (plans and executes software-engineering tasks) author: Agent-Field +# The Go node in go/ is the maintained SWE node: same reasoners, same +# interface, one static binary, and it ships the coding engine. Installing this +# repo installs that instead — so `af install https://github.com/Agent-Field/SWE-AF` +# is the one thing a user has to know, before and after the switch. +# +# go/ declares this same name deliberately, so the switch is a replacement in +# place: same node id, same triggers, node-scoped secrets kept. Only one of the +# two can be installed at a time, which is the point. +# +# This manifest stays here as the redirect, so the Python node is still what +# `python -m swe_af` and the Docker images run. The redirect is a git-install +# behaviour only: to install this node deliberately, clone the repo and install +# the checkout as a local path. +superseded_by: https://github.com/Agent-Field/SWE-AF//go + entrypoint: start: python -m swe_af healthcheck: /health @@ -28,16 +43,17 @@ user_environment: description: OpenRouter API key (DeepSeek/Qwen/Llama/… — 200+ models) type: secret scope: global - required: - # SWE-AF's core loop clones a repo, pushes a branch, and opens a pull - # request — all of which need GitHub write access. `gh`/`git` read this - # token for authentication (see prompts/github_pr.py). Required so setup - # prompts for it up front rather than failing partway through a build. + optional: + # `gh`/`git` read this token for authentication (see prompts/github_pr.py). + # Optional so an LLM key is the only secret needed to get started: builds + # on local/public repos run without it; it is needed to clone private + # repos, push branches, and open pull requests. - name: GH_TOKEN - description: GitHub token (repo scope) for cloning repos and opening pull requests + description: GitHub token (repo scope) — needed to clone private repos, push branches, and open pull requests type: secret scope: global - optional: + - name: SWE_DEFAULT_RUNTIME + description: Coding runtime for every role (claude_code | open_code | codex) - name: SWE_DEFAULT_MODEL description: Override the model id for every role (e.g. openrouter/deepseek/deepseek-v4-flash) - name: AGENTFIELD_SERVER diff --git a/docker-compose.go.yml b/docker-compose.go.yml index 2661ca19..93a411fb 100644 --- a/docker-compose.go.yml +++ b/docker-compose.go.yml @@ -2,8 +2,11 @@ # # The Python docker-compose.yml is the DEFAULT stack (control plane + the Python # swe-planner/swe-fast nodes) and is left 100% untouched. This file adds ONLY -# the two Go nodes, registered under DISTINCT identities so both stacks can run -# against one control plane simultaneously: +# the two Go nodes. Both implementations now default to the SAME node ids +# (swe-planner / swe-fast) — they are one node, not siblings — so running them +# together requires overriding NODE_ID here, which is what the services below +# do. That override exists solely for side-by-side comparison; an installed Go +# node keeps its own default ids. # # swe-agent-go -> node id "swe-planner-go", :8005 # swe-fast-go -> node id "swe-fast-go", :8006 @@ -45,11 +48,30 @@ services: - NODE_ID=swe-planner-go - PORT=8005 - AGENT_CALLBACK_URL=http://swe-agent-go:8005 - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + # 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. + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - GH_TOKEN=${GH_TOKEN:-} + # High-performance coding engine. The on-by-default behaviour belongs to + # `af install` alone (its manifest injects SWE_PRO_ENGINE=1); a checkout + # running in production keeps the binary's own default — off — so export + # SWE_PRO_ENGINE=1 to enable it here. The public URL must be + # container-reachable: the engine otherwise advertises + # http://localhost:8801, which the control plane cannot reach from its + # own container. + - SWE_PRO_ENGINE=${SWE_PRO_ENGINE:-} + - SWE_PRO_PUBLIC_URL=${SWE_PRO_PUBLIC_URL:-http://swe-agent-go:8801} + - SWE_PRO_MODELS_HIGH=${SWE_PRO_MODELS_HIGH:-} + - SWE_PRO_MODELS_LOW=${SWE_PRO_MODELS_LOW:-} + - SWE_PRO_VARIANT=${SWE_PRO_VARIANT:-} + - SWE_PRO_MAX_COST=${SWE_PRO_MAX_COST:-} # Go direct-LLM path (run_qa_synthesizer via the SDK ai.DefaultConfig # client). An OPENAI/OPENROUTER key enables it; AI_BASE_URL/AI_MODEL # override the endpoint/default model. No key -> deterministic fallback. @@ -76,9 +98,9 @@ services: - NODE_ID=swe-fast-go - PORT=8006 - AGENT_CALLBACK_URL=http://swe-fast-go:8006 - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} - - GH_TOKEN=${GH_TOKEN} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - GH_TOKEN=${GH_TOKEN:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} @@ -87,7 +109,8 @@ services: - AI_BASE_URL=${AI_BASE_URL:-} - AI_MODEL=${AI_MODEL:-} - OPENCODE_MODEL=${OPENCODE_MODEL:-} - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + # Empty = auto (see swe-agent-go note). + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - 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.local.yml b/docker-compose.local.yml index 9c3d07aa..252a0edf 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -5,7 +5,7 @@ # # Prerequisites: # - AgentField control plane running on host at localhost:8080 -# - .env file with ANTHROPIC_API_KEY (and optionally GH_TOKEN) +# - .env file with ANTHROPIC_API_KEY or OPENROUTER_API_KEY (and optionally GH_TOKEN) services: # Ephemeral Postgres for build-time integration checks (see docker-compose.yml @@ -33,9 +33,18 @@ services: - PORT=8003 # Callback URL for control plane to reach this agent - AGENT_CALLBACK_URL=http://localhost:8003 - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + # 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. + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - 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 + # works as well as writing them into .env. + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - GH_TOKEN=${GH_TOKEN:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - DATABASE_URL_TEST=${DATABASE_URL_TEST:-postgres://builder:builder@build-db:5432/buildtest} ports: diff --git a/docker-compose.yml b/docker-compose.yml index dd76a9dc..1daf1b9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,9 +40,18 @@ services: - NODE_ID=swe-planner - PORT=8003 - AGENT_CALLBACK_URL=http://swe-agent:8003 - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + # 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. + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - 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 + # works as well as writing them into .env (swe-fast already did this). + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - GH_TOKEN=${GH_TOKEN:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - DATABASE_URL_TEST=${DATABASE_URL_TEST:-postgres://builder:builder@build-db:5432/buildtest} ports: @@ -69,14 +78,16 @@ services: - NODE_ID=swe-fast - PORT=8004 - AGENT_CALLBACK_URL=http://swe-fast:8004 - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} - - GH_TOKEN=${GH_TOKEN} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} + - GH_TOKEN=${GH_TOKEN:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - OPENCODE_MODEL=${OPENCODE_MODEL:-} - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + # Empty = auto: open_code when only an OpenRouter key is present, + # else claude_code (see swe-agent note). + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7454588b..22ce6576 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -441,5 +441,11 @@ Runtime defaults: | Runtime | Base default | Special default | |---|---|---| | `claude_code` | `sonnet` | `qa_synthesizer=haiku` | -| `open_code` | `minimax/minimax-m2.5` | none | +| `open_code` | `openrouter/deepseek/deepseek-v4-flash` | none | | `codex` | `gpt-5.3-codex` | none | + +The `open_code` default applies both when the runtime is auto-selected (only +`OPENROUTER_API_KEY` present — no `ANTHROPIC_API_KEY`, no +`SWE_DEFAULT_RUNTIME`) and when `SWE_DEFAULT_RUNTIME=open_code` is set +explicitly. It is also what fast mode resolves to on that path. Override the +base default with `SWE_DEFAULT_MODEL` or per-request `models`. diff --git a/docs/deployment.md b/docs/deployment.md index c8e97049..70b555b9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,18 +20,16 @@ Copy `.env.example` to `.env` and configure at least one authentication method: cp .env.example .env ``` -**Required (one of):** +**Required — exactly one LLM provider key.** `.env.example` ships with all of +them commented out; uncomment one. Note that any non-empty `ANTHROPIC_API_KEY` +(including a leftover placeholder) forces the `claude_code` runtime, so leave +it commented out unless you mean to use it. | Variable | Purpose | |---|---| -| `ANTHROPIC_API_KEY` | Anthropic API key for Claude models | +| `OPENROUTER_API_KEY` | **Recommended** — OpenRouter key (200+ models). The only secret needed to get started: on its own it auto-selects the `open_code` runtime and defaults every role to `openrouter/deepseek/deepseek-v4-flash` | +| `ANTHROPIC_API_KEY` | Anthropic API key for Claude models (`claude_code` runtime) | | `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code subscription token (uses Pro/Max credits) | - -**For open-source models (alternative to Claude):** - -| Variable | Purpose | -|---|---| -| `OPENROUTER_API_KEY` | OpenRouter API key (200+ models) | | `OPENAI_API_KEY` | OpenAI API key | | `GOOGLE_API_KEY` | Google Gemini API key | @@ -46,7 +44,7 @@ cp .env.example .env | Variable | Purpose | Default | |---|---|---| -| `GH_TOKEN` | GitHub PAT with `repo` scope for draft PRs | *(none)* | +| `GH_TOKEN` | GitHub PAT with `repo` scope — needed only to clone private repos, push branches, and open PRs. Builds on local or public repos work without it | *(none)* | | `AGENTFIELD_SERVER` | Control plane URL | `http://control-plane:8080` (Docker) | | `NODE_ID` | Agent node identifier | `swe-planner` | | `PORT` | Agent listen port | `8003` | @@ -67,7 +65,7 @@ cp .env.example .env ```bash git clone https://github.com/Agent-Field/SWE-AF cd SWE-AF -cp .env.example .env # fill in API keys +cp .env.example .env # uncomment exactly ONE provider key docker compose up -d ``` @@ -85,7 +83,7 @@ If you already have an AgentField control plane running: ```bash git clone https://github.com/Agent-Field/SWE-AF cd SWE-AF -cp .env.example .env # fill in API keys +cp .env.example .env # uncomment exactly ONE provider key # Set AGENTFIELD_SERVER in .env to your control plane URL docker compose -f docker-compose.local.yml up -d diff --git a/go/.gitignore b/go/.gitignore index bf9fcf7a..e6764f83 100644 --- a/go/.gitignore +++ b/go/.gitignore @@ -1,3 +1,11 @@ +# Local build outputs. bin/ is a versioned directory — it carries the vendored +# pro-engine builds under their swe-pro-- names — so the +# unsuffixed names a local `go build -o bin/...` or the installer's build step +# produces are ignored individually rather than by ignoring bin/ wholesale. +bin/swe-planner +bin/swe-fast +bin/swe-pro + # Local Go workspace used during SDK-parity development to resolve the # agentfield Go SDK to the parity worktree. Must NOT be committed — CI/Docker # rely on the go.mod replace + a pinned agentfield checkout instead. diff --git a/go/Dockerfile b/go/Dockerfile index af62556d..23c3a1e4 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -69,7 +69,11 @@ ENV DEBIAN_FRONTEND=noninteractive # OpenCode CLI (open_code runtime), Codex CLI (codex runtime), Claude Code CLI # (claude_code runtime). RUN apt-get update && apt-get install -y --no-install-recommends \ - git curl ca-certificates openssh-client jq nodejs npm && \ + git curl ca-certificates openssh-client jq nodejs npm \ + # Python test tooling: repos under build are frequently Python, and both + # the verifier role and evidence-based audits need a runnable pytest — + # without it, Python-repo verification is impossible in this image. + python3 python3-pytest && \ # GitHub CLI curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ @@ -146,19 +150,26 @@ RUN git config --global user.name "SWE-AF" && \ COPY --from=builder /out/swe-planner /usr/local/bin/swe-planner COPY --from=builder /out/swe-fast /usr/local/bin/swe-fast +# Prebuilt pro-engine binary (static, vendored at go/bin). go/bin carries one +# build per supported platform; this image is linux/amd64 only, so the amd64 +# one is copied to the unsuffixed path the supervisor's default SWE_PRO_BIN +# already points at. Inert unless the node is started with SWE_PRO_ENGINE=1 +# (see go/docs/pro-engine.md). +COPY go/bin/swe-pro-linux-amd64 /usr/local/bin/swe-pro + # Pre-create /workspaces so named-volume mounts inherit correct permissions # (without this, Docker creates it as root read-only on fresh deployments). RUN mkdir -p /workspaces && chmod 777 /workspaces EXPOSE 8005 -# Defaults register the Go planner as "swe-planner-go" on :8005 — a distinct -# identity from the Python swe-planner node (:8003) so both stacks can run -# against one control plane. The swe-fast service overrides NODE_ID/PORT to -# swe-fast-go/:8006 via compose. +# Defaults register this as "swe-planner" on :8005 — the product's node id, so +# callers' triggers are the same whichever implementation is serving them. The +# compose files override NODE_ID where two stacks share one control plane and +# 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-go + NODE_ID=swe-planner 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 5a9ca216..b41355c3 100644 --- a/go/Makefile +++ b/go/Makefile @@ -24,11 +24,11 @@ lint: @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || \ echo "golangci-lint not installed; skipping (install: https://golangci-lint.run)" -# Run the full-pipeline node (swe-planner-go, default port 8005). +# Run the full-pipeline node (swe-planner, default port 8005). run-planner: go run ./cmd/swe-planner -# Run the fast-mode node (swe-fast-go, default port 8006). +# Run the fast-mode node (swe-fast, default port 8006). run-fast: go run ./cmd/swe-fast @@ -47,7 +47,7 @@ docker-build: --build-arg AGENTFIELD_SDK_REF=$(AGENTFIELD_SDK_REF) \ -t $(IMAGE) .. -# Bring up the Go nodes (swe-agent-go:8005 + swe-fast-go:8006) as an ADD-ON to +# Bring up the Go nodes (swe-agent-go:8005 + swe-fast:8006) as an ADD-ON to # the Python stack. Start the Python stack first (`docker compose up` — it owns # the control plane + shared network); this add-on joins that network as an # external reference. diff --git a/go/README.md b/go/README.md index ed8be695..88c8a11b 100644 --- a/go/README.md +++ b/go/README.md @@ -10,26 +10,30 @@ Two binaries: | Binary | Node ID | Default port | Role | |-------------------|------------------|--------------|-----------------------------------| -| `swe-planner` | `swe-planner-go` | `8005` | Full pipeline (plan → DAG → PR) | -| `swe-fast` | `swe-fast-go` | `8006` | Fast mode (lighter-weight path) | +| `swe-planner` | `swe-planner` | `8005` | Full pipeline (plan → DAG → PR) | +| `swe-fast` | `swe-fast` | `8006` | Fast mode (lighter-weight path) | Module path: `github.com/Agent-Field/SWE-AF/go`. -## Opt-in alongside Python +## This is the installed node -The Python node is the **default**: `swe-planner` on `:8003` (and `swe-fast` on -`:8004`), unchanged. This Go port registers **separately** under distinct -identities — `swe-planner-go` on `:8005` and `swe-fast-go` on `:8006` — so both -stacks can run against **one** control plane at the same time. Nothing is -replaced; callers **opt in** by targeting the `-go` reasoner path, e.g. +`af install https://github.com/Agent-Field/SWE-AF` installs this, under the +name `swe-planner` — the repo-root manifest declares itself `superseded_by` +this directory, so the bare URL lands here and an existing Python install is +replaced in place. Same node id, same reasoner names, same triggers: ```bash -curl -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.build \ +curl -X POST http://localhost:8080/api/v1/execute/async/swe-planner.build \ -H 'Content-Type: application/json' \ -d '{"input":{"goal":"...","repo_url":"https://github.com/you/repo"}}' ``` -`NODE_ID` / `PORT` still override the defaults if you want different ids/ports. +The Python package under `swe_af/` is untouched and still what `python -m +swe_af` and the Python compose stack run. Because both now answer to the same +node id, running them together against **one** control plane needs an explicit +`NODE_ID` on one of them — `docker-compose.go.yml` sets `swe-planner-go` / +`swe-fast-go` for exactly that. `NODE_ID` / `PORT` override the defaults +anywhere you need different ids or ports. ## Depending on the AgentField Go SDK @@ -62,8 +66,8 @@ make build # go build ./... make vet # go vet ./... make test # go test ./... make check # vet + test -make run-planner # run the full-pipeline node (swe-planner-go, :8005) -make run-fast # run the fast-mode node (swe-fast-go, :8006) +make run-planner # run the full-pipeline node (swe-planner, :8005) +make run-fast # run the fast-mode node (swe-fast, :8006) ``` `make run-planner` / `make run-fast` need a control plane reachable at @@ -125,10 +129,14 @@ make docker-down Adds: -| Service | Port | Node id | Notes | -|----------------|--------|------------------|----------------------------------------| -| `swe-agent-go` | `8005` | `swe-planner-go` | full pipeline | -| `swe-fast-go` | `8006` | `swe-fast-go` | fast mode (runs the `swe-fast` binary) | +| Service | Port | Node id | Notes | +|----------------|--------|------------------|-------------------------------------------| +| `swe-agent-go` | `8005` | `swe-planner-go` | full pipeline | +| `swe-fast-go` | `8006` | `swe-fast-go` | fast mode (runs the `swe-fast` binary) | + +This add-on stack overrides `NODE_ID` to the `-go` ids on purpose: it joins a +running Python stack on one control plane, and the two would otherwise register +under the same names. The control plane (`:8080`), `build-db`, and the `workspaces` volume come from the Python stack — the Go add-on joins them via the external `swe-af_default` @@ -148,15 +156,19 @@ set; the load-bearing ones: |-----------------------------------------------------------|------------------------------------------------------| | `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`) | -| `GH_TOKEN` | GitHub PAT (`repo` scope) for PRs | -| `SWE_DEFAULT_RUNTIME` | `claude_code` \| `open_code` \| `codex` (default `claude_code`) | +| `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_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 | | `AGENTFIELD_SERVER` | Control-plane URL (default `http://localhost:8080`) | | `AGENT_CALLBACK_URL` | Public URL the control plane calls the node back on. **Required for any containerized/remote deploy that isn't this compose file** (compose sets it per service) — without it the CP gets `504 agent_unreachable` | -| `NODE_ID` | Node ID (`swe-planner-go` / `swe-fast-go`) | +| `NODE_ID` | Node ID (`swe-planner` / `swe-fast`) | | `PORT` | Listen port (`8005` / `8006`) | +| `SWE_PRO_ENGINE` | Opt-in preview: route per-issue coding through the bundled high-performance coding engine. **Default unset (off)**; `1`/`true`/`yes`/`on` enables it | +| `SWE_PRO_VARIANT` | Engine reasoning-effort variant (e.g. `low` for fastest turnaround, `high` for depth). Unset keeps the engine's own default | +| `SWE_PRO_MAX_COST` | Per-run cost ceiling in USD forwarded to the engine on every dispatch. Unset: no SWE-AF-side ceiling | +| `SWE_PRO_PUBLIC_URL` | Callback base URL for the engine, mirroring `AGENT_CALLBACK_URL` on the nodes. **In Docker this must be set to a container-reachable URL**, otherwise the control plane can't call the engine back | Advanced knobs (HITL/approvals: `HAX_API_KEY`, `HAX_SDK_URL`, `HAX_SENDER_NAME`, `HAX_SENDER_KEY`, `AGENTFIELD_APPROVAL_USER_ID`; git identity for the resolve @@ -166,20 +178,37 @@ authoritative set. The per-request build config JSON (`runtime`, `models`, budget/iteration knobs) is byte-identical to the Python node's — see the root [README](../README.md) and `.env.example` for the schema and examples. -## Deployment: `af install` via the subdirectory selector +## Coding engine + +The Go node bundles a prebuilt high-performance coding engine alongside the +classic coding loop, and runs it **by default**: `agentfield-package.yaml` +declares `SWE_PRO_ENGINE` with `default: "1"`, so an `af install` node +supervises the engine as a sidecar and routes per-issue coding through it. Set +`SWE_PRO_ENGINE=0` and builds use the classic coder → reviewer/QA loop +instead. A missing binary is not fatal — the node logs a warning and keeps +using the classic loop. + +Full env surface (including the `SWE_PRO_*` knobs above, model pools, and the +sidecar's restart behaviour): [`docs/pro-engine.md`](docs/pro-engine.md). + +## Deployment: `af install` -This directory ships its own `agentfield-package.yaml` (node `swe-planner-go`), -so the Go node installs like any other package — address the subdirectory with -the installer's `//` selector: +This directory ships its own `agentfield-package.yaml` (node `swe-planner`), +and the repo-root manifest redirects here, so the bare repo URL is enough. The +`//` subdirectory selector still works if you want to be explicit: ```bash -af install https://github.com/Agent-Field/SWE-AF//go -af run swe-planner-go # builds bin/swe-planner at install time (needs Go) -af uninstall swe-planner-go +af install https://github.com/Agent-Field/SWE-AF # redirects here +af install https://github.com/Agent-Field/SWE-AF//go # same thing, explicit +af run swe-planner # builds bin/swe-planner at install time (needs Go) +af uninstall swe-planner ``` -The root manifest still installs the **Python** node (`swe-planner`); both can -be installed side by side — the registry is keyed by manifest name. The SDK is +Both manifests declare the name `swe-planner`, so only one can be installed at +a time — installing this replaces an existing Python install in place, keeping +its node-scoped secrets. To install the Python node deliberately, clone the +repo and install the checkout as a local path; local-path installs do not +follow the redirect. The SDK is pinned in `go.mod` by pseudo-version (the same commit the Dockerfile pins), so the module resolves without the dev workspace. Docker image / compose / bare binary remain the container deployment paths. diff --git a/go/agentfield-package.yaml b/go/agentfield-package.yaml index 59492e6d..ea5d9101 100644 --- a/go/agentfield-package.yaml +++ b/go/agentfield-package.yaml @@ -1,5 +1,11 @@ config_version: v1 -name: swe-planner-go # MUST differ from root "swe-planner" (installer registry is keyed by name) +# This is THE SWE node. It deliberately shares the root manifest's name: the +# root declares `superseded_by` pointing here, so installing this repo installs +# this package, and a user who already has the Python swe-planner gets it +# replaced in place — same name, same node id, same triggers, secrets kept. +# Installing the root as a local path (the documented escape hatch) is the one +# way to get the Python node, and it necessarily takes this name over. +name: swe-planner version: 0.1.0 description: Autonomous SWE planning/execution agent node (Go port; 5 orchestrators + 25 role reasoners) author: Agent-Field @@ -11,7 +17,9 @@ entrypoint: healthcheck: /health agent_node: - node_id: swe-planner-go + node_id: swe-planner + # 8005 rather than the Python node's 8003: during the changeover both may be + # running, and triggers resolve by node id, not port. default_port: 8005 user_environment: @@ -30,18 +38,30 @@ user_environment: description: OpenRouter API key (DeepSeek/Qwen/Llama/… — 200+ models) type: secret scope: global - required: - # The core loop clones a repo, pushes a branch, and opens a pull request — - # all of which need GitHub write access. + optional: + # Optional so an LLM key is the only secret needed to get started: builds + # on local/public repos run without it; it is needed to clone private + # repos, push branches, and open pull requests. - name: GH_TOKEN - description: GitHub token (repo scope) for cloning repos and opening pull requests + description: GitHub token (repo scope) — needed to clone private repos, push branches, and open pull requests type: secret scope: global - optional: - name: SWE_DEFAULT_RUNTIME description: Coding runtime for every role (claude_code | open_code | codex) - name: SWE_DEFAULT_MODEL description: Override the model id for every role (e.g. openrouter/deepseek/deepseek-v4-flash) + - name: SWE_PRO_ENGINE + description: >- + High-performance coding engine. On by default — set it to 0 (or false) + to use the classic coding loop instead. + # The resolver injects this last unless the process env or a stored + # secret overrides it, so an `af install` gets the engine without asking + # and `SWE_PRO_ENGINE=0 af run` still opts out. + default: "1" + - name: SWE_PRO_VARIANT + description: Engine reasoning-effort variant (low | high) — unset keeps the engine default + - name: SWE_PRO_MAX_COST + description: Per-run USD ceiling for the engine — unset means no per-run cap - name: AGENTFIELD_SERVER description: Control-plane URL default: http://localhost:8080 diff --git a/go/bin/swe-pro-darwin-arm64 b/go/bin/swe-pro-darwin-arm64 new file mode 100755 index 00000000..7039a35a Binary files /dev/null and b/go/bin/swe-pro-darwin-arm64 differ diff --git a/go/bin/swe-pro-linux-amd64 b/go/bin/swe-pro-linux-amd64 new file mode 100755 index 00000000..46495bd8 Binary files /dev/null and b/go/bin/swe-pro-linux-amd64 differ diff --git a/go/cmd/swe-fast/main.go b/go/cmd/swe-fast/main.go index a2ec2b07..f3f95501 100644 --- a/go/cmd/swe-fast/main.go +++ b/go/cmd/swe-fast/main.go @@ -13,12 +13,13 @@ import ( ) func main() { - // Defaults: NODE_ID "swe-fast-go", PORT 8006 — a distinct identity from the - // Python swe-fast node (fast/app.py:24-31) so the Go port runs as an opt-in - // sibling alongside Python against one control plane. NODE_ID / PORT env - // vars still override. + // Defaults: NODE_ID "swe-fast", PORT 8006 — the product's name, matching + // cmd/swe-planner, so callers' triggers survive the move from Python to Go. + // To run this alongside the Python node against one control plane, give it + // a distinct NODE_ID; the compose files do exactly that. NODE_ID / PORT env + // vars override both defaults. n, err := node.BuildAgent( - "swe-fast-go", + "swe-fast", "8006", "Speed-optimized SWE agent — single-pass planning, sequential execution", ) diff --git a/go/cmd/swe-planner/main.go b/go/cmd/swe-planner/main.go index 6870c3b9..2fdda1af 100644 --- a/go/cmd/swe-planner/main.go +++ b/go/cmd/swe-planner/main.go @@ -2,29 +2,49 @@ // app.py). It builds the agent from the environment and registers the full // swe-planner surface — 5 orchestrators + 25 role reasoners — then serves until // SIGINT/SIGTERM. agent.Run installs its own signal handling, so main passes a -// plain context.Background() and does not double-handle signals. +// plain context; the cancellable wrapper below exists only to stop the +// pro-engine sidecar when Run returns. package main import ( "context" "log" + "time" "github.com/Agent-Field/SWE-AF/go/internal/node" + "github.com/Agent-Field/SWE-AF/go/internal/pro" ) func main() { - // Defaults: NODE_ID "swe-planner-go", PORT 8005 — a distinct identity from - // the Python swe-planner node (app.py:51-59) so the Go port runs as an - // opt-in sibling alongside Python against one control plane. NODE_ID / PORT - // env vars still override. - n, err := node.BuildAgent("swe-planner-go", "8005", "Autonomous SWE planning pipeline") + // Defaults: NODE_ID "swe-planner", PORT 8005. This is the SWE node, so it + // registers under the product's name rather than a port-specific variant — + // callers' triggers do not change when a node moves from Python to Go. To + // run this alongside the Python node against one control plane, give it a + // distinct NODE_ID; the compose files do exactly that. NODE_ID / PORT env + // vars override both defaults. + n, err := node.BuildAgent("swe-planner", "8005", "Autonomous SWE planning pipeline") if err != nil { log.Fatalf("swe-planner: build agent: %v", err) } n.RegisterPlanner() - if err := n.App.Run(context.Background()); err != nil { - log.Fatalf("swe-planner: run: %v", err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Pro-engine sidecar: inert unless SWE_PRO_ENGINE is truthy, which the + // af-install manifest defaults to "1" and SWE_PRO_ENGINE=0 turns off. The + // sidecar registers its own node on the same control plane; a missing + // binary logs a warning and the planner comes up as usual. + var sup *pro.Supervisor + if pro.Enabled() { + sup = pro.Start(ctx, pro.Options{Server: n.AgentFieldServer, Token: n.Token}) + } + + runErr := n.App.Run(ctx) + cancel() + sup.Wait(10 * time.Second) // nil-safe; bounded wind-down for the sidecar + if runErr != nil { + log.Fatalf("swe-planner: run: %v", runErr) } } diff --git a/go/doc.go b/go/doc.go index a87461a3..0ed6829c 100644 --- a/go/doc.go +++ b/go/doc.go @@ -7,8 +7,8 @@ // // Layout (design §1.3): // -// cmd/swe-planner full-pipeline node entry point (node id "swe-planner-go") -// cmd/swe-fast fast-mode node entry point (node id "swe-fast-go") +// cmd/swe-planner full-pipeline node entry point (node id "swe-planner") +// cmd/swe-fast fast-mode node entry point (node id "swe-fast") // internal/afx small AgentField SDK ergonomics (input binding, notes) // internal/... schemas, config, prompts, roles, execution engine, etc. // diff --git a/go/docs/pro-engine.md b/go/docs/pro-engine.md new file mode 100644 index 00000000..28f1b771 --- /dev/null +++ b/go/docs/pro-engine.md @@ -0,0 +1,111 @@ +# Pro engine + +The Go node runs a high-performance coding engine, shipped as prebuilt +binaries — one per supported platform, vendored at `go/bin` as +`swe-pro-darwin-arm64` and `swe-pro-linux-amd64`, because one checkout is +installed on macOS and Linux alike and the node picks the matching build at +startup. + +It is **on by default** for nodes installed with `af install`: +`agentfield-package.yaml` declares `SWE_PRO_ENGINE` with `default: "1"`, and +the installer's env resolver injects that into the node process. Turning it +off is a one-variable change and every existing integration — reasoner calls, +cron triggers, `execute_fn_target` overrides — behaves identically either way. + +The gate itself is still purely the env var, so a binary launched outside the +`af` runner (a bare `swe-planner`, a container that sets nothing) starts on the +classic loop. + +## Opting out + +```sh +SWE_PRO_ENGINE=0 swe-planner # classic coder → reviewer/QA loop +``` + +`0`, `false`, `off`, and any other non-truthy value disable it; only +`1`/`true`/`yes`/`on` (case-insensitive) enable it. + +## Opting back in explicitly + +```sh +SWE_PRO_ENGINE=1 \ +SWE_PRO_BIN=/usr/local/bin/swe-pro \ # default shown +swe-planner +``` + +On startup the node logs an acknowledgement that the engine is enabled and how +to switch back. Two things differ from the classic loop, both additive: + +1. **Engine node.** A supervised sidecar registers on the same control plane + as its own node (default id `swe-pro`) exposing: + - `code_task` — run one autonomous coding task: `{goal, dir}` required, + plus optional `high` / `low` / `frontier` (model pools), `variant` + (reasoning effort), `hard`, `pr_ready`, `max_cost`, `max_hours`. + Returns `{status, reason, cost_usd, run_id, elapsed_ms, ...}`; a failed + task is reported in `status`, not as an execution error. + - `code_resume` — re-enter a previous task in the same workspace. +2. **Seamless routing.** `build` and `execute` requests that do not name an + `execute_fn_target` route per-issue coding through `pro_execute` on this + node (each run carries a note saying so). Requests that pass an explicit + `execute_fn_target` keep full control, and `pro_execute` can also be + targeted directly from any config. + +The engine never pushes or opens PRs — branch, push and PR creation stay with +the standard pipeline, so the deliverables are unchanged. + +If the flag is set but no *runnable* engine binary is found — missing, or +present without its execute bit — the node logs a warning naming the path and +comes up on the classic coding loop: `pro_execute` is not registered and +nothing is routed to an engine node that never joined. The binary is searched +for at `SWE_PRO_BIN` when set (authoritative — no fallback), else +`/usr/local/bin/swe-pro` (the Docker image layout: one image, one platform, so +the image build copies its own `swe-pro-linux-amd64` to that path), else next +to the running executable — the layout an `af install` checkout produces — +first as `swe-pro--` and then as plain `swe-pro`. The suffix is +what keeps a macOS install from exec'ing the Linux build. + +## Environment reference + +| Variable | Default | Purpose | +|---|---|---| +| `SWE_PRO_ENGINE` | `1` via the manifest (unset = off for a bare binary) | Truthy (`1`/`true`/`yes`/`on`) enables; `0`/`false` opts out | +| `SWE_PRO_BIN` | `/usr/local/bin/swe-pro`, else a `swe-pro--` / `swe-pro` sibling | Engine binary path (authoritative when set) | +| `SWE_PRO_NODE_ID` | `swe-pro` | Engine's control-plane node id | +| `SWE_PRO_PORT` | `8801` | Engine's listen port | +| `SWE_PRO_PUBLIC_URL` | `http://localhost:8801` (engine default) | Callback base URL — **must** be set to a container-reachable address in Docker, otherwise the control plane cannot reach the engine | +| `SWE_PRO_MAX_COST` | unset | Per-dispatch cost ceiling (USD) for `pro_execute` | +| `SWE_PRO_MODELS_HIGH` | engine default | High-tier model pool (comma-separated) | +| `SWE_PRO_MODELS_LOW` | engine default | Low-tier model pool | +| `SWE_PRO_VARIANT` | engine default | Reasoning effort (`low` = fastest) | + +The engine inherits `OPENROUTER_API_KEY` and the control-plane coordinates +(`AGENTFIELD_SERVER`, `AGENTFIELD_API_KEY`) from the node's environment. + +**OpenRouter-only deployments:** nothing to configure. The compose files leave +`SWE_DEFAULT_RUNTIME` unset, so with an OpenRouter key as the only provider +credential the node auto-selects the `open_code` runtime and defaults every +role — including the advisory and verification roles that run outside the +engine — to `openrouter/deepseek/deepseek-v4-flash`. Setting +`SWE_DEFAULT_RUNTIME` explicitly is supported but unnecessary here. + +## Control-plane inactivity sweep + +The engine does one issue's coding inside a single long call, where the classic +loop makes many short ones. A control plane that reaps executions by "time +since last activity" therefore sees the waiting parent as idle and can mark a +healthy build `execution timed out (no activity)` while the engine is still +working — the engine keeps going and finishes, but the run is already reported +failed. + +AgentField fixes this by not reaping an execution that is waiting on a +non-terminal child. On an older control plane, raise +`agentfield.execution_cleanup.stale_execution_timeout` (shipped default `10m`) +past the longest single issue you expect, or bound engine runs with +`SWE_PRO_MAX_COST` / `max_hours` so they finish inside the window. + +## Rollout + +The pro engine is the default for `af install` nodes as of this release. The +classic coding loop remains fully supported and is one variable away +(`SWE_PRO_ENGINE=0`); existing reasoner names and input/output shapes are +identical either way, so switching costs nothing but a restart. diff --git a/go/internal/coding/loop.go b/go/internal/coding/loop.go index e77ffff4..cefc538c 100644 --- a/go/internal/coding/loop.go +++ b/go/internal/coding/loop.go @@ -30,7 +30,7 @@ import ( ) // CallFn dispatches to an AI agent (coder, reviewer, QA, synthesizer) by target -// (e.g. "swe-planner-go.run_coder") with the same keyword args Python passes. The +// (e.g. "swe-planner.run_coder") with the same keyword args Python passes. The // DAG executor supplies a closure over agent.Call + envelope.UnwrapCallResult; // tests supply a scripted function. A returned *fatal.FatalHarnessError is // propagated (never swallowed into a fallback). diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 82a47b94..938dbf39 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -118,11 +118,11 @@ func TestResolveRuntimeModels_ClaudeCodeDefaults(t *testing.T) { } func TestResolveRuntimeModels_OpenCodeDefaults(t *testing.T) { - clearProviderEnv(t) // no provider env -> not auto-openrouter -> minimax base + clearProviderEnv(t) // no provider env -> the shared open_code base applies got := mustResolve(t, "open_code", nil) for _, field := range AllModelFields { - if got[field] != "openrouter/minimax/minimax-m2.5" { - t.Errorf("field %s = %q, want minimax", field, got[field]) + if got[field] != "openrouter/deepseek/deepseek-v4-flash" { + t.Errorf("field %s = %q, want deepseek base", field, got[field]) } } } @@ -138,14 +138,18 @@ func TestResolveRuntimeModels_OpenRouterAutoDefaults(t *testing.T) { } } -func TestResolveRuntimeModels_ExplicitOpenCodeKeepsMinimax(t *testing.T) { +// TestResolveRuntimeModels_ExplicitOpenCodeSameDefault: an explicit +// SWE_DEFAULT_RUNTIME=open_code resolves to the SAME model as the +// auto-selected OpenRouter path — opting in explicitly must never silently +// swap the model. +func TestResolveRuntimeModels_ExplicitOpenCodeSameDefault(t *testing.T) { clearProviderEnv(t) t.Setenv("OPENROUTER_API_KEY", "sk-or") t.Setenv("SWE_DEFAULT_RUNTIME", "open_code") got := mustResolve(t, "open_code", nil) for _, field := range AllModelFields { - if got[field] != "openrouter/minimax/minimax-m2.5" { - t.Errorf("field %s = %q, want minimax (explicit)", field, got[field]) + if got[field] != "openrouter/deepseek/deepseek-v4-flash" { + t.Errorf("field %s = %q, want deepseek (explicit)", field, got[field]) } } } @@ -219,7 +223,7 @@ func TestResolveRuntimeModels_EmptyEnvTreatedAsUnset(t *testing.T) { t.Setenv("HARNESS_MODEL", " ") got := mustResolve(t, "open_code", nil) for _, field := range AllModelFields { - if got[field] != "openrouter/minimax/minimax-m2.5" { + if got[field] != "openrouter/deepseek/deepseek-v4-flash" { t.Errorf("empty env -> base: field %s = %q", field, got[field]) } } @@ -357,7 +361,7 @@ func TestBuildConfig_OpenCodeProvider(t *testing.T) { if err != nil { t.Fatal(err) } - if resolved["coder_model"] != "openrouter/minimax/minimax-m2.5" { + if resolved["coder_model"] != "openrouter/deepseek/deepseek-v4-flash" { t.Errorf("coder_model = %q", resolved["coder_model"]) } } @@ -572,7 +576,7 @@ func TestBuildConfig_ToExecutionConfigDictRoundtrip(t *testing.T) { if execCfg.CoderModel() != "deepseek/deepseek-chat" { t.Errorf("exec coder_model = %q", execCfg.CoderModel()) } - if execCfg.QAModel() != "openrouter/minimax/minimax-m2.5" { + if execCfg.QAModel() != "openrouter/deepseek/deepseek-v4-flash" { t.Errorf("exec qa_model = %q", execCfg.QAModel()) } if execCfg.MaxRetriesPerIssue != 2 { @@ -644,7 +648,7 @@ func TestExecutionConfig_CIFixerRole(t *testing.T) { if mustLoadExec(t, map[string]any{"runtime": "claude_code"}).CIFixerModel() != "sonnet" { t.Error("ci_fixer default claude") } - if mustLoadExec(t, map[string]any{"runtime": "open_code"}).CIFixerModel() != "openrouter/minimax/minimax-m2.5" { + if mustLoadExec(t, map[string]any{"runtime": "open_code"}).CIFixerModel() != "openrouter/deepseek/deepseek-v4-flash" { t.Error("ci_fixer default opencode") } cfg := mustLoadExec(t, map[string]any{"runtime": "claude_code", "models": map[string]any{"ci_fixer": "opus"}}) @@ -706,6 +710,10 @@ func TestDefaultFastRuntime(t *testing.T) { {"empty -> claude_code", map[string]string{"SWE_DEFAULT_RUNTIME": ""}, true, "claude_code"}, {"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"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -759,8 +767,33 @@ func TestFastResolveModels(t *testing.T) { cfg, _ := LoadFastBuildConfig(map[string]any{"runtime": "open_code"}) got, _ := FastResolveModels(cfg) for _, role := range fastRoles { - if got[role] != "qwen/qwen-2.5-coder-32b-instruct" { - t.Errorf("%s = %q", role, got[role]) + if got[role] != "openrouter/deepseek/deepseek-v4-flash" { + t.Errorf("%s = %q, want the shared open_code default", role, got[role]) + } + } + }) + + t.Run("env cascade applies to fast roles", func(t *testing.T) { + t.Setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder") + cfg, _ := LoadFastBuildConfig(map[string]any{"runtime": "open_code"}) + got, _ := FastResolveModels(cfg) + for _, role := range fastRoles { + if got[role] != "openrouter/qwen/qwen-3-coder" { + t.Errorf("%s = %q, want env cascade value", role, got[role]) + } + } + }) + + t.Run("config models beat the env cascade", func(t *testing.T) { + t.Setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder") + cfg, _ := LoadFastBuildConfig(map[string]any{ + "runtime": "open_code", + "models": map[string]any{"default": "openrouter/z-ai/glm-5"}, + }) + got, _ := FastResolveModels(cfg) + for _, role := range fastRoles { + if got[role] != "openrouter/z-ai/glm-5" { + t.Errorf("%s = %q, want config override", role, got[role]) } } }) diff --git a/go/internal/config/fastconfig.go b/go/internal/config/fastconfig.go index 04f2a22d..0d26ee4d 100644 --- a/go/internal/config/fastconfig.go +++ b/go/internal/config/fastconfig.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "os" "strings" "github.com/Agent-Field/SWE-AF/go/internal/runtimex" @@ -15,7 +14,9 @@ import ( const ( fastClaudeCodeDefault = "haiku" - fastOpenCodeDefault = "qwen/qwen-2.5-coder-32b-instruct" + // Fast mode shares the open_code default with the main path so an + // OpenRouter-only install behaves the same on both nodes. + fastOpenCodeDefault = openRouterAutoDefaultModel ) // fastRoles ports _FAST_ROLES — the four resolved model keys, in order. @@ -44,13 +45,17 @@ var fastValidKeys = map[string]struct{}{ "git": {}, } -// DefaultFastRuntime ports _default_fast_runtime. Note: no OpenRouter -// auto-detect and no strip — os.getenv default is "claude_code", and any value -// not in RUNTIME_VALUES falls back to "claude_code". +// DefaultFastRuntime ports _default_fast_runtime, 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 (openRouterOnlyEnv) — else +// claude_code. An invalid value falls back to claude_code. func DefaultFastRuntime() string { - value, ok := os.LookupEnv("SWE_DEFAULT_RUNTIME") - if !ok { - value = "claude_code" + value := envStripped("SWE_DEFAULT_RUNTIME") + if value == "" { + if openRouterOnlyEnv() { + return "open_code" + } + return "claude_code" } for _, rv := range runtimex.RuntimeValues { if value == rv { @@ -120,8 +125,10 @@ func LoadFastBuildConfig(raw map[string]any) (*FastBuildConfig, error) { } // FastResolveModels ports fast_resolve_models — resolves the four role model -// strings. Resolution order (last wins): runtime default → models["default"] → -// models[""]. An unknown key yields the verbatim "Unknown role key" error. +// strings. Resolution order (last wins): runtime default → env cascade +// (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL, same as the main path) → +// models["default"] → models[""]. An unknown key yields the verbatim +// "Unknown role key" error. func FastResolveModels(config *FastBuildConfig) (map[string]string, error) { runtimeDefault := fastRuntimeDefault(config.Runtime) @@ -130,6 +137,15 @@ func FastResolveModels(config *FastBuildConfig) (map[string]string, error) { resolved[role] = runtimeDefault } + // Deployer env cascade: lets the same variable that selects a model for + // the main node select it for fast builds too. Caller-supplied models + // (below) still win. + if envModel := defaultModelFromEnv(); envModel != "" { + for _, role := range fastRoles { + resolved[role] = envModel + } + } + if config.Models != nil { // Validate all keys first. for key := range config.Models { diff --git a/go/internal/config/modeltiers_test.go b/go/internal/config/modeltiers_test.go index 24d76c4f..5e09f714 100644 --- a/go/internal/config/modeltiers_test.go +++ b/go/internal/config/modeltiers_test.go @@ -22,7 +22,7 @@ var highTierFields = map[string]bool{ } // openCodeBaseModel ports _OPEN_CODE_BASE. -const openCodeBaseModel = "openrouter/minimax/minimax-m2.5" +const openCodeBaseModel = "openrouter/deepseek/deepseek-v4-flash" // TestModelTiers_NoTierEnvsUnchanged ports TestNoTierEnvsUnchanged: no tier // envs set → resolution unchanged for all runtimes. diff --git a/go/internal/config/resolve.go b/go/internal/config/resolve.go index aa7b6b14..639fb081 100644 --- a/go/internal/config/resolve.go +++ b/go/internal/config/resolve.go @@ -131,7 +131,10 @@ 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 auto-selected OpenRouter path (see openRouterOnlyEnv). + // 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 + // never silently swaps the model. openRouterAutoDefaultModel = "openrouter/deepseek/deepseek-v4-flash" ) @@ -149,7 +152,7 @@ func runtimeBaseModels(runtime string) map[string]string { base["qa_synthesizer_model"] = "haiku" case "open_code": for _, field := range AllModelFields { - base[field] = "openrouter/minimax/minimax-m2.5" + base[field] = openRouterAutoDefaultModel } case "codex": for _, field := range AllModelFields { diff --git a/go/internal/dag/executor.go b/go/internal/dag/executor.go index d3e3f34c..2a506fc7 100644 --- a/go/internal/dag/executor.go +++ b/go/internal/dag/executor.go @@ -11,7 +11,7 @@ import ( "github.com/Agent-Field/SWE-AF/go/internal/schemas" ) -// CallFn dispatches to a reasoner by target (e.g. "swe-planner-go.run_merger") with +// CallFn dispatches to a reasoner by target (e.g. "swe-planner.run_merger") with // the same keyword args Python passes. Callers supply a closure over agent.Call // + envelope.UnwrapCallResult (so results arrive already unwrapped); a returned // *fatal.FatalHarnessError is honoured throughout. Alias of coding.CallFn so the @@ -126,7 +126,7 @@ func RunDAG( cfg = def } if nodeID == "" { - nodeID = "swe-planner-go" + nodeID = "swe-planner" } dagState := initDAGState(planResult, repoPath, o.gitConfig, o.buildID) diff --git a/go/internal/dag/executor_test.go b/go/internal/dag/executor_test.go index c20ad23a..8a61229c 100644 --- a/go/internal/dag/executor_test.go +++ b/go/internal/dag/executor_test.go @@ -193,7 +193,7 @@ func TestSingleIssueCompletes(t *testing.T) { plan := makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}) cfg := testCfg(t, nil) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", cfg) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", cfg) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -222,7 +222,7 @@ func TestLevelBarrierWaitsForAll(t *testing.T) { []map[string]any{issue("a"), issue("b"), issue("c", "a", "b")}, [][]string{{"a", "b"}, {"c"}}, ) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -263,7 +263,7 @@ func TestConcurrencyBounded(t *testing.T) { cfg := testCfg(t, map[string]any{"max_concurrent_issues": 3}) dagState := initDAGState(makePlan(issues, [][]string{level}), "/repo", nil, "") - lr := executeLevel(context.Background(), issues, nil, dagState, cfg, 0, m.fn, "swe-planner-go", nil, nil) + lr := executeLevel(context.Background(), issues, nil, dagState, cfg, 0, m.fn, "swe-planner", nil, nil) if len(lr.Completed) != 6 { t.Fatalf("expected 6 completed, got %d", len(lr.Completed)) } @@ -294,7 +294,7 @@ func TestUnlimitedConcurrencyWhenZero(t *testing.T) { } cfg := testCfg(t, map[string]any{"max_concurrent_issues": 0}) dagState := initDAGState(makePlan(issues, [][]string{level}), "/repo", nil, "") - executeLevel(context.Background(), issues, nil, dagState, cfg, 0, m.fn, "swe-planner-go", nil, nil) + executeLevel(context.Background(), issues, nil, dagState, cfg, 0, m.fn, "swe-planner", nil, nil) if got := atomic.LoadInt32(&m.maxActive); got != 4 { t.Fatalf("expected all 4 concurrent (unlimited), got maxActive=%d", got) } @@ -321,7 +321,7 @@ func TestAdvisorTimeoutFailsNotHang(t *testing.T) { done := make(chan struct{}) var state *schemas.DAGState go func() { - s, _ := RunDAG(context.Background(), makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}), "/repo", m.fn, "swe-planner-go", cfg) + s, _ := RunDAG(context.Background(), makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}), "/repo", m.fn, "swe-planner", cfg) state = s close(done) }() @@ -345,7 +345,7 @@ func TestCheckpointWrittenAndRoundTrips(t *testing.T) { plan["artifacts_dir"] = dir m := newMock() - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -452,7 +452,7 @@ func TestSplitGateCreatesSubIssuesRemovesParent(t *testing.T) { }, nil }) plan := makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -502,7 +502,7 @@ func TestReplanModifyDAGResetsLevel0(t *testing.T) { }) cfg := testCfg(t, map[string]any{"enable_issue_advisor": false}) plan := makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", cfg) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", cfg) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -539,7 +539,7 @@ func TestReplanContinueSkipsDownstream(t *testing.T) { []map[string]any{issue("a"), issue("b", "a")}, [][]string{{"a"}, {"b"}}, ) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", cfg) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", cfg) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -565,7 +565,7 @@ func TestLevelFailureThresholdAborts(t *testing.T) { []map[string]any{issue("a"), issue("b"), issue("c", "a")}, [][]string{{"a", "b"}, {"c"}}, ) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", cfg) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", cfg) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -606,7 +606,7 @@ func TestDebtNotesInjectedDownstream(t *testing.T) { []map[string]any{issue("a"), issue("b", "a")}, [][]string{{"a"}, {"b"}}, ) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -640,7 +640,7 @@ func TestCleanupAwaitedBeforeAdvance(t *testing.T) { [][]string{{"a"}, {"b"}}, ) git := map[string]any{"integration_branch": "integ/main", "original_branch": "main", "mode": "existing"} - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil), WithGitConfig(git)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil), WithGitConfig(git)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -684,7 +684,7 @@ func makeManifest() map[string]any { func TestMultiRepoGitInitPerRepo(t *testing.T) { m := newMock() plan := makePlan(nil, nil) // no issues -> exercises init only - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil), + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil), WithWorkspaceManifest(makeManifest())) if err != nil { t.Fatalf("RunDAG: %v", err) @@ -713,7 +713,7 @@ func TestMultiRepoGitInitPerRepo(t *testing.T) { func TestWorkspaceManifestNoneSingleRepo(t *testing.T) { m := newMock() plan := makePlan(nil, nil) - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -738,7 +738,7 @@ func TestRepoNameBackfilledFromTargetRepo(t *testing.T) { iss := issue("feat") iss["target_repo"] = "myrepo" dagState := initDAGState(makePlan([]map[string]any{iss}, [][]string{{"feat"}}), "/repo", nil, "") - lr := executeLevel(context.Background(), []map[string]any{iss}, nil, dagState, testCfg(t, nil), 0, m.fn, "swe-planner-go", nil, nil) + lr := executeLevel(context.Background(), []map[string]any{iss}, nil, dagState, testCfg(t, nil), 0, m.fn, "swe-planner", nil, nil) if len(lr.Completed) != 1 || lr.Completed[0].RepoName != "myrepo" { t.Fatalf("repo_name not backfilled: %+v", lr.Completed) } @@ -760,7 +760,7 @@ func TestMergeGateSingleRepo(t *testing.T) { }) plan := makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}) git := map[string]any{"integration_branch": "integ/main", "original_branch": "main", "mode": "existing"} - state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil), WithGitConfig(git)) + state, err := RunDAG(context.Background(), plan, "/repo", m.fn, "swe-planner", testCfg(t, nil), WithGitConfig(git)) if err != nil { t.Fatalf("RunDAG: %v", err) } @@ -790,7 +790,7 @@ func TestContextCancellationStops(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel before running plan := makePlan([]map[string]any{issue("a")}, [][]string{{"a"}}) - _, err := RunDAG(ctx, plan, "/repo", m.fn, "swe-planner-go", testCfg(t, nil)) + _, err := RunDAG(ctx, plan, "/repo", m.fn, "swe-planner", testCfg(t, nil)) if err == nil { t.Fatal("expected cancellation error, got nil") } diff --git a/go/internal/fast/build.go b/go/internal/fast/build.go index 7fdbf6b9..8bcd67e2 100644 --- a/go/internal/fast/build.go +++ b/go/internal/fast/build.go @@ -32,7 +32,7 @@ import ( // Registration surface (consumed by T6.2) // --------------------------------------------------------------------------- -// CallFn dispatches to a reasoner by target (e.g. "swe-fast-go.run_coder") with the +// CallFn dispatches to a reasoner by target (e.g. "swe-fast.run_coder") with the // same keyword args Python passes to app.call. The wiring task supplies a // closure over agent.Call + envelope.UnwrapCallResult (so the returned map is // already unwrapped); tests supply a scripted function. It is structurally @@ -63,11 +63,11 @@ func Handlers() map[string]Handler { } } -// defaultNodeID is the Go fast node's default identity when NODE_ID is unset. -// The Go port registers as "swe-fast-go" (an opt-in sibling of the Python -// swe-fast node) so both can run against one control plane; Python's -// module-level default is os.getenv("NODE_ID", "swe-fast"). -const defaultNodeID = "swe-fast-go" +// defaultNodeID is the fast node's default identity when NODE_ID is unset. It +// is the same identity the Python fast node uses (module-level default +// os.getenv("NODE_ID", "swe-fast")), because the two are one node rather than +// siblings — whichever is serving, the trigger is swe-fast.. +const defaultNodeID = "swe-fast" // --------------------------------------------------------------------------- // Shared collaborators @@ -76,7 +76,7 @@ const defaultNodeID = "swe-fast-go" // Deps carries the seams every fast reasoner needs. Harness drives the // structured-output subprocess (fast_plan_tasks); Call is the app.call seam used // by fast_execute_tasks, fast_verify and the build orchestrator; Note is the -// observability channel; NodeID is the target prefix (default "swe-fast-go") +// observability channel; NodeID is the target prefix (default "swe-fast") // used when composing app.call targets — mirroring Python's f"{NODE_ID}.". type Deps struct { Harness harnessx.HarnessCaller @@ -85,8 +85,8 @@ type Deps struct { NodeID string } -// nodeID returns the configured node id, defaulting to "swe-fast-go" (the Go -// port's opt-in-sibling identity) when NodeID is unset. +// nodeID returns the configured node id, defaulting to "swe-fast" when NodeID +// is unset. func (d *Deps) nodeID() string { if d != nil && d.NodeID != "" { return d.NodeID diff --git a/go/internal/fast/build_test.go b/go/internal/fast/build_test.go index fa541ed6..15b12026 100644 --- a/go/internal/fast/build_test.go +++ b/go/internal/fast/build_test.go @@ -8,7 +8,7 @@ import ( func buildDeps(fn func(ctx context.Context, target string, kwargs map[string]any) (map[string]any, error)) (*Deps, *callScripter) { s := &callScripter{fn: fn} - return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast-go"}, s + return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast"}, s } var ( @@ -68,11 +68,11 @@ func TestBuild_SuccessAndStageOrder(t *testing.T) { // Stage order via CallFn: git_init → plan → execute → verify → finalize. want := []string{ - "swe-fast-go.run_git_init", - "swe-fast-go.fast_plan_tasks", - "swe-fast-go.fast_execute_tasks", - "swe-fast-go.fast_verify", - "swe-fast-go.run_repo_finalize", + "swe-fast.run_git_init", + "swe-fast.fast_plan_tasks", + "swe-fast.fast_execute_tasks", + "swe-fast.fast_verify", + "swe-fast.run_repo_finalize", } got := s.targets() if len(got) < len(want) { @@ -85,7 +85,7 @@ func TestBuild_SuccessAndStageOrder(t *testing.T) { } // No PR stage since remote_url is empty. for _, tgt := range got { - if tgt == "swe-fast-go.run_github_pr" { + if tgt == "swe-fast.run_github_pr" { t.Error("run_github_pr should not be called when remote_url is empty") } } diff --git a/go/internal/fast/executor_test.go b/go/internal/fast/executor_test.go index bf6a3533..86cfd36e 100644 --- a/go/internal/fast/executor_test.go +++ b/go/internal/fast/executor_test.go @@ -18,7 +18,7 @@ var sampleTask = map[string]any{ func execDeps(fn func(ctx context.Context, target string, kwargs map[string]any) (map[string]any, error)) (*Deps, *callScripter) { s := &callScripter{fn: fn} - return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast-go"}, s + return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast"}, s } // Contract: a successful coder call (complete=true) → outcome "completed". @@ -42,7 +42,7 @@ func TestFastExecuteTasks_CompletedOutcome(t *testing.T) { } // run_coder must be the call target, args forwarded. rec := s.snapshot() - if got := rec[0].target; got != "swe-fast-go.run_coder" { + if got := rec[0].target; got != "swe-fast.run_coder" { t.Errorf("target = %q, want swe-fast.run_coder", got) } if rec[0].kwargs["worktree_path"] != "/tmp/repo" { diff --git a/go/internal/fast/verifier_test.go b/go/internal/fast/verifier_test.go index 01c82d81..f3186d09 100644 --- a/go/internal/fast/verifier_test.go +++ b/go/internal/fast/verifier_test.go @@ -9,7 +9,7 @@ import ( func verifyDeps(fn func(ctx context.Context, target string, kwargs map[string]any) (map[string]any, error)) (*Deps, *callScripter) { s := &callScripter{fn: fn} - return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast-go"}, s + return &Deps{Call: s.call, Note: ¬eRecorder{}, NodeID: "swe-fast"}, s } var verifyInput = map[string]any{ @@ -55,7 +55,7 @@ func TestFastVerify_Success(t *testing.T) { } // task_results split into completed/failed before forwarding. rec := s.snapshot() - if got := rec[0].target; got != "swe-fast-go.run_verifier" { + if got := rec[0].target; got != "swe-fast.run_verifier" { t.Errorf("target = %q, want swe-fast.run_verifier", got) } ci := rec[0].kwargs["completed_issues"].([]map[string]any) diff --git a/go/internal/node/aiconfig_test.go b/go/internal/node/aiconfig_test.go index c30b762d..8be13b93 100644 --- a/go/internal/node/aiconfig_test.go +++ b/go/internal/node/aiconfig_test.go @@ -37,9 +37,9 @@ func TestResolveAIConfigWithoutKey(t *testing.T) { // (AIConfig nil) when no AI key is present. func TestBuildAgentConstructsWithoutKey(t *testing.T) { clearAIKeys(t) - t.Setenv("NODE_ID", "swe-planner-go-test") + t.Setenv("NODE_ID", "swe-planner-test") - n, err := BuildAgent("swe-planner-go", "8005", "test node") + n, err := BuildAgent("swe-planner", "8005", "test node") if err != nil { t.Fatalf("BuildAgent errored without an AI key: %v", err) } diff --git a/go/internal/node/node.go b/go/internal/node/node.go index badd8507..d3cebc56 100644 --- a/go/internal/node/node.go +++ b/go/internal/node/node.go @@ -64,7 +64,7 @@ func (n *Node) RegisteredNames() []string { // BuildAgent constructs the SWE-AF agent from the environment exactly as the // Python entry points do (app.py:51-59 / fast/app.py:24-31): // -// - NODE_ID default defaultNodeID ("swe-planner-go" / "swe-fast-go") +// - NODE_ID default defaultNodeID ("swe-planner" / "swe-fast") // - AGENTFIELD_SERVER default "http://localhost:8080" // - AGENTFIELD_API_KEY -> Config.Token (bearer) // - PORT default defaultPort ("8005" / "8006") -> ListenAddress diff --git a/go/internal/node/node_test.go b/go/internal/node/node_test.go index e8c685b4..2ff8a066 100644 --- a/go/internal/node/node_test.go +++ b/go/internal/node/node_test.go @@ -61,40 +61,43 @@ var pythonFastReasoners = []string{"build", "fast_plan_tasks", "fast_execute_tas var pythonIssueReasoners = []string{"implement_issue"} func TestRegisterPlannerExactSurface(t *testing.T) { - n, err := BuildAgent("swe-planner-go", "8005", "Autonomous SWE planning pipeline") + // Pin the pro engine off so an inherited SWE_PRO_ENGINE cannot + // widen the surface under test (the gated surface has its own test). + t.Setenv("SWE_PRO_ENGINE", "") + n, err := BuildAgent("swe-planner", "8005", "Autonomous SWE planning pipeline") if err != nil { t.Fatalf("BuildAgent: %v", err) } n.RegisterPlanner() - // swe-planner-go surface = 25 roles + 5 orchestrators + implement_issue + // swe-planner surface = 25 roles + 5 orchestrators + implement_issue // = 31 unique names. want := append(append([]string(nil), pythonRoleSurface...), pythonOrchestrators...) want = append(want, pythonIssueReasoners...) - assertSurface(t, "swe-planner-go", n.RegisteredNames(), want) + assertSurface(t, "swe-planner", n.RegisteredNames(), want) } func TestRegisterFastExactSurface(t *testing.T) { - n, err := BuildAgent("swe-fast-go", "8006", "fast desc") + n, err := BuildAgent("swe-fast", "8006", "fast desc") if err != nil { t.Fatalf("BuildAgent: %v", err) } n.RegisterFast() - // swe-fast-go surface = 25 roles + 4 fast reasoners + implement_issue + // swe-fast surface = 25 roles + 4 fast reasoners + implement_issue // = 30 unique names. It must NOT contain plan/execute/resolve/resume_build - // (those live only on swe-planner-go) — assertSurface's extra-name check + // (those live only on swe-planner) — assertSurface's extra-name check // enforces that. want := append(append([]string(nil), pythonRoleSurface...), pythonFastReasoners...) want = append(want, pythonIssueReasoners...) - assertSurface(t, "swe-fast-go", n.RegisteredNames(), want) + assertSurface(t, "swe-fast", n.RegisteredNames(), want) } // TestFastWrappersAreBackedByRoles verifies the seven delegating wrappers -// (fast/__init__.py) are present on the swe-fast-go surface — each is one of the +// (fast/__init__.py) are present on the swe-fast surface — each is one of the // role names, backed by the full-pipeline role handler (fast.Wrappers identity). func TestFastWrappersAreBackedByRoles(t *testing.T) { - n, err := BuildAgent("swe-fast-go", "8006", "fast desc") + n, err := BuildAgent("swe-fast", "8006", "fast desc") if err != nil { t.Fatalf("BuildAgent: %v", err) } @@ -103,7 +106,7 @@ func TestFastWrappersAreBackedByRoles(t *testing.T) { got := toSet(n.RegisteredNames()) for _, w := range fast.WrapperNames() { if !got[w] { - t.Errorf("fast wrapper %q not registered on swe-fast-go surface", w) + t.Errorf("fast wrapper %q not registered on swe-fast surface", w) } } // Every wrapper must also be one of the role names (identity delegation). diff --git a/go/internal/node/pro_surface_test.go b/go/internal/node/pro_surface_test.go new file mode 100644 index 00000000..804166ef --- /dev/null +++ b/go/internal/node/pro_surface_test.go @@ -0,0 +1,84 @@ +package node + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/SWE-AF/go/internal/pro" +) + +// fakeEngineBin creates an existing dummy engine binary and points SWE_PRO_BIN +// at it, so pro.Available() sees the flag-on-and-binary-present state. +func fakeEngineBin(t *testing.T) { + t.Helper() + bin := filepath.Join(t.TempDir(), "engine") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("fake engine bin: %v", err) + } + t.Setenv(pro.EnvBin, bin) +} + +// TestRegisterPlannerProSurfaceGated: with SWE_PRO_ENGINE set and the engine +// binary present, the planner surface is the default 31 names plus exactly the +// pro handlers — and nothing on the fast node changes (the pro surface is +// planner-only). +func TestRegisterPlannerProSurfaceGated(t *testing.T) { + t.Setenv(pro.EnvEnabled, "1") + fakeEngineBin(t) + + n, err := BuildAgent("swe-planner", "8005", "Autonomous SWE planning pipeline") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + n.RegisterPlanner() + + want := append(append([]string(nil), pythonRoleSurface...), pythonOrchestrators...) + want = append(want, pythonIssueReasoners...) + for name := range pro.Handlers() { + want = append(want, name) + } + assertSurface(t, "swe-planner[pro]", n.RegisteredNames(), want) + + f, err := BuildAgent("swe-fast", "8006", "fast desc") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + f.RegisterFast() + if toSet(f.RegisteredNames())["pro_execute"] { + t.Error("pro_execute must not register on the fast node") + } +} + +// TestProSurfaceOffByDefault: with the flag unset the planner registers no pro +// reasoner — the complement of the exact-surface parity test, stated directly. +func TestProSurfaceOffByDefault(t *testing.T) { + t.Setenv(pro.EnvEnabled, "") + + n, err := BuildAgent("swe-planner", "8005", "Autonomous SWE planning pipeline") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + n.RegisterPlanner() + if toSet(n.RegisteredNames())["pro_execute"] { + t.Error("pro_execute registered without SWE_PRO_ENGINE — the surface must stay flag-gated") + } +} + +// TestProSurfaceEnabledButBinaryMissing: the flag alone is not enough — with +// no engine binary on disk the planner must keep the classic surface (and the +// classic coding loop) instead of routing every issue to a node that never +// joins the control plane. +func TestProSurfaceEnabledButBinaryMissing(t *testing.T) { + t.Setenv(pro.EnvEnabled, "1") + t.Setenv(pro.EnvBin, filepath.Join(t.TempDir(), "missing")) + + n, err := BuildAgent("swe-planner", "8005", "Autonomous SWE planning pipeline") + if err != nil { + t.Fatalf("BuildAgent: %v", err) + } + n.RegisterPlanner() + if toSet(n.RegisteredNames())["pro_execute"] { + t.Error("pro_execute registered with SWE_PRO_ENGINE set but no binary — must degrade to the classic loop") + } +} diff --git a/go/internal/node/register.go b/go/internal/node/register.go index 8e3b0ac9..f0db852c 100644 --- a/go/internal/node/register.go +++ b/go/internal/node/register.go @@ -14,14 +14,16 @@ package node // those role names, backed by the full-pipeline role handlers (fast.Wrappers // is the identity delegation map that documents this). // -// Tags: the Go port registers under a distinct identity from the Python node -// (swe-planner-go / swe-fast-go) so both stacks can run against one control -// plane. Role reasoners carry ["swe-planner-go"] on BOTH nodes — mirroring the -// Python structure where they are registered through the swe-planner-tagged -// AgentRouter, but grouped under the Go node's -go identity. The four fast-node -// reasoners carry ["swe-fast-go"] (Python: fast_router tags=["swe-fast"]). The -// five orchestrators carry ["swe-planner-go"] to group them with the node in the -// control-plane UI (design §8). +// Tags match the Python node's exactly, because this registers under the same +// identity: a caller's trigger does not change when the implementation does. +// Role reasoners carry ["swe-planner"] on BOTH nodes — mirroring the Python +// structure where they are registered through the swe-planner-tagged +// AgentRouter. The four fast-node reasoners carry ["swe-fast"] (Python: +// fast_router tags=["swe-fast"]). The five orchestrators carry ["swe-planner"] +// to group them with the node in the control-plane UI (design §8). +// +// Running this alongside the Python node against one control plane therefore +// needs an explicit NODE_ID on one of them; docker-compose.go.yml does that. import ( "context" @@ -39,11 +41,12 @@ import ( "github.com/Agent-Field/SWE-AF/go/internal/fast" "github.com/Agent-Field/SWE-AF/go/internal/issue" + "github.com/Agent-Field/SWE-AF/go/internal/pro" ) const ( - tagPlanner = "swe-planner-go" - tagFast = "swe-fast-go" + tagPlanner = "swe-planner" + tagFast = "swe-fast" ) // RegisterPlanner registers the full swe-planner surface: 25 role reasoners + @@ -52,6 +55,9 @@ func (n *Node) RegisterPlanner() { n.registerRoles() n.registerOrchestrators() n.registerIssueReasoner() + if pro.Available() { + n.registerProReasoners() + } } // RegisterFast registers the swe-fast surface: the same 25 role reasoners + the @@ -70,7 +76,7 @@ func (n *Node) RegisterFast() { // registerRoles wires the 25 execution/planning role reasoners, each backed by // its package handler and threaded with the Deps built from the agent. All are -// tagged ["swe-planner-go"] (Python groups them under the swe-planner router). +// tagged ["swe-planner"] (Python groups them under the swe-planner router). func (n *Node) registerRoles() { tag := agent.WithReasonerTags(tagPlanner) @@ -130,6 +136,16 @@ func (n *Node) registerOrchestrators() { CIGate: orch.RunCIGate, ApprovalGate: orch.PlanApprovalGate, } + // Engine default routing (seamless path): with the flag truthy AND the + // binary present, builds and execute calls that name no execute_fn_target + // route per-issue coding through pro_execute on this node. Callers that pass + // a target keep full control. Flag-on with a missing binary degrades to the + // classic loop + // (pro.Start logs the warning) instead of routing to a node that never + // joined. + if pro.Available() { + deps.DefaultExecuteFnTarget = n.NodeID + ".pro_execute" + } handlers := orch.Handlers() // {"build": Build} orch.RegisterPlan(handlers) // adds {"plan": Plan} @@ -239,6 +255,35 @@ func (n *Node) registerIssueReasoner() { } } +// --------------------------------------------------------------------------- +// Pro-engine surface (SWE_PRO_ENGINE-gated, swe-planner only) +// --------------------------------------------------------------------------- + +// registerProReasoners wires the pro-engine adapter. Called only when +// pro.Available(), so the classic surface — and the parity test asserting it — +// is unchanged whenever SWE_PRO_ENGINE is falsy or the binary is missing. +func (n *Node) registerProReasoners() { + deps := &pro.Deps{ + Call: newCallFn(n.App), + Note: n.App, + EngineNode: pro.NodeID(), + } + for name, h := range pro.Handlers() { + opts := []agent.ReasonerOption{ + agent.WithReasonerTags(tagPlanner), + agent.WithDescription( + "Pro-engine executor: implements ONE fully-scoped issue via the " + + "bundled pro coding engine. Matches the execute_fn_target contract — " + + "set config.execute_fn_target to \".pro_execute\" on build/execute " + + "to route per-issue coding through it."), + } + if s, ok := proSchemas[name]; ok { + opts = append(opts, agent.WithInputSchema(s)) + } + regHandler(n, name, deps, h, opts...) + } +} + // --------------------------------------------------------------------------- // Registration helper // --------------------------------------------------------------------------- @@ -324,6 +369,13 @@ var issueSchemas = map[string]json.RawMessage{ `"artifacts_dir":{"type":"string"},"additional_context":{"type":"string"},"config":{"type":"object"}}}`), } +// proSchemas maps the opt-in pro-engine reasoners to their input schemas. +var proSchemas = map[string]json.RawMessage{ + // pro_execute(issue, repo_path) — the execute_fn_target calling convention. + "pro_execute": schema(`{"type":"object","additionalProperties":true,"required":["issue","repo_path"],"properties":{` + + `"issue":{"type":"object"},"repo_path":{"type":"string"}}}`), +} + // fastSchemas maps the 4 fast reasoner names to their input schemas. var fastSchemas = map[string]json.RawMessage{ // build(goal, repo_path="", repo_url="", artifacts_dir=".artifacts", diff --git a/go/internal/orch/approval_gate.go b/go/internal/orch/approval_gate.go index ec9f7a6e..1f7c2616 100644 --- a/go/internal/orch/approval_gate.go +++ b/go/internal/orch/approval_gate.go @@ -87,7 +87,7 @@ func PlanApprovalGate(ctx context.Context, req ApprovalRequest) (ApprovalOutcome userID := strings.TrimSpace(os.Getenv("AGENTFIELD_APPROVAL_USER_ID")) nodeID := deps.NodeID if nodeID == "" { - nodeID = "swe-planner-go" + nodeID = "swe-planner" } for revisionIter := 0; revisionIter <= maxRev; revisionIter++ { diff --git a/go/internal/orch/approval_gate_test.go b/go/internal/orch/approval_gate_test.go index 67efa8d6..3a080906 100644 --- a/go/internal/orch/approval_gate_test.go +++ b/go/internal/orch/approval_gate_test.go @@ -156,7 +156,7 @@ func TestApprovalApprovedProceeds(t *testing.T) { defer wireHax(t, server, fake)() app, calls := replanApp() - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} plan := samplePlan() out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), plan, t.TempDir())) @@ -193,7 +193,7 @@ func TestApprovalChangesThenApproved(t *testing.T) { defer wireHax(t, server, fake)() app, calls := replanApp() - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), samplePlan(), t.TempDir())) if err != nil { @@ -235,7 +235,7 @@ func TestApprovalRevisionLimit(t *testing.T) { defer wireHax(t, server, fake)() app, _ := replanApp() - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 1), samplePlan(), t.TempDir())) if err != nil { @@ -274,7 +274,7 @@ func TestApprovalRejected(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { t.Fatal("rejected must not replan") return nil, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), samplePlan(), t.TempDir())) if err != nil { @@ -299,7 +299,7 @@ func TestApprovalExpired(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), samplePlan(), t.TempDir())) if err != nil { @@ -321,7 +321,7 @@ func TestApprovalNoHaxClientSkips(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} plan := samplePlan() out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), plan, t.TempDir())) @@ -353,7 +353,7 @@ func TestApprovalNoClientWiredSkips(t *testing.T) { } pauserProvider = nil - deps := &Deps{App: &mockApp{}, NodeID: "swe-planner-go"} + deps := &Deps{App: &mockApp{}, NodeID: "swe-planner"} plan := samplePlan() out, err := PlanApprovalGate(context.Background(), req(deps, testCfg(t, 2), plan, t.TempDir())) if err != nil { diff --git a/go/internal/orch/build.go b/go/internal/orch/build.go index 2ee27105..c25ad0b5 100644 --- a/go/internal/orch/build.go +++ b/go/internal/orch/build.go @@ -177,6 +177,13 @@ func Build(ctx context.Context, deps *Deps, input map[string]any) (any, error) { planCh <- callRes{raw: raw, err: perr} }() + // Keep the harness's own .artifacts/ and .worktrees/ out of the target + // repo's git view before any stage can stage them, and record the commit + // the workspace starts on so the integration branch can be checked against + // it below. + excludeHarnessMetadata(ctx, repoPath) + buildBaseSHA := headSHA(ctx, repoPath) + maxGitInitRetries := cfg.GitInitMaxRetries var gitInit map[string]any var previousError any // None on first attempt, string thereafter @@ -220,6 +227,20 @@ func Build(ctx context.Context, deps *Deps, input map[string]any) (any, error) { if asBool(gitInit["success"]) { deps.Note(ctx, fmt.Sprintf("Git init succeeded on attempt %d", attempt), "build", "git_init", "success") + // The agent branches by hand; make sure it branched from where this + // run actually started, or the issue it was asked to fix may not + // even be present on the branch the work merges into. + if integrationBranchBase( + ctx, repoPath, mapStr(gitInit, "integration_branch", ""), buildBaseSHA, + ) { + deps.Note(ctx, fmt.Sprintf( + "Integration branch %s did not descend from the run's starting commit %s — re-cut from it", + mapStr(gitInit, "integration_branch", ""), buildBaseSHA), + "build", "git_init", "rebased") + } + // git_init is told to create .worktrees/ and may rewrite + // .gitignore; re-assert the exclusions and drop anything it staged. + excludeHarnessMetadata(ctx, repoPath) break } diff --git a/go/internal/orch/build_test.go b/go/internal/orch/build_test.go index e6f70b26..f6ff04e2 100644 --- a/go/internal/orch/build_test.go +++ b/go/internal/orch/build_test.go @@ -54,7 +54,7 @@ func TestBuildEmptyGuardReportsFailed(t *testing.T) { app := &mockApp{handler: buildHandler(emptyExec, func(map[string]any) map[string]any { return map[string]any{"passed": false, "criteria_results": []any{}, "summary": "nope"} })} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := Build(context.Background(), deps, map[string]any{ "goal": "do a thing", @@ -103,7 +103,7 @@ func TestBuildPartialNotEmpty(t *testing.T) { app := &mockApp{handler: buildHandler(exec, func(map[string]any) map[string]any { return map[string]any{"passed": false, "criteria_results": []any{}, "summary": "partial"} })} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := Build(context.Background(), deps, map[string]any{ "goal": "thing", @@ -134,7 +134,7 @@ func TestBuildVerifiedSuccess(t *testing.T) { app := &mockApp{handler: buildHandler(exec, func(map[string]any) map[string]any { return map[string]any{"passed": true, "criteria_results": []any{}, "summary": "ok"} })} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := Build(context.Background(), deps, map[string]any{ "goal": "thing", "repo_path": t.TempDir(), "config": map[string]any{"git_init_max_retries": 1}, @@ -152,7 +152,7 @@ func TestBuildRequiresRepoPathOrURL(t *testing.T) { defer withExecCtx("r", "e")() deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} _, err := Build(context.Background(), deps, map[string]any{"goal": "x"}) if err == nil || !strings.Contains(err.Error(), "Either repo_path or repo_url") { t.Fatalf("expected repo_path/url error, got %v", err) @@ -188,7 +188,7 @@ func TestBuildIsolationConcurrent(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} _, _ = Build(context.Background(), deps, map[string]any{ "goal": "g", "repo_path": t.TempDir(), "config": map[string]any{"git_init_max_retries": 1}, diff --git a/go/internal/orch/cigate_loop_test.go b/go/internal/orch/cigate_loop_test.go index 09d318d5..57207e1b 100644 --- a/go/internal/orch/cigate_loop_test.go +++ b/go/internal/orch/cigate_loop_test.go @@ -51,7 +51,7 @@ func ciCfg() *config.BuildConfig { func ciReq(app *mockApp, resolved map[string]string) CIGateRequest { return CIGateRequest{ - Deps: &Deps{App: app, NodeID: "swe-planner-go"}, + Deps: &Deps{App: app, NodeID: "swe-planner"}, Cfg: ciCfg(), Resolved: resolved, RepoPath: "/tmp/repo", diff --git a/go/internal/orch/common.go b/go/internal/orch/common.go index ba0e3b85..422bdd65 100644 --- a/go/internal/orch/common.go +++ b/go/internal/orch/common.go @@ -46,7 +46,7 @@ type Handler func(ctx context.Context, deps *Deps, input map[string]any) (any, e // Deps carries the collaborators an orchestrator handler needs. // // - App: the control-plane-routed call + note surface (a *agent.Agent). -// - NodeID: the node id calls are addressed to (NODE_ID, default "swe-planner-go"). +// - NodeID: the node id calls are addressed to (NODE_ID, default "swe-planner"). // - AgentFieldServer: the control-plane base URL — the approval webhook base. // (The empty-build failure carrier no longer POSTs here: build returns the // SDK's &agent.ReasonerFailed and the async handler posts status=failed + @@ -63,6 +63,14 @@ type Deps struct { AgentFieldServer string CIGate CIGateRunner ApprovalGate ApprovalGate + + // DefaultExecuteFnTarget, when non-empty, is the external coder target + // applied by the execute path whenever a request does not name one — the + // node-level engine opt-in seam. A caller-supplied execute_fn_target + // (request kwarg or config key) always wins; empty leaves the built-in + // coding loop as the default, so the wiring is inert unless the node + // registration sets it. + DefaultExecuteFnTarget string } // --------------------------------------------------------------------------- @@ -122,12 +130,12 @@ func (d *Deps) CallRaw(ctx context.Context, name string, kwargs map[string]any) } // target renders the fully-qualified "." call target. At runtime -// NodeID is always set (BuildAgent default "swe-planner-go" or the NODE_ID env); +// NodeID is always set (BuildAgent default "swe-planner" or the NODE_ID env); // the fallback only guards zero-value Deps in tests. func (d *Deps) target(name string) string { nodeID := d.NodeID if nodeID == "" { - nodeID = "swe-planner-go" + nodeID = "swe-planner" } return nodeID + "." + name } diff --git a/go/internal/orch/common_test.go b/go/internal/orch/common_test.go index 75553b04..8bb6b9e0 100644 --- a/go/internal/orch/common_test.go +++ b/go/internal/orch/common_test.go @@ -89,9 +89,9 @@ func TestNewCallFnUnwrapsResult(t *testing.T) { "result": map[string]any{"x": float64(1)}, }, nil }} - d := &Deps{App: app, NodeID: "swe-planner-go"} + d := &Deps{App: app, NodeID: "swe-planner"} fn := d.NewCallFn() - out, err := fn(context.Background(), "swe-planner-go.run_coder", nil) + out, err := fn(context.Background(), "swe-planner.run_coder", nil) if err != nil { t.Fatalf("unexpected err: %v", err) } @@ -104,7 +104,7 @@ func TestCallPropagatesFailureEnvelope(t *testing.T) { app := &mockApp{handler: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { return map[string]any{"status": "failed", "error_message": "boom"}, nil }} - d := &Deps{App: app, NodeID: "swe-planner-go"} + d := &Deps{App: app, NodeID: "swe-planner"} if _, err := d.Call(context.Background(), "execute", nil, "execute"); err == nil { t.Fatal("expected error from failed envelope") } @@ -115,7 +115,7 @@ func TestCallRawReturnsEnvelope(t *testing.T) { app := &mockApp{handler: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { return env, nil }} - d := &Deps{App: app, NodeID: "swe-planner-go"} + d := &Deps{App: app, NodeID: "swe-planner"} raw, err := d.CallRaw(context.Background(), "run_git_init", nil) if err != nil { t.Fatal(err) diff --git a/go/internal/orch/execute.go b/go/internal/orch/execute.go index 6224bfb1..654b6a72 100644 --- a/go/internal/orch/execute.go +++ b/go/internal/orch/execute.go @@ -3,6 +3,7 @@ package orch import ( "context" "encoding/json" + "fmt" "github.com/Agent-Field/SWE-AF/go/internal/config" "github.com/Agent-Field/SWE-AF/go/internal/dag" @@ -69,6 +70,16 @@ func ExecuteHandler(ctx context.Context, deps *Deps, input map[string]any) (any, // await app.call(execute_fn_target, issue=issue, repo_path=dag_state.repo_path) // callFn already dispatches to the raw target and unwraps the envelope, so it // is the correct primitive for an external (non-node-local) call. + // + // A request that names no target falls back to the node-level default (the + // engine opt-in seam); build forwards its config's value here, so this one + // check covers both direct execute calls and full builds. + if in.ExecuteFnTarget == "" && deps.DefaultExecuteFnTarget != "" { + in.ExecuteFnTarget = deps.DefaultExecuteFnTarget + deps.Note(ctx, fmt.Sprintf( + "pro engine: per-issue coding routed via %s — set SWE_PRO_ENGINE=0 to use the classic coding loop", + in.ExecuteFnTarget), "pro", "default") + } var executeFn dag.ExecuteFn if in.ExecuteFnTarget != "" { target := in.ExecuteFnTarget diff --git a/go/internal/orch/execute_test.go b/go/internal/orch/execute_test.go index dc521789..58530e30 100644 --- a/go/internal/orch/execute_test.go +++ b/go/internal/orch/execute_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "github.com/Agent-Field/SWE-AF/go/internal/config" @@ -56,7 +57,7 @@ func TestExecuteConfigResolvedAndForwarded(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} plan := minimalPlan() _, err := ExecuteHandler(context.Background(), deps, map[string]any{ @@ -77,7 +78,7 @@ func TestExecuteConfigResolvedAndForwarded(t *testing.T) { if captured.repoPath != "/tmp/target-repo" { t.Errorf("repo_path not forwarded: got %q", captured.repoPath) } - if captured.nodeID != "swe-planner-go" { + if captured.nodeID != "swe-planner" { t.Errorf("node_id not forwarded: got %q", captured.nodeID) } if captured.callFn == nil { @@ -222,7 +223,7 @@ func TestExecuteWorkspaceManifestNonePassthrough(t *testing.T) { t.Errorf("no reasoner call expected for empty single-repo build, got %q", target) return map[string]any{}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out := mustExecute(t, deps, map[string]any{ "plan_result": minimalPlan(), @@ -249,7 +250,7 @@ func TestExecuteWorkspaceManifestForwarded(t *testing.T) { // _init_all_repos dispatches run_git_init per repo; success is fine. return map[string]any{"success": true, "mode": "existing", "integration_branch": "main"}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out := mustExecute(t, deps, map[string]any{ "plan_result": minimalPlan(), @@ -272,7 +273,7 @@ func TestExecuteWorkspaceManifestForwarded(t *testing.T) { func TestExecuteBuildIDForwarded(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} out := mustExecute(t, deps, map[string]any{ "plan_result": minimalPlan(), @@ -310,7 +311,7 @@ func TestExecuteResumeForwarded(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} out := mustExecute(t, deps, map[string]any{ "plan_result": plan, @@ -340,7 +341,7 @@ func TestExecuteExternalTargetPath(t *testing.T) { } return map[string]any{}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} plan := minimalPlan() plan["issues"] = []any{map[string]any{ @@ -367,6 +368,82 @@ func TestExecuteExternalTargetPath(t *testing.T) { } } +// The node-level default target applies when the request names none — the +// engine opt-in seam — and a caller-supplied target always beats it. +func TestExecuteDefaultExecuteFnTarget(t *testing.T) { + const defaultTarget = "swe-planner.pro_execute" + const explicitTarget = "coder-agent.code_issue" + + run := func(t *testing.T, inputTarget, wantTarget string) { + t.Helper() + var seenTargets []string + app := &mockApp{handler: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + seenTargets = append(seenTargets, target) + return map[string]any{"outcome": "completed", "result_summary": "Done"}, nil + }} + deps := &Deps{App: app, NodeID: "swe-planner", DefaultExecuteFnTarget: defaultTarget} + + plan := minimalPlan() + plan["issues"] = []any{map[string]any{ + "name": "impl", "title": "t", "description": "d", + "acceptance_criteria": []any{}, "depends_on": []any{}, + "files_to_create": []any{}, "files_to_modify": []any{}, + }} + plan["levels"] = []any{[]any{"impl"}} + + input := map[string]any{"plan_result": plan, "repo_path": "/tmp/repo"} + if inputTarget != "" { + input["execute_fn_target"] = inputTarget + } + mustExecute(t, deps, input) + + if !contains(seenTargets, wantTarget) { + t.Fatalf("want dispatch to %q, targets=%v", wantTarget, seenTargets) + } + other := defaultTarget + if wantTarget == defaultTarget { + other = explicitTarget + } + if contains(seenTargets, other) { + t.Errorf("dispatched to %q as well as %q; targets=%v", other, wantTarget, seenTargets) + } + } + + t.Run("default applies when request names none", func(t *testing.T) { + run(t, "", defaultTarget) + }) + t.Run("explicit target beats the default", func(t *testing.T) { + run(t, explicitTarget, explicitTarget) + }) +} + +// Without a node-level default, an empty execute_fn_target keeps the built-in +// coding loop — the pre-existing behavior every current caller relies on. The +// external path is observed by its absence: nothing dispatches to pro_execute. +func TestExecuteNoDefaultKeepsBuiltinLoop(t *testing.T) { + var seenTargets []string + app := &mockApp{handler: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + seenTargets = append(seenTargets, target) + return map[string]any{}, nil + }} + deps := &Deps{App: app, NodeID: "swe-planner"} + + plan := minimalPlan() + plan["issues"] = []any{map[string]any{ + "name": "impl", "title": "t", "description": "d", + "acceptance_criteria": []any{}, "depends_on": []any{}, + "files_to_create": []any{}, "files_to_modify": []any{}, + }} + plan["levels"] = []any{[]any{"impl"}} + + mustExecute(t, deps, map[string]any{"plan_result": plan, "repo_path": "/tmp/repo"}) + for _, target := range seenTargets { + if strings.HasSuffix(target, ".pro_execute") { + t.Fatalf("built-in loop expected, but dispatched to %q; targets=%v", target, seenTargets) + } + } +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- diff --git a/go/internal/orch/plan_test.go b/go/internal/orch/plan_test.go index fe745b51..ed666485 100644 --- a/go/internal/orch/plan_test.go +++ b/go/internal/orch/plan_test.go @@ -145,7 +145,7 @@ func planApp(sprint map[string]any) (*Deps, *planMock) { "run_sprint_planner": constResp(sprint), "run_issue_writer": constResp(map[string]any{"success": true, "path": "/tmp/x.md"}), }} - return &Deps{App: m, NodeID: "swe-planner-go"}, m + return &Deps{App: m, NodeID: "swe-planner"}, m } func runPlan(t *testing.T, deps *Deps, repoPath string, extra map[string]any) (map[string]any, error) { @@ -434,7 +434,7 @@ func TestPlanOpenRouterOnlyDefaults(t *testing.T) { "run_sprint_planner": constResp(sprintResult(issue("my-issue", nil, []any{"thing.py"}))), "run_issue_writer": constResp(map[string]any{"success": true}), }} - deps := &Deps{App: m, NodeID: "swe-planner-go"} + deps := &Deps{App: m, NodeID: "swe-planner"} // Omit ai_provider/*_model so env resolution runs. if _, err := Plan(context.Background(), deps, map[string]any{ diff --git a/go/internal/orch/repohygiene.go b/go/internal/orch/repohygiene.go new file mode 100644 index 00000000..368fff1d --- /dev/null +++ b/go/internal/orch/repohygiene.go @@ -0,0 +1,140 @@ +package orch + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// harnessMetadataPatterns are the directories the build harness writes INTO the +// target repository: the plan/issue/checkpoint artifacts and the per-issue +// worktrees. They are ours, not the user's work. +var harnessMetadataPatterns = []string{".artifacts/", ".worktrees/"} + +// excludeHarnessMetadata keeps the harness's own bookkeeping out of the target +// repository's git view. +// +// The engine stages with `git add -A` at several points (auditor gate, review +// gate, leaf merger, diff fingerprint). None of them pass -f, so a pattern in +// .git/info/exclude is enough to stop every one of them at once — which is why +// this is a single repo-local write rather than a change at each add site. +// +// .git/info/exclude rather than .gitignore on purpose: it is not versioned, so +// it never appears in a diff, never conflicts with the user's own ignore rules, +// and disappears with the clone. The user's working files are untouched. +// +// Without it the harness's own metadata lands in `git status` and in the index, +// and any acceptance criterion about repository cleanliness becomes +// unsatisfiable — an issue whose AC read "`git diff --name-only HEAD` lists only +// ordinals.go" failed four consecutive engine cycles on .artifacts/ and +// .worktrees/ files while the build and test suites were green the whole time. +// Beyond that gate, a build that leaves harness droppings in a user's +// repository is simply wrong. +// +// Anything already staged by an earlier stage of this run is removed from the +// index (content on disk is kept). Every step is best-effort: a repo_path that +// is not a git repository yet — the fresh-folder path, where git_init has not +// run — must not fail the build. +func excludeHarnessMetadata(ctx context.Context, repoPath string) { + if strings.TrimSpace(repoPath) == "" { + return + } + gitDir := runProc(ctx, repoPath, "git", "rev-parse", "--git-dir") + if gitDir.ExitCode != 0 { + return + } + dir := strings.TrimSpace(gitDir.Stdout) + if dir == "" { + return + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(repoPath, dir) + } + excludePath := filepath.Join(dir, "info", "exclude") + if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil { + return + } + present := map[string]struct{}{} + if data, err := os.ReadFile(excludePath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + present[strings.TrimSpace(line)] = struct{}{} + } + } + missing := make([]string, 0, len(harnessMetadataPatterns)) + for _, pattern := range harnessMetadataPatterns { + if _, ok := present[pattern]; !ok { + missing = append(missing, pattern) + } + } + if len(missing) > 0 { + if file, err := os.OpenFile( + excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644, + ); err == nil { + _, _ = file.WriteString(strings.Join(missing, "\n") + "\n") + _ = file.Close() + } + } + // An exclude pattern does not evict a path git is already tracking, so drop + // anything an earlier stage staged. --cached keeps the files on disk. + for _, pattern := range harnessMetadataPatterns { + runProc(ctx, repoPath, "git", "rm", "-r", "--cached", "-q", + "--ignore-unmatch", "--", strings.TrimSuffix(pattern, "/")) + } +} + +// integrationBranchBase re-points the integration branch at the commit the +// workspace was on when the build started. +// +// run_git_init is a model role: its prompt says to branch from HEAD, but the +// agent runs the git commands itself and can pick a different base. Observed on +// a real repository — HEAD carried the reported bug, and the agent cut +// feature/- from HEAD~1 instead, so the defect was not an +// ancestor of the branch the build would merge into. Every test on that branch +// passed for the wrong reason: the bug had never been there. +// +// baseSHA is resolved in code before the role is dispatched, so this repairs +// the branch against a fact rather than against the agent's own report. It only +// acts when the branch demonstrably does not contain baseSHA, and it refuses to +// touch a branch carrying commits of its own — by then the agent has done work +// a reset would discard, and losing that is worse than a wrong base. Returns +// whether it moved the branch. +func integrationBranchBase( + ctx context.Context, repoPath, branch, baseSHA string, +) bool { + if strings.TrimSpace(repoPath) == "" || + strings.TrimSpace(branch) == "" || strings.TrimSpace(baseSHA) == "" { + return false + } + if runProc(ctx, repoPath, "git", "rev-parse", "--verify", + "--quiet", branch+"^{commit}").ExitCode != 0 { + return false + } + if runProc(ctx, repoPath, "git", "merge-base", + "--is-ancestor", baseSHA, branch).ExitCode == 0 { + return false // already descends from the run's starting commit + } + ahead := runProc(ctx, repoPath, "git", "rev-list", "--count", baseSHA+".."+branch) + if strings.TrimSpace(ahead.Stdout) != "0" && ahead.ExitCode == 0 { + return false // the branch carries work; do not discard it + } + current := strings.TrimSpace( + runProc(ctx, repoPath, "git", "rev-parse", "--abbrev-ref", "HEAD").Stdout) + if current == branch { + return runProc(ctx, repoPath, "git", "reset", "--hard", baseSHA).ExitCode == 0 + } + return runProc(ctx, repoPath, "git", "branch", "-f", branch, baseSHA).ExitCode == 0 +} + +// headSHA resolves repoPath's current commit, or "" when there is none (a fresh +// folder, or a repo with no commits yet). +func headSHA(ctx context.Context, repoPath string) string { + if strings.TrimSpace(repoPath) == "" { + return "" + } + res := runProc(ctx, repoPath, "git", "rev-parse", "--verify", "--quiet", "HEAD") + if res.ExitCode != 0 { + return "" + } + return strings.TrimSpace(res.Stdout) +} diff --git a/go/internal/orch/repohygiene_test.go b/go/internal/orch/repohygiene_test.go new file mode 100644 index 00000000..e04ba756 --- /dev/null +++ b/go/internal/orch/repohygiene_test.go @@ -0,0 +1,185 @@ +package orch + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func hygieneRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + ctx := context.Background() + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.name", "hygiene"}, + {"config", "user.email", "hygiene@example.test"}, + } { + if res := runProc(ctx, repo, "git", args...); res.ExitCode != 0 { + t.Fatalf("git %v: %s", args, res.Stderr) + } + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# demo\n"), 0o644); err != nil { + t.Fatal(err) + } + if res := runProc(ctx, repo, "git", "add", "README.md"); res.ExitCode != 0 { + t.Fatal(res.Stderr) + } + if res := runProc(ctx, repo, "git", "commit", "-m", "seed"); res.ExitCode != 0 { + t.Fatal(res.Stderr) + } + return repo +} + +func writeUnder(t *testing.T, repo, rel, body string) { + t.Helper() + path := filepath.Join(repo, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestExcludeHarnessMetadataKeepsGitViewClean: the harness writes .artifacts/ +// and .worktrees/ into the target repo, and the engine stages with `git add -A` +// at several points. After the exclusion neither shows up in status, so an +// acceptance criterion about repository cleanliness is satisfiable and the +// user's repo stays unpolluted. +func TestExcludeHarnessMetadataKeepsGitViewClean(t *testing.T) { + ctx := context.Background() + repo := hygieneRepo(t) + writeUnder(t, repo, ".artifacts/plan/prd.md", "# prd\n") + writeUnder(t, repo, ".artifacts/execution/checkpoint.json", "{}\n") + writeUnder(t, repo, ".worktrees/issue-01/marker", "x\n") + writeUnder(t, repo, "ordinals.go", "package humanize\n") + + excludeHarnessMetadata(ctx, repo) + + // The engine's staging step, verbatim in shape. + if res := runProc(ctx, repo, "git", "add", "-A"); res.ExitCode != 0 { + t.Fatalf("git add -A: %s", res.Stderr) + } + staged := runProc(ctx, repo, "git", "diff", "--name-only", "HEAD").Stdout + for _, unwanted := range []string{".artifacts", ".worktrees"} { + if strings.Contains(staged, unwanted) { + t.Errorf("harness metadata %s reached the index:\n%s", unwanted, staged) + } + } + if !strings.Contains(staged, "ordinals.go") { + t.Errorf("real work must still be staged, got:\n%s", staged) + } + // AC-shaped check: exactly the deliverable. + if strings.TrimSpace(staged) != "ordinals.go" { + t.Errorf("git diff --name-only HEAD = %q, want only ordinals.go", strings.TrimSpace(staged)) + } +} + +// TestExcludeHarnessMetadataUnstagesWhatEarlierStagesStaged: an exclude pattern +// does not evict an already-tracked path, so the helper must also drop what a +// previous stage of the same run put in the index. +func TestExcludeHarnessMetadataUnstagesWhatEarlierStagesStaged(t *testing.T) { + ctx := context.Background() + repo := hygieneRepo(t) + writeUnder(t, repo, ".artifacts/plan/prd.md", "# prd\n") + if res := runProc(ctx, repo, "git", "add", "-A"); res.ExitCode != 0 { + t.Fatal(res.Stderr) + } + if !strings.Contains(runProc(ctx, repo, "git", "diff", "--name-only", "HEAD").Stdout, ".artifacts") { + t.Fatal("fixture precondition: .artifacts should be staged") + } + excludeHarnessMetadata(ctx, repo) + if staged := runProc(ctx, repo, "git", "diff", "--name-only", "HEAD").Stdout; strings.Contains(staged, ".artifacts") { + t.Errorf("staged metadata not dropped:\n%s", staged) + } + if _, err := os.Stat(filepath.Join(repo, ".artifacts", "plan", "prd.md")); err != nil { + t.Errorf("--cached must keep the file on disk: %v", err) + } +} + +// TestExcludeHarnessMetadataToleratesNonRepo: repo_path may not be a git repo +// yet (the fresh-folder path runs before git_init). It must not fail the build. +func TestExcludeHarnessMetadataToleratesNonRepo(t *testing.T) { + excludeHarnessMetadata(context.Background(), t.TempDir()) + excludeHarnessMetadata(context.Background(), "") +} + +// TestIntegrationBranchBaseRecutsFromRunStart is the observed defect: git_init +// cut feature/ from HEAD~1, so the reported bug was not an ancestor of the +// branch the build merges into and its tests passed for the wrong reason. +func TestIntegrationBranchBaseRecutsFromRunStart(t *testing.T) { + ctx := context.Background() + repo := hygieneRepo(t) + preBug := headSHA(ctx, repo) + writeUnder(t, repo, "ordinals.go", "package humanize // buggy\n") + runProc(ctx, repo, "git", "add", "-A") + runProc(ctx, repo, "git", "commit", "-m", "plant the bug") + buggy := headSHA(ctx, repo) + if buggy == preBug || buggy == "" { + t.Fatal("fixture precondition") + } + + // git_init's mistake: branch from the commit BEFORE the bug. + if res := runProc(ctx, repo, "git", "checkout", "-q", "-b", "feature/x", preBug); res.ExitCode != 0 { + t.Fatal(res.Stderr) + } + if runProc(ctx, repo, "git", "merge-base", "--is-ancestor", buggy, "feature/x").ExitCode == 0 { + t.Fatal("fixture precondition: bug must not be an ancestor yet") + } + + if !integrationBranchBase(ctx, repo, "feature/x", buggy) { + t.Fatal("expected the branch to be re-cut") + } + if runProc(ctx, repo, "git", "merge-base", "--is-ancestor", buggy, "feature/x").ExitCode != 0 { + t.Error("feature/x must descend from the run's starting commit") + } +} + +// TestIntegrationBranchBaseLeavesCorrectAndWorkingBranchesAlone: no-op when the +// branch already descends from the base, and never discards real work. +func TestIntegrationBranchBaseLeavesCorrectAndWorkingBranchesAlone(t *testing.T) { + ctx := context.Background() + + t.Run("already correct", func(t *testing.T) { + repo := hygieneRepo(t) + base := headSHA(ctx, repo) + runProc(ctx, repo, "git", "checkout", "-q", "-b", "feature/ok") + if integrationBranchBase(ctx, repo, "feature/ok", base) { + t.Error("must not touch a branch that already descends from base") + } + }) + + t.Run("branch carries work", func(t *testing.T) { + repo := hygieneRepo(t) + preBug := headSHA(ctx, repo) + writeUnder(t, repo, "a.go", "package a\n") + runProc(ctx, repo, "git", "add", "-A") + runProc(ctx, repo, "git", "commit", "-m", "bug") + buggy := headSHA(ctx, repo) + runProc(ctx, repo, "git", "checkout", "-q", "-b", "feature/busy", preBug) + writeUnder(t, repo, "work.go", "package work\n") + runProc(ctx, repo, "git", "add", "-A") + runProc(ctx, repo, "git", "commit", "-m", "agent work") + before := headSHA(ctx, repo) + if integrationBranchBase(ctx, repo, "feature/busy", buggy) { + t.Error("must not reset a branch that carries commits") + } + if headSHA(ctx, repo) != before { + t.Error("agent work was discarded") + } + }) + + t.Run("missing branch and empty args", func(t *testing.T) { + repo := hygieneRepo(t) + base := headSHA(ctx, repo) + if integrationBranchBase(ctx, repo, "feature/nope", base) { + t.Error("missing branch must be a no-op") + } + if integrationBranchBase(ctx, repo, "", base) || integrationBranchBase(ctx, repo, "b", "") { + t.Error("empty args must be a no-op") + } + }) +} diff --git a/go/internal/orch/resolve_test.go b/go/internal/orch/resolve_test.go index 7f0a6861..8a8f1237 100644 --- a/go/internal/orch/resolve_test.go +++ b/go/internal/orch/resolve_test.go @@ -146,7 +146,7 @@ func TestAttemptBaseMergeConflictListsUnmergedFiles(t *testing.T) { func TestResolveMissingRequiredArgsRaises(t *testing.T) { deps := &Deps{App: &mockApp{handler: func(context.Context, string, map[string]any) (map[string]any, error) { return map[string]any{}, nil - }}, NodeID: "swe-planner-go"} + }}, NodeID: "swe-planner"} cases := []map[string]any{ {"pr_url": "", "pr_number": 1, "repo_url": "https://github.com/o/r.git", "head_branch": "feature/x"}, @@ -226,7 +226,7 @@ func TestResolveCallsResolverAndPostsThreads(t *testing.T) { gateCalled := false deps := &Deps{ App: app, - NodeID: "swe-planner-go", + NodeID: "swe-planner", CIGate: func(_ context.Context, req CIGateRequest) (map[string]any, error) { gateCalled = true gateReq = req @@ -411,7 +411,7 @@ func TestResolvePushesWhenAgentCommittedButDidntPush(t *testing.T) { }} deps := &Deps{ App: app, - NodeID: "swe-planner-go", + NodeID: "swe-planner", CIGate: func(context.Context, CIGateRequest) (map[string]any, error) { return map[string]any{"final_status": "passed"}, nil }, @@ -467,7 +467,7 @@ func TestResolveCommitterIdentityFromEnv(t *testing.T) { } return map[string]any{}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} _, err := ResolveHandler(context.Background(), deps, map[string]any{ "pr_url": "https://github.com/o/r/pull/3", @@ -506,7 +506,7 @@ func TestResolveCommitterIdentityDefaults(t *testing.T) { } return map[string]any{}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} _, err := ResolveHandler(context.Background(), deps, map[string]any{ "pr_url": "https://github.com/o/r/pull/4", @@ -568,7 +568,7 @@ func TestResolveSkipsCIGateWhenCheckCIFalse(t *testing.T) { gateCalled := false deps := &Deps{ App: app, - NodeID: "swe-planner-go", + NodeID: "swe-planner", CIGate: func(context.Context, CIGateRequest) (map[string]any, error) { gateCalled = true return map[string]any{"final_status": "passed"}, nil @@ -618,7 +618,7 @@ func TestResolveFailureSuccessFalse(t *testing.T) { } return map[string]any{}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := ResolveHandler(context.Background(), deps, map[string]any{ "pr_url": "https://github.com/o/r/pull/6", diff --git a/go/internal/orch/resume_test.go b/go/internal/orch/resume_test.go index d7d76c94..25aba8c2 100644 --- a/go/internal/orch/resume_test.go +++ b/go/internal/orch/resume_test.go @@ -12,7 +12,7 @@ import ( // resume_build with a missing checkpoint -> the exact Python error message. func TestResumeMissingCheckpoint(t *testing.T) { repo := t.TempDir() - deps := &Deps{App: &mockApp{}, NodeID: "swe-planner-go"} + deps := &Deps{App: &mockApp{}, NodeID: "swe-planner"} _, err := ResumeBuildHandler(context.Background(), deps, map[string]any{ "repo_path": repo, @@ -54,7 +54,7 @@ func TestResumeReconstructsPlanAndCallsExecute(t *testing.T) { gotInput = input return rawEnvelope, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} out, err := ResumeBuildHandler(context.Background(), deps, map[string]any{ "repo_path": repo, @@ -69,7 +69,7 @@ func TestResumeReconstructsPlanAndCallsExecute(t *testing.T) { if !reflect.DeepEqual(out, rawEnvelope) { t.Fatalf("resume must return the raw execute envelope, got %v", out) } - if gotTarget != "swe-planner-go.execute" { + if gotTarget != "swe-planner.execute" { t.Fatalf("target = %q", gotTarget) } if gotInput["resume"] != true { @@ -125,7 +125,7 @@ func TestResumeArtifactsDirDefault(t *testing.T) { gotInput = input return map[string]any{"status": "succeeded"}, nil }} - deps := &Deps{App: app, NodeID: "swe-planner-go"} + deps := &Deps{App: app, NodeID: "swe-planner"} if _, err := ResumeBuildHandler(context.Background(), deps, map[string]any{"repo_path": repo}); err != nil { t.Fatal(err) diff --git a/go/internal/pro/adapter.go b/go/internal/pro/adapter.go new file mode 100644 index 00000000..5152987a --- /dev/null +++ b/go/internal/pro/adapter.go @@ -0,0 +1,207 @@ +package pro + +// adapter.go bridges the planner's external-executor contract onto the pro +// engine's native task interface. pro_execute is registered (env-gated) on the +// planner node and matches the execute_fn_target calling convention exactly — +// (issue, repo_path) in, an issue-execution result out — so a build opts in +// with `config.execute_fn_target = ".pro_execute"` and nothing else. + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/Agent-Field/SWE-AF/go/internal/afx" + "github.com/Agent-Field/SWE-AF/go/internal/schemas" +) + +// CallFn dispatches to a reasoner by target with the same keyword args Python +// passes to app.call. Structurally identical to coding.CallFn / issue.CallFn, +// so the node's single agent.Call + envelope-unwrap closure satisfies all. +type CallFn func(ctx context.Context, target string, kwargs map[string]any) (map[string]any, error) + +// Noter matches the SDK's *agent.Agent.Note method; tests supply a recorder. +type Noter interface { + Note(ctx context.Context, message string, tags ...string) +} + +// Handler is the registration signature, matching the roles/issue packages. +type Handler func(ctx context.Context, deps *Deps, input map[string]any) (any, error) + +// Handlers returns the name→handler map for the env-gated pro surface. +func Handlers() map[string]Handler { + return map[string]Handler{"pro_execute": ProExecute} +} + +// Deps carries the seams pro_execute needs. +type Deps struct { + Call CallFn + Note Noter + // EngineNode is the engine's control-plane node id (NodeID() in wiring); + // dispatch goes to ".code_task". + EngineNode string +} + +func (d *Deps) note(ctx context.Context, msg string, tags ...string) { + if d != nil && d.Note != nil { + d.Note.Note(ctx, msg, tags...) + } +} + +type proExecuteInput struct { + Issue map[string]any `json:"issue"` + RepoPath string `json:"repo_path"` +} + +// ProExecute routes one fully-scoped issue to the pro engine and maps the +// engine's terminal result onto the shape runExecuteFn consumes (outcome / +// result_summary / error_message / error_context). Engine-reported failures +// come back as a failed outcome — not an error — so the advisor loop owns the +// retry/split/escalate decision, same as the built-in coding loop. +func ProExecute(ctx context.Context, deps *Deps, input map[string]any) (any, error) { + in, err := afx.Bind[proExecuteInput](input) + if err != nil { + return nil, err + } + if len(in.Issue) == 0 { + return nil, fmt.Errorf("pro_execute: issue is required") + } + if in.RepoPath == "" { + return nil, fmt.Errorf("pro_execute: repo_path is required") + } + + kwargs := map[string]any{ + "goal": ComposeGoal(in.Issue), + "dir": in.RepoPath, + } + // Optional env-driven dispatch overrides: cost ceiling, sub-agent model + // pools and reasoning-effort variant. Unset keeps the engine's defaults. + for env, kw := range map[string]string{ + EnvMaxCost: "max_cost", + EnvModelsHigh: "high", + EnvModelsLow: "low", + EnvVariant: "variant", + } { + if v := os.Getenv(env); v != "" { + kwargs[kw] = v + } + } + + name, _ := in.Issue["name"].(string) + deps.note(ctx, fmt.Sprintf("pro engine: dispatching issue %q", name), "pro") + res, err := deps.Call(ctx, deps.EngineNode+".code_task", kwargs) + if err != nil { + // Transport/engine-crash errors go to runExecuteFn's retry path. + return nil, err + } + out := mapEngineResult(res) + deps.note(ctx, fmt.Sprintf("pro engine: issue %q → %s", name, out["outcome"]), "pro") + return out, nil +} + +// ComposeGoal flattens an issue dict (PlannedIssue / IssueSpec shape) into the +// single goal text the engine plans from. Sections are omitted when absent so +// a minimal {title, description} issue still reads naturally. +func ComposeGoal(issue map[string]any) string { + var b strings.Builder + title, _ := issue["title"].(string) + if title == "" { + title, _ = issue["name"].(string) + } + if title != "" { + b.WriteString(title) + b.WriteString("\n\n") + } + if d, _ := issue["description"].(string); d != "" { + b.WriteString(d) + b.WriteString("\n") + } + if ac := asStrings(issue["acceptance_criteria"]); len(ac) > 0 { + b.WriteString("\nAcceptance criteria:\n") + for _, c := range ac { + b.WriteString("- ") + b.WriteString(c) + b.WriteString("\n") + } + } + if fc := asStrings(issue["files_to_create"]); len(fc) > 0 { + b.WriteString("\nFiles to create: ") + b.WriteString(strings.Join(fc, ", ")) + b.WriteString("\n") + } + if fm := asStrings(issue["files_to_modify"]); len(fm) > 0 { + b.WriteString("Files to modify: ") + b.WriteString(strings.Join(fm, ", ")) + b.WriteString("\n") + } + if ts, _ := issue["testing_strategy"].(string); ts != "" { + b.WriteString("\nTesting strategy: ") + b.WriteString(ts) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +// mapEngineResult maps the engine's terminal result {status, reason, cost_usd, +// cycle, run_id, ...} onto the external-executor result shape. +// +// Status mapping: "pass" completes; "budget-exhausted" is unrecoverable +// (retrying would burn the same budget again); everything else — fail, +// escalated, crashed, unknown — is retryable so SWE-AF's own advisor loop +// decides what happens next (the engine's internal escalation is not this +// DAG's escalation). +func mapEngineResult(res map[string]any) map[string]any { + status, _ := res["status"].(string) + reason, _ := res["reason"].(string) + + var outcome schemas.IssueOutcome + switch status { + case "pass": + outcome = schemas.IssueOutcomeCompleted + case "budget-exhausted": + outcome = schemas.IssueOutcomeFailedUnrecoverable + default: + outcome = schemas.IssueOutcomeFailedRetryable + } + + summary := fmt.Sprintf("pro engine: status=%s", status) + if runID, _ := res["run_id"].(string); runID != "" { + summary += " run=" + runID + } + if cost, ok := res["cost_usd"].(float64); ok { + summary += fmt.Sprintf(" cost=$%.2f", cost) + } + + out := map[string]any{ + "outcome": string(outcome), + "result_summary": summary, + } + if outcome != schemas.IssueOutcomeCompleted { + msg := "pro engine reported status " + status + if reason != "" { + msg += ": " + reason + } + out["error_message"] = msg + out["error_context"] = summary + } + return out +} + +// asStrings coerces a JSON-decoded list ([]any of strings) into []string, +// tolerating an already-typed []string. +func asStrings(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/go/internal/pro/adapter_test.go b/go/internal/pro/adapter_test.go new file mode 100644 index 00000000..c0067cfb --- /dev/null +++ b/go/internal/pro/adapter_test.go @@ -0,0 +1,179 @@ +package pro + +import ( + "context" + "errors" + "strings" + "testing" +) + +type callRec struct { + target string + kwargs map[string]any + res map[string]any + err error +} + +func (c *callRec) call(_ context.Context, target string, kwargs map[string]any) (map[string]any, error) { + c.target = target + c.kwargs = kwargs + return c.res, c.err +} + +type noteRec struct{ notes []string } + +func (n *noteRec) Note(_ context.Context, msg string, _ ...string) { n.notes = append(n.notes, msg) } + +func sampleIssue() map[string]any { + return map[string]any{ + "name": "add-abs", + "title": "Add Abs helper", + "description": "Implement Abs(int) int in mathx.", + "acceptance_criteria": []any{"Abs(-2) == 2", "Abs(2) == 2"}, + "files_to_create": []any{"mathx/abs.go"}, + "files_to_modify": []any{"mathx/doc.go"}, + "testing_strategy": "table-driven unit tests", + } +} + +func TestComposeGoal(t *testing.T) { + goal := ComposeGoal(sampleIssue()) + for _, want := range []string{ + "Add Abs helper", + "Implement Abs(int) int in mathx.", + "- Abs(-2) == 2", + "Files to create: mathx/abs.go", + "Files to modify: mathx/doc.go", + "Testing strategy: table-driven unit tests", + } { + if !strings.Contains(goal, want) { + t.Errorf("ComposeGoal missing %q in:\n%s", want, goal) + } + } +} + +func TestComposeGoalMinimal(t *testing.T) { + goal := ComposeGoal(map[string]any{"name": "fix-typo", "description": "Fix the typo."}) + if !strings.HasPrefix(goal, "fix-typo") || !strings.Contains(goal, "Fix the typo.") { + t.Errorf("minimal goal = %q", goal) + } + if strings.Contains(goal, "Acceptance criteria") { + t.Errorf("empty sections must be omitted: %q", goal) + } +} + +func TestProExecutePass(t *testing.T) { + for _, env := range []string{EnvMaxCost, EnvModelsHigh, EnvModelsLow, EnvVariant} { + t.Setenv(env, "") + } + rec := &callRec{res: map[string]any{ + "status": "pass", "run_id": "r-1", "cost_usd": 1.25, "cycle": 2.0, + }} + notes := ¬eRec{} + deps := &Deps{Call: rec.call, Note: notes, EngineNode: "swe-pro"} + + out, err := ProExecute(context.Background(), deps, + map[string]any{"issue": sampleIssue(), "repo_path": "/tmp/repo"}) + if err != nil { + t.Fatal(err) + } + if rec.target != "swe-pro.code_task" { + t.Errorf("target = %q, want swe-pro.code_task", rec.target) + } + if rec.kwargs["dir"] != "/tmp/repo" { + t.Errorf("dir = %v", rec.kwargs["dir"]) + } + for _, kw := range []string{"max_cost", "high", "low", "variant"} { + if _, ok := rec.kwargs[kw]; ok { + t.Errorf("%s forwarded without its env override set", kw) + } + } + m := out.(map[string]any) + if m["outcome"] != "completed" { + t.Errorf("outcome = %v, want completed", m["outcome"]) + } + if s := m["result_summary"].(string); !strings.Contains(s, "run=r-1") || !strings.Contains(s, "cost=$1.25") { + t.Errorf("summary = %q", s) + } + if _, ok := m["error_message"]; ok { + t.Error("error_message set on a pass") + } + if len(notes.notes) != 2 { + t.Errorf("want dispatch+outcome notes, got %v", notes.notes) + } +} + +func TestProExecuteFailureMapping(t *testing.T) { + cases := map[string]string{ + "fail": "failed_retryable", + "escalated": "failed_retryable", + "crashed": "failed_retryable", + "unknown": "failed_retryable", + "budget-exhausted": "failed_unrecoverable", + } + for status, wantOutcome := range cases { + rec := &callRec{res: map[string]any{"status": status, "reason": "because"}} + deps := &Deps{Call: rec.call, EngineNode: "swe-pro"} + out, err := ProExecute(context.Background(), deps, + map[string]any{"issue": sampleIssue(), "repo_path": "/tmp/repo"}) + if err != nil { + t.Fatalf("%s: %v", status, err) + } + m := out.(map[string]any) + if m["outcome"] != wantOutcome { + t.Errorf("status %q → outcome %v, want %s", status, m["outcome"], wantOutcome) + } + if msg := m["error_message"].(string); !strings.Contains(msg, status) || !strings.Contains(msg, "because") { + t.Errorf("status %q error_message = %q", status, msg) + } + } +} + +func TestProExecuteMaxCostForwarded(t *testing.T) { + t.Setenv(EnvMaxCost, "2.50") + rec := &callRec{res: map[string]any{"status": "pass"}} + deps := &Deps{Call: rec.call, EngineNode: "swe-pro"} + if _, err := ProExecute(context.Background(), deps, + map[string]any{"issue": sampleIssue(), "repo_path": "/tmp/repo"}); err != nil { + t.Fatal(err) + } + if rec.kwargs["max_cost"] != "2.50" { + t.Errorf("max_cost = %v, want 2.50", rec.kwargs["max_cost"]) + } +} + +func TestProExecuteModelOverridesForwarded(t *testing.T) { + t.Setenv(EnvModelsHigh, "openrouter/openai/gpt-5.6-sol") + t.Setenv(EnvModelsLow, "openrouter/openai/gpt-5.6-sol") + t.Setenv(EnvVariant, "low") + rec := &callRec{res: map[string]any{"status": "pass"}} + deps := &Deps{Call: rec.call, EngineNode: "swe-pro"} + if _, err := ProExecute(context.Background(), deps, + map[string]any{"issue": sampleIssue(), "repo_path": "/tmp/repo"}); err != nil { + t.Fatal(err) + } + if rec.kwargs["high"] != "openrouter/openai/gpt-5.6-sol" || + rec.kwargs["low"] != "openrouter/openai/gpt-5.6-sol" || + rec.kwargs["variant"] != "low" { + t.Errorf("model overrides not forwarded: %v", rec.kwargs) + } +} + +func TestProExecuteCallErrorPropagates(t *testing.T) { + rec := &callRec{err: errors.New("connection refused")} + deps := &Deps{Call: rec.call, EngineNode: "swe-pro"} + if _, err := ProExecute(context.Background(), deps, + map[string]any{"issue": sampleIssue(), "repo_path": "/tmp/repo"}); err == nil { + t.Fatal("transport error must propagate for the retry loop") + } +} + +func TestProExecuteValidation(t *testing.T) { + deps := &Deps{Call: (&callRec{}).call, EngineNode: "swe-pro"} + if _, err := ProExecute(context.Background(), deps, map[string]any{"repo_path": "/tmp/repo"}); err == nil { + t.Error("missing issue must error") + } + if _, err := ProExecute(context.Background(), deps, map[string]any{"issue": sampleIssue()}); err == nil { + t.Error("missing repo_path must error") + } +} diff --git a/go/internal/pro/pro.go b/go/internal/pro/pro.go new file mode 100644 index 00000000..10f69a33 --- /dev/null +++ b/go/internal/pro/pro.go @@ -0,0 +1,360 @@ +// Package pro is the "pro engine" integration: a prebuilt coding-engine binary +// shipped alongside SWE-AF that registers on the same control plane as its own +// node and can take over per-issue coding work. The repo vendors one build per +// supported platform under go/bin, named swe-pro--; ResolveBin +// picks the one matching the host. +// +// Everything in this package is inert unless SWE_PRO_ENGINE holds a truthy +// value: no child process is spawned, no reasoner is registered, and the +// default swe-planner surface (and its parity test) is byte-identical to a +// build without this package. That gate is unchanged — what changed is who +// sets it. The agentfield-package.yaml manifest defaults SWE_PRO_ENGINE to +// "1", so an `af install` node runs the engine and users opt OUT with +// SWE_PRO_ENGINE=0; a bare binary launched without the manifest still starts +// classic, which keeps this package's default-off code path honest. +// +// Two surfaces, mirroring the two ways SWE-AF itself is used: +// +// - Supervisor (this file): spawns ` serve` as a sidecar so the engine +// registers its own node (default "swe-pro") with its native reasoners — +// the sub-harness surface other reasoners call directly. +// - pro_execute (adapter.go): an execute_fn_target-compatible reasoner on the +// planner node that routes one issue's coding to the engine, so a normal +// `build` opts in per request via the existing config key with no schema +// changes. +package pro + +import ( + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Env var surface. SWE-AF-side names only; the supervisor translates them to +// the engine's own env contract in childEnv so callers never see engine names. +const ( + // EnvEnabled gates the whole package: "1"/"true"/"yes"/"on" enable it, + // anything else (notably "0" and "false") disables it. The manifest + // defaults it to "1", so this is the opt-OUT knob for installed nodes. + EnvEnabled = "SWE_PRO_ENGINE" + // EnvBin overrides the engine binary path. + EnvBin = "SWE_PRO_BIN" + // EnvNodeID overrides the engine's control-plane node id. + EnvNodeID = "SWE_PRO_NODE_ID" + // EnvPort overrides the engine's listen port. + EnvPort = "SWE_PRO_PORT" + // EnvPublicURL sets the engine's callback base URL (containers), mirroring + // AGENT_CALLBACK_URL on the SWE-AF nodes. + EnvPublicURL = "SWE_PRO_PUBLIC_URL" + // EnvMaxCost, when set, is forwarded as the engine's per-run cost ceiling + // (USD) on every pro_execute dispatch. + EnvMaxCost = "SWE_PRO_MAX_COST" + // EnvModelsHigh / EnvModelsLow, when set, are forwarded as the engine's + // model pools (comma-separated ids) on every pro_execute dispatch — the + // models its sub-agents run on. Unset keeps the engine's defaults. + EnvModelsHigh = "SWE_PRO_MODELS_HIGH" + EnvModelsLow = "SWE_PRO_MODELS_LOW" + // EnvVariant, when set, is forwarded as the engine's reasoning-effort + // variant (e.g. "low" for fastest turnaround, "high" for depth). + EnvVariant = "SWE_PRO_VARIANT" + + // DefaultBin is where the Docker image installs the engine: one image, one + // platform, so the copy lands under the unsuffixed name. + DefaultBin = "/usr/local/bin/swe-pro" + DefaultNodeID = "swe-pro" + DefaultPort = "8801" +) + +// Restart policy. Vars, not consts, so tests can tighten them. +var ( + backoffInitial = time.Second + backoffMax = 30 * time.Second + // healthyUptime is the run length after which the backoff and the + // fast-crash counter reset — the sidecar was evidently serving. + healthyUptime = 60 * time.Second + // maxFastCrashes stops the restart loop after this many consecutive + // short-lived exits: a binary that can never start (bad glibc, bad arch, + // port taken) should log and give up, not spin forever. + maxFastCrashes = 10 +) + +// Enabled reports whether SWE_PRO_ENGINE holds a truthy value. Unset is false: +// the manifest is what turns the engine on for installed nodes, so a bare +// binary stays on the classic loop. +func Enabled() bool { + switch strings.ToLower(os.Getenv(EnvEnabled)) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// BinPath returns the engine binary path (SWE_PRO_BIN or the default). +func BinPath() string { return envOr(EnvBin, DefaultBin) } + +// runnable reports whether path is an existing regular file we could actually +// spawn. Mere existence is not enough: a copy that lost its execute bit (some +// installers create destination files with a fresh 0644 mode) would otherwise +// look available and then fail at exec time, which is exactly the state the +// availability gate exists to avoid. +func runnable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Mode().Perm()&0o111 != 0 +} + +// osExecutable is os.Executable, indirected so tests can point the sibling +// search at a temp dir instead of the test binary's own directory. +var osExecutable = os.Executable + +// siblingNames lists the engine binary names to look for next to the running +// executable, best candidate first. The repo vendors one engine build per +// supported platform under go/bin (swe-pro-darwin-arm64, swe-pro-linux-amd64, +// …) because a single checkout is installed on macOS and Linux alike, so the +// GOOS/GOARCH suffix is what picks the build that can actually exec here — the +// unsuffixed name would be a coin flip and yield "exec format error". The +// plain name stays as a fallback for layouts that place one hand-built engine +// beside the node (an unpacked image, a local engine build). +func siblingNames() []string { + return []string{"swe-pro-" + runtime.GOOS + "-" + runtime.GOARCH, "swe-pro"} +} + +// ResolveBin returns the first runnable engine binary on disk. An explicit +// SWE_PRO_BIN is authoritative (no fallback past it); otherwise DefaultBin is +// tried (the Docker image layout, where the image build copies its one +// platform's binary to the unsuffixed path), then the siblingNames candidates +// next to the running executable — the layout an `af install` checkout +// produces, where the installer builds bin/swe-planner into the same bin/ dir +// that carries the vendored engines. ok=false means no usable binary was +// found; path then names the location worth reporting. +func ResolveBin() (path string, ok bool) { + if v := os.Getenv(EnvBin); v != "" { + return v, runnable(v) + } + if runnable(DefaultBin) { + return DefaultBin, true + } + if exe, err := osExecutable(); err == nil { + dir := filepath.Dir(exe) + unusable := "" + for _, name := range siblingNames() { + sibling := filepath.Join(dir, name) + if runnable(sibling) { + return sibling, true + } + // Name a present-but-unusable sibling rather than the default path, + // so the warning points at the file that actually needs attention. + // Candidates are in preference order, so the first one found is the + // one the user meant to be used. + if unusable == "" { + if _, err := os.Stat(sibling); err == nil { + unusable = sibling + } + } + } + if unusable != "" { + return unusable, false + } + } + return DefaultBin, false +} + +// Available reports whether the engine is opted in AND its binary exists — +// the gate for registering pro_execute and defaulting builds through it. +// Enabled-but-missing must degrade to the classic coding loop (with Start's +// warning) rather than route every issue to a node that never joins. +func Available() bool { + if !Enabled() { + return false + } + _, ok := ResolveBin() + return ok +} + +// NodeID returns the engine's control-plane node id (SWE_PRO_NODE_ID or the +// default). The adapter dispatches to ".". +func NodeID() string { return envOr(EnvNodeID, DefaultNodeID) } + +// Port returns the engine's listen port (SWE_PRO_PORT or the default). +func Port() string { return envOr(EnvPort, DefaultPort) } + +// Options carries the control-plane coordinates the sidecar inherits from the +// host node — the same values node.BuildAgent resolved from the environment. +type Options struct { + // Server is the control-plane base URL (AGENTFIELD_SERVER). + Server string + // Token is the control-plane bearer token (AGENTFIELD_API_KEY); may be "". + Token string + // Bin overrides the engine binary path; empty means BinPath(). + Bin string + // Stdout/Stderr receive the sidecar's prefixed output; nil means the + // process's own streams. Test seams. + Stdout, Stderr io.Writer +} + +// Supervisor owns the sidecar process lifecycle: spawn, restart with backoff, +// stop on context cancellation. +type Supervisor struct { + done chan struct{} +} + +// Start launches the engine sidecar under supervision and returns immediately. +// A missing binary is a warning, not an error — the host node must come up +// regardless — so Start returns nil in that case (and Wait on nil is a no-op). +// Cancel ctx to stop the sidecar; then Wait for the loop to wind down. +func Start(ctx context.Context, opts Options) *Supervisor { + bin := opts.Bin + if bin == "" { + resolved, ok := ResolveBin() + if !ok { + log.Printf("pro engine: no runnable binary at %s (missing or not executable) — "+ + "sidecar disabled, builds use the classic coding engine", resolved) + return nil + } + bin = resolved + } else if !runnable(bin) { + log.Printf("pro engine: no runnable binary at %s — sidecar disabled, builds use the classic coding engine", bin) + return nil + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + // One clear line so a user who never set the flag — the manifest turns it + // on for `af install` — knows the engine is on, how coding routes, and + // how to get back to the classic loop. + log.Printf("pro engine enabled: engine node %q joins the control plane; "+ + "builds route per-issue coding through it. Set %s=0 to use the classic coding loop instead.", + NodeID(), EnvEnabled) + s := &Supervisor{done: make(chan struct{})} + go s.loop(ctx, bin, opts) + return s +} + +// Wait blocks until the supervision loop has exited (after ctx cancellation or +// give-up), or until timeout. Safe on a nil Supervisor. +func (s *Supervisor) Wait(timeout time.Duration) { + if s == nil { + return + } + select { + case <-s.done: + case <-time.After(timeout): + } +} + +func (s *Supervisor) loop(ctx context.Context, bin string, opts Options) { + defer close(s.done) + backoff := backoffInitial + fastCrashes := 0 + for { + if ctx.Err() != nil { + return + } + start := time.Now() + err := runOnce(ctx, bin, opts) + uptime := time.Since(start) + if ctx.Err() != nil { + return + } + if uptime >= healthyUptime { + backoff = backoffInitial + fastCrashes = 0 + } else { + fastCrashes++ + if fastCrashes >= maxFastCrashes { + log.Printf("pro engine: %d consecutive fast exits — giving up (last: %v)", fastCrashes, err) + return + } + } + log.Printf("pro engine: sidecar exited after %s (%v) — restarting in %s", + uptime.Round(time.Second), err, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff *= 2; backoff > backoffMax { + backoff = backoffMax + } + } +} + +// runOnce runs one ` serve` process to completion. Cancellation sends +// SIGINT (the engine shuts its node down cleanly), escalating to SIGKILL after +// WaitDelay. +func runOnce(ctx context.Context, bin string, opts Options) error { + cmd := exec.CommandContext(ctx, bin, "serve") + cmd.Env = childEnv(os.Environ(), opts) + cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } + cmd.WaitDelay = 5 * time.Second + setSysProcAttr(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + copied := make(chan struct{}, 2) + go func() { pipeLines(opts.Stdout, stdout); copied <- struct{}{} }() + go func() { pipeLines(opts.Stderr, stderr); copied <- struct{}{} }() + <-copied + <-copied + return cmd.Wait() +} + +// childEnv builds the sidecar environment: the parent env (so provider keys +// like OPENROUTER_API_KEY pass through) plus the engine's own env surface +// derived from the SWE-AF-side names. os/exec keeps the LAST value for a +// duplicated key, so the appended entries win over anything inherited. +func childEnv(base []string, opts Options) []string { + env := append([]string(nil), base...) + env = append(env, + "AGENTFIELD_URL="+opts.Server, + "AGENT_NODE_ID="+NodeID(), + "AGENT_LISTEN_ADDR=:"+Port(), + ) + if opts.Token != "" { + env = append(env, "AGENTFIELD_TOKEN="+opts.Token) + } + if pub := os.Getenv(EnvPublicURL); pub != "" { + env = append(env, "AGENT_PUBLIC_URL="+pub) + } + return env +} + +// pipeLines copies r to w line by line under a "[pro-engine]" prefix so the +// sidecar's output is attributable in the host node's logs. +func pipeLines(w io.Writer, r io.Reader) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + fmt.Fprintf(w, "[pro-engine] %s\n", sc.Text()) + } +} + +// envOr returns the value of key, or def when unset or empty. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/go/internal/pro/pro_test.go b/go/internal/pro/pro_test.go new file mode 100644 index 00000000..3bcbf0eb --- /dev/null +++ b/go/internal/pro/pro_test.go @@ -0,0 +1,344 @@ +package pro + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func TestEnabled(t *testing.T) { + cases := map[string]bool{ + "": false, + "0": false, + "false": false, + "off": false, + "nope": false, + "1": true, + "true": true, + "TRUE": true, + "yes": true, + "on": true, + } + for val, want := range cases { + t.Setenv(EnvEnabled, val) + if got := Enabled(); got != want { + t.Errorf("Enabled() with %s=%q = %v, want %v", EnvEnabled, val, got, want) + } + } +} + +// TestEnabledOptOutContract pins the two halves of the default-on rollout. The +// manifest declares SWE_PRO_ENGINE with default "1", and the installer's env +// resolver injects a declared default unconditionally — so unlike before, a +// value is always present on an installed node and "turning it off" can only +// mean writing a falsy one. "0" and "false" must therefore read as disabled, +// and a genuinely absent variable (a bare binary, no manifest) must still read +// as disabled so this package keeps its own default-off behaviour. +func TestEnabledOptOutContract(t *testing.T) { + t.Setenv(EnvEnabled, "1") + if !Enabled() { + t.Errorf("%s=1 must enable the engine — this is what the manifest default injects", EnvEnabled) + } + for _, off := range []string{"0", "false", "FALSE"} { + t.Setenv(EnvEnabled, off) + if Enabled() { + t.Errorf("%s=%q must disable the engine — it is the documented opt-out", EnvEnabled, off) + } + } + // Genuinely unset, not merely empty: t.Setenv registers the restore, then + // Unsetenv removes the variable for the rest of this test. + t.Setenv(EnvEnabled, "") + if err := os.Unsetenv(EnvEnabled); err != nil { + t.Fatal(err) + } + if Enabled() { + t.Errorf("unset %s must leave the engine off (bare binary, no manifest)", EnvEnabled) + } +} + +func TestDefaults(t *testing.T) { + t.Setenv(EnvBin, "") + t.Setenv(EnvNodeID, "") + t.Setenv(EnvPort, "") + if BinPath() != DefaultBin { + t.Errorf("BinPath() = %q, want %q", BinPath(), DefaultBin) + } + if NodeID() != DefaultNodeID { + t.Errorf("NodeID() = %q, want %q", NodeID(), DefaultNodeID) + } + if Port() != DefaultPort { + t.Errorf("Port() = %q, want %q", Port(), DefaultPort) + } + t.Setenv(EnvNodeID, "swe-pro-2") + if NodeID() != "swe-pro-2" { + t.Errorf("NodeID() override = %q, want swe-pro-2", NodeID()) + } +} + +// TestResolveBin covers the three-step search: explicit SWE_PRO_BIN is +// authoritative (found or not — no fall-through), and Available() is the +// flag AND binary-presence conjunction. +func TestResolveBin(t *testing.T) { + dir := t.TempDir() + present := filepath.Join(dir, "engine") + if err := os.WriteFile(present, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + missing := filepath.Join(dir, "missing") + + t.Setenv(EnvBin, present) + if path, ok := ResolveBin(); !ok || path != present { + t.Errorf("ResolveBin() with existing override = (%q, %v), want (%q, true)", path, ok, present) + } + + t.Setenv(EnvBin, missing) + if path, ok := ResolveBin(); ok || path != missing { + t.Errorf("ResolveBin() with missing override = (%q, %v), want (%q, false) — no fall-through", path, ok, missing) + } + + // A present-but-not-executable binary must be treated as unavailable: it + // would fail at exec time, and routing coding to an engine that can never + // start is worse than staying on the classic loop. (Some install paths + // copy files without preserving the source's execute bit.) + nonExec := filepath.Join(dir, "not-executable") + if err := os.WriteFile(nonExec, []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(EnvBin, nonExec) + if _, ok := ResolveBin(); ok { + t.Error("ResolveBin() reported a non-executable file as usable") + } + t.Setenv(EnvEnabled, "1") + if Available() { + t.Error("Available() = true for a non-executable binary — must degrade to the classic loop") + } + + // A directory at the binary path is likewise not runnable (os.Stat alone + // succeeds on directories). + t.Setenv(EnvBin, dir) + if _, ok := ResolveBin(); ok { + t.Error("ResolveBin() reported a directory as usable") + } + t.Setenv(EnvEnabled, "") + + t.Setenv(EnvEnabled, "1") + t.Setenv(EnvBin, present) + if !Available() { + t.Error("Available() = false with flag on and binary present") + } + t.Setenv(EnvBin, missing) + if Available() { + t.Error("Available() = true with flag on but binary missing") + } + t.Setenv(EnvEnabled, "") + t.Setenv(EnvBin, present) + if Available() { + t.Error("Available() = true with flag off") + } +} + +// TestResolveBinSiblings covers the `af install` layout, where the vendored +// engines sit in the same bin/ dir the installer builds the node into. One +// checkout carries a build per platform, so the swe-pro-- +// sibling must be preferred over a plain swe-pro — running the wrong one is an +// "exec format error", not a fallback — while a lone plain swe-pro (an +// unpacked image, a local engine build) still resolves. +func TestResolveBinSiblings(t *testing.T) { + if runnable(DefaultBin) { + t.Skipf("%s exists on this host and short-circuits the sibling search", DefaultBin) + } + suffixed := "swe-pro-" + runtime.GOOS + "-" + runtime.GOARCH + + cases := []struct { + name string + // present maps sibling file name to its mode; 0o644 is the + // present-but-unusable case the availability gate must reject. + present map[string]os.FileMode + want string // sibling name, or "" for "no usable engine" + wantOK bool + }{ + {"suffixed preferred over plain", map[string]os.FileMode{suffixed: 0o755, "swe-pro": 0o755}, suffixed, true}, + {"suffixed alone", map[string]os.FileMode{suffixed: 0o755}, suffixed, true}, + {"plain alone is the fallback", map[string]os.FileMode{"swe-pro": 0o755}, "swe-pro", true}, + {"neither present", nil, "", false}, + {"plain usable, suffixed not", map[string]os.FileMode{suffixed: 0o644, "swe-pro": 0o755}, "swe-pro", true}, + // Both unusable: the warning must name the suffixed candidate, the one + // this platform was meant to run. + {"both unusable names the best candidate", map[string]os.FileMode{suffixed: 0o644, "swe-pro": 0o644}, suffixed, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvBin, "") + dir := t.TempDir() + for name, mode := range tc.present { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), mode); err != nil { + t.Fatal(err) + } + } + orig := osExecutable + osExecutable = func() (string, error) { return filepath.Join(dir, "swe-planner"), nil } + defer func() { osExecutable = orig }() + + want := DefaultBin + if tc.want != "" { + want = filepath.Join(dir, tc.want) + } + path, ok := ResolveBin() + if path != want || ok != tc.wantOK { + t.Errorf("ResolveBin() = (%q, %v), want (%q, %v)", path, ok, want, tc.wantOK) + } + }) + } +} + +// TestChildEnv asserts the SWE-AF → engine env translation, including that the +// appended entries win over inherited duplicates (os/exec last-wins) and that +// provider keys pass through untouched. +func TestChildEnv(t *testing.T) { + t.Setenv(EnvNodeID, "") + t.Setenv(EnvPort, "9911") + t.Setenv(EnvPublicURL, "http://pro.example:9911") + base := []string{"OPENROUTER_API_KEY=sk-or-test", "AGENTFIELD_URL=http://stale:1"} + env := childEnv(base, Options{Server: "http://cp:8080", Token: "tok"}) + + want := map[string]string{ + "AGENTFIELD_URL": "http://cp:8080", + "AGENT_NODE_ID": DefaultNodeID, + "AGENT_LISTEN_ADDR": ":9911", + "AGENTFIELD_TOKEN": "tok", + "AGENT_PUBLIC_URL": "http://pro.example:9911", + "OPENROUTER_API_KEY": "sk-or-test", + } + got := map[string]string{} + for _, kv := range env { // later entries overwrite: mirror os/exec last-wins + k, v, _ := strings.Cut(kv, "=") + got[k] = v + } + for k, v := range want { + if got[k] != v { + t.Errorf("childEnv[%s] = %q, want %q", k, got[k], v) + } + } +} + +func TestChildEnvNoTokenNoPublicURL(t *testing.T) { + t.Setenv(EnvPublicURL, "") + env := childEnv(nil, Options{Server: "http://cp:8080"}) + for _, kv := range env { + if strings.HasPrefix(kv, "AGENTFIELD_TOKEN=") || strings.HasPrefix(kv, "AGENT_PUBLIC_URL=") { + t.Errorf("childEnv unexpectedly set %q", kv) + } + } +} + +func TestStartMissingBinary(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := Start(ctx, Options{Server: "http://cp:8080", Bin: filepath.Join(t.TempDir(), "nope")}) + if s != nil { + t.Fatal("Start with a missing binary should return nil") + } + s.Wait(time.Millisecond) // nil-safe +} + +// fakeBin writes an executable shell script and returns its path. +func fakeBin(t *testing.T, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell-script fake binary; supervisor behavior is POSIX-tested") + } + p := filepath.Join(t.TempDir(), "swe-pro") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +// syncBuffer is a concurrency-safe io.Writer for capturing sidecar output: the +// pipeLines goroutines write while the test goroutine reads. +type syncBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// waitFor polls until want appears in b, or the deadline passes. Returns +// whether it appeared. Polling rather than sleeping a fixed interval keeps the +// test honest on a loaded machine, where spawning a shell can take far longer +// than any hardcoded guess. +func waitFor(b *syncBuffer, want string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if strings.Contains(b.String(), want) { + return true + } + time.Sleep(5 * time.Millisecond) + } + return strings.Contains(b.String(), want) +} + +// TestSupervisorStopsOnCancel: a long-running sidecar is interrupted by ctx +// cancellation and the loop winds down promptly. +func TestSupervisorStopsOnCancel(t *testing.T) { + bin := fakeBin(t, `echo up; trap 'exit 0' INT TERM; while true; do sleep 0.1; done`) + ctx, cancel := context.WithCancel(context.Background()) + out := &syncBuffer{} + s := Start(ctx, Options{Server: "http://cp:8080", Bin: bin, Stdout: out, Stderr: out}) + if s == nil { + t.Fatal("Start returned nil for an existing binary") + } + // Cancel only once the sidecar has demonstrably started and its output has + // been captured — cancelling before it prints is what the old fixed sleep + // raced against under parallel package load. + if !waitFor(out, "[pro-engine] up", 15*time.Second) { + cancel() + t.Fatalf("sidecar stdout not prefixed/captured: %q", out.String()) + } + cancel() + done := make(chan struct{}) + go func() { s.Wait(10 * time.Second); close(done) }() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("supervisor did not stop after cancel") + } +} + +// TestSupervisorGivesUpOnFastCrashes: a binary that always exits immediately +// stops being restarted after maxFastCrashes. +func TestSupervisorGivesUpOnFastCrashes(t *testing.T) { + bin := fakeBin(t, `exit 3`) + origInitial, origMax, origCrashes := backoffInitial, backoffMax, maxFastCrashes + backoffInitial, backoffMax, maxFastCrashes = time.Millisecond, 2*time.Millisecond, 3 + defer func() { backoffInitial, backoffMax, maxFastCrashes = origInitial, origMax, origCrashes }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := Start(ctx, Options{Server: "http://cp:8080", Bin: bin, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}) + if s == nil { + t.Fatal("Start returned nil") + } + done := make(chan struct{}) + go func() { s.Wait(10 * time.Second); close(done) }() + select { + case <-done: // gave up without ctx cancellation — expected + case <-time.After(15 * time.Second): + t.Fatal("supervisor kept restarting a fast-crashing binary") + } +} diff --git a/go/internal/pro/sysproc_linux.go b/go/internal/pro/sysproc_linux.go new file mode 100644 index 00000000..7d13bc87 --- /dev/null +++ b/go/internal/pro/sysproc_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package pro + +import ( + "os/exec" + "syscall" +) + +// setSysProcAttr asks the kernel to SIGTERM the sidecar if the host node dies +// without running its shutdown path (log.Fatalf, OOM-kill) — the ctx-based +// cancellation in runOnce only covers orderly returns. +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} +} diff --git a/go/internal/pro/sysproc_other.go b/go/internal/pro/sysproc_other.go new file mode 100644 index 00000000..f57ea61b --- /dev/null +++ b/go/internal/pro/sysproc_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package pro + +import "os/exec" + +// setSysProcAttr is a no-op off Linux: parent-death signals are a Linux +// feature; elsewhere the ctx cancellation in runOnce is the only kill path. +func setSysProcAttr(*exec.Cmd) {} diff --git a/go/internal/prompts/coding/issue_writer.go b/go/internal/prompts/coding/issue_writer.go index c76ccb1d..80c3946a 100644 --- a/go/internal/prompts/coding/issue_writer.go +++ b/go/internal/prompts/coding/issue_writer.go @@ -97,6 +97,15 @@ Read Section X.Y () for: - The Testing Strategy section MUST be concrete: name exact test file paths, the test framework, and map acceptance criteria to test categories. Do NOT write vague strategies like "add unit tests." +- Acceptance criteria describe the DELIVERABLE, never the state of the whole + repository. Never write a criterion that constrains global cleanliness — + ` + "`" + `git status` + "`" + ` being empty, or ` + "`" + `git diff --name-only` + "`" + ` listing an exact set + of files. The build harness and the coding engine both write bookkeeping into + the working tree, so such a criterion can never hold and the issue can never + be completed. "` + "`" + `ordinals.go` + "`" + ` contains the corrected modulus" is a good + criterion; "` + "`" + `git diff --name-only HEAD` + "`" + ` lists only ` + "`" + `ordinals.go` + "`" + `" is not. + Constraining which files the coder must not MODIFY is fine — say it in prose + ("do not change the tests"), not as a command over the whole index. - Use the numbered naming convention: ` + "`" + `issue--.md` + "`" + ` (e.g. ` + "`" + `issue-01-lexer.md` + "`" + `) ## Tools Available diff --git a/go/internal/prompts/coding/testdata/sys_issue_writer.txt b/go/internal/prompts/coding/testdata/sys_issue_writer.txt index d791364f..23e1f226 100644 --- a/go/internal/prompts/coding/testdata/sys_issue_writer.txt +++ b/go/internal/prompts/coding/testdata/sys_issue_writer.txt @@ -88,6 +88,15 @@ Read Section X.Y () for: - The Testing Strategy section MUST be concrete: name exact test file paths, the test framework, and map acceptance criteria to test categories. Do NOT write vague strategies like "add unit tests." +- Acceptance criteria describe the DELIVERABLE, never the state of the whole + repository. Never write a criterion that constrains global cleanliness — + `git status` being empty, or `git diff --name-only` listing an exact set + of files. The build harness and the coding engine both write bookkeeping into + the working tree, so such a criterion can never hold and the issue can never + be completed. "`ordinals.go` contains the corrected modulus" is a good + criterion; "`git diff --name-only HEAD` lists only `ordinals.go`" is not. + Constraining which files the coder must not MODIFY is fine — say it in prose + ("do not change the tests"), not as a command over the whole index. - Use the numbered naming convention: `issue--.md` (e.g. `issue-01-lexer.md`) ## Tools Available diff --git a/go/internal/roles/coding/coding.go b/go/internal/roles/coding/coding.go index ebfa9fa0..47b0868f 100644 --- a/go/internal/roles/coding/coding.go +++ b/go/internal/roles/coding/coding.go @@ -494,19 +494,34 @@ func RunQASynthesizer(ctx context.Context, deps *Deps, input map[string]any) (an }, nil } -// mapSynthModel maps the short role model alias (haiku/sonnet/opus, the coding -// loop's cfg.QASynthesizerModel() default is "haiku") to a provider-qualified -// model id when the direct-LLM client targets OpenRouter — its OpenAI-compatible -// endpoint has no "haiku" model, so an unmapped alias 400s. Ids already carrying -// a "/" (already provider-qualified) pass through unchanged, and when the client -// is not OpenRouter (OpenAI-compatible or no key configured) the alias is left -// as-is. The OpenRouter decision is re-derived from the same env ai.DefaultConfig -// reads, matching the AIConfig wired onto the agent in node.BuildAgent. +// mapSynthModel translates a role model id for the direct-LLM client. Two +// translations, both only when that client targets OpenRouter: +// +// - Short aliases (haiku/sonnet/opus — cfg.QASynthesizerModel() defaults to +// "haiku") become provider-qualified ids, because OpenRouter's +// OpenAI-compatible endpoint has no "haiku" model and 400s on an unmapped +// alias. +// - A leading "openrouter/" is stripped. Model ids are LiteLLM-style +// throughout this repo's config — config.openRouterAutoDefaultModel is +// "openrouter/deepseek/deepseek-v4-flash", which is exactly right for the +// open_code harness runtime — but OpenRouter's own API names that model +// "deepseek/deepseek-v4-flash" and rejects the routing prefix with a 400. +// The harness path keeps the prefix; only this direct boundary drops it. +// +// Other ids carrying a "/" are already in OpenRouter's namespace and pass +// through. When the client is not OpenRouter (OpenAI-compatible or no key +// configured) nothing is rewritten — including the prefix, which is meaningful +// to a LiteLLM-style proxy. The OpenRouter decision is re-derived from the same +// env ai.DefaultConfig reads, matching the AIConfig wired onto the agent in +// node.BuildAgent. func mapSynthModel(model string) string { - if strings.Contains(model, "/") { + if !ai.DefaultConfig().IsOpenRouter() { return model } - if !ai.DefaultConfig().IsOpenRouter() { + if stripped, ok := strings.CutPrefix(model, "openrouter/"); ok { + return stripped + } + if strings.Contains(model, "/") { return model } switch model { diff --git a/go/internal/roles/coding/synthmodel_test.go b/go/internal/roles/coding/synthmodel_test.go index b6444ebc..1e7ca88e 100644 --- a/go/internal/roles/coding/synthmodel_test.go +++ b/go/internal/roles/coding/synthmodel_test.go @@ -25,7 +25,12 @@ func TestMapSynthModelOpenRouter(t *testing.T) { "anthropic/claude-x": "anthropic/claude-x", // already qualified -> passthrough "deepseek/deepseek-chat": "deepseek/deepseek-chat", "some-unknown-alias": "some-unknown-alias", // unknown, no "/" -> passthrough - "openrouter/z-ai/glm-5": "openrouter/z-ai/glm-5", + // LiteLLM-style ids carry an "openrouter/" routing prefix that + // OpenRouter's own API rejects with a 400. This is the id + // config.openRouterAutoDefaultModel hands every role on an + // OpenRouter-only install, so the direct client must drop the prefix. + "openrouter/deepseek/deepseek-v4-flash": "deepseek/deepseek-v4-flash", + "openrouter/z-ai/glm-5": "z-ai/glm-5", } for in, want := range cases { if got := mapSynthModel(in); got != want { @@ -49,4 +54,12 @@ func TestMapSynthModelNonOpenRouter(t *testing.T) { if got := mapSynthModel("anthropic/claude-x"); got != "anthropic/claude-x" { t.Errorf("qualified id must pass through, got %q", got) } + // The "openrouter/" prefix is stripped ONLY for an OpenRouter client. To + // anything else — a LiteLLM-style proxy behind AI_BASE_URL, say — the + // prefix is the routing instruction, so removing it would send the request + // to the wrong provider. + const prefixed = "openrouter/deepseek/deepseek-v4-flash" + if got := mapSynthModel(prefixed); got != prefixed { + t.Errorf("mapSynthModel(%q) = %q, want passthrough — the prefix is only stripped for OpenRouter", prefixed, got) + } } diff --git a/go/internal/roles/planning/planning_test.go b/go/internal/roles/planning/planning_test.go index dfd15f1e..efc286e2 100644 --- a/go/internal/roles/planning/planning_test.go +++ b/go/internal/roles/planning/planning_test.go @@ -74,7 +74,7 @@ func haxTestServer(t *testing.T) (*hitl.HaxClient, func()) { // newDeps builds Deps with a recording note channel and no HITL (Hax nil). func newDeps(h *fakeHarness) (*Deps, *recNote) { notes := &recNote{} - return &Deps{Harness: h, App: notes, NodeID: "swe-planner-go"}, notes + return &Deps{Harness: h, App: notes, NodeID: "swe-planner"}, notes } func keys(m map[string]any) map[string]bool { diff --git a/go/swe-planner b/go/swe-planner new file mode 100755 index 00000000..a108ba59 Binary files /dev/null and b/go/swe-planner differ diff --git a/go/test/e2e-fast/run.sh b/go/test/e2e-fast/run.sh index cc2a0e0c..7a42146f 100755 --- a/go/test/e2e-fast/run.sh +++ b/go/test/e2e-fast/run.sh @@ -122,15 +122,15 @@ git clone --quiet "https://x-access-token:${GH_TOKEN_VAL}@github.com/${REPO_FULL ok "repo reset to clean seed commit" # --------------------------------------------------------------------------- -# 5. Start swe-planner (:18005) with the claude shim on PATH. NODE_ID exercises -# the new default identity (swe-planner-go) so the POST below opts in via the -# -go reasoner path. +# 5. Start swe-planner (:18005) with the claude shim on PATH. NODE_ID is set +# explicitly to the default identity so the POST below targets it by name +# regardless of what the binary would default to. # --------------------------------------------------------------------------- log "starting swe-planner on :$PLANNER_PORT (shim=$SHIM)" PATH="$SHIM:$PATH" \ AGENTFIELD_SERVER="$CP_URL" \ AGENT_CALLBACK_URL="$PLANNER_URL" \ - NODE_ID="swe-planner-go" \ + NODE_ID="swe-planner" \ PORT="$PLANNER_PORT" \ GH_TOKEN="$GH_TOKEN_VAL" \ SWE_MOCK_SCENARIO="$RUN_DIR/scenario.json" \ @@ -145,7 +145,7 @@ done curl -sf --connect-timeout 3 -m 8 "$PLANNER_URL/health" >/dev/null 2>&1 || { err "planner did not come up (see $RUN_DIR/planner.log)"; exit 1; } # Wait until the CP knows the planner's reasoners. for i in $(seq 1 30); do - RC="$(curl -s --connect-timeout 3 -m 30 "$CP_URL/api/v1/nodes/swe-planner-go" | grep -c run_coder || true)" + RC="$(curl -s --connect-timeout 3 -m 30 "$CP_URL/api/v1/nodes/swe-planner" | grep -c run_coder || true)" [[ "$RC" -ge 1 ]] && break sleep 1 done @@ -163,8 +163,8 @@ read -r -d '' BODY </dev/null)" WF_ID="$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("workflow_id",""))' 2>/dev/null)" diff --git a/go/test/functional/README.md b/go/test/functional/README.md index e6540854..6461b30b 100644 --- a/go/test/functional/README.md +++ b/go/test/functional/README.md @@ -1,7 +1,7 @@ # SWE-AF Go port — functional (black-box) parity tests These tests exercise the **live** stack — the AgentField control-plane plus the -two Go nodes (`swe-planner-go` on `:8005`, `swe-fast-go` on `:8006`) — brought +two Go nodes (`swe-planner` on `:8005`, `swe-fast` on `:8006`) — brought up via the self-contained `compose.functional.yml`, and assert the byte-level parity contracts the Python→Go port must preserve (design `§11(b)`, work-breakdown `T7.2`). @@ -14,7 +14,7 @@ They are isolated behind the `functional` build tag, so the unit CI job | Test | Contract | |---|---| | `TestHealth` | `GET /health` on both Go nodes returns `200`. | -| `TestRegistrationParity` | `swe-planner-go` registers **exactly** 30 reasoners and `swe-fast-go` **exactly** 29 — the parity checklist (name-set equality, no missing/extra). Names come from the Python registration surface, not from the Go `register.go`. | +| `TestRegistrationParity` | `swe-planner` registers **exactly** 31 reasoners and `swe-fast` **exactly** 30 — the parity checklist (name-set equality, no missing/extra). Names come from the Python registration surface, not from the Go `register.go`. | | `TestDeterministicReasonerKeySets` | `run_ci_watcher` (the only no-LLM reasoner) on **both** nodes, called against a nonexistent repo path (deterministic — `gh pr checks` fails immediately), returns a result whose key set is exactly the Python `CIWatchResult.model_dump()` set: `status, pr_number, elapsed_seconds, failed_checks, summary`. | | `TestReasonerFailedStatusContract` | The control-plane persistence contract the ReasonerFailed carrier (design `§4.5`) relies on: `status=failed` + `result` + `error` persist **together**, and a resultless `failed` re-post (what the SDK sends) does **not** clobber the carried result. | | `TestEmptyBuildGuardViaBuild` | **Always skipped** — triggering the real empty-build guard needs an LLM plan/execute cycle; its CP contract is covered by `TestReasonerFailedStatusContract`, end-to-end by the gated build test below. | @@ -40,7 +40,7 @@ message**; if Docker is available but `up` fails, the suite **fails** (that is a real breakage, not an environmental skip). The override file remaps only the **host** port bindings — control-plane -`:18080`, swe-planner-go `:18005`, swe-fast-go `:18006` — so the functional +`:18080`, swe-planner `:18005`, swe-fast `:18006` — so the functional stack can run alongside anything already occupying `8080/8005/8006` on the host (a host control-plane, the Go add-on nodes, unrelated projects). Container ports and every service-to-service URL are unchanged. The dedicated compose project diff --git a/go/test/functional/build_llm_test.go b/go/test/functional/build_llm_test.go index 895f7034..55ab2d51 100644 --- a/go/test/functional/build_llm_test.go +++ b/go/test/functional/build_llm_test.go @@ -21,7 +21,7 @@ var buildResultKeys = []string{ "summary", "pr_results", "ci_gate_results", "pr_url", } -// expectedPlanDAGReasoners are the role reasoners a swe-planner-go.build MUST fan +// expectedPlanDAGReasoners are the role reasoners a swe-planner.build MUST fan // out to during planning, as child executions in the control-plane DAG (design // constraint #2, §11(b) DAG parity). Issue-writer runs per issue. var expectedPlanDAGReasoners = []string{ @@ -32,7 +32,7 @@ var expectedPlanDAGReasoners = []string{ "run_issue_writer", } -// TestBuildLLMAndDAGParity runs a real, minimal swe-planner-go.build end to end and +// TestBuildLLMAndDAGParity runs a real, minimal swe-planner.build end to end and // asserts (a) the BuildResult key set and (b) that the control-plane execution // DAG contains child executions for the expected planning role reasoners. // diff --git a/go/test/functional/compose.functional.yml b/go/test/functional/compose.functional.yml index edb842bf..48bb100f 100644 --- a/go/test/functional/compose.functional.yml +++ b/go/test/functional/compose.functional.yml @@ -9,8 +9,8 @@ # remap host ports. # # Nodes register under the Go port's opt-in-sibling identities: -# swe-agent-go -> node id "swe-planner-go", :8005 -# swe-fast-go -> node id "swe-fast-go", :8006 +# swe-agent-go -> node id "swe-planner", :8005 +# swe-fast -> node id "swe-fast", :8006 services: control-plane: image: agentfield/control-plane:latest @@ -53,10 +53,10 @@ services: env_file: ../../../.env environment: - AGENTFIELD_SERVER=http://control-plane:8080 - - NODE_ID=swe-planner-go + - NODE_ID=swe-planner - PORT=8005 - AGENT_CALLBACK_URL=http://swe-agent-go:8005 - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - SWE_DEFAULT_MODEL=${SWE_DEFAULT_MODEL:-} - SWE_CODEX_AUTH_MODE=${SWE_CODEX_AUTH_MODE:-auto} - OPENAI_API_KEY=${OPENAI_API_KEY:-} @@ -72,7 +72,7 @@ services: build-db: condition: service_healthy - swe-fast-go: + swe-fast: build: context: ../../.. dockerfile: go/Dockerfile @@ -80,9 +80,9 @@ services: env_file: ../../../.env environment: - AGENTFIELD_SERVER=http://control-plane:8080 - - NODE_ID=swe-fast-go + - NODE_ID=swe-fast - PORT=8006 - - AGENT_CALLBACK_URL=http://swe-fast-go:8006 + - AGENT_CALLBACK_URL=http://swe-fast:8006 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} - GH_TOKEN=${GH_TOKEN} @@ -90,7 +90,7 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - OPENCODE_MODEL=${OPENCODE_MODEL:-} - - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code} + - SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-} - 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/test/functional/compose.override.functional.yml b/go/test/functional/compose.override.functional.yml index c19b20cd..203c21fb 100644 --- a/go/test/functional/compose.override.functional.yml +++ b/go/test/functional/compose.override.functional.yml @@ -20,6 +20,6 @@ services: swe-agent-go: ports: !override - "18005:8005" - swe-fast-go: + swe-fast: ports: !override - "18006:8006" diff --git a/go/test/functional/health_test.go b/go/test/functional/health_test.go index 6723f8d0..aff20fb2 100644 --- a/go/test/functional/health_test.go +++ b/go/test/functional/health_test.go @@ -19,8 +19,8 @@ func TestHealth(t *testing.T) { name string url string }{ - {"swe-planner-go", plannerBaseURL + "/health"}, - {"swe-fast-go", fastBaseURL + "/health"}, + {"swe-planner", plannerBaseURL + "/health"}, + {"swe-fast", fastBaseURL + "/health"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/test/functional/main_test.go b/go/test/functional/main_test.go index f36228a6..d12263de 100644 --- a/go/test/functional/main_test.go +++ b/go/test/functional/main_test.go @@ -2,21 +2,21 @@ // Package functional holds the black-box / functional parity tests for the // SWE-AF Go port (design §11(b), work-breakdown T7.2). They exercise the *live* -// stack — a control-plane plus the two Go nodes (swe-planner-go :8005, -// swe-fast-go :8006) — brought up via the self-contained compose.functional.yml, +// stack — a control-plane plus the two Go nodes (swe-planner :8005, +// swe-fast :8006) — brought up via the self-contained compose.functional.yml, // and assert the byte-level parity contracts that the Python→Go port must // preserve: // // - /health on both nodes returns 200 (TestHealth); // - each node registers EXACTLY its expected reasoner-name set — the parity -// checklist: 30 on swe-planner-go, 29 on swe-fast-go (TestRegistrationParity); +// checklist: 30 on swe-planner, 29 on swe-fast (TestRegistrationParity); // - a deterministic (no-LLM) reasoner call returns the exact pydantic // model_dump() key set — CIWatchResult (TestDeterministicReasonerKeySets); // - the control-plane status contract that the ReasonerFailed carrier // (design §4.5) depends on: status=failed + result + error persist together, // and a resultless failed re-post does not clobber the result // (TestReasonerFailedStatusContract); -// - (env-gated, default-skipped) a real minimal swe-planner-go.build returns the +// - (env-gated, default-skipped) a real minimal swe-planner.build returns the // BuildResult key set and the control-plane DAG contains child executions // for the expected role reasoners (TestBuildLLMAndDAGParity). // @@ -68,8 +68,8 @@ const ( plannerBaseURL = "http://localhost:18005" fastBaseURL = "http://localhost:18006" - plannerNodeID = "swe-planner-go" - fastNodeID = "swe-fast-go" + plannerNodeID = "swe-planner" + fastNodeID = "swe-fast" // Generous ceilings: the Go images are multi-stage builds that clone the // AgentField SDK, so a cold `up --build` can take several minutes. diff --git a/go/test/functional/reasoner_api_test.go b/go/test/functional/reasoner_api_test.go index d6884aa0..125d8134 100644 --- a/go/test/functional/reasoner_api_test.go +++ b/go/test/functional/reasoner_api_test.go @@ -25,8 +25,8 @@ var ciWatchResultKeys = []string{ // returned result carries exactly the CIWatchResult key set. // // This exercises the full HTTP surface end to end: CP sync execute -> node -// dispatch -> reasoner -> result serialization -> CP envelope. Both swe-planner-go -// and swe-fast-go register run_ci_watcher (it is one of the 25 shared roles), so +// dispatch -> reasoner -> result serialization -> CP envelope. Both swe-planner +// and swe-fast register run_ci_watcher (it is one of the 25 shared roles), so // hitting both is the "one more deterministic reasoner" coverage. func TestDeterministicReasonerKeySets(t *testing.T) { requireStack(t) diff --git a/go/test/functional/registration_test.go b/go/test/functional/registration_test.go index 24d16d7b..753a1d16 100644 --- a/go/test/functional/registration_test.go +++ b/go/test/functional/registration_test.go @@ -14,10 +14,12 @@ import ( // fast reasoners + the same roles), NOT from reading the Go register.go. The // test fails loudly on any missing or extra name. -// plannerReasoners: 5 orchestrators + 25 role reasoners = 30. +// plannerReasoners: 5 orchestrators + 25 role reasoners + implement_issue = 31. var plannerReasoners = []string{ // orchestrators (swe_af/app.py) "build", "plan", "execute", "resolve", "resume_build", + // issue-level entry point (swe_af/issue, included by both apps) + "implement_issue", // planning roles "run_product_manager", "run_environment_scout", "run_architect", "run_tech_lead", "run_sprint_planner", @@ -33,9 +35,9 @@ var plannerReasoners = []string{ "run_ci_watcher", "run_ci_fixer", "run_pr_resolver", } -// fastReasoners: 4 fast reasoners + the same 25 role reasoners = 29. The fast -// node deliberately does NOT register the 5 orchestrators (swe_af/fast/app.py -// only defines its own `build`). +// fastReasoners: 4 fast reasoners + the same 25 role reasoners + implement_issue +// = 30. The fast node deliberately does NOT register the 5 orchestrators +// (swe_af/fast/app.py only defines its own `build`). var fastReasoners = func() []string { fast := []string{"build", "fast_plan_tasks", "fast_execute_tasks", "fast_verify"} // the 25 roles = plannerReasoners minus the 5 orchestrators. @@ -60,8 +62,8 @@ func TestRegistrationParity(t *testing.T) { want []string count int }{ - {plannerNodeID, plannerReasoners, 30}, - {fastNodeID, fastReasoners, 29}, + {plannerNodeID, plannerReasoners, 31}, + {fastNodeID, fastReasoners, 30}, } for _, tc := range cases { diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index d8ea04b0..7c191b11 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -606,13 +606,18 @@ class QASynthesisResult(BaseModel): _CODEX_API_KEY_MODEL = "gpt-5.3-codex" # OpenAI API-key auth (api_key mode) _CODEX_CHATGPT_MODEL = "gpt-5.5" # ChatGPT-account auth (-codex blocked) +# Default model for the open_code runtime — both the auto-selected OpenRouter +# path (see _openrouter_only_env) and an explicit SWE_DEFAULT_RUNTIME=open_code +# resolve here, so opting in explicitly never silently swaps the model. +_OPENROUTER_AUTO_DEFAULT_MODEL = "openrouter/deepseek/deepseek-v4-flash" + _RUNTIME_BASE_MODELS: dict[str, dict[str, str]] = { "claude_code": { **{field: "sonnet" for field in ALL_MODEL_FIELDS}, "qa_synthesizer_model": "haiku", }, "open_code": { - **{field: "openrouter/minimax/minimax-m2.5" for field in ALL_MODEL_FIELDS}, + **{field: _OPENROUTER_AUTO_DEFAULT_MODEL for field in ALL_MODEL_FIELDS}, }, "codex": { **{field: _CODEX_API_KEY_MODEL for field in ALL_MODEL_FIELDS}, @@ -651,10 +656,6 @@ def _runtime_to_provider(runtime: str) -> Literal["claude", "opencode", "codex"] return runtime_to_harness_provider(runtime) # type: ignore[return-value] -# Default model for the auto-selected OpenRouter path (see _openrouter_only_env). -_OPENROUTER_AUTO_DEFAULT_MODEL = "openrouter/deepseek/deepseek-v4-flash" - - def _openrouter_only_env() -> bool: """Whether the deployer implicitly chose the OpenRouter runtime. diff --git a/swe_af/fast/schemas.py b/swe_af/fast/schemas.py index 411cd6eb..f173ecae 100644 --- a/swe_af/fast/schemas.py +++ b/swe_af/fast/schemas.py @@ -13,7 +13,11 @@ # --------------------------------------------------------------------------- _CLAUDE_CODE_DEFAULT = "haiku" -_OPEN_CODE_DEFAULT = "qwen/qwen-2.5-coder-32b-instruct" +# Fast mode shares the open_code default with the main path so an +# OpenRouter-only install behaves the same on both nodes. Keep in sync with +# ``swe_af.execution.schemas._OPENROUTER_AUTO_DEFAULT_MODEL`` (not imported at +# module scope to avoid a circular import). +_OPEN_CODE_DEFAULT = "openrouter/deepseek/deepseek-v4-flash" _RUNTIME_DEFAULTS: dict[str, str] = { "claude_code": _CLAUDE_CODE_DEFAULT, @@ -104,7 +108,16 @@ class FastVerificationResult(BaseModel): # --------------------------------------------------------------------------- def _default_fast_runtime() -> str: - value = os.getenv("SWE_DEFAULT_RUNTIME", "claude_code") + """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``. + """ + 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 value if value in RUNTIME_VALUES else "claude_code" @@ -145,9 +158,10 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]: """Resolve the four role model strings for a fast build run. Resolution order (last wins): - 1. Runtime default (haiku or qwen depending on runtime) - 2. ``models["default"]`` — overrides all roles - 3. ``models[""]`` — overrides a specific role (pm, coder, verifier, git) + 1. Runtime default (haiku or the shared open_code default, per runtime) + 2. Env cascade: ``SWE_DEFAULT_MODEL`` → ``AI_MODEL`` → ``HARNESS_MODEL`` + 3. ``models["default"]`` — overrides all roles + 4. ``models[""]`` — overrides a specific role (pm, coder, verifier, git) Args: config: A :class:`FastBuildConfig` instance. @@ -164,6 +178,15 @@ def fast_resolve_models(config: FastBuildConfig) -> dict[str, str]: resolved: dict[str, str] = {role: runtime_default for role in _FAST_ROLES} + # Deployer env cascade (SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL), same + # as the main path — lets the variable that selects a model for the main + # node select it for fast builds too. Caller-supplied models still win. + from swe_af.execution.schemas import _default_model_from_env # noqa: PLC0415 + + env_model = _default_model_from_env() + if env_model: + resolved = {role: env_model for role in _FAST_ROLES} + if config.models: # Validate all keys first valid_keys = {"default"} | set(_ROLE_KEY_MAP.keys()) diff --git a/tests/fast/test_app_planner_executor_verifier_wiring.py b/tests/fast/test_app_planner_executor_verifier_wiring.py index d7160551..849324be 100644 --- a/tests/fast/test_app_planner_executor_verifier_wiring.py +++ b/tests/fast/test_app_planner_executor_verifier_wiring.py @@ -749,13 +749,13 @@ def test_verifier_model_from_config_matches_verifier_param_name(self) -> None: ) def test_open_code_runtime_models_flow_correctly(self) -> None: - """For open_code runtime, all four roles must resolve to the qwen model.""" + """For open_code runtime, all four roles must resolve to the shared default.""" from swe_af.fast.schemas import fast_resolve_models, FastBuildConfig # noqa: PLC0415 config = FastBuildConfig(runtime="open_code") resolved = fast_resolve_models(config) - expected = "qwen/qwen-2.5-coder-32b-instruct" + expected = "openrouter/deepseek/deepseek-v4-flash" for role in ("pm_model", "coder_model", "verifier_model", "git_model"): assert resolved[role] == expected, ( f"open_code runtime: {role} should be {expected!r}, got {resolved[role]!r}" diff --git a/tests/fast/test_docker_config.py b/tests/fast/test_docker_config.py index 97f355bc..f56decb0 100644 --- a/tests/fast/test_docker_config.py +++ b/tests/fast/test_docker_config.py @@ -98,7 +98,10 @@ def test_codex_auth_mode_env_in_swe_agent_and_swe_fast(): def test_default_runtime_env_in_swe_agent_and_swe_fast(): - expected = "SWE_DEFAULT_RUNTIME=${SWE_DEFAULT_RUNTIME:-claude_code}" + # 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. + 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_fast_app_executor_verifier_crossfeature.py b/tests/fast/test_fast_app_executor_verifier_crossfeature.py index 50d3fdd4..e2b2b2a5 100644 --- a/tests/fast/test_fast_app_executor_verifier_crossfeature.py +++ b/tests/fast/test_fast_app_executor_verifier_crossfeature.py @@ -153,8 +153,8 @@ def test_coder_model_key_matches_fast_execute_tasks_param(self) -> None: "fast_execute_tasks must accept 'coder_model' param — " "schemas→executor cross-feature contract broken" ) - assert resolved["coder_model"] == "qwen/qwen-2.5-coder-32b-instruct", ( - f"open_code runtime default must be qwen model, got {resolved['coder_model']!r}" + assert resolved["coder_model"] == "openrouter/deepseek/deepseek-v4-flash", ( + f"open_code runtime default must be the shared default, got {resolved['coder_model']!r}" ) def test_verifier_model_key_matches_fast_verify_param(self) -> None: diff --git a/tests/fast/test_fast_router_schema_pipeline_integration.py b/tests/fast/test_fast_router_schema_pipeline_integration.py index 690eb272..4bd0548e 100644 --- a/tests/fast/test_fast_router_schema_pipeline_integration.py +++ b/tests/fast/test_fast_router_schema_pipeline_integration.py @@ -562,17 +562,17 @@ def test_claude_code_runtime_produces_haiku_models_for_all_roles(self) -> None: f"claude_code runtime: role {role!r} should be 'haiku', got {model!r}" ) - def test_open_code_runtime_produces_qwen_models_for_all_roles(self) -> None: - """For open_code runtime, all 4 resolved models must be qwen.""" + def test_open_code_runtime_produces_default_models_for_all_roles(self) -> None: + """For open_code runtime, all 4 resolved models must be the shared default.""" from swe_af.fast.schemas import FastBuildConfig, fast_resolve_models # noqa: PLC0415 cfg = FastBuildConfig(runtime="open_code") resolved = fast_resolve_models(cfg) - qwen_model = "qwen/qwen-2.5-coder-32b-instruct" + open_code_model = "openrouter/deepseek/deepseek-v4-flash" for role, model in resolved.items(): - assert model == qwen_model, ( - f"open_code runtime: role {role!r} should be {qwen_model!r}, got {model!r}" + assert model == open_code_model, ( + f"open_code runtime: role {role!r} should be {open_code_model!r}, got {model!r}" ) def test_custom_model_override_for_coder_role(self) -> None: diff --git a/tests/fast/test_integration.py b/tests/fast/test_integration.py index 492719fe..6b859620 100644 --- a/tests/fast/test_integration.py +++ b/tests/fast/test_integration.py @@ -137,14 +137,14 @@ def test_ac_4_claude_code_defaults_to_haiku(): # --------------------------------------------------------------------------- -def test_ac_5_open_code_defaults_to_qwen(): - """AC-5: fast_resolve_models() returns qwen model for all roles with open_code runtime.""" +def test_ac_5_open_code_defaults_to_shared_default(): + """AC-5: fast_resolve_models() returns the shared open_code default for all roles.""" code = """ from swe_af.fast.schemas import fast_resolve_models, FastBuildConfig cfg = FastBuildConfig(runtime='open_code') resolved = fast_resolve_models(cfg) for role, model in resolved.items(): - assert model == 'qwen/qwen-2.5-coder-32b-instruct', f'{role}={model!r}' + assert model == 'openrouter/deepseek/deepseek-v4-flash', f'{role}={model!r}' print('OK') """ result = _run(code) diff --git a/tests/fast/test_schemas.py b/tests/fast/test_schemas.py index 96714434..7a0928e8 100644 --- a/tests/fast/test_schemas.py +++ b/tests/fast/test_schemas.py @@ -25,10 +25,21 @@ class TestFastBuildConfigDefaults: - def test_runtime_default(self) -> None: + def test_runtime_default(self, monkeypatch) -> None: + for var in ("SWE_DEFAULT_RUNTIME", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"): + monkeypatch.delenv(var, raising=False) cfg = FastBuildConfig() assert cfg.runtime == "claude_code" + def test_runtime_auto_selects_open_code_with_only_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. + 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" + def test_max_tasks_default(self) -> None: cfg = FastBuildConfig() assert cfg.max_tasks == 10 @@ -104,15 +115,45 @@ def test_returns_all_four_roles(self) -> None: # fast_resolve_models — open_code runtime (AC-5) # --------------------------------------------------------------------------- -_QWEN_MODEL = "qwen/qwen-2.5-coder-32b-instruct" +_OPEN_CODE_MODEL = "openrouter/deepseek/deepseek-v4-flash" class TestFastResolveModelsOpenCode: - def test_all_roles_are_qwen(self) -> None: + def test_open_code_default_matches_the_main_path(self) -> None: + """Fast mode and the main path must resolve open_code to the same model. + + The constant is duplicated rather than imported at module scope (that + import would be circular), so nothing but this test stops the two from + drifting apart — each side's own tests would keep passing. + """ + from swe_af.execution.schemas import _OPENROUTER_AUTO_DEFAULT_MODEL # noqa: PLC0415 + from swe_af.fast.schemas import _OPEN_CODE_DEFAULT # noqa: PLC0415 + + assert _OPEN_CODE_DEFAULT == _OPENROUTER_AUTO_DEFAULT_MODEL + + def test_all_roles_use_open_code_default(self, monkeypatch) -> None: + for var in ("SWE_DEFAULT_MODEL", "AI_MODEL", "HARNESS_MODEL"): + monkeypatch.delenv(var, raising=False) + cfg = FastBuildConfig(runtime="open_code") + resolved = fast_resolve_models(cfg) + for role in _ALL_FOUR_ROLES: + assert resolved[role] == _OPEN_CODE_MODEL, f"{role} should be the shared open_code default" + + def test_env_cascade_applies_to_fast_roles(self, monkeypatch) -> None: + # SWE_DEFAULT_MODEL → AI_MODEL → HARNESS_MODEL applies to fast builds + # exactly like the main path. + monkeypatch.setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder") cfg = FastBuildConfig(runtime="open_code") resolved = fast_resolve_models(cfg) for role in _ALL_FOUR_ROLES: - assert resolved[role] == _QWEN_MODEL, f"{role} should be qwen model" + assert resolved[role] == "openrouter/qwen/qwen-3-coder" + + def test_config_models_beat_env_cascade(self, monkeypatch) -> None: + monkeypatch.setenv("SWE_DEFAULT_MODEL", "openrouter/qwen/qwen-3-coder") + cfg = FastBuildConfig(runtime="open_code", models={"default": "openrouter/z-ai/glm-5"}) + resolved = fast_resolve_models(cfg) + for role in _ALL_FOUR_ROLES: + assert resolved[role] == "openrouter/z-ai/glm-5" def test_returns_all_four_roles(self) -> None: cfg = FastBuildConfig(runtime="open_code") diff --git a/tests/test_model_config.py b/tests/test_model_config.py index 04495fcd..677ff12f 100644 --- a/tests/test_model_config.py +++ b/tests/test_model_config.py @@ -83,11 +83,11 @@ def test_claude_code_defaults(self) -> None: self.assertEqual(resolved["qa_synthesizer_model"], "haiku") def test_open_code_defaults(self) -> None: - # No provider env set → not the auto-OpenRouter path → minimax base default. + # No provider env set → the shared open_code base default applies. with _provider_env(): resolved = resolve_runtime_models(runtime="open_code", models=None) for field in ALL_MODEL_FIELDS: - self.assertEqual(resolved[field], "openrouter/minimax/minimax-m2.5") + self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash") def test_models_default_applies_to_all(self) -> None: resolved = resolve_runtime_models( @@ -125,7 +125,7 @@ def test_open_code_runtime_provider(self) -> None: cfg = BuildConfig(runtime="open_code") self.assertEqual(cfg.ai_provider, "opencode") resolved = cfg.resolved_models() - self.assertEqual(resolved["coder_model"], "openrouter/minimax/minimax-m2.5") + self.assertEqual(resolved["coder_model"], "openrouter/deepseek/deepseek-v4-flash") class TestOpenRouterAutoSelection(unittest.TestCase): @@ -160,13 +160,14 @@ def test_auto_openrouter_defaults_to_deepseek(self) -> None: for field in ALL_MODEL_FIELDS: self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash") - def test_explicit_open_code_keeps_minimax(self) -> None: - # A deployer who explicitly sets open_code keeps the runtime's own - # default even with an OpenRouter key present. + def test_explicit_open_code_same_default(self) -> None: + # A deployer who explicitly sets open_code resolves to the SAME model + # as the auto-selected OpenRouter path — opting in explicitly must + # never silently swap the model. with _provider_env(OPENROUTER_API_KEY="sk-or", SWE_DEFAULT_RUNTIME="open_code"): resolved = resolve_runtime_models(runtime="open_code", models=None) for field in ALL_MODEL_FIELDS: - self.assertEqual(resolved[field], "openrouter/minimax/minimax-m2.5") + self.assertEqual(resolved[field], "openrouter/deepseek/deepseek-v4-flash") def test_swe_default_model_overrides_auto_deepseek(self) -> None: with _provider_env(OPENROUTER_API_KEY="sk-or", SWE_DEFAULT_MODEL="openrouter/qwen/qwen-3"): @@ -188,7 +189,7 @@ def test_to_execution_config_dict_roundtrips(self) -> None: self.assertEqual(d["models"]["coder"], "deepseek/deepseek-chat") exec_cfg = ExecutionConfig(**d) self.assertEqual(exec_cfg.coder_model, "deepseek/deepseek-chat") - self.assertEqual(exec_cfg.qa_model, "openrouter/minimax/minimax-m2.5") + self.assertEqual(exec_cfg.qa_model, "openrouter/deepseek/deepseek-v4-flash") def test_legacy_top_level_keys_rejected(self) -> None: with self.assertRaises(ValueError) as ctx: @@ -369,7 +370,7 @@ def test_empty_env_value_treated_as_unset(self) -> None: resolved = resolve_runtime_models(runtime="open_code", models=None) for field in ALL_MODEL_FIELDS: self.assertEqual( - resolved[field], "openrouter/minimax/minimax-m2.5" + resolved[field], "openrouter/deepseek/deepseek-v4-flash" ) def test_unset_env_uses_runtime_base(self) -> None: @@ -379,7 +380,7 @@ def test_unset_env_uses_runtime_base(self) -> None: resolved = resolve_runtime_models(runtime="open_code", models=None) for field in ALL_MODEL_FIELDS: self.assertEqual( - resolved[field], "openrouter/minimax/minimax-m2.5" + resolved[field], "openrouter/deepseek/deepseek-v4-flash" ) def test_ai_model_env_used_when_swe_default_unset(self) -> None: @@ -457,8 +458,8 @@ def test_default_resolution(self) -> None: def test_open_code_resolution(self) -> None: cfg = ExecutionConfig(runtime="open_code") self.assertEqual(cfg.ai_provider, "opencode") - self.assertEqual(cfg.coder_model, "openrouter/minimax/minimax-m2.5") - self.assertEqual(cfg.qa_synthesizer_model, "openrouter/minimax/minimax-m2.5") + self.assertEqual(cfg.coder_model, "openrouter/deepseek/deepseek-v4-flash") + self.assertEqual(cfg.qa_synthesizer_model, "openrouter/deepseek/deepseek-v4-flash") def test_models_override(self) -> None: cfg = ExecutionConfig(runtime="claude_code", models={"default": "sonnet", "qa": "opus"}) @@ -494,7 +495,7 @@ def test_ci_fixer_role_resolves(self) -> None: self.assertEqual(cfg.ci_fixer_model, "sonnet") cfg = ExecutionConfig(runtime="open_code") - self.assertEqual(cfg.ci_fixer_model, "openrouter/minimax/minimax-m2.5") + self.assertEqual(cfg.ci_fixer_model, "openrouter/deepseek/deepseek-v4-flash") cfg = ExecutionConfig( runtime="claude_code", models={"ci_fixer": "opus"} diff --git a/tests/test_model_tiers.py b/tests/test_model_tiers.py index 207e5c3b..e30a76d1 100644 --- a/tests/test_model_tiers.py +++ b/tests/test_model_tiers.py @@ -26,7 +26,7 @@ _HIGH_FIELDS = {"pm_model", "architect_model", "tech_lead_model", "replan_model"} _LOW_FIELDS = {"qa_synthesizer_model", "git_model"} -_OPEN_CODE_BASE = "openrouter/minimax/minimax-m2.5" +_OPEN_CODE_BASE = "openrouter/deepseek/deepseek-v4-flash" # Env vars that steer provider/runtime/model selection. Cleared before every # test so assertions never depend on the developer's ambient shell.