package tools
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
const (
// DefaultCommandTimeout is how long a command gets when nothing says
// otherwise, and MaxCommandTimeout is the ceiling on asking for more.
DefaultCommandTimeout = 60 * time.Second
MaxCommandTimeout = 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
)
// RunCommand runs argv in dir and returns the same report the bash tool
// reports: the exit code and elapsed time, then whatever each stream produced,
// capped at both ends. failed says the command did not exit zero — whether it
// was killed by the timeout, never started, or simply returned non-zero.
//
// It lives apart from the tool because the tool is not the only caller: the
// TUI's shell mode runs what the user types at the prompt, and the awkward
// parts here — a process group so children die with the parent, a wait delay
// so a killed command cannot hold the pipes open — are worth having once.
func RunCommand(ctx context.Context, dir string, argv []string, timeout time.Duration) (report string, failed bool) {
if len(argv) == 0 {
return "failed to run: no command", true
}
if timeout <= 0 {
timeout = DefaultCommandTimeout
}
if timeout > MaxCommandTimeout {
timeout = MaxCommandTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = resolveBase(dir, "")
// 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
// Both streams are captured for the report and, when something is
// watching, copied out as they arrive. They share one sink: a reader
// watching a test run wants what a terminal would have shown, and that is
// both streams interleaved in the order they were written.
var stdout, stderr bytes.Buffer
progress := ProgressFrom(ctx)
cmd.Stdout = tee(&stdout, progress)
cmd.Stderr = tee(&stderr, progress)
start := time.Now()
runErr := cmd.Run()
elapsed := time.Since(start)
exitCode := -1
if cmd.ProcessState != nil {
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())
return strings.TrimRight(b.String(), "\n"), timedOut || exitCode != 0
}
// 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. Ignored when background is set"`
Background bool `json:"background,omitempty" jsonschema_description:"Start the command and return a handle at once instead of waiting for it. For servers and watchers"`
}
// NewBash builds the bash tool. With WithProcesses it also accepts
// background: true; without it, asking for background is an error rather than
// a command that silently blocks for ten minutes.
func NewBash(root string, opts ...Option) Tool {
o := applyOptions(opts)
desc := `
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.`
if o.procs != nil {
desc += `
A command that does not return on its own — a dev server, a watcher — must be
started with background: true, which answers immediately with a handle. Read
its output with process_output and stop it with process_kill. Everything still
running is killed when the session ends.`
}
return New("bash", strings.TrimSpace(desc),
func(ctx context.Context, in BashArgs) (string, error) {
if strings.TrimSpace(in.Command) == "" {
return "", fmt.Errorf("command is required")
}
if in.Background {
if o.procs == nil {
return "", fmt.Errorf("background is not available in this session; run the command in the foreground, or start it with & and a redirect if it must outlive the call")
}
id, err := o.procs.Start(root, in.Command)
if err != nil {
return "", err
}
return fmt.Sprintf("Started %s in the background. Read its output with process_output(id=%q) and stop it with process_kill(id=%q).", id, id, id), nil
}
timeout := DefaultCommandTimeout
if in.TimeoutMS > 0 {
timeout = time.Duration(in.TimeoutMS) * time.Millisecond
}
// The command goes through a file rather than `bash -c`. With -c the
// whole script is the shell's argv, and a `pkill -f "vite preview"`
// inside it finds and kills the shell running it — which is what a
// model does when it wants to stop a server it started.
script, err := stageScript(in.Command)
if err != nil {
return "", err
}
defer os.Remove(script)
out, failed := RunCommand(ctx, root, []string{"bash", script}, timeout)
// 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 failed {
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))
}