first commit
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user