mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
613 lines
25 KiB
Go
613 lines
25 KiB
Go
package tools
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os/exec"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Gitlawb/zero/internal/execution"
|
||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||
"github.com/Gitlawb/zero/internal/secrets"
|
||
)
|
||
|
||
const defaultBashTimeoutMS = 120000
|
||
const maxBashTimeoutMS = 600000
|
||
|
||
type bashTool struct {
|
||
baseTool
|
||
workspaceRoot string
|
||
scope PathScope
|
||
}
|
||
|
||
func (bashTool) outputCategory(args map[string]any) outputCategory {
|
||
command, _ := bashCommandArg(args)
|
||
return shellOutputCategory(command)
|
||
}
|
||
|
||
func bashCommandArg(args map[string]any) (string, error) {
|
||
return aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
|
||
}
|
||
|
||
func NewBashTool(workspaceRoot string) Tool {
|
||
return NewScopedBashTool(workspaceRoot, nil)
|
||
}
|
||
|
||
func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
|
||
shellGuidance := shellGuidanceForGOOS(runtime.GOOS)
|
||
return bashTool{
|
||
baseTool: baseTool{
|
||
name: "bash",
|
||
description: "Execute a shell command inside the workspace (or an explicitly granted extra directory) after permission is granted. " + shellGuidance,
|
||
deferred: true,
|
||
parameters: Schema{
|
||
Type: "object",
|
||
Properties: map[string]PropertySchema{
|
||
"command": {Type: "string", Description: "Shell command to execute using the host shell. " + shellGuidance},
|
||
"cwd": {Type: "string", Description: "Directory to run the command in. Relative paths stay in the workspace; use an absolute path to run in a granted extra directory. Defaults to workspace root. Prefer cwd over cd when changing directories.", Default: "."},
|
||
"timeout_ms": {Type: "integer", Description: "Command timeout in milliseconds.", Default: defaultBashTimeoutMS, Minimum: intPtr(1), Maximum: intPtr(maxBashTimeoutMS)},
|
||
"sandbox_permissions": {Type: "string", Enum: []string{string(SandboxPermissionsUseDefault), string(SandboxPermissionsWithAdditionalPermissions), string(SandboxPermissionsRequireEscalated)}, Description: "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions` for sandboxed file/network access, or `require_escalated` only when the command must run outside the sandbox, such as host/global process, socket, service, or desktop state hidden by sandbox namespaces.", Default: string(SandboxPermissionsUseDefault)},
|
||
"additional_permissions": {
|
||
Type: "object",
|
||
Description: "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`.",
|
||
Properties: additionalPermissionsProperties(),
|
||
},
|
||
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
|
||
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
|
||
},
|
||
Required: []string{"command"},
|
||
AdditionalProperties: false,
|
||
},
|
||
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
|
||
// One-shot shell can rewrite the workspace (and more). Serialized
|
||
// as WorkspaceWrite so external-mutator audits include bash; not
|
||
// session-bound Interactive (that is reserved for PTY/terminal tools).
|
||
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||
},
|
||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||
scope: scope,
|
||
}
|
||
}
|
||
|
||
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
|
||
return tool.run(ctx, args, nil, true)
|
||
}
|
||
|
||
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
|
||
return tool.run(ctx, args, engine, true)
|
||
}
|
||
|
||
func (tool bashTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||
return tool.run(ctx, args, options.Sandbox, false)
|
||
}
|
||
|
||
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
|
||
commandText, err := bashCommandArg(args)
|
||
if err != nil {
|
||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||
}
|
||
cwd, err := stringArg(args, "cwd", ".", false)
|
||
if err != nil {
|
||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||
}
|
||
timeoutMS, err := intArg(args, "timeout_ms", defaultBashTimeoutMS, 1, maxBashTimeoutMS)
|
||
if err != nil {
|
||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||
}
|
||
sandboxPermissions, err := sandboxPermissionsArg(args)
|
||
if err != nil {
|
||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||
}
|
||
// Resolve the command engine before the MSYS preflight check so an
|
||
// approved require_escalated call (commandEngine == nil, truly
|
||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||
// hard-blocked by the same check it was meant to escalate past.
|
||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||
return shellIssueBlockResult(*issue)
|
||
}
|
||
|
||
// Pre-execution safety: refuse interactive commands (editors, pagers, REPLs,
|
||
// remote shells, etc.) that would hang the non-interactive agent until the
|
||
// timeout fires. This runs before the command is built or launched.
|
||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtime.GOOS); interactive.Interactive {
|
||
return interactiveBlockResult(interactive)
|
||
}
|
||
|
||
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, cwd)
|
||
if err != nil {
|
||
return errorResult("Error running bash: " + err.Error())
|
||
}
|
||
|
||
commandCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond)
|
||
defer cancel()
|
||
|
||
meta := map[string]string{
|
||
"cwd": relativeCwd,
|
||
"timeout_ms": strconv.Itoa(timeoutMS),
|
||
}
|
||
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
|
||
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
|
||
}
|
||
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
|
||
if err != nil {
|
||
meta["exit_code"] = "-1"
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: "Error preparing sandboxed bash command: " + err.Error(),
|
||
Meta: meta,
|
||
}
|
||
}
|
||
defer plan.Cleanup()
|
||
addSandboxMeta(meta, plan)
|
||
executionRequest := execExecutionRequest(command, plan, absoluteCwd, false)
|
||
changeObserver := execution.NewChangeObserver(plan.WorkspaceRoot)
|
||
|
||
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
|
||
// can't grow Zero's memory before truncation: only the head+tail each stream
|
||
// will ever surface to the model are retained, the middle is discarded as it
|
||
// streams, and the true size is counted for the truncation marker.
|
||
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||
command.Stdout = stdout
|
||
command.Stderr = stderr
|
||
|
||
// Kill the shell as a process group on timeout and bound the post-kill I/O
|
||
// wait, so a backgrounded child cannot outlive the command or hang Run().
|
||
hardenProcessLifetime(command)
|
||
|
||
// Capture sandbox denials when the plan opted in (macOS + Policy.MonitorDenials).
|
||
// A no-op when MonitorTag is empty, so the default path is unchanged.
|
||
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
|
||
err = command.Run()
|
||
exitCode := commandExitCode(err)
|
||
adapterReport, reportErr := plan.ExecutionReport()
|
||
meta["exit_code"] = strconv.Itoa(exitCode)
|
||
stdoutText := stdout.retained()
|
||
stderrRetained := stderr.retained()
|
||
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
|
||
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
|
||
// count them toward the true total so the budget/marker stay accurate.
|
||
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
|
||
|
||
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
|
||
result := Result{
|
||
Status: StatusError,
|
||
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
|
||
Meta: meta,
|
||
}
|
||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
|
||
}
|
||
if err != nil {
|
||
if exitCode < 0 {
|
||
result := Result{
|
||
Status: StatusError,
|
||
Output: "Error executing command: " + err.Error(),
|
||
Meta: meta,
|
||
}
|
||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||
}
|
||
if adapterReport.Denial != nil {
|
||
markStructuredSandboxDenial(meta, *adapterReport.Denial)
|
||
}
|
||
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
|
||
result := Result{
|
||
Status: StatusError,
|
||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||
Truncated: truncated,
|
||
Meta: meta,
|
||
}
|
||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||
}
|
||
|
||
if adapterReport.Denial != nil {
|
||
markStructuredSandboxDenial(meta, *adapterReport.Denial)
|
||
}
|
||
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
|
||
result := Result{
|
||
Status: StatusOK,
|
||
Output: formatBashOutput(outText, errText, exitCode),
|
||
Truncated: truncated,
|
||
Meta: meta,
|
||
}
|
||
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
|
||
}
|
||
|
||
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
|
||
input := execToolResultInput{
|
||
exited: true,
|
||
exitCode: exitCode,
|
||
enforcement: executionEnforcement(plan),
|
||
request: request,
|
||
report: report,
|
||
reportErr: reportErr,
|
||
changes: changes,
|
||
}
|
||
outcome := execExecutionOutcome(input)
|
||
if timedOut && report.Denial == nil && reportErr == nil {
|
||
outcome.State = execution.StateFailed
|
||
outcome.Kind = execution.OutcomeTimedOut
|
||
}
|
||
result.ExecutionRequest = &request
|
||
result.ExecutionOutcome = &outcome
|
||
result.ChangedFiles = executionChangedFiles(changes)
|
||
result.ChangeSummaries = executionChangeSummaries(changes)
|
||
return result
|
||
}
|
||
|
||
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
|
||
if sandboxPermissions == SandboxPermissionsRequireEscalated && (engine == nil || engine.UnsandboxedExecutionAllowed()) {
|
||
return nil
|
||
}
|
||
return engine
|
||
}
|
||
|
||
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
|
||
// longer applies because sandbox_permissions: require_escalated actually
|
||
// resolved to unsandboxed, host-level execution (commandEngine is nil only
|
||
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
|
||
// of the sandbox's restricted token/handles, so running outside it removes
|
||
// the failure mode this guard exists for. Any other issue kind (e.g. a real
|
||
// cmd.exe syntax problem) still blocks regardless of escalation, since
|
||
// running outside the sandbox does not fix a syntax error.
|
||
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
|
||
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
|
||
}
|
||
|
||
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
|
||
// the sandbox log monitor captured, so the model can see what was blocked. With no
|
||
// blocks the stderr is returned unchanged.
|
||
func appendSandboxBlocks(stderr string, blocks []string) string {
|
||
if len(blocks) == 0 {
|
||
return stderr
|
||
}
|
||
var builder strings.Builder
|
||
builder.WriteString("<sandbox_blocks>\n")
|
||
for _, block := range blocks {
|
||
builder.WriteString(block)
|
||
builder.WriteString("\n")
|
||
}
|
||
builder.WriteString("</sandbox_blocks>")
|
||
if strings.TrimSpace(stderr) == "" {
|
||
return builder.String()
|
||
}
|
||
return stderr + "\n" + builder.String()
|
||
}
|
||
|
||
func shellIssueBlockResult(issue shellIssue) Result {
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: appendShellIssueHint("", issue),
|
||
Meta: map[string]string{
|
||
"exit_code": "-1",
|
||
"shell_issue": issue.Kind,
|
||
},
|
||
Display: Display{
|
||
Summary: issue.Message,
|
||
Kind: "shell",
|
||
},
|
||
}
|
||
}
|
||
|
||
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
||
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
|
||
// the fallback. Only cmd.exe needs the raw command-line override.
|
||
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
|
||
hostShell := detectShellRuntime(runtime.GOOS)
|
||
spec := zeroSandbox.CommandSpec{
|
||
Name: hostShell.Executable,
|
||
Args: hostShell.arguments(commandText),
|
||
Dir: absoluteCwd,
|
||
}
|
||
if engine != nil {
|
||
command, plan, err := engine.CommandContext(ctx, spec)
|
||
if err == nil {
|
||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||
}
|
||
return command, plan, err
|
||
}
|
||
directPolicy := zeroSandbox.DefaultPolicy()
|
||
directPolicy.Mode = zeroSandbox.ModeDisabled
|
||
directPolicy.Network = zeroSandbox.NetworkAllow
|
||
plan := zeroSandbox.CommandPlan{
|
||
Backend: zeroSandbox.Backend{
|
||
Name: zeroSandbox.BackendUnavailable,
|
||
Message: "sandbox engine not provided",
|
||
},
|
||
TargetBackend: zeroSandbox.BackendNone,
|
||
WorkspaceRoot: absoluteCwd,
|
||
Policy: directPolicy,
|
||
PermissionProfile: zeroSandbox.PermissionProfileFromPolicy(absoluteCwd, directPolicy, nil),
|
||
Wrapped: false,
|
||
EnforcementLevel: zeroSandbox.EnforcementDisabled,
|
||
Name: spec.Name,
|
||
Args: spec.Args,
|
||
Dir: spec.Dir,
|
||
}
|
||
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
||
command.Dir = spec.Dir
|
||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||
return command, plan, nil
|
||
}
|
||
|
||
func addSandboxMeta(meta map[string]string, plan zeroSandbox.CommandPlan) {
|
||
if plan.Backend.Name == "" {
|
||
return
|
||
}
|
||
meta["sandbox_backend"] = string(plan.Backend.Name)
|
||
if plan.TargetBackend != "" {
|
||
meta["sandbox_target_backend"] = string(plan.TargetBackend)
|
||
}
|
||
meta["sandbox_wrapped"] = strconv.FormatBool(plan.Wrapped)
|
||
if plan.EnforcementLevel != "" {
|
||
meta["sandbox_enforcement_level"] = string(plan.EnforcementLevel)
|
||
}
|
||
if plan.DowngradeReason != "" {
|
||
meta["sandbox_downgrade_reason"] = plan.DowngradeReason
|
||
}
|
||
meta["sandbox_requires_platform"] = strconv.FormatBool(plan.RequiresPlatformSandbox)
|
||
if plan.Backend.Message != "" {
|
||
meta["sandbox_message"] = plan.Backend.Message
|
||
}
|
||
if plan.SandboxDir != "" {
|
||
meta["sandbox_cwd"] = plan.SandboxDir
|
||
}
|
||
}
|
||
|
||
// interactiveBlockResult builds the structured tool Result returned when a
|
||
// command is refused before execution because it would hang the agent. The
|
||
// block is surfaced both in Output (clearly delimited) and in Meta/Display
|
||
// so downstream consumers and the TUI can render it consistently.
|
||
func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Result {
|
||
message := fmt.Sprintf(
|
||
"Error: Blocked interactive command %q before execution: %s. This would hang the non-interactive agent.\nSuggestion: %s",
|
||
detection.Command, detection.Reason, detection.Suggestion,
|
||
)
|
||
return Result{
|
||
Status: StatusError,
|
||
Output: message,
|
||
Meta: map[string]string{
|
||
"exit_code": "-1",
|
||
"safety_block": "interactive_command",
|
||
"safety_cmd": detection.Command,
|
||
},
|
||
Display: Display{
|
||
Summary: "Blocked interactive command: " + detection.Command,
|
||
Kind: "shell",
|
||
},
|
||
}
|
||
}
|
||
|
||
func commandExitCode(err error) int {
|
||
if err == nil {
|
||
return 0
|
||
}
|
||
var exitError *exec.ExitError
|
||
if errors.As(err, &exitError) {
|
||
return exitError.ExitCode()
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||
parts := []string{}
|
||
stdout = strings.TrimRight(stdout, "\r\n")
|
||
stderr = strings.TrimRight(stderr, "\r\n")
|
||
// Redact high-confidence secrets a command may have printed, so they are not
|
||
// echoed back into the model context (additive to the configured-key scrub
|
||
// done at the registry boundary).
|
||
stdout, outFindings := secrets.Redact(stdout)
|
||
stderr, errFindings := secrets.Redact(stderr)
|
||
if stdout != "" {
|
||
parts = append(parts, "stdout:\n"+stdout)
|
||
}
|
||
if stderr != "" {
|
||
parts = append(parts, "stderr:\n"+stderr)
|
||
}
|
||
if exitCode != 0 {
|
||
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
|
||
}
|
||
if n := len(outFindings) + len(errFindings); n > 0 {
|
||
parts = append(parts, fmt.Sprintf("[zero] redacted %d likely secret(s) from this output before showing it.", n))
|
||
}
|
||
if len(parts) == 0 {
|
||
return "Command completed with no output."
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
|
||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
|
||
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
|
||
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
|
||
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
|
||
// following turn. Head+tail truncation keeps both the start and the end of an
|
||
// oversized stream, since build/test failures usually surface at the tail, and
|
||
// the full captured text is spilled to disk so nothing is lost — the model
|
||
// greps/reads the spill instead of re-running the command.
|
||
const bashOutputBudgetBytes = 32 * 1024
|
||
|
||
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
|
||
// stream during capture. Deliberately larger than the emit budget: the extra
|
||
// retained bytes never reach the transcript, but they make the spill file —
|
||
// the model's recovery path — cover 6× more of the output.
|
||
const bashCaptureBudgetBytes = 96 * 1024
|
||
|
||
// prepareBashOutput keeps direct callers on the established positional budget.
|
||
// Registry calls retain the existing bounded capture but leave final semantic
|
||
// reduction and spill creation to the post-redaction registry boundary.
|
||
func prepareBashOutput(out string, outTotal int, errStr string, errTotal int, meta map[string]string, directBudget bool) (string, string, bool) {
|
||
if directBudget {
|
||
return budgetBashCapture(out, outTotal, errStr, errTotal, meta)
|
||
}
|
||
outText := sectionWithCaptureGap(out, outTotal)
|
||
errText := sectionWithCaptureGap(errStr, errTotal)
|
||
truncated := outTotal > len(out) || errTotal > len(errStr)
|
||
if meta != nil {
|
||
meta["raw_bytes"] = strconv.Itoa(outTotal + errTotal)
|
||
meta["emitted_bytes"] = strconv.Itoa(len(outText) + len(errText))
|
||
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(outText) + len(errText)))
|
||
if truncated {
|
||
meta["truncated"] = "true"
|
||
meta["truncation_reason"] = "capture_budget"
|
||
}
|
||
}
|
||
return outText, errText, truncated
|
||
}
|
||
|
||
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
|
||
// and errTotal are the true byte counts (from boundedBuffer.total), which may
|
||
// exceed the retained strings when the middle was dropped during capture. Meta's
|
||
// raw_bytes therefore reflects everything the command produced, not just what was
|
||
// kept in memory.
|
||
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
|
||
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
|
||
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
|
||
truncated := outTrunc || errTrunc
|
||
if truncated {
|
||
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
|
||
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
|
||
if errTrunc {
|
||
errText += hint
|
||
} else {
|
||
outText += hint
|
||
}
|
||
if meta != nil {
|
||
meta["spill_path"] = spillPath
|
||
}
|
||
}
|
||
}
|
||
if meta != nil {
|
||
emitted := len(outText) + len(errText)
|
||
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
|
||
meta["emitted_bytes"] = strconv.Itoa(emitted)
|
||
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
|
||
if truncated {
|
||
meta["truncated"] = "true"
|
||
}
|
||
}
|
||
return outText, errText, truncated
|
||
}
|
||
|
||
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
|
||
// the spill directory as one sectioned file and returns its path, or "" when
|
||
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
|
||
// tail per stream — everything zero kept in memory, which is more than the
|
||
// emit budget shows the model but not necessarily the whole output. When the
|
||
// capture itself dropped the middle of a stream (total exceeds the retained
|
||
// bytes), a gap marker is inserted at the head/tail junction so the spilled
|
||
// log never reads as contiguous when it is not.
|
||
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
|
||
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
|
||
stderr = sectionWithCaptureGap(stderr, stderrTotal)
|
||
var combined strings.Builder
|
||
combined.Grow(len(stdout) + len(stderr) + 64)
|
||
combined.WriteString("### stdout\n")
|
||
combined.WriteString(stdout)
|
||
if !strings.HasSuffix(stdout, "\n") {
|
||
combined.WriteString("\n")
|
||
}
|
||
combined.WriteString("### stderr\n")
|
||
combined.WriteString(stderr)
|
||
return spillTruncatedOutput("bash", combined.String())
|
||
}
|
||
|
||
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
|
||
// of a stream. When total exceeds the retained bytes, the retained text is the
|
||
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
|
||
// followed immediately by the rolling tail — the junction sits at the head cap,
|
||
// snapped back to a rune boundary.
|
||
func sectionWithCaptureGap(text string, total int) string {
|
||
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
|
||
return text
|
||
}
|
||
head := utf8Prefix(text, bashCaptureBudgetBytes)
|
||
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
|
||
return head + marker + text[len(head):]
|
||
}
|
||
|
||
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
|
||
// and tailCap bytes from the end of a stream while counting the total written, so
|
||
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
|
||
// memory: the middle is discarded as it arrives instead of buffered whole and then
|
||
// truncated. total records the full size for the truncation marker even though the
|
||
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
|
||
// Stderr from separate goroutines, so each stream gets its own buffer.
|
||
type boundedBuffer struct {
|
||
head []byte
|
||
headCap int
|
||
tail []byte
|
||
tailCap int
|
||
total int
|
||
}
|
||
|
||
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
|
||
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
|
||
}
|
||
|
||
func (b *boundedBuffer) Write(p []byte) (int, error) {
|
||
n := len(p)
|
||
b.total += n
|
||
// Fill the head until it reaches headCap; the head is written once and frozen.
|
||
if len(b.head) < b.headCap {
|
||
take := b.headCap - len(b.head)
|
||
if take > len(p) {
|
||
take = len(p)
|
||
}
|
||
b.head = append(b.head, p[:take]...)
|
||
p = p[take:]
|
||
}
|
||
// Anything past the head feeds a rolling tail that keeps only the last tailCap
|
||
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
|
||
// while memory never exceeds ~2×tailCap.
|
||
if len(p) > 0 && b.tailCap > 0 {
|
||
b.tail = append(b.tail, p...)
|
||
if len(b.tail) > 2*b.tailCap {
|
||
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
|
||
}
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
|
||
// between head and tail lands in the middle, which the display budget trims away;
|
||
// callers that need the true size read total separately.
|
||
func (b *boundedBuffer) retained() string {
|
||
if len(b.tail) > b.tailCap {
|
||
// Not yet compacted since the last overflow; expose only the last tailCap.
|
||
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
|
||
}
|
||
return string(b.head) + string(b.tail)
|
||
}
|
||
|
||
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
|
||
// the full original byte count — for the "N bytes omitted" marker and the raw
|
||
// count. total may exceed len(value) when the middle was already discarded during
|
||
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
|
||
// and this trims it to the display budget while still reporting the true total.
|
||
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
|
||
if maxBytes <= 0 || total <= maxBytes {
|
||
return value, total, false
|
||
}
|
||
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
|
||
budget := maxBytes - len(marker)
|
||
if budget < 0 {
|
||
budget = 0
|
||
}
|
||
head := budget / 2
|
||
tail := budget - head
|
||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||
}
|
||
|
||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||
output := formatBashOutput(stdout, stderr, exitCode)
|
||
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
|
||
meta["shell_issue"] = issue.Kind
|
||
output = appendShellIssueHint(output, *issue)
|
||
}
|
||
return output
|
||
}
|