Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ PR_AF_AI_MAX_RETRIES=3
PR_AF_AI_INITIAL_BACKOFF_SECONDS=2.0
PR_AF_AI_MAX_BACKOFF_SECONDS=8.0
PR_AF_OPENCODE_BIN=opencode
# Optional provider-agnostic harness executable override (leave unset to use provider defaults)
# PR_AF_HARNESS_BIN=
PR_AF_OPENCODE_SERVER=
# Harness no-output watchdog window (seconds). Harness CLIs in JSON mode emit
# events only at completion boundaries, so long single completions look silent.
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ services:
- AGENT_CALLBACK_URL=http://pr-af-go:8007
- PR_AF_PROVIDER=${PR_AF_PROVIDER:-opencode}
- PR_AF_MODEL=${PR_AF_MODEL:-openrouter/moonshotai/kimi-k2.5}
# Optional generic executable override; leave unset for provider defaults.
- PR_AF_HARNESS_BIN
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- GH_TOKEN=${GH_TOKEN:-}
- XDG_DATA_HOME=/home/praf/.local/share
Expand Down
2 changes: 2 additions & 0 deletions go/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ FROM debian:bookworm-slim AS runtime

ARG OPENCODE_VERSION=1.17.15

# PR_AF_HARNESS_BIN remains unset by default; set it only to override every
# provider's executable path.
ENV AGENTFIELD_SERVER=http://agentfield:8080 \
PR_AF_PROVIDER=opencode \
PR_AF_MODEL=openrouter/moonshotai/kimi-k2.5 \
Expand Down
1 change: 1 addition & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ The node is configured entirely through the environment.
| `PORT` | Listen port (default `8007`) |
| `PR_AF_PROVIDER` | Harness provider (default `opencode`) |
| `PR_AF_MODEL` | Harness model (default `openrouter/moonshotai/kimi-k2.5`) |
| `PR_AF_HARNESS_BIN` | Optional harness executable override for every provider; unset uses provider defaults |
| `PR_AF_MAX_COST_USD` | Per-run cost ceiling in USD (default `2.0`) |
| `PR_AF_MAX_DURATION_SECONDS`| Per-run wall-clock ceiling in seconds (default `3600`) |
| `AGENTFIELD_HARNESS_IDLE_SECONDS` | Harness no-output watchdog window in seconds (default `360`) — harness CLIs in JSON mode emit events only at completion boundaries, so long single completions look silent |
Expand Down
2 changes: 2 additions & 0 deletions go/agentfield-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ user_environment:
- name: PR_AF_MODEL
description: harness model
default: openrouter/moonshotai/kimi-k2.5
- name: PR_AF_HARNESS_BIN
description: optional executable override for every harness provider (unset uses provider defaults)
- name: PR_AF_MAX_COST_USD
description: per-run cost ceiling
default: "2.0"
Expand Down
1 change: 1 addition & 0 deletions go/cmd/pr-af/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// PORT listen port (default 8007)
// PR_AF_PROVIDER harness provider (default opencode)
// PR_AF_MODEL harness model (env wins over the code default)
// PR_AF_HARNESS_BIN optional executable override for every harness provider
// OPENROUTER_API_KEY LLM key — required for the .ai() gates; AIConfig is only
// attached when set (SDK rejects an empty key)
// GH_TOKEN GitHub token for FetchPR/clone/PostReview
Expand Down
9 changes: 6 additions & 3 deletions go/internal/blastradius/blastradius.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ func buildPythonModuleMap(pyFiles []string, repoPath string) map[string]string {
mapping := make(map[string]string, len(pyFiles))
for _, pyFile := range pyFiles {
rel := relPathOf(repoPath, pyFile)
module := strings.ReplaceAll(rel, string(os.PathSeparator), ".")
// Repository-relative paths are part of the cross-platform data contract;
// normalize before turning a path into a Python module name.
module := strings.ReplaceAll(filepath.ToSlash(rel), "/", ".")
module = strings.TrimSuffix(module, ".py")
module = strings.TrimSuffix(module, ".__init__")
mapping[module] = rel
Expand All @@ -153,13 +155,14 @@ func extractPythonImports(content string) []string {
return modules
}

// relPathOf mirrors os.path.relpath(file, repoPath) with the OS separator.
// relPathOf mirrors os.path.relpath(file, repoPath) using repository-standard
// slash separators, independent of the host OS.
func relPathOf(repoPath, file string) string {
rel, err := filepath.Rel(repoPath, file)
if err != nil {
return file
}
return rel
return filepath.ToSlash(rel)
}

// containsSkipToken reports whether any skip token is a substring of relRoot,
Expand Down
4 changes: 2 additions & 2 deletions go/internal/blastradius/blastradius_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ func TestBuildPythonModuleMap(t *testing.T) {
}
got := buildPythonModuleMap(files, repo)
want := map[string]string{
"a.b": filepath.Join("a", "b.py"),
"pkg": filepath.Join("pkg", "__init__.py"),
"a.b": "a/b.py",
"pkg": "pkg/__init__.py",
"top": "top.py",
}
if !reflect.DeepEqual(got, want) {
Expand Down
5 changes: 4 additions & 1 deletion go/internal/config/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type AIIntegrationConfig struct {
InitialBackoffSeconds float64 `json:"initial_backoff_seconds"`
MaxBackoffSeconds float64 `json:"max_backoff_seconds"`
OpencodeBin string `json:"opencode_bin"`
HarnessBin string `json:"harness_bin"`
OpencodeServer *string `json:"opencode_server"`
}

Expand Down Expand Up @@ -61,7 +62,9 @@ func AIConfigFromEnv() (AIIntegrationConfig, error) {
InitialBackoffSeconds: initialBackoff,
MaxBackoffSeconds: maxBackoff,
OpencodeBin: strEnv("PR_AF_OPENCODE_BIN", "opencode"),
OpencodeServer: lookupPtr("PR_AF_OPENCODE_SERVER"),
// An empty generic binary override deliberately means no override.
HarnessBin: strEnv("PR_AF_HARNESS_BIN", ""),
OpencodeServer: lookupPtr("PR_AF_OPENCODE_SERVER"),
}, nil
}

Expand Down
17 changes: 16 additions & 1 deletion go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var configEnvKeys = []string{
"PR_AF_PROVIDER", "PR_AF_MODEL", "PR_AF_AI_MODEL",
"PR_AF_MAX_TURNS", "PR_AF_AI_MAX_RETRIES",
"PR_AF_AI_INITIAL_BACKOFF_SECONDS", "PR_AF_AI_MAX_BACKOFF_SECONDS",
"PR_AF_OPENCODE_BIN", "PR_AF_OPENCODE_SERVER",
"PR_AF_OPENCODE_BIN", "PR_AF_HARNESS_BIN", "PR_AF_OPENCODE_SERVER",
"PR_AF_MAX_COST_USD", "PR_AF_MAX_DURATION_SECONDS",
"PR_AF_EVIDENCE_PACK", "PR_AF_POSTWORTHINESS_GATE",
"HAX_API_KEY", "AGENTFIELD_APPROVAL_USER_ID",
Expand Down Expand Up @@ -154,6 +154,9 @@ func TestAIConfigFromEnvDefaults(t *testing.T) {
if c.OpencodeBin != "opencode" {
t.Errorf("OpencodeBin = %q, want opencode", c.OpencodeBin)
}
if c.HarnessBin != "" {
t.Errorf("HarnessBin = %q, want empty when unset", c.HarnessBin)
}
if c.OpencodeServer != nil {
t.Errorf("OpencodeServer = %v, want nil", *c.OpencodeServer)
}
Expand All @@ -168,6 +171,7 @@ func TestAIConfigFromEnvOverrides(t *testing.T) {
t.Setenv("PR_AF_AI_INITIAL_BACKOFF_SECONDS", "1.5")
t.Setenv("PR_AF_AI_MAX_BACKOFF_SECONDS", "16")
t.Setenv("PR_AF_OPENCODE_BIN", "/usr/bin/opencode")
t.Setenv("PR_AF_HARNESS_BIN", "C:/bin/provider-custom")
t.Setenv("PR_AF_OPENCODE_SERVER", "http://localhost:9000")

c := mustAIConfig(t)
Expand All @@ -190,6 +194,9 @@ func TestAIConfigFromEnvOverrides(t *testing.T) {
if c.OpencodeServer == nil || *c.OpencodeServer != "http://localhost:9000" {
t.Errorf("OpencodeServer = %v", c.OpencodeServer)
}
if c.HarnessBin != "C:/bin/provider-custom" {
t.Errorf("HarnessBin = %q, want exact configured value", c.HarnessBin)
}

// PR_AF_AI_MODEL, when set, wins over PR_AF_MODEL for AIModel only.
t.Setenv("PR_AF_AI_MODEL", "anthropic/claude-x")
Expand All @@ -202,6 +209,14 @@ func TestAIConfigFromEnvOverrides(t *testing.T) {
}
}

func TestAIConfigFromEnvEmptyHarnessBinIsNoOverride(t *testing.T) {
clearConfigEnv(t)
t.Setenv("PR_AF_HARNESS_BIN", "")
if got := mustAIConfig(t).HarnessBin; got != "" {
t.Errorf("HarnessBin = %q, want empty for explicit empty environment value", got)
}
}

func TestProviderEnv(t *testing.T) {
clearConfigEnv(t)
xdg := t.TempDir()
Expand Down
77 changes: 75 additions & 2 deletions go/internal/evidence/evidence.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ func findFunctionCallers(ctx context.Context, repoPath, functionName, excludeFil
args := append([]string{"-RInE", pattern, "."}, skipDirGrepArgs...)
stdout, ran := runGrep(ctx, repoPath, args)
if !ran {
return []string{}
stdout = fallbackFunctionGrep(repoPath, pattern)
}

normalizedExclude := normalizeRelativePath(repoPath, excludeFile)
Expand Down Expand Up @@ -444,7 +444,11 @@ func buildImportContext(ctx context.Context, repoPath, filePath string) string {
if moduleName != "" {
regex := `^\s*(?:from\s+` + reEscape(moduleName) + `\b|import\s+` + reEscape(moduleName) + `\b)`
args := append([]string{"-RIlE", regex, ".", "--include=*.py"}, skipDirGrepArgs...)
if stdout, ran := runGrep(ctx, repoPath, args); ran {
stdout, ran := runGrep(ctx, repoPath, args)
if !ran {
stdout = fallbackImportGrep(repoPath, regex)
}
{
for _, rawPath := range splitLines(stdout) {
rel := normalizeRelativePath(repoPath, rawPath)
if rel != normalized {
Expand Down Expand Up @@ -790,6 +794,75 @@ func runGrep(ctx context.Context, repoPath string, args []string) (string, bool)
return out.String(), true
}

// The production implementation follows Python by using grep when available.
// Windows development environments commonly lack it, so retain equivalent
// caller/import discovery with a small filesystem fallback.
func fallbackFunctionGrep(repoPath, pattern string) string {
re, err := regexp.Compile(pattern)
if err != nil {
return ""
}
var matches []string
forEachRepoFile(repoPath, func(rel, abs string) {
data, err := os.ReadFile(abs)
if err != nil {
return
}
for lineNo, line := range splitLines(string(data)) {
if re.MatchString(line) {
matches = append(matches, rel+":"+strconv.Itoa(lineNo+1)+":"+line)
}
}
})
return strings.Join(matches, "\n")
}

func fallbackImportGrep(repoPath, pattern string) string {
re, err := regexp.Compile(pattern)
if err != nil {
return ""
}
var matches []string
forEachRepoFile(repoPath, func(rel, abs string) {
if !strings.HasSuffix(rel, ".py") {
return
}
data, err := os.ReadFile(abs)
if err == nil && re.Match(data) {
matches = append(matches, rel)
}
})
return strings.Join(matches, "\n")
}

func forEachRepoFile(repoPath string, visit func(rel, abs string)) {
_ = filepath.WalkDir(repoPath, func(abs string, d os.DirEntry, err error) error {
if err != nil || d == nil {
return nil
}
if d.IsDir() {
if abs != repoPath && isSkippedGrepDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
rel, err := filepath.Rel(repoPath, abs)
if err == nil {
visit(filepath.ToSlash(rel), abs)
}
return nil
})
}

func isSkippedGrepDir(name string) bool {
for _, skipped := range []string{".git", "node_modules", "__pycache__", ".venv", "vendor", "venv"} {
if name == skipped {
return true
}
}
return false
}

// readFileLines ports _read_file_lines (minus the perf cache): the file split
// into lines WITH their terminators, or nil on read error.
func readFileLines(absPath string) []string {
Expand Down
46 changes: 37 additions & 9 deletions go/internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import (

const defaultBaseURL = "https://api.github.com"

const postReviewMaxAttempts = 3

var prURLRe = regexp.MustCompile(`^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)`)

// APIError is returned for any GitHub response with status >= 400. It carries
Expand Down Expand Up @@ -506,15 +508,41 @@ func (c *client) PostReview(ctx context.Context, owner, repo string, number int,
if err != nil {
return nil, err
}
body, err := c.request(ctx, http.MethodPost,
fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number),
authHeaders, payload, 60*time.Second)
if err != nil {
return nil, err
endpoint := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number)

var lastErr error
for attempt := 0; attempt < postReviewMaxAttempts; attempt++ {
body, err := c.request(ctx, http.MethodPost, endpoint, authHeaders, payload, 60*time.Second)
if err == nil {
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
if !isRetryablePostReviewError(err) {
return nil, err
}

lastErr = err
if attempt == postReviewMaxAttempts-1 {
break
}
if err := c.sleep(ctx, time.Duration(1<<(attempt+1))*time.Second); err != nil {
return nil, err
}
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
return nil, lastErr
}

// isRetryablePostReviewError reports whether a review creation failure is
// transient. Client errors are deliberately excluded so the orchestrator can
// retain ownership of its semantic 422 own-PR fallback.
func isRetryablePostReviewError(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode >= 500 && apiErr.StatusCode <= 599
}
return result, nil
var transportErr *transportError
return errors.As(err, &transportErr)
}
Loading
Loading