329 lines
12 KiB
Go
329 lines
12 KiB
Go
// This file implements the bash tool (US-018): run a shell command, streaming
|
|
// stdout/stderr back as tool_execution_update partials, honoring a timeout and
|
|
// context cancellation (which kills the child process group). A non-zero exit
|
|
// is surfaced as an error (isError) whose message carries the captured output.
|
|
package agenttool
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// bashDefaultTimeout bounds a command that does not specify one.
|
|
const bashDefaultTimeout = 2 * time.Minute
|
|
|
|
// bashMaxTimeout caps any requested timeout.
|
|
const bashMaxTimeout = 10 * time.Minute
|
|
|
|
// bashMaxOutputBytes caps how many bytes of combined stdout/stderr the bash tool
|
|
// returns to the model. A single command can emit megabytes (build logs, a big
|
|
// cat), which — unlike the timeout cap — would otherwise flow into context whole
|
|
// and blow the window. Output past this size is truncated to a head + tail
|
|
// preview (see truncateBashOutput), mirroring search's searchMaxResults/"[truncated
|
|
// …]" convention. This is the tool's own inner cap; a later executor-layer budget
|
|
// may impose a stricter outer limit.
|
|
const bashMaxOutputBytes = 30_000
|
|
|
|
// truncateBashOutput caps s at bashMaxOutputBytes using the shared
|
|
// truncateToBudget idiom (head + "[truncated N bytes]" marker + tail, cut on
|
|
// UTF-8 rune boundaries). It is the bash tool's own inner cap; the executor
|
|
// layer applies a separate, uniform outer budget afterward.
|
|
func truncateBashOutput(s string) string {
|
|
return truncateToBudget(s, bashMaxOutputBytes)
|
|
}
|
|
|
|
// trimUTF8Prefix drops trailing bytes of s that form an incomplete rune, so the
|
|
// returned prefix ends on a rune boundary.
|
|
func trimUTF8Prefix(s string) string {
|
|
for len(s) > 0 {
|
|
if r, size := utf8.DecodeLastRuneInString(s); r == utf8.RuneError && size <= 1 {
|
|
s = s[:len(s)-1]
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return s
|
|
}
|
|
|
|
// trimUTF8Suffix drops leading bytes of s that form an incomplete rune, so the
|
|
// returned suffix starts on a rune boundary.
|
|
func trimUTF8Suffix(s string) string {
|
|
for len(s) > 0 {
|
|
if r, size := utf8.DecodeRuneInString(s); r == utf8.RuneError && size <= 1 {
|
|
s = s[1:]
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return s
|
|
}
|
|
|
|
// BashTool runs shell commands. Dir bounds the working directory (empty = the
|
|
// process CWD). Shell selects the interpreter (empty = "bash -c").
|
|
type BashTool struct {
|
|
// Dir is the working directory for commands. Empty uses the process CWD.
|
|
Dir string
|
|
// Shell is the interpreter path. Empty defaults to "bash".
|
|
Shell string
|
|
// Jobs holds background jobs launched with run_in_background. When nil,
|
|
// run_in_background is rejected (the front-end did not wire a store).
|
|
Jobs *BashJobStore
|
|
}
|
|
|
|
// bashToolArgs is the decoded argument shape for BashTool.
|
|
type bashToolArgs struct {
|
|
// Command is the shell command line to run.
|
|
Command string `json:"command"`
|
|
// TimeoutMs optionally overrides the default timeout (milliseconds).
|
|
TimeoutMs int `json:"timeout_ms,omitempty"`
|
|
// RunInBackground detaches the command from the turn: it keeps running after
|
|
// Execute returns, and its output is drained later via bash_output. A
|
|
// background command has no default timeout (so dev servers/watchers run
|
|
// indefinitely); timeout_ms still caps it if given.
|
|
RunInBackground bool `json:"run_in_background,omitempty"`
|
|
}
|
|
|
|
// Name implements AgentTool.
|
|
func (t *BashTool) Name() string { return "bash" }
|
|
|
|
// Description implements AgentTool.
|
|
func (t *BashTool) Description() string {
|
|
return "Run a shell command, streaming stdout/stderr. Supports a timeout " +
|
|
"and cancellation. A non-zero exit code is reported as an error. " +
|
|
"Set run_in_background=true for long-running commands (dev servers, " +
|
|
"watchers): it returns immediately with a bash_id you drain with " +
|
|
"bash_output and stop with kill_bash. " +
|
|
"On Windows the command runs under bash if available (Git Bash/WSL), " +
|
|
"else PowerShell, else cmd — prefer portable commands."
|
|
}
|
|
|
|
// Schema implements AgentTool.
|
|
func (t *BashTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {"type": "string", "description": "Shell command line to run."},
|
|
"timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (capped at 10 minutes). Ignored in background unless set.", "minimum": 0},
|
|
"run_in_background": {"type": "boolean", "description": "Run detached and return immediately with a bash_id; drain output with bash_output, stop with kill_bash."}
|
|
},
|
|
"required": ["command"],
|
|
"additionalProperties": false
|
|
}`)
|
|
}
|
|
|
|
// ExecutionMode implements AgentTool. Commands can have side effects → sequential.
|
|
func (t *BashTool) ExecutionMode() agentcore.ToolExecutionMode {
|
|
return agentcore.ToolExecutionSequential
|
|
}
|
|
|
|
// shellLookPath resolves a program on PATH. It is a package var so tests can
|
|
// simulate a Windows box with or without bash installed.
|
|
var shellLookPath = exec.LookPath
|
|
|
|
// resolveShell picks the interpreter and the flag that makes it read the command
|
|
// from the next argument. An explicit shell (BashTool.Shell) is always honored as
|
|
// a POSIX-style "<shell> -c <command>".
|
|
//
|
|
// On Windows with no explicit shell, the naive "bash -c" hardcode fails on stock
|
|
// machines that have no bash on PATH — the model then retries bash blindly and
|
|
// every call errors (issue #518). So we prefer a real bash when one is present
|
|
// (Git Bash / WSL / MSYS), since commands are authored in bash syntax, and fall
|
|
// back to PowerShell, then cmd, so a command still runs on a bare Windows box.
|
|
func resolveShell(explicit, goos string, lookPath func(string) (string, error)) (shell, flag string) {
|
|
if explicit != "" {
|
|
return explicit, "-c"
|
|
}
|
|
if goos == "windows" {
|
|
if p, err := lookPath("bash"); err == nil {
|
|
return p, "-c"
|
|
}
|
|
if p, err := lookPath("powershell"); err == nil {
|
|
return p, "-Command"
|
|
}
|
|
return "cmd", "/C"
|
|
}
|
|
return "bash", "-c"
|
|
}
|
|
|
|
// streamWriter forwards each written chunk to onUpdate as a growing partial
|
|
// result while accumulating the full output. It is safe for concurrent use so
|
|
// stdout and stderr can share the same combined buffer.
|
|
type streamWriter struct {
|
|
mu *sync.Mutex
|
|
buf *bytes.Buffer
|
|
onUpdate agentcore.ToolUpdateFunc
|
|
}
|
|
|
|
func (w streamWriter) Write(p []byte) (int, error) {
|
|
w.mu.Lock()
|
|
w.buf.Write(p)
|
|
snapshot := w.buf.String()
|
|
w.mu.Unlock()
|
|
if w.onUpdate != nil {
|
|
w.onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(snapshot)}})
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// Execute implements AgentTool. It streams combined stdout/stderr via onUpdate,
|
|
// enforces a timeout, and kills the process on context cancellation. A non-zero
|
|
// exit returns a Go error (→ isError) carrying the exit code and output.
|
|
func (t *BashTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
|
a, bad := decodeArgs[bashToolArgs](args, "bash")
|
|
if bad != nil {
|
|
return *bad, nil
|
|
}
|
|
if a.Command == "" {
|
|
return errorResult("bash: command is required"), nil
|
|
}
|
|
|
|
if a.RunInBackground {
|
|
return t.startBackground(a)
|
|
}
|
|
|
|
timeout := bashDefaultTimeout
|
|
if a.TimeoutMs > 0 {
|
|
timeout = time.Duration(a.TimeoutMs) * time.Millisecond
|
|
}
|
|
if timeout > bashMaxTimeout {
|
|
timeout = bashMaxTimeout
|
|
}
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
|
|
cmd := exec.CommandContext(runCtx, shell, flag, a.Command)
|
|
if t.Dir != "" {
|
|
cmd.Dir = t.Dir
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var combined bytes.Buffer
|
|
sw := streamWriter{mu: &mu, buf: &combined, onUpdate: onUpdate}
|
|
cmd.Stdout = sw
|
|
cmd.Stderr = sw
|
|
|
|
err := cmd.Run()
|
|
|
|
mu.Lock()
|
|
output := combined.String()
|
|
mu.Unlock()
|
|
|
|
// Cap the output before it enters any ToolResult / error message, so a single
|
|
// command's huge output cannot blow the model's context. Truncation keeps a
|
|
// head + tail preview with a "[truncated N bytes]" marker in the middle.
|
|
output = truncateBashOutput(output)
|
|
|
|
// Context cancellation / timeout takes precedence in the message.
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
|
fmt.Errorf("bash: command timed out after %s\n%s", timeout, output)
|
|
}
|
|
if ctx.Err() == context.Canceled {
|
|
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
|
fmt.Errorf("bash: command canceled\n%s", output)
|
|
}
|
|
|
|
// A missing interpreter (no bash/powershell/cmd on PATH) surfaces as an
|
|
// *exec.Error before the command ever runs. Report it with actionable
|
|
// guidance instead of a bare "code -1", so the model stops retrying blindly.
|
|
var execErr *exec.Error
|
|
if errors.As(err, &execErr) {
|
|
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(output)}},
|
|
fmt.Errorf("bash: could not start shell %q: %v. On Windows install Git Bash or WSL (or configure a shell); commands are bash syntax", shell, execErr.Err)
|
|
}
|
|
|
|
if err != nil {
|
|
exitCode := -1
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
exitCode = ee.ExitCode()
|
|
}
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
|
|
Details: map[string]any{"exitCode": exitCode},
|
|
},
|
|
fmt.Errorf("bash: command exited with code %d\n%s", exitCode, output)
|
|
}
|
|
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(output)},
|
|
Details: map[string]any{"exitCode": 0},
|
|
}, nil
|
|
}
|
|
|
|
// startBackground launches the command detached from the turn context and
|
|
// returns immediately with a bash_id. The job runs under its own cancelable
|
|
// context (rooted at context.Background(), not the turn ctx which is canceled
|
|
// when the turn ends), so it survives past Execute. A background command has no
|
|
// default timeout — a dev server or watcher is expected to run indefinitely —
|
|
// but an explicit timeout_ms still caps it. Its combined output accumulates in
|
|
// the job's buffer for bash_output to drain; kill_bash cancels its context.
|
|
func (t *BashTool) startBackground(a bashToolArgs) (agentcore.AgentToolResult, error) {
|
|
if t.Jobs == nil {
|
|
return errorResult("bash: run_in_background is not available in this environment"), nil
|
|
}
|
|
|
|
var jobCtx context.Context
|
|
var cancel context.CancelFunc
|
|
if a.TimeoutMs > 0 {
|
|
timeout := time.Duration(a.TimeoutMs) * time.Millisecond
|
|
if timeout > bashMaxTimeout {
|
|
timeout = bashMaxTimeout
|
|
}
|
|
jobCtx, cancel = context.WithTimeout(context.Background(), timeout)
|
|
} else {
|
|
jobCtx, cancel = context.WithCancel(context.Background())
|
|
}
|
|
|
|
shell, flag := resolveShell(t.Shell, runtime.GOOS, shellLookPath)
|
|
cmd := exec.CommandContext(jobCtx, shell, flag, a.Command)
|
|
if t.Dir != "" {
|
|
cmd.Dir = t.Dir
|
|
}
|
|
|
|
job := t.Jobs.create(a.Command, cancel)
|
|
w := job.writer()
|
|
cmd.Stdout = w
|
|
cmd.Stderr = w
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
job.finish(-1, err.Error())
|
|
return errorResult(fmt.Sprintf("bash: could not start background command: %v", err)), nil
|
|
}
|
|
|
|
go func() {
|
|
err := cmd.Wait()
|
|
cancel()
|
|
exitCode := 0
|
|
errMsg := ""
|
|
if err != nil {
|
|
exitCode = -1
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
exitCode = ee.ExitCode()
|
|
}
|
|
errMsg = err.Error()
|
|
}
|
|
job.finish(exitCode, errMsg)
|
|
}()
|
|
|
|
msg := fmt.Sprintf("started background command %s: %s\nuse bash_output %q to read its output, kill_bash %q to stop it", job.ID, a.Command, job.ID, job.ID)
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
|
Details: map[string]any{"bash_id": job.ID, "background": true},
|
|
}, nil
|
|
}
|