Vectrum
Sign In
103 lines · 3.1 KB · Go
package tools

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

const (
	defaultBashTimeout = 60 * time.Second
	maxBashTimeout     = 10 * time.Minute
	// Output is capped head and tail. Head-only truncation would cut off the
	// end of a test run, which is exactly where the failure is.
	bashHeadLines = 100
	bashTailLines = 100
)

// BashArgs is the bash input.
type BashArgs struct {
	Command   string `json:"command" jsonschema:"description=Shell command to run, from the working root"`
	TimeoutMS int    `json:"timeout_ms,omitempty" jsonschema:"description=Timeout in milliseconds; defaults to 60000, maximum 600000"`
}

// NewBash builds the bash tool.
func NewBash(root string) Tool {
	return New("bash", strings.TrimSpace(`
Run a shell command from the working root and return its exit code, stdout and
stderr. Use it to run the project's tests and build. Output is capped, keeping
the first and last hundred lines. Prefer glob and grep over find and cat: they
are faster and their output is bounded.`),
		func(ctx context.Context, in BashArgs) (string, error) {
			if strings.TrimSpace(in.Command) == "" {
				return "", fmt.Errorf("command is required")
			}
			timeout := defaultBashTimeout
			if in.TimeoutMS > 0 {
				timeout = time.Duration(in.TimeoutMS) * time.Millisecond
			}
			if timeout > maxBashTimeout {
				timeout = maxBashTimeout
			}

			ctx, cancel := context.WithTimeout(ctx, timeout)
			defer cancel()

			cmd := exec.CommandContext(ctx, "bash", "-c", in.Command)
			cmd.Dir = resolveBase(root, "")
			// A new process group, so a command that spawns children can be
			// killed whole. Killing only the leader leaves orphans holding the
			// pipes open and the loop hangs waiting on them.
			cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
			cmd.Cancel = func() error {
				if cmd.Process == nil {
					return nil
				}
				return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
			}
			cmd.WaitDelay = 2 * time.Second

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

			start := time.Now()
			runErr := cmd.Run()
			elapsed := time.Since(start)

			exitCode := cmd.ProcessState.ExitCode()
			timedOut := ctx.Err() == context.DeadlineExceeded

			var b strings.Builder
			switch {
			case timedOut:
				fmt.Fprintf(&b, "timed out after %s (killed)\n", timeout)
			case runErr != nil && exitCode < 0:
				fmt.Fprintf(&b, "failed to run: %v\n", runErr)
			default:
				fmt.Fprintf(&b, "exit code: %d (%s)\n", exitCode, elapsed.Round(time.Millisecond))
			}
			writeStream(&b, "stdout", stdout.String())
			writeStream(&b, "stderr", stderr.String())

			out := strings.TrimRight(b.String(), "\n")
			// A non-zero exit is reported as an error so the model sees it as a
			// failure it has to respond to, with the output still attached.
			if timedOut || exitCode != 0 {
				return "", fmt.Errorf("%s", out)
			}
			return out, nil
		})
}

func writeStream(b *strings.Builder, label, s string) {
	s = strings.TrimRight(s, "\n")
	if s == "" {
		return
	}
	fmt.Fprintf(b, "\n--- %s ---\n%s\n", label, headTail(s, bashHeadLines, bashTailLines))
}