diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 0ba30842..1b61823a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -375,8 +375,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (result *RunResult, } // Phase 1: Prune old tool results before resorting to full compaction (at 70% of budget). - if historyTokens >= int(float64(historyBudget)*0.7) && !rs.midLoopPruned { - rs.midLoopPruned = true + // Re-triggers each iteration — new tool results may have grown context since last prune. + if historyTokens >= int(float64(historyBudget)*0.7) { pruned := pruneContextMessages(messages, l.contextWindow, l.contextPruningCfg) if len(pruned) > 0 { messages = pruned diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 873ea725..5e33317d 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -519,7 +519,6 @@ type runState struct { // Mid-loop compaction and overhead calibration midLoopCompacted bool - midLoopPruned bool overheadTokens int // non-history token overhead (system prompt + tools + context files) overheadCalibrated bool diff --git a/internal/agent/pruning.go b/internal/agent/pruning.go index ef1e7137..bc5793be 100644 --- a/internal/agent/pruning.go +++ b/internal/agent/pruning.go @@ -11,10 +11,10 @@ import ( // Context pruning defaults matching TS DEFAULT_CONTEXT_PRUNING_SETTINGS. const ( defaultKeepLastAssistants = 3 - defaultSoftTrimRatio = 0.3 + defaultSoftTrimRatio = 0.25 defaultHardClearRatio = 0.5 defaultMinPrunableToolChars = 50000 - defaultSoftTrimMaxChars = 4000 + defaultSoftTrimMaxChars = 3000 defaultSoftTrimHeadChars = 1500 defaultSoftTrimTailChars = 1500 defaultHardClearPlaceholder = "[Old tool result content cleared]" @@ -148,8 +148,42 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg return msgs } - // Pass 1: Soft trim long tool results. + // Pass 0: Per-result context guard — force-trim any single tool result + // exceeding 30% of the context window. Catches outlier outputs even + // when overall context ratio is low. + maxSingleResultChars := charWindow * 3 / 10 var result []providers.Message + for _, idx := range prunableIndexes { + msgChars := estimateMessageChars(msgs[idx]) + if msgChars > maxSingleResultChars { + if result == nil { + result = make([]providers.Message, len(msgs)) + copy(result, msgs) + } + msg := msgs[idx] + head := takeHead(msg.Content, maxSingleResultChars*7/10) + tail := takeTail(msg.Content, maxSingleResultChars*3/10) + trimmed := fmt.Sprintf("%s\n\n⚠️ [... middle content omitted ...]\n\n%s\n\n[Single tool result trimmed: %d chars exceeded per-result limit of %d chars.]", + head, tail, msgChars, maxSingleResultChars) + result[idx] = providers.Message{ + Role: msg.Role, + Content: trimmed, + ToolCallID: msg.ToolCallID, + } + totalChars += len(trimmed) - msgChars + } + } + if result != nil { + msgs = result + result = nil + // Re-check ratio after per-result guard. + ratio = float64(totalChars) / float64(charWindow) + if ratio < settings.softTrimRatio { + return msgs + } + } + + // Pass 1: Soft trim long tool results. for i := range prunableIndexes { idx := prunableIndexes[i] msg := msgs[idx] @@ -165,10 +199,19 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg copy(result, msgs) } - head := takeHead(msg.Content, settings.softTrimHeadChars) - tail := takeTail(msg.Content, settings.softTrimTailChars) + // Tail-aware split: if tail has important content (errors, summaries), + // use dynamic 70/30 split. Otherwise use configured head/tail sizes. + headChars := settings.softTrimHeadChars + tailChars := settings.softTrimTailChars + if hasImportantTail(msg.Content) { + totalBudget := headChars + tailChars + headChars = totalBudget * 7 / 10 + tailChars = totalBudget - headChars + } + head := takeHead(msg.Content, headChars) + tail := takeTail(msg.Content, tailChars) trimmed := fmt.Sprintf("%s\n...\n%s\n\n[Tool result trimmed: kept first %d chars and last %d chars of %d chars.]", - head, tail, settings.softTrimHeadChars, settings.softTrimTailChars, msgChars) + head, tail, headChars, tailChars, msgChars) result[idx] = providers.Message{ Role: msg.Role, @@ -250,6 +293,17 @@ func estimateMessageChars(m providers.Message) int { return utf8.RuneCountInString(m.Content) } +// hasImportantTail checks if the last ~500 chars of content contain error/summary keywords. +func hasImportantTail(content string) bool { + runes := []rune(content) + checkLen := 500 + if checkLen > len(runes) { + checkLen = len(runes) + } + tail := string(runes[len(runes)-checkLen:]) + return importantTailRe.MatchString(tail) +} + // takeHead returns the first n runes of s. func takeHead(s string, n int) string { if n <= 0 { diff --git a/internal/agent/tool_result_truncation.go b/internal/agent/tool_result_truncation.go new file mode 100644 index 00000000..ce517334 --- /dev/null +++ b/internal/agent/tool_result_truncation.go @@ -0,0 +1,8 @@ +package agent + +import "regexp" + +// importantTailRe matches keywords in the tail of output that indicate +// the tail contains important information (errors, summaries, results). +// Used by pruning.go's hasImportantTail() for tail-aware soft trim. +var importantTailRe = regexp.MustCompile(`(?i)(error|exception|failed|fatal|traceback|panic|stack trace|exit code|total|summary|result|complete|finished|done)\b`) diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index d5de99fe..1fb41363 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -223,7 +223,9 @@ func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath strin if output == "" { output = "(command completed with no output)" } - return SilentResult(ScrubCredentials(output)) + output = ScrubCredentials(output) + output = capExecOutput(output, execMaxOutputChars) + return SilentResult(output) } // buildCredentialedEnv creates a minimal environment with injected credentials. @@ -270,7 +272,9 @@ func formatCredentialedResult(binary string, args []string, if output == "" { output = "(command completed with no output)" } - return SilentResult(ScrubCredentials(output)) + output = ScrubCredentials(output) + output = capExecOutput(output, execMaxOutputChars) + return SilentResult(output) } // lookupCredentialedBinary checks if a command's binary has credential config. diff --git a/internal/tools/exec_output_cap.go b/internal/tools/exec_output_cap.go new file mode 100644 index 00000000..de1250d3 --- /dev/null +++ b/internal/tools/exec_output_cap.go @@ -0,0 +1,84 @@ +package tools + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// cutAtLastNewline trims text to the nearest newline in the last 20% of the string. +func cutAtLastNewline(text string) string { + threshold := len(text) * 80 / 100 + if idx := strings.LastIndex(text[threshold:], "\n"); idx >= 0 { + return text[:threshold+idx] + } + return text +} + +// cutAtFirstNewline trims text from the start at the nearest newline in the first 20%. +func cutAtFirstNewline(text string) string { + searchEnd := len(text) * 20 / 100 + if searchEnd == 0 { + return text + } + if idx := strings.Index(text[:searchEnd], "\n"); idx >= 0 { + return text[idx+1:] + } + return text +} + +// execMaxOutputChars is the maximum characters kept from exec/shell output. +// Larger outputs are truncated with head+tail strategy. +const execMaxOutputChars = 30000 + +// execImportantTailRe matches keywords indicating the tail contains important info. +var execImportantTailRe = regexp.MustCompile(`(?i)(error|exception|failed|fatal|traceback|panic|stack trace|exit code|total|summary|result|complete|finished|done|}\s*$)`) + +// capExecOutput truncates exec output to maxChars using smart head+tail strategy. +// If the tail contains important content (errors, summaries), keeps 70% head + 30% tail. +// Otherwise keeps head only. Returns the original text if it fits within maxChars. +func capExecOutput(output string, maxChars int) string { + if utf8.RuneCountInString(output) <= maxChars { + return output + } + + runes := []rune(output) + totalRunes := len(runes) + suffix := fmt.Sprintf("\n\n[Output truncated: %d chars total. Redirect to file for full output: command > output.txt]", totalRunes) + budget := maxChars - utf8.RuneCountInString(suffix) + if budget < 2000 { + budget = 2000 + } + + // Check if tail has important content. + tailCheckLen := 2000 + if tailCheckLen > totalRunes { + tailCheckLen = totalRunes + } + tailSample := string(runes[totalRunes-tailCheckLen:]) + + if execImportantTailRe.MatchString(tailSample) && budget > 4000 { + // Smart split: 70% head + 30% tail. + headBudget := budget * 7 / 10 + tailBudget := budget - headBudget + if tailBudget > 4000 { + tailBudget = 4000 + headBudget = budget - tailBudget + } + + head := string(runes[:headBudget]) + tail := string(runes[totalRunes-tailBudget:]) + + // Cut at newline boundaries for cleaner output. + head = cutAtLastNewline(head) + tail = cutAtFirstNewline(tail) + + return head + "\n\n⚠️ [... middle content omitted ...]\n\n" + tail + suffix + } + + // Head-only truncation. + head := string(runes[:budget]) + head = cutAtLastNewline(head) + return head + suffix +} diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index 536d9723..7505a2a9 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -69,8 +69,10 @@ func NewSandboxedReadFileTool(workspace string, restrict bool, mgr sandbox.Manag // SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe). func (t *ReadFileTool) SetSandboxKey(key string) {} -func (t *ReadFileTool) Name() string { return "read_file" } -func (t *ReadFileTool) Description() string { return "Read the contents of a file" } +func (t *ReadFileTool) Name() string { return "read_file" } +func (t *ReadFileTool) Description() string { + return "Read the contents of a file. For large files, use offset and limit to read specific line ranges." +} func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", @@ -79,6 +81,14 @@ func (t *ReadFileTool) Parameters() map[string]any { "type": "string", "description": "File path (relative to workspace, or absolute)", }, + "offset": map[string]any{ + "type": "integer", + "description": "Start reading from this line number (0-indexed). Defaults to 0.", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of lines to return. Omit to read until output cap.", + }, }, "required": []string{"path"}, } @@ -136,7 +146,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result // Sandbox routing (sandboxKey from ctx — thread-safe) sandboxKey := ToolSandboxKeyFromCtx(ctx) if t.sandboxMgr != nil && sandboxKey != "" { - return t.executeInSandbox(ctx, path, sandboxKey) + return t.executeInSandbox(ctx, path, sandboxKey, args) } // Host execution — use per-user workspace from context if available @@ -170,10 +180,10 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result return ErrorResult(msg) } - return SilentResult(string(data)) + return t.paginateOutput(string(data), args) } -func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey string) *Result { +func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey string, args map[string]any) *Result { bridge, err := t.getFsBridge(ctx, sandboxKey) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) @@ -190,7 +200,7 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st return ErrorResult(fmt.Sprintf("failed to read file: %v", err) + MaybeFsBridgeHint(err)) } - return SilentResult(data) + return t.paginateOutput(data, args) } func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { @@ -201,6 +211,87 @@ func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*san return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil } +// readFileMaxChars is the output cap for read_file. Large files require offset/limit pagination. +const readFileMaxChars = 50000 + +// paginateOutput applies offset/limit slicing and output capping to file content. +// Returns a SilentResult with pagination metadata when the output is truncated. +func (t *ReadFileTool) paginateOutput(content string, args map[string]any) *Result { + lines := strings.Split(content, "\n") + totalLines := len(lines) + + // Parse offset (0-indexed line number). + offset := 0 + if v, ok := args["offset"]; ok { + switch n := v.(type) { + case float64: + offset = int(n) + case int: + offset = n + } + } + if offset < 0 { + offset = 0 + } + if offset >= totalLines { + return SilentResult(fmt.Sprintf("(offset %d exceeds file length of %d lines)", offset, totalLines)) + } + + // Parse limit (max lines to return). + limit := 0 // 0 = no explicit limit + if v, ok := args["limit"]; ok { + switch n := v.(type) { + case float64: + limit = int(n) + case int: + limit = n + } + } + + // Slice lines by offset and limit. + sliced := lines[offset:] + if limit > 0 && limit < len(sliced) { + sliced = sliced[:limit] + } + + output := strings.Join(sliced, "\n") + shownLines := len(sliced) + endLine := offset + shownLines + + // Check output char cap. + runeCount := len([]rune(output)) + if runeCount <= readFileMaxChars { + // Fits within cap — add line info if offset was used or file was partially read. + if offset > 0 || endLine < totalLines { + output += fmt.Sprintf("\n\n[Showing lines %d-%d of %d total]", offset, endLine-1, totalLines) + } + return SilentResult(output) + } + + // Output exceeds cap — truncate at line boundary within budget. + charCount := 0 + truncIdx := len(sliced) + for i, line := range sliced { + charCount += len([]rune(line)) + 1 // +1 for newline + if charCount > readFileMaxChars { + truncIdx = i + break + } + } + if truncIdx < 1 { + truncIdx = 1 + } + + output = strings.Join(sliced[:truncIdx], "\n") + shownLines = truncIdx + nextOffset := offset + shownLines + + output += fmt.Sprintf("\n\n[Output capped. File has %d lines, showed %d (lines %d-%d). Use offset=%d to continue reading.]", + totalLines, shownLines, offset, offset+shownLines-1, nextOffset) + + return SilentResult(output) +} + // allowedWithTeamWorkspace returns the allowed prefixes with team workspace appended // if present in context. Thread-safe: creates a new slice per request. func allowedWithTeamWorkspace(ctx context.Context, base []string) []string { diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 9bc8f35f..9b0cb9ae 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -290,7 +290,7 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu result = "(command completed with no output)" } - return SilentResult(result) + return SilentResult(capExecOutput(result, execMaxOutputChars)) } // executeInSandbox routes a command through a Docker sandbox container. @@ -339,7 +339,7 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe output = "(command completed with no output)" } - return SilentResult(output) + return SilentResult(capExecOutput(output, execMaxOutputChars)) } // limitedBuffer caps output to prevent OOM from runaway commands.