From a4ec5d17bf07e0bf689639907935c48df551a5ad Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 9 Jul 2026 17:05:15 -0400 Subject: [PATCH] feat(control-plane): af install support for Go agent nodes PackageMetadata gains an explicit language field with go.mod detection fallback (additive to config v1; Python manifests unchanged). entrypoint.build compiles the node at install time via a resolved Go toolchain (pyinterp-style discovery with actionable missing/too-old errors); af run launches the built binary with identical port, healthcheck, secret, and env semantics. Out-of-tree replace directives are refused with vendoring guidance, with an AGENTFIELD_GO_REPLACE override for dev installs. Service-layer install/start paths route through the shared dispatcher so both code paths stay in lockstep. Co-Authored-By: Claude Fable 5 --- .../internal/core/services/agent_service.go | 8 +- .../internal/core/services/package_service.go | 6 +- .../packages/config_version_fixtures_test.go | 10 + .../internal/packages/go_runtime_test.go | 443 ++++++++++++++++++ control-plane/internal/packages/gointerp.go | 375 +++++++++++++++ control-plane/internal/packages/installer.go | 73 ++- control-plane/internal/packages/runner.go | 28 +- docs/installing-agent-nodes.md | 69 ++- 8 files changed, 985 insertions(+), 27 deletions(-) create mode 100644 control-plane/internal/packages/go_runtime_test.go create mode 100644 control-plane/internal/packages/gointerp.go diff --git a/control-plane/internal/core/services/agent_service.go b/control-plane/internal/core/services/agent_service.go index e61e1109..5abf9a78 100644 --- a/control-plane/internal/core/services/agent_service.go +++ b/control-plane/internal/core/services/agent_service.go @@ -590,11 +590,15 @@ func (as *DefaultAgentService) buildProcessConfig(agentNode packages.InstalledPa } // Launch via the manifest entrypoint (e.g. "python -m pr_af.app"). When the - // program token is python/python3, substitute the resolved interpreter. + // program token is python/python3, substitute the resolved interpreter. A Go + // node launches its install-time-built binary; a package-relative binary path + // is resolved against the install dir so exec finds it regardless of cwd. startArgs := metadata.StartCommand() command := startArgs[0] args := startArgs[1:] - if command == "python" || command == "python3" { + if metadata.IsGo() { + command = packages.GoBinaryProgram(agentNode.Path, command) + } else if command == "python" || command == "python3" { command = pythonPath } diff --git a/control-plane/internal/core/services/package_service.go b/control-plane/internal/core/services/package_service.go index 3fd958b2..8cff3032 100644 --- a/control-plane/internal/core/services/package_service.go +++ b/control-plane/internal/core/services/package_service.go @@ -545,9 +545,11 @@ func (ps *DefaultPackageService) copyFile(src, dst string) error { return err } -// installDependencies installs package dependencies +// installDependencies installs package dependencies for the node's language +// (Go build or Python venv), delegating to the shared, language-aware installer +// so this service path handles Go nodes identically to the CLI installer. func (ps *DefaultPackageService) installDependencies(packagePath string, metadata *packages.PackageMetadata) error { - return packages.InstallPythonDependencies(packagePath, metadata.Dependencies.Python, metadata.Dependencies.System) + return packages.InstallDependencies(packagePath, metadata) } // hasRequirementsFile checks if requirements.txt exists diff --git a/control-plane/internal/packages/config_version_fixtures_test.go b/control-plane/internal/packages/config_version_fixtures_test.go index d6158948..9cebb311 100644 --- a/control-plane/internal/packages/config_version_fixtures_test.go +++ b/control-plane/internal/packages/config_version_fixtures_test.go @@ -127,6 +127,7 @@ name: pr-af version: 0.2.0 description: Opens draft PRs from a task description author: Agent-Field +language: python entrypoint: start: python -m pr_af.app healthcheck: /health @@ -167,6 +168,15 @@ user_environment: if md.Name != "pr-af" || md.Version != "0.2.0" { t.Errorf("basics: name=%q version=%q", md.Name, md.Version) } + // `language` is an additive optional field (added without a + // config_version bump): the current-version fixture asserts the + // reader extracts it and that "python" is not treated as a Go node. + if md.Language != "python" { + t.Errorf("language = %q, want python", md.Language) + } + if md.IsGo() { + t.Errorf("a python node must not be classified as Go") + } if got := md.StartCommand(); len(got) == 0 || got[0] != "python" { t.Errorf("StartCommand() = %v", got) } diff --git a/control-plane/internal/packages/go_runtime_test.go b/control-plane/internal/packages/go_runtime_test.go new file mode 100644 index 00000000..7da6687c --- /dev/null +++ b/control-plane/internal/packages/go_runtime_test.go @@ -0,0 +1,443 @@ +package packages + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Validation contract for Go-language agent nodes (mirrors the Python path): +// - a manifest may declare `language: go`; when absent, a go.mod at the root +// is detected as a Go node, and everything else (no language, no go.mod) +// stays Python — full backward compatibility. +// - StartCommand launches a prebuilt binary (entrypoint.start) or defaults to +// `go run .`. +// - install builds the Go node with the discovered toolchain; a missing `go` +// toolchain is an actionable error, not a raw build failure. +// - a go.mod local replace escaping the package tree is refused with guidance +// (it would break after the package is copied), unless vendored/overridden. + +// writeGoManifest writes an agentfield-package.yaml and a go.mod (with the given +// go directive and optional extra body) into dir, producing a Go-node layout. +func writeGoManifest(t *testing.T, dir, manifest, goDirective, gomodExtra string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "agentfield-package.yaml"), []byte(manifest), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + gomod := "module example.com/node\n\ngo " + goDirective + "\n" + gomodExtra + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } +} + +// stubGo installs a fake `go` on PATH that answers `go version`, materializes the +// `-o ` output of `go build`, and no-ops `go build ./...` and `go mod edit`. +// version is what `go version` reports (e.g. "1.21.0"). +func stubGo(t *testing.T, version string) { + t.Helper() + bin := t.TempDir() + script := "#!/bin/sh\n" + + "if [ \"$1\" = \"version\" ]; then echo \"go version go" + version + " linux/amd64\"; exit 0; fi\n" + + "if [ \"$1\" = \"build\" ]; then\n" + + " prev=\"\"\n" + + " for a in \"$@\"; do\n" + + " if [ \"$prev\" = \"-o\" ]; then printf '#!/bin/sh\\nexit 0\\n' > \"$a\"; chmod +x \"$a\"; fi\n" + + " prev=\"$a\"\n" + + " done\n" + + " exit 0\n" + + "fi\n" + + "exit 0\n" + writeExecutable(t, filepath.Join(bin, "go"), script) + t.Setenv("PATH", bin) +} + +func TestParseGoVersion(t *testing.T) { + cases := []struct { + in string + want goVersion + valid bool + }{ + {"1.21", goVersion{1, 21, 0}, true}, + {"1.21.5", goVersion{1, 21, 5}, true}, + {"go1.25.4", goVersion{1, 25, 4}, true}, + {"1", goVersion{1, 0, 0}, true}, + {" 1.22 ", goVersion{1, 22, 0}, true}, + {"", goVersion{}, false}, + {"go", goVersion{}, false}, + {"abc", goVersion{}, false}, + } + for _, c := range cases { + got, ok := parseGoVersion(c.in) + if ok != c.valid || (ok && got != c.want) { + t.Errorf("parseGoVersion(%q) = %v,%v; want %v,%v", c.in, got, ok, c.want, c.valid) + } + } +} + +func TestGoVersionAtLeast(t *testing.T) { + cases := []struct { + have, want goVersion + ok bool + }{ + {goVersion{1, 25, 4}, goVersion{1, 21, 0}, true}, + {goVersion{1, 21, 0}, goVersion{1, 21, 0}, true}, + {goVersion{1, 20, 9}, goVersion{1, 21, 0}, false}, + {goVersion{2, 0, 0}, goVersion{1, 30, 0}, true}, + {goVersion{1, 21, 4}, goVersion{1, 21, 5}, false}, + } + for _, c := range cases { + if got := c.have.atLeast(c.want); got != c.ok { + t.Errorf("%v.atLeast(%v) = %v; want %v", c.have, c.want, got, c.ok) + } + } +} + +func TestReadGoDirective(t *testing.T) { + t.Run("declared", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "go.mod", "module x\n\ngo 1.21\n\nrequire foo v1.0.0\n") + if got := readGoDirective(dir); got != "1.21" { + t.Fatalf("got %q; want 1.21", got) + } + }) + t.Run("no go.mod", func(t *testing.T) { + if got := readGoDirective(t.TempDir()); got != "" { + t.Fatalf("got %q; want empty", got) + } + }) + t.Run("no go directive", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "go.mod", "module x\n") + if got := readGoDirective(dir); got != "" { + t.Fatalf("got %q; want empty", got) + } + }) +} + +// Contract: language is resolved from the explicit field, then go.mod detection, +// then defaults to Python. Existing Python manifests are unaffected. +func TestLanguageResolutionAndDetection(t *testing.T) { + t.Run("explicit language: go", func(t *testing.T) { + dir := t.TempDir() + writeTestPackage(t, dir, "name: n\nversion: 0.1.0\nlanguage: go\n") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !md.IsGo() { + t.Fatalf("explicit language: go must be a Go node") + } + }) + + t.Run("detected via go.mod when language absent", func(t *testing.T) { + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\n", "1.21", "") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !md.IsGo() { + t.Fatalf("a package with a go.mod must be detected as a Go node") + } + }) + + t.Run("back-compat: no language, no go.mod is Python", func(t *testing.T) { + dir := t.TempDir() + writeTestPackage(t, dir, "name: n\nversion: 0.1.0\nentrypoint:\n start: python -m n.app\n") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if md.IsGo() { + t.Fatalf("a Python manifest must not be treated as Go") + } + }) + + t.Run("explicit language: python wins over a stray go.mod", func(t *testing.T) { + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\nlanguage: python\n", "1.21", "") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if md.IsGo() { + t.Fatalf("explicit language: python must override go.mod detection") + } + }) +} + +// Contract: StartCommand for a Go node runs the prebuilt binary, or defaults to +// `go run .` when no entrypoint.start is declared. +func TestStartCommandGo(t *testing.T) { + t.Run("prebuilt binary path", func(t *testing.T) { + md := &PackageMetadata{Language: "go", Entrypoint: EntrypointConfig{Start: "bin/swe-planner"}} + got := md.StartCommand() + if len(got) != 1 || got[0] != "bin/swe-planner" { + t.Fatalf("StartCommand() = %v; want [bin/swe-planner]", got) + } + }) + t.Run("go run form", func(t *testing.T) { + md := &PackageMetadata{Language: "go", Entrypoint: EntrypointConfig{Start: "go run ./cmd/swe-planner"}} + got := md.StartCommand() + if len(got) != 3 || got[0] != "go" || got[2] != "./cmd/swe-planner" { + t.Fatalf("StartCommand() = %v; want [go run ./cmd/swe-planner]", got) + } + }) + t.Run("default go run . when start empty", func(t *testing.T) { + md := &PackageMetadata{Language: "go"} + got := md.StartCommand() + if len(got) != 3 || got[0] != "go" || got[1] != "run" || got[2] != "." { + t.Fatalf("StartCommand() = %v; want [go run .]", got) + } + }) + t.Run("python default is unchanged for non-Go", func(t *testing.T) { + md := &PackageMetadata{} + got := md.StartCommand() + if len(got) != 2 || got[0] != "python" || got[1] != "main.py" { + t.Fatalf("StartCommand() = %v; want [python main.py]", got) + } + }) +} + +func TestGoBuildTarget(t *testing.T) { + cases := []struct { + name string + md *PackageMetadata + wantPkg, wantB string + }{ + { + name: "build + binary start", + md: &PackageMetadata{Entrypoint: EntrypointConfig{Build: "./cmd/swe-planner", Start: "bin/swe-planner"}}, + wantPkg: "./cmd/swe-planner", wantB: "bin/swe-planner", + }, + { + name: "build only derives bin name", + md: &PackageMetadata{Entrypoint: EntrypointConfig{Build: "./cmd/swe-fast"}}, + wantPkg: "./cmd/swe-fast", wantB: filepath.Join("bin", "swe-fast"), + }, + { + name: "no build -> compile check only", + md: &PackageMetadata{Entrypoint: EntrypointConfig{Start: "go run ."}}, + wantPkg: "", wantB: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pkg, bin := c.md.goBuildTarget() + if pkg != c.wantPkg || bin != c.wantB { + t.Fatalf("goBuildTarget() = (%q,%q); want (%q,%q)", pkg, bin, c.wantPkg, c.wantB) + } + }) + } +} + +func TestGoBinaryProgram(t *testing.T) { + dir := "/home/x/.agentfield/packages/n" + cases := map[string]string{ + "bin/swe-planner": filepath.Join(dir, "bin/swe-planner"), + "./bin/swe-planner": filepath.Join(dir, "bin/swe-planner"), + "go": "go", + "/abs/bin/node": "/abs/bin/node", + "": "", + } + for in, want := range cases { + if got := GoBinaryProgram(dir, in); got != want { + t.Errorf("GoBinaryProgram(%q) = %q; want %q", in, got, want) + } + } +} + +// Contract: install builds the Go node with the discovered toolchain, producing +// the declared binary. +func TestInstallGoDependencies_BuildsBinary(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", "") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := InstallGoDependencies(dir, md); err != nil { + t.Fatalf("InstallGoDependencies: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "bin", "node")); err != nil { + t.Fatalf("expected built binary bin/node: %v", err) + } +} + +// Contract: a `go run` node has no prebuilt output; install only compile-checks +// and produces no binary, but succeeds. +func TestInstallGoDependencies_GoRunCompileChecks(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n start: go run ./cmd/node\n", + "1.21", "") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if err := InstallGoDependencies(dir, md); err != nil { + t.Fatalf("InstallGoDependencies (go run): %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "bin")); !os.IsNotExist(err) { + t.Fatalf("go run node should not produce a bin/ dir") + } +} + +// Contract: install through the shared dispatcher routes a Go node to the Go +// builder (proving InstallDependencies is language-aware). +func TestInstallDependencies_DispatchesGo(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", "") + md, err := ParsePackageMetadata(dir) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !md.IsGo() { + t.Fatalf("detected Go node expected") + } + if err := InstallDependencies(dir, md); err != nil { + t.Fatalf("InstallDependencies: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "bin", "node")); err != nil { + t.Fatalf("expected Go build to run via dispatcher: %v", err) + } +} + +// Contract: a missing `go` toolchain is an actionable error naming Go, not a raw +// exec failure. +func TestResolveGoToolchain_MissingToolchain(t *testing.T) { + empty := t.TempDir() // a PATH with no `go` + t.Setenv("PATH", empty) + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\nlanguage: go\n", "1.21", "") + + _, err := resolveGoToolchain(dir) + if err == nil { + t.Fatal("expected an error when no go toolchain is on PATH") + } + if !strings.Contains(err.Error(), "go.dev/dl") || !strings.Contains(strings.ToLower(err.Error()), "toolchain") { + t.Fatalf("error should guide installing Go: %v", err) + } + + md, _ := ParsePackageMetadata(dir) + if err := InstallGoDependencies(dir, md); err == nil { + t.Fatal("InstallGoDependencies should fail without a toolchain") + } +} + +// Contract: an installed toolchain older than the go.mod directive is refused +// with an upgrade hint. +func TestResolveGoToolchain_TooOld(t *testing.T) { + stubGo(t, "1.20.0") // reports 1.20 + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\nlanguage: go\n", "1.99", "") // requires 1.99 + + _, err := resolveGoToolchain(dir) + if err == nil { + t.Fatal("expected an error when installed Go is older than go.mod requires") + } + if !strings.Contains(err.Error(), "1.99") || !strings.Contains(err.Error(), "1.20") { + t.Fatalf("error should name required (1.99) and found (1.20): %v", err) + } +} + +// Contract: a satisfying toolchain passes the version gate. +func TestResolveGoToolchain_Satisfies(t *testing.T) { + stubGo(t, "1.25.4") + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\nlanguage: go\n", "1.21", "") + got, err := resolveGoToolchain(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "go" { + t.Fatalf("got %q; want go", got) + } +} + +// Contract: a go.mod local replace that escapes the package tree is refused with +// guidance (vendor / override) rather than a confusing raw build failure. +func TestGoReplaceDirective_OutOfTreeRefused(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", + "\nreplace example.com/sdk => ../../other/sdk\n") + + md, _ := ParsePackageMetadata(dir) + err := InstallGoDependencies(dir, md) + if err == nil { + t.Fatal("expected refusal for an out-of-tree replace directive") + } + if !strings.Contains(err.Error(), "vendor") || !strings.Contains(err.Error(), "replace") { + t.Fatalf("error should explain vendoring/replace: %v", err) + } +} + +// Contract: an in-tree replace (pointing inside the package) is fine, and a +// vendored package with an out-of-tree replace builds (vendor ships the dep). +func TestGoReplaceDirective_InTreeAndVendoredAllowed(t *testing.T) { + t.Run("in-tree replace allowed", func(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", "\nreplace example.com/sub => ./internal/sub\n") + md, _ := ParsePackageMetadata(dir) + if err := InstallGoDependencies(dir, md); err != nil { + t.Fatalf("in-tree replace should build: %v", err) + } + }) + + t.Run("vendored out-of-tree replace allowed", func(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", "\nreplace example.com/sdk => ../../other/sdk\n") + if err := os.MkdirAll(filepath.Join(dir, "vendor"), 0o755); err != nil { + t.Fatal(err) + } + md, _ := ParsePackageMetadata(dir) + if err := InstallGoDependencies(dir, md); err != nil { + t.Fatalf("vendored package should build despite out-of-tree replace: %v", err) + } + }) +} + +// Contract: the AGENTFIELD_GO_REPLACE override bypasses the out-of-tree guard so +// an out-of-tree dependency can be repointed at install time. +func TestGoReplaceOverride_Bypasses(t *testing.T) { + stubGo(t, "1.21.0") + dir := t.TempDir() + writeGoManifest(t, dir, + "name: n\nversion: 0.1.0\nlanguage: go\nentrypoint:\n build: ./cmd/node\n start: bin/node\n", + "1.21", "\nreplace example.com/sdk => ../../other/sdk\n") + t.Setenv("AGENTFIELD_GO_REPLACE", "example.com/sdk=/opt/sdk") + md, _ := ParsePackageMetadata(dir) + if err := InstallGoDependencies(dir, md); err != nil { + t.Fatalf("override should allow the build to proceed: %v", err) + } +} + +// Contract: a Go node with a go.mod passes ValidatePackage even without an +// explicit entrypoint.start or a main.py. +func TestValidatePackage_GoModOnly(t *testing.T) { + dir := t.TempDir() + writeGoManifest(t, dir, "name: n\nversion: 0.1.0\n", "1.21", "") + if err := ValidatePackage(dir); err != nil { + t.Fatalf("a Go module package should validate: %v", err) + } +} diff --git a/control-plane/internal/packages/gointerp.go b/control-plane/internal/packages/gointerp.go new file mode 100644 index 00000000..e5e90bf7 --- /dev/null +++ b/control-plane/internal/packages/gointerp.go @@ -0,0 +1,375 @@ +package packages + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// This file is the Go-toolchain counterpart to pyinterp.go. Where pyinterp.go +// resolves a Python interpreter that satisfies a node's requires-python, this +// resolves the `go` toolchain that builds a Go node and drives the install-time +// build. The two follow the same philosophy: discover the toolchain, fail with +// an *actionable* message when it is missing or too old, and never block on +// something we cannot parse. + +// goVersion is a parsed (major, minor, patch) Go toolchain version. Go releases +// are identified by major.minor (e.g. 1.21); patch is tracked but rarely gates a +// build. +type goVersion struct { + major, minor, patch int +} + +func (v goVersion) String() string { + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) +} + +// atLeast reports whether v is greater than or equal to o. +func (v goVersion) atLeast(o goVersion) bool { + for _, d := range []int{v.major - o.major, v.minor - o.minor, v.patch - o.patch} { + if d < 0 { + return false + } + if d > 0 { + return true + } + } + return true +} + +// parseGoVersion parses a Go version like "1.21", "1.21.5", or "go1.21.5" into a +// goVersion. A missing minor/patch defaults to 0. It reports false only when the +// major component is absent or non-numeric, so an unparseable value never blocks +// a build (the caller treats false as "unknown, don't gate"). +func parseGoVersion(s string) (goVersion, bool) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "go") + if s == "" { + return goVersion{}, false + } + parts := strings.Split(s, ".") + nums := [3]int{} + for i := 0; i < len(parts) && i < 3; i++ { + digits := leadingDigits(parts[i]) + if digits == "" { + if i == 0 { + return goVersion{}, false + } + break + } + n, err := strconv.Atoi(digits) + if err != nil { + return goVersion{}, false + } + nums[i] = n + } + return goVersion{nums[0], nums[1], nums[2]}, true +} + +// readGoDirective returns the version from the `go X.Y[.Z]` directive in the +// package's go.mod, or "" when there is no go.mod or no go directive. This is the +// minimum toolchain version the module was written against. +func readGoDirective(packagePath string) string { + f, err := os.Open(filepath.Join(packagePath, "go.mod")) + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Match a bare "go X.Y" directive, not "go run" text or "toolchain". + if strings.HasPrefix(line, "go ") { + ver := strings.TrimSpace(strings.TrimPrefix(line, "go ")) + // Guard against comments trailing the directive. + if i := strings.IndexAny(ver, " \t/"); i >= 0 { + ver = ver[:i] + } + if _, ok := parseGoVersion(ver); ok { + return ver + } + } + } + return "" +} + +// installedGoVersion runs `go version` and returns the parsed toolchain version. +func installedGoVersion(goCmd string) (goVersion, bool) { + out, err := exec.Command(goCmd, "version").Output() + if err != nil { + return goVersion{}, false + } + // Output form: "go version go1.21.5 linux/amd64". + for _, tok := range strings.Fields(string(out)) { + if strings.HasPrefix(tok, "go1") || strings.HasPrefix(tok, "go2") { + if v, ok := parseGoVersion(tok); ok { + return v, true + } + } + } + return goVersion{}, false +} + +// resolveGoToolchain locates the `go` toolchain used to build a Go node. It +// returns: +// - (goCmd, nil) when a usable `go` is on PATH (and satisfies the go.mod +// directive, if any). +// - ("", err) with an actionable message when `go` is absent, or present but +// older than the version the module's go.mod requires. +// +// Mirrors pyinterp.go's resolveVenvInterpreter: discover, gate on the declared +// minimum, and explain how to fix a miss rather than failing later inside the +// raw build. +func resolveGoToolchain(packagePath string) (string, error) { + goCmd := firstOnPath("go") + if goCmd == "" { + return "", fmt.Errorf( + "this agent node is a Go node, but no `go` toolchain was found on PATH.\n" + + "Install Go and ensure `go` is on PATH, then run `af install` again:\n" + + " • macOS: brew install go\n" + + " • Ubuntu: sudo apt-get install golang-go (or the official tarball)\n" + + " • or download the installer from https://go.dev/dl/") + } + + want := readGoDirective(packagePath) + if want != "" { + wantV, wantOK := parseGoVersion(want) + haveV, haveOK := installedGoVersion(goCmd) + if wantOK && haveOK && !haveV.atLeast(wantV) { + return "", fmt.Errorf( + "this agent node requires Go %s or newer (from its go.mod), but `go` on PATH is %s "+ + "— upgrade Go (https://go.dev/dl/) and run `af install` again", + want, haveV) + } + } + return goCmd, nil +} + +// InstallGoDependencies builds a Go agent node at install time so `af run` +// launches a compiled binary instead of recompiling on every start. It is the +// Go analogue of InstallPythonDependencies: discover the toolchain, resolve the +// module's dependencies (via `go build`, which downloads modules as needed), and +// leave the package ready to launch. +// +// Build target (from goBuildTarget): +// - entrypoint.build set, entrypoint.start a binary path -> `go build -o +// / `, producing the runnable binary. +// - otherwise (a `go run ...` / empty entrypoint) -> `go build ./...`, a +// compile check only; the binary is produced by `go run` at launch. +// +// A vendored module (vendor/ present) builds with -mod=vendor so it is fully +// hermetic after the package is copied into ~/.agentfield/packages. +func InstallGoDependencies(packagePath string, metadata *PackageMetadata) error { + goCmd, err := resolveGoToolchain(packagePath) + if err != nil { + return err + } + + // Guard against local replace directives that escape the package tree: after + // the node is copied into ~/.agentfield/packages, a "replace => ../../x" path + // no longer resolves. Fail early with guidance rather than a raw build error. + if err := checkGoReplaceDirectives(packagePath); err != nil { + return err + } + // Apply any build-time replace overrides (AGENTFIELD_GO_REPLACE) so an + // out-of-tree dependency can be repointed at install time. + if err := applyGoReplaceOverrides(goCmd, packagePath); err != nil { + return err + } + + buildPkg, outBin := metadata.goBuildTarget() + + args := []string{"build"} + if hasVendorDir(packagePath) { + args = append(args, "-mod=vendor") + } + if outBin != "" { + outAbs := filepath.Join(packagePath, outBin) + if err := os.MkdirAll(filepath.Dir(outAbs), 0o755); err != nil { + return fmt.Errorf("failed to create Go build output directory: %w", err) + } + args = append(args, "-o", outAbs, buildPkg) + } else { + // Compile-check the whole module; the binary is produced by `go run`. + args = append(args, "./...") + } + + cmd := exec.Command(goCmd, args...) + cmd.Dir = packagePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to build Go node (go %s): %w\nOutput: %s", strings.Join(args, " "), err, out) + } + return nil +} + +// goBuildTarget returns the Go package to compile and the package-relative +// output binary path for this node. When entrypoint.build is declared, the node +// is prebuilt: the build package is compiled to entrypoint.start (a bare binary +// path). When build is empty, there is no prebuilt output (outBin == "") and the +// caller compile-checks the module instead, leaving launch to `go run`. +func (m *PackageMetadata) goBuildTarget() (buildPkg, outBin string) { + build := strings.TrimSpace(m.Entrypoint.Build) + if build == "" { + return "", "" + } + start := strings.Fields(m.Entrypoint.Start) + // The output is the start token when start is a bare binary path (not a + // `go run ...` command). Otherwise derive a sensible default under bin/. + if len(start) > 0 && start[0] != "go" { + return build, start[0] + } + return build, defaultGoBinName(build) +} + +// defaultGoBinName derives a bin/ output path from a Go build package spec +// like "./cmd/swe-planner" -> "bin/swe-planner". +func defaultGoBinName(buildPkg string) string { + name := filepath.Base(strings.TrimSpace(buildPkg)) + if name == "" || name == "." || name == "/" || name == "..." { + name = "app" + } + return filepath.Join("bin", name) +} + +// hasVendorDir reports whether the package ships a Go vendor/ directory, which +// makes the build hermetic (and immune to out-of-tree replace directives). +func hasVendorDir(packagePath string) bool { + info, err := os.Stat(filepath.Join(packagePath, "vendor")) + return err == nil && info.IsDir() +} + +// outOfTreeReplaces returns the go.mod local replace directives whose target is +// a filesystem path that escapes the package directory. These are the ones that +// break after the package is copied into ~/.agentfield/packages. Module-version +// replacements (no path RHS) and in-tree paths are not returned. +func outOfTreeReplaces(packagePath string) []string { + data, err := os.ReadFile(filepath.Join(packagePath, "go.mod")) + if err != nil { + return nil + } + var bad []string + inBlock := false + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "//") { + continue + } + switch { + case strings.HasPrefix(line, "replace ("): + inBlock = true + continue + case inBlock && line == ")": + inBlock = false + continue + case strings.HasPrefix(line, "replace "): + if spec := replaceEscapesTree(packagePath, strings.TrimPrefix(line, "replace ")); spec != "" { + bad = append(bad, spec) + } + case inBlock: + if spec := replaceEscapesTree(packagePath, line); spec != "" { + bad = append(bad, spec) + } + } + } + return bad +} + +// replaceEscapesTree parses one replace body ("old => new" or "old v => new v") +// and returns the directive text when its RHS is a filesystem path outside +// packagePath, or "" otherwise. +func replaceEscapesTree(packagePath, body string) string { + body = strings.TrimSpace(body) + parts := strings.SplitN(body, "=>", 2) + if len(parts) != 2 { + return "" + } + rhs := strings.TrimSpace(parts[1]) + // The target may be "path" or "path version"; a path starts with . or / (a + // module-version replacement like "example.com/x v1.2.3" is not a path). + target := strings.Fields(rhs) + if len(target) == 0 { + return "" + } + p := target[0] + if !strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "/") { + return "" // module-version replacement, not a local path + } + abs := p + if !filepath.IsAbs(p) { + abs = filepath.Join(packagePath, p) + } + rel, err := filepath.Rel(packagePath, filepath.Clean(abs)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return strings.TrimSpace(body) + } + return "" +} + +// checkGoReplaceDirectives fails when go.mod has a local replace directive that +// points outside the package tree, since that path won't resolve once the node +// is copied into ~/.agentfield/packages. It is a no-op when the package is +// vendored (vendor/ ships the dependency), when an AGENTFIELD_GO_REPLACE override +// is supplied, or when every replace is in-tree / a module-version replacement. +func checkGoReplaceDirectives(packagePath string) error { + if hasVendorDir(packagePath) || strings.TrimSpace(os.Getenv("AGENTFIELD_GO_REPLACE")) != "" { + return nil + } + bad := outOfTreeReplaces(packagePath) + if len(bad) == 0 { + return nil + } + return fmt.Errorf( + "this Go node's go.mod has local replace directive(s) that point outside the package and won't resolve after install:\n"+ + " replace %s\n"+ + "Fix it one of these ways, then reinstall:\n"+ + " • vendor the module so it ships with the node: go mod vendor (recommended)\n"+ + " • or publish/tag the replaced module and use a versioned require instead of a local replace\n"+ + " • or repoint it at install time: AGENTFIELD_GO_REPLACE=\"=\" af install", + strings.Join(bad, "\n replace ")) +} + +// applyGoReplaceOverrides applies each entry of the AGENTFIELD_GO_REPLACE env var +// (comma-separated "old=new" pairs, in `go mod edit -replace` syntax) to the +// package's go.mod before building. This is the build-arg-style escape hatch for +// out-of-tree replace directives when vendoring is not an option. A no-op when +// the variable is unset. +func applyGoReplaceOverrides(goCmd, packagePath string) error { + raw := strings.TrimSpace(os.Getenv("AGENTFIELD_GO_REPLACE")) + if raw == "" { + return nil + } + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + cmd := exec.Command(goCmd, "mod", "edit", "-replace="+entry) + cmd.Dir = packagePath + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to apply Go replace override %q: %w\nOutput: %s", entry, err, out) + } + } + return nil +} + +// GoBinaryProgram resolves a Go node's start program token to an absolute path +// when it is a package-relative binary (e.g. "bin/swe-planner" or +// "./bin/swe-planner"), so exec launches it regardless of the parent process's +// working directory. A bare command such as "go" (the `go run` form) or an +// already-absolute path is returned unchanged. It is the Go counterpart to the +// venv-python substitution the runner does for Python nodes. +func GoBinaryProgram(packageDir, program string) string { + if program == "" || program == "go" || filepath.IsAbs(program) { + return program + } + if strings.ContainsRune(program, '/') || strings.ContainsRune(program, filepath.Separator) { + return filepath.Join(packageDir, program) + } + return program +} diff --git a/control-plane/internal/packages/installer.go b/control-plane/internal/packages/installer.go index 70c26c82..45b66d17 100644 --- a/control-plane/internal/packages/installer.go +++ b/control-plane/internal/packages/installer.go @@ -104,12 +104,18 @@ type PackageMetadata struct { // reader stay compatible as the manifest format evolves. Absent means "v0" — // the pre-versioning format — which is read leniently. See CurrentConfigVersion // for the bump policy (breaking changes only). - ConfigVersion string `yaml:"config_version"` - Name string `yaml:"name"` - Version string `yaml:"version"` - Description string `yaml:"description"` - Author string `yaml:"author"` - Type string `yaml:"type"` + ConfigVersion string `yaml:"config_version"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Author string `yaml:"author"` + Type string `yaml:"type"` + // Language is the node's implementation language: "python" (default) or "go". + // It selects the install/build and launch strategy. When empty it is resolved + // at parse time by detection (a go.mod at the package root => "go", otherwise + // "python"), so existing Python manifests keep working with no new field. This + // is an *additive* optional key: it does NOT bump config_version. + Language string `yaml:"language"` Main string `yaml:"main"` Entrypoint EntrypointConfig `yaml:"entrypoint"` AgentNode AgentNodeConfig `yaml:"agent_node"` @@ -123,8 +129,16 @@ type PackageMetadata struct { type EntrypointConfig struct { // Start is the shell-free command used to launch the node, e.g. // "python -m pr_af.app". The first token is resolved against the package - // venv when it is "python"/"python3". Empty falls back to "python main.py". + // venv when it is "python"/"python3". For a Go node it is either a + // package-relative binary path built at install time (e.g. "bin/swe-planner") + // or a "go run ./cmd/..." form. Empty falls back to "python main.py" for a + // Python node and "go run ." for a Go node. Start string `yaml:"start"` + // Build names the Go package to compile at install time for a Go node, e.g. + // "./cmd/swe-planner". The installer runs `go build -o `, so + // Start is the resulting binary path. Ignored for Python nodes and for Go + // nodes launched via `go run` (which compile on start). Additive optional key. + Build string `yaml:"build"` // Healthcheck is the HTTP path polled to confirm readiness (default "/health"). Healthcheck string `yaml:"healthcheck"` } @@ -538,7 +552,10 @@ func (pi *PackageInstaller) validatePackage(sourcePath string) error { // ValidatePackage checks that a directory is an installable agent node: it must // have an agentfield-package.yaml and declare how to start — either a manifest // entrypoint.start (e.g. "python -m pr_af.app") or a top-level main.py. Real -// nodes use a module entrypoint and have no main.py, so main.py is not required. +// Python nodes use a module entrypoint and have no main.py, so main.py is not +// required. A Go node is buildable/runnable from its module, so a go.mod at the +// root satisfies the "how to start" requirement even without an explicit +// entrypoint.start (it defaults to `go run .`). func ValidatePackage(sourcePath string) error { packageYamlPath := filepath.Join(sourcePath, "agentfield-package.yaml") if _, err := os.Stat(packageYamlPath); os.IsNotExist(err) { @@ -552,20 +569,37 @@ func ValidatePackage(sourcePath string) error { if metadata.Entrypoint.Start != "" { return nil } + if metadata.IsGo() && fileExistsAt(sourcePath, "go.mod") { + return nil + } mainPyPath := filepath.Join(sourcePath, "main.py") if _, err := os.Stat(mainPyPath); os.IsNotExist(err) { - return fmt.Errorf("package must declare entrypoint.start in agentfield-package.yaml or contain a main.py") + return fmt.Errorf("package must declare entrypoint.start in agentfield-package.yaml, contain a main.py (Python), or ship a go.mod (Go)") } return nil } +// IsGo reports whether this node is a Go node. It reflects the resolved +// language (the explicit `language:` field, or go.mod detection applied by +// ParsePackageMetadata). A metadata value built without going through the parser +// (e.g. &PackageMetadata{}) is treated as Python, preserving legacy behavior. +func (m *PackageMetadata) IsGo() bool { + return strings.EqualFold(strings.TrimSpace(m.Language), "go") +} + // StartCommand returns the tokens used to launch the node. It prefers the -// manifest entrypoint.start and falls back to "python
" (default main.py). +// manifest entrypoint.start; otherwise it falls back to a language-appropriate +// default: "go run ." for a Go node, "python
" (default main.py) for a +// Python node. For a Go node whose Start is a package-relative binary path, the +// runner resolves that path against the package directory (see GoBinaryProgram). func (m *PackageMetadata) StartCommand() []string { if strings.TrimSpace(m.Entrypoint.Start) != "" { return strings.Fields(m.Entrypoint.Start) } + if m.IsGo() { + return []string{"go", "run", "."} + } main := m.Main if main == "" { main = "main.py" @@ -655,6 +689,14 @@ func ParsePackageMetadata(dir string) (*PackageMetadata, error) { metadata.Main = "main.py" // Default } + // Resolve the implementation language. An explicit `language:` wins; when it + // is absent we detect a Go module by a go.mod at the package root. This keeps + // legacy Python manifests (no language, no go.mod) reading as Python while a + // Go node need only ship its go.mod to be recognized. + if strings.TrimSpace(metadata.Language) == "" && fileExistsAt(dir, "go.mod") { + metadata.Language = "go" + } + return &metadata, nil } @@ -778,6 +820,17 @@ func (pi *PackageInstaller) copyFile(src, dst string) error { // installDependencies installs package dependencies func (pi *PackageInstaller) installDependencies(packagePath string, metadata *PackageMetadata) error { + return InstallDependencies(packagePath, metadata) +} + +// InstallDependencies resolves and installs a node's dependencies for its +// implementation language: it builds the Go binary for a Go node, or provisions +// the Python venv + pip installs for a Python node. It is the single entry point +// shared by the CLI installer and the package service so both stay in lockstep. +func InstallDependencies(packagePath string, metadata *PackageMetadata) error { + if metadata.IsGo() { + return InstallGoDependencies(packagePath, metadata) + } return InstallPythonDependencies(packagePath, metadata.Dependencies.Python, metadata.Dependencies.System) } diff --git a/control-plane/internal/packages/runner.go b/control-plane/internal/packages/runner.go index a2f86663..edbc3297 100644 --- a/control-plane/internal/packages/runner.go +++ b/control-plane/internal/packages/runner.go @@ -153,19 +153,29 @@ func (ar *AgentNodeRunner) startAgentNodeProcess(agentNode InstalledPackage, por env = append(env, fmt.Sprintf("%s=%s", key, value)) } - // Prepare command - use virtual environment if available + // Prepare command - resolve the launcher for the node's language. startArgs := metadata.StartCommand() program := startArgs[0] args := startArgs[1:] - venvPath := filepath.Join(agentNode.Path, "venv") - if program == "python" || program == "python3" { - if p := venvPython(venvPath); p != "" { - program = p - fmt.Printf("🐍 Using virtual environment: %s\n", venvPath) - } else { - program = "python" - fmt.Printf("⚠️ Virtual environment not found, using system Python\n") + if metadata.IsGo() { + // Go nodes launch a compiled binary (built at install time) or `go run`. + // A package-relative binary path is resolved against the install dir so + // exec finds it regardless of the parent's working directory. + if resolved := GoBinaryProgram(agentNode.Path, program); resolved != program { + program = resolved + fmt.Printf("🐹 Launching Go binary: %s\n", program) + } + } else { + venvPath := filepath.Join(agentNode.Path, "venv") + if program == "python" || program == "python3" { + if p := venvPython(venvPath); p != "" { + program = p + fmt.Printf("🐍 Using virtual environment: %s\n", venvPath) + } else { + program = "python" + fmt.Printf("⚠️ Virtual environment not found, using system Python\n") + } } } diff --git a/docs/installing-agent-nodes.md b/docs/installing-agent-nodes.md index d9bbdfce..b16efce4 100644 --- a/docs/installing-agent-nodes.md +++ b/docs/installing-agent-nodes.md @@ -14,12 +14,17 @@ af install https://github.com/Agent-Field/pr-af af run pr-af ``` +Nodes can be written in **Python** (venv + pip) or **Go** (compiled binary). +`af install`/`af run` pick the right toolchain per node; the port, health check, +secret injection, and control-plane wiring are identical either way. See +[Language: Python or Go](#language-python-or-go) for the Go specifics. + Everything lives under `~/.agentfield/` (override with `AGENTFIELD_HOME`): ``` ~/.agentfield/ ├── installed.yaml # registry of installed nodes + runtime state -├── packages// # the installed node + its Python venv +├── packages// # the installed node + its Python venv or built Go binary ├── logs/.log # process logs (af logs ) ├── keyring/master.key # 0600 — local key that decrypts your secrets └── secrets/ @@ -30,8 +35,8 @@ Everything lives under `~/.agentfield/` (override with `AGENTFIELD_HOME`): ## The manifest: `agentfield-package.yaml` Every installable node has this file at its repo root. Only `name`, `version`, -and a way to start (either `entrypoint.start` or a top-level `main.py`) are -required; everything else is optional. +and a way to start (an `entrypoint.start`, a top-level `main.py` for a Python +node, or a `go.mod` for a Go node) are required; everything else is optional. ```yaml config_version: v1 # manifest *schema* version (see below). Omit = v0 (legacy). @@ -111,7 +116,7 @@ existing config actually changes. | `config_version` | Reader behavior | | ---------------- | ---------------------------------------------------------- | | absent / `v0` | Legacy format, read leniently. Every field below is optional except the manifest basics. | -| `v1` | Same fields as v0, now explicitly versioned. Current default for new manifests. | +| `v1` | Same fields as v0, now explicitly versioned. Current default for new manifests. Later *additive* keys (e.g. `language`, `entrypoint.build` for Go nodes) live here too — they did not bump the version. | ### `require_one_of` — "at least one of these" @@ -142,6 +147,62 @@ The venv is built with the `python3`/`python` on your `PATH`. If a node declares reports it and install fails — point `af` at a compatible interpreter (e.g. via `pyenv`/`PATH`) and reinstall. +### Language: Python or Go + +A node's implementation language is set by the optional top-level `language` +field: `python` (the default) or `go`. When `language` is omitted, `af` detects +a Go node by the presence of a `go.mod` at the package root; anything else is +treated as Python. Existing Python manifests need no changes. + +```yaml +language: go +entrypoint: + build: ./cmd/swe-planner # Go package to compile at install time + start: bin/swe-planner # resulting binary, launched by `af run` + healthcheck: /health +``` + +At install time a Go node is **compiled**, not pip-installed: + +- The `go` toolchain is discovered on `PATH`. A missing `go` is an actionable + error (how to install it); a `go` older than the module's `go.mod` directive is + refused with an upgrade hint — the Go analogue of the `requires-python` check. +- With `entrypoint.build` set, `af` runs `go build -o `, leaving a + runnable binary at the `entrypoint.start` path. `af run` launches that binary + directly — same `PORT`, health check, secrets, and control-plane env as a + Python node. +- Alternatively, use a `go run` entrypoint (`start: go run ./cmd/swe-planner`) + or omit `entrypoint.start` entirely (defaults to `go run .`). Install then only + compile-checks (`go build ./...`) and the binary is built on launch. This is + simpler but recompiles each start, so a prebuilt binary is preferred for large + nodes. + +A Go node reads the same runtime environment as a Python node — `PORT`, +`AGENTFIELD_SERVER`, and any declared `user_environment` secrets are injected +into the process identically, and readiness is confirmed by polling +`entrypoint.healthcheck`. + +#### `replace` directives and vendoring + +Go modules that use a **local `replace` directive pointing outside the package** +(e.g. `replace example.com/sdk => ../../other/sdk`, as the SWE-AF Go port does for +the AgentField Go SDK) will not build after install, because the node is copied +into `~/.agentfield/packages//` and the relative path no longer resolves. +`af install` detects this and refuses with guidance rather than a confusing raw +build failure. Fix it one of these ways: + +- **Vendor the module** (recommended): run `go mod vendor` in the node repo and + commit the `vendor/` directory. It ships with the package, so the build is + hermetic (`go build -mod=vendor`) regardless of `replace` targets. +- **Publish/tag the replaced module** and use a versioned `require` instead of a + local `replace`. +- **Override at install time** with `AGENTFIELD_GO_REPLACE` — a comma-separated + list of `go mod edit -replace` specs applied before building, e.g. + `AGENTFIELD_GO_REPLACE="example.com/sdk=/abs/path/to/sdk" af install `. + +In-tree replaces (pointing inside the package) and module-version replaces are +always fine. + ### Node dependencies `dependencies.nodes` lets one node declare that it needs others. Each entry is