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