Skip to content
Closed
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
84 changes: 84 additions & 0 deletions src/runners/agy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package runners

import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)

type AgyRunner struct{}

func (r *AgyRunner) Name() string { return "agy" }

func (r *AgyRunner) Available() bool {
_, err := exec.LookPath("agy")
return err == nil
}

func (r *AgyRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) {
// agy --print reads the prompt from stdin; supplying it as a trailing
// argv leaves stdin empty and the process hangs until --print-timeout
// (5m default) fires.
args := []string{"--print", "--dangerously-skip-permissions"}

cmd := exec.CommandContext(ctx, "agy", args...)
if opts.WorkDir != "" {
cmd.Dir = opts.WorkDir
}
cmd.Stdin = strings.NewReader(prompt)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return RunResult{ExitCode: 1}, fmt.Errorf("agy failed to run: %w", err)
}
}

output := stdout.String()
result := RunResult{Output: output, ExitCode: exitCode}
// Observed against agy v1.0.0: print-timeout and similar failures exit
// 0 with a short single-line "Error: ..." on stdout. Treat that as
// failure; see agyOutputIsError for the heuristic and its bounds (model
// responses that discuss errors are longer/multi-paragraph and pass).
if exitCode == 0 && agyOutputIsError(output) {
return result, fmt.Errorf("agy reported error: %s", TruncStr(strings.TrimSpace(output), 200))
}
if exitCode != 0 {
errMsg := stderr.String()
if errMsg == "" {
errMsg = output
}
return result, fmt.Errorf("agy exited %d: %s", exitCode, TruncStr(errMsg, 200))
}
return result, nil
}

// agyOutputIsError decides whether an exit-0 stdout payload from `agy --print`
// is actually an agy-emitted error rather than a model response.
//
// Genuine agy errors are short, single-line, case-sensitive "Error: ..."
// strings. Model outputs that legitimately discuss errors are typically
// multi-paragraph (contain a blank line) or substantially longer, so those
// pass through.
func agyOutputIsError(output string) bool {
trimmed := strings.TrimSpace(output)
if !strings.HasPrefix(trimmed, "Error:") {
return false
}
if len(trimmed) > 300 {
return false
}
if strings.Contains(trimmed, "\n\n") {
return false
}
return true
}
49 changes: 49 additions & 0 deletions src/runners/agy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package runners

import "testing"

func TestAgyRunnerName(t *testing.T) {
r := &AgyRunner{}
if r.Name() != "agy" {
t.Errorf("Name() = %q, want %q", r.Name(), "agy")
}
}

func TestAgyOutputIsError(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{"observed print-timeout", "Error: timed out waiting for response", true},
{"hypothetical print timeout", "Error: print timeout", true},
{"leading and trailing whitespace", " Error: rate limited\n", true},

{"model discussing errors (multi-paragraph)", "Error: NullPointerException at line 42\n\nThe root cause is the nil dereference in handleRequest()...", false},
{"long model response starting with Error:", "Error: " + longString(400), false},
{"Error: mid-body, not at prefix", "Here's a review.\n\nError: this is a discussion of error handling.", false},
{"empty output", "", false},
{"whitespace only", " \n \t ", false},
{"lowercase error: is not an agy error", "error: lowercase from a model response", false},
{"uppercase ERROR: is not matched (case-sensitive)", "ERROR: yelling", false},
{"Error- with dash, not colon", "Error- not the right delimiter", false},
{"plain success output", "ok", false},
{"multi-line model output", "Here is my review:\n- point 1\n- point 2", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := agyOutputIsError(tt.output); got != tt.want {
t.Errorf("agyOutputIsError(%q) = %v, want %v", tt.output, got, tt.want)
}
})
}
}

func longString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'x'
}
return string(b)
}
1 change: 1 addition & 0 deletions src/runners/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func DetectRunners() []Runner {
all := []Runner{
&ClaudeRunner{},
&CodexRunner{},
&AgyRunner{},
&GeminiRunner{},
&LocalRunner{},
}
Expand Down
Loading