Skip to content

Commit ea27648

Browse files
KRATOSKRATOS
authored andcommitted
fix(output): apply semantic budgets to captured output
1 parent b9e811e commit ea27648

9 files changed

Lines changed: 229 additions & 36 deletions

File tree

‎internal/tools/bash.go‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,18 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
7171
}
7272

7373
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
74-
return tool.run(ctx, args, nil)
74+
return tool.run(ctx, args, nil, true)
7575
}
7676

7777
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
78-
return tool.run(ctx, args, engine)
78+
return tool.run(ctx, args, engine, true)
7979
}
8080

81-
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
81+
func (tool bashTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
82+
return tool.run(ctx, args, options.Sandbox, false)
83+
}
84+
85+
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
8286
commandText, err := aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
8387
if err != nil {
8488
return errorResult("Error: Invalid arguments for bash: " + err.Error())
@@ -180,7 +184,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
180184
}
181185
}
182186
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
183-
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
187+
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
184188
return Result{
185189
Status: StatusError,
186190
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
@@ -190,7 +194,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
190194
}
191195

192196
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
193-
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
197+
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
194198
if meta[SandboxLikelyDeniedMeta] == "true" {
195199
return Result{
196200
Status: StatusError,
@@ -423,6 +427,28 @@ func budgetBashOutput(stdout string, stderr string, meta map[string]string) (str
423427
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
424428
}
425429

430+
// prepareBashOutput keeps direct callers on the established positional budget.
431+
// Registry calls retain the existing bounded capture but leave final semantic
432+
// reduction and spill creation to the post-redaction registry boundary.
433+
func prepareBashOutput(out string, outTotal int, errStr string, errTotal int, meta map[string]string, directBudget bool) (string, string, bool) {
434+
if directBudget {
435+
return budgetBashCapture(out, outTotal, errStr, errTotal, meta)
436+
}
437+
outText := sectionWithCaptureGap(out, outTotal)
438+
errText := sectionWithCaptureGap(errStr, errTotal)
439+
truncated := outTotal > len(out) || errTotal > len(errStr)
440+
if meta != nil {
441+
meta["raw_bytes"] = strconv.Itoa(outTotal + errTotal)
442+
meta["emitted_bytes"] = strconv.Itoa(len(outText) + len(errText))
443+
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(outText) + len(errText)))
444+
if truncated {
445+
meta["truncated"] = "true"
446+
meta["truncation_reason"] = "capture_budget"
447+
}
448+
}
449+
return outText, errText, truncated
450+
}
451+
426452
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
427453
// and errTotal are the true byte counts (from boundedBuffer.total), which may
428454
// exceed the retained strings when the middle was dropped during capture. Meta's

‎internal/tools/bash_budget_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
3737
}
3838
}
3939

40+
func TestPrepareBashOutputDefersRegistrySemanticBudget(t *testing.T) {
41+
output := "START\n" + strings.Repeat("progress\n", 8_000) + "ERROR: retained\nEND"
42+
registryOutput, _, registryTruncated := prepareBashOutput(output, len(output), "", 0, map[string]string{}, false)
43+
if registryTruncated || registryOutput != output {
44+
t.Fatalf("registry preparation must preserve capture-bounded output for semantic reduction: truncated=%t bytes=%d", registryTruncated, len(registryOutput))
45+
}
46+
directOutput, _, directTruncated := prepareBashOutput(output, len(output), "", 0, map[string]string{}, true)
47+
if !directTruncated || len(directOutput) > bashOutputBudgetBytes+512 {
48+
t.Fatalf("direct path must preserve legacy positional budget: truncated=%t bytes=%d", directTruncated, len(directOutput))
49+
}
50+
}
51+
4052
// Oversized stdout is truncated head+tail: both the first and last lines survive,
4153
// the middle is dropped behind a marker, meta is flagged, and the captured text
4254
// is spilled to a re-readable file.

‎internal/tools/exec_command.go‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -483,11 +483,15 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
483483
}
484484

485485
func (tool execCommandTool) Run(ctx context.Context, args map[string]any) Result {
486-
return tool.run(ctx, args, nil)
486+
return tool.run(ctx, args, nil, true)
487487
}
488488

489489
func (tool execCommandTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
490-
return tool.run(ctx, args, engine)
490+
return tool.run(ctx, args, engine, true)
491+
}
492+
493+
func (tool execCommandTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
494+
return tool.run(ctx, args, options.Sandbox, false)
491495
}
492496

493497
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
@@ -502,7 +506,7 @@ func (tool execCommandTool) StopAllExecSessions() []int {
502506
return tool.manager.stopAll()
503507
}
504508

505-
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
509+
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
506510
commandText, err := aliasedStringArg(args, []string{"cmd", "command", "script", "shell"}, "", true, false)
507511
if err != nil {
508512
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
@@ -558,7 +562,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
558562
if exited {
559563
tool.manager.remove(session.id)
560564
}
561-
return execToolResult(execToolResultInput{
565+
return execToolResultWithBudget(execToolResultInput{
562566
commandText: commandText,
563567
output: output,
564568
outputBufferTruncated: outputTruncated,
@@ -569,7 +573,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
569573
tty: session.tty,
570574
plan: session.plan,
571575
maxOutputTokens: maxOutputTokens,
572-
})
576+
}, directBudget)
573577
}
574578

575579
func (tool execCommandTool) startSession(commandText string, absoluteCwd string, relativeCwd string, ttyRequested bool, engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) (*execSession, error) {
@@ -855,7 +859,15 @@ type execToolResultInput struct {
855859
}
856860

857861
func execToolResult(input execToolResultInput) Result {
858-
output, truncated := truncateExecOutput(input.output, input.maxOutputTokens)
862+
return execToolResultWithBudget(input, true)
863+
}
864+
865+
func execToolResultWithBudget(input execToolResultInput, directBudget bool) Result {
866+
output := input.output
867+
truncated := false
868+
if directBudget {
869+
output, truncated = truncateExecOutput(input.output, input.maxOutputTokens)
870+
}
859871
meta := map[string]string{
860872
"cwd": input.relativeCwd,
861873
"tty": strconv.FormatBool(input.tty),

‎internal/tools/output_boundary.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,50 @@ func applyRegistryOutputBudget(tool Tool, toolName string, args map[string]any,
3434
return result
3535
}
3636

37+
// applySelfManagedOutputBudget applies semantic retention to tools that retain
38+
// output with their own capture-aware limits. Their explicit budgets remain
39+
// authoritative, rather than being replaced by the registry default ceiling.
40+
func applySelfManagedOutputBudget(tool Tool, toolName string, args map[string]any, result Result) Result {
41+
budget := selfManagedOutputBudget(toolName, args)
42+
if budget.maxEstimatedTokens <= 0 && budget.hardMaxBytes <= 0 {
43+
return annotateSelfBudgetedOutput(tool, toolName, args, result)
44+
}
45+
46+
category := resolveOutputCategory(tool, toolName, args)
47+
budgeted := budgetSemanticOutput(result.Output, category, budget)
48+
if result.Truncated && !budgeted.truncated {
49+
budgeted.truncated = true
50+
budgeted.reason = result.Meta["truncation_reason"]
51+
if budgeted.reason == "" {
52+
budgeted.reason = "upstream_tool_budget"
53+
}
54+
}
55+
if budgeted.truncated {
56+
budgeted = attachExistingSpill(toolName, result.Output, budget, budgeted)
57+
}
58+
result.Output = budgeted.text
59+
result.Truncated = result.Truncated || budgeted.truncated
60+
result.Meta = addOutputBudgetMetadata(result.Meta, budgeted)
61+
return result
62+
}
63+
64+
func selfManagedOutputBudget(toolName string, args map[string]any) outputBudget {
65+
switch toolName {
66+
case "bash":
67+
// bash previously exposed at most 32 KiB from each stream. Keep that
68+
// combined 64 KiB ceiling while selecting its contents semantically.
69+
return outputBudget{maxEstimatedTokens: (bashOutputBudgetBytes * 2) / 4, hardMaxBytes: bashOutputBudgetBytes * 2}
70+
case ExecCommandToolName:
71+
maxOutputTokens := defaultMaxOutputTokens
72+
if parsed, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest); err == nil {
73+
maxOutputTokens = parsed
74+
}
75+
return outputBudget{maxEstimatedTokens: maxOutputTokens, hardMaxBytes: maxOutputTokens * 4}
76+
default:
77+
return outputBudget{}
78+
}
79+
}
80+
3781
// RebudgetAfterHook reapplies the registry's redaction and output limits after
3882
// an afterTool hook appends model-visible feedback to a completed result. The
3983
// initial registry pass has already run; this second pass is limited to the
@@ -150,6 +194,11 @@ func attachExistingSpill(toolName, output string, budget outputBudget, current b
150194
base.text = text
151195
base.retainedBytes = len(text)
152196
base.estimatedRetainedTokens = estimateOutputTokens(text)
197+
base.truncated = true
198+
if base.reason == "" {
199+
base.reason = current.reason
200+
}
201+
base.category = current.category
153202
base.spillPath = path
154203
return base
155204
}

‎internal/tools/output_boundary_test.go‎

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ func TestRegistryLargeSingleLineFileRetainsHeadAndTail(t *testing.T) {
163163
if len(result.Output) > readOutputBudgetBytes || !utf8.ValidString(result.Output) {
164164
t.Fatalf("single-line file output is not safely bounded: bytes=%d valid=%t", len(result.Output), utf8.ValidString(result.Output))
165165
}
166+
if rawBytes, err := strconv.Atoi(result.Meta["raw_bytes"]); err != nil || rawBytes <= len(result.Output) {
167+
t.Fatalf("single-line file capture was not bounded head/tail output: raw=%q emitted=%d", result.Meta["raw_bytes"], len(result.Output))
168+
}
166169
for _, want := range []string{"File: large.min.js", "HEAD_SINGLE_LINE_MARK", "TAIL_SINGLE_LINE_MARK"} {
167170
if !strings.Contains(result.Output, want) {
168171
t.Fatalf("single-line file output missing %q", want)
@@ -235,13 +238,72 @@ func TestShellOutputCategoryClassification(t *testing.T) {
235238
}
236239
}
237240

238-
func TestSelfBudgetedToolDeclaresSemanticCategoryWithoutRebudgeting(t *testing.T) {
241+
func TestSelfManagedOutputBudgetUsesSemanticTestPolicy(t *testing.T) {
242+
setTestTempDir(t)
243+
input := "stdout:\n" + strings.Repeat("PASS progress\n", 8_000) +
244+
"--- FAIL: TestImportant (0.01s)\nthing_test.go:42: expected 7, got 9\nFAIL\nexit_code: 1"
245+
tests := []struct {
246+
name string
247+
tool Tool
248+
args map[string]any
249+
}{
250+
{name: "bash", tool: NewBashTool(t.TempDir()), args: map[string]any{"command": "go test ./..."}},
251+
{name: "exec", tool: NewExecCommandTool(t.TempDir(), newExecSessionManager()), args: map[string]any{"cmd": "go test ./...", "max_output_tokens": 16_000}},
252+
}
253+
for _, test := range tests {
254+
t.Run(test.name, func(t *testing.T) {
255+
got := applySelfManagedOutputBudget(test.tool, test.tool.Name(), test.args, Result{Status: StatusError, Output: input, Meta: map[string]string{}})
256+
if !got.Truncated || got.Meta[outputBudgetCategoryMeta] != string(outputCategoryTest) {
257+
t.Fatalf("semantic budget was not applied: %#v", got)
258+
}
259+
for _, want := range []string{"TestImportant", "expected 7", "FAIL", "exit_code: 1"} {
260+
if !strings.Contains(got.Output, want) {
261+
t.Fatalf("semantic test output missing %q:\n%s", want, got.Output)
262+
}
263+
}
264+
})
265+
}
266+
}
267+
268+
type semanticBashFakeTool struct{ ceilingFakeTool }
269+
270+
func (semanticBashFakeTool) managesOutputBudget() {}
271+
272+
func (semanticBashFakeTool) outputCategory(args map[string]any) outputCategory {
273+
command, _ := args["command"].(string)
274+
return shellOutputCategory(command)
275+
}
276+
277+
func TestRegistryAppliesSemanticBudgetToSelfManagedShellOutput(t *testing.T) {
278+
setTestTempDir(t)
279+
input := "startup\n" + strings.Repeat("PASS progress\n", 8_000) +
280+
"--- FAIL: TestRegistryImportant (0.01s)\nexpected 7, got 9\nFAIL\nexit_code: 1"
281+
registry := NewRegistry()
282+
registry.Register(semanticBashFakeTool{newCeilingFakeTool("bash", input)})
283+
result := registry.Run(context.Background(), "bash", map[string]any{"command": "go test ./..."})
284+
if !result.Truncated || result.Meta[outputBudgetReasonMeta] != "semantic_test_budget" {
285+
t.Fatalf("registry did not apply semantic test budget: %#v", result.Meta)
286+
}
287+
for _, want := range []string{"TestRegistryImportant", "expected 7", "FAIL", "exit_code: 1"} {
288+
if !strings.Contains(result.Output, want) {
289+
t.Fatalf("registry semantic test output missing %q:\n%s", want, result.Output)
290+
}
291+
}
292+
}
293+
294+
func TestRegistryAppliesSemanticBudgetToSelfManagedProcessOutput(t *testing.T) {
295+
setTestTempDir(t)
296+
input := "starting build\n" + strings.Repeat("progress\n", 8_000) +
297+
"WARNING: cache is cold\nERROR: compilation failed\nexit_code: 1"
239298
registry := NewRegistry()
240-
registry.Register(NewBashTool(t.TempDir()))
241-
tool, _ := registry.Get("bash")
242-
result := Result{Status: StatusOK, Output: "already bounded", Meta: map[string]string{}}
243-
got := annotateSelfBudgetedOutput(tool, "bash", map[string]any{"command": "go test ./..."}, result)
244-
if got.Output != result.Output || got.Meta[outputBudgetCategoryMeta] != string(outputCategoryTest) {
245-
t.Fatalf("self-budgeted category annotation changed output or lost category: %#v", got)
299+
registry.Register(semanticBashFakeTool{newCeilingFakeTool("bash", input)})
300+
result := registry.Run(context.Background(), "bash", map[string]any{"command": "make build"})
301+
if !result.Truncated || result.Meta[outputBudgetReasonMeta] != "semantic_process_budget" {
302+
t.Fatalf("registry did not apply semantic process budget: %#v", result.Meta)
303+
}
304+
for _, want := range []string{"starting build", "WARNING", "ERROR", "exit_code: 1"} {
305+
if !strings.Contains(result.Output, want) {
306+
t.Fatalf("registry semantic process output missing %q:\n%s", want, result.Output)
307+
}
246308
}
247309
}

‎internal/tools/output_budget.go‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ func estimatedTokensFromBytes(bytes int) int {
218218

219219
type outputBudgetBuilder struct {
220220
builder strings.Builder
221+
capture *boundedBuffer
221222
rawBytes int
222223
maxBytes int
223224
hint string
@@ -227,8 +228,24 @@ func newOutputBudgetBuilder(maxBytes int, hint string) *outputBudgetBuilder {
227228
return &outputBudgetBuilder{maxBytes: maxBytes, hint: hint}
228229
}
229230

231+
// newHeadTailOutputBudgetBuilder bounds capture while retaining both ends of
232+
// an input for a later semantic policy. It is used for registry-driven file
233+
// reads; direct Tool.Run calls keep the legacy prefix-only builder above.
234+
func newHeadTailOutputBudgetBuilder(maxBytes int, hint string) *outputBudgetBuilder {
235+
headBytes := maxBytes / 2
236+
return &outputBudgetBuilder{
237+
capture: newBoundedBuffer(headBytes, maxBytes-headBytes),
238+
maxBytes: maxBytes,
239+
hint: hint,
240+
}
241+
}
242+
230243
func (builder *outputBudgetBuilder) WriteString(value string) {
231244
builder.rawBytes += len(value)
245+
if builder.capture != nil {
246+
_, _ = builder.capture.Write([]byte(value))
247+
return
248+
}
232249
if builder.maxBytes <= 0 {
233250
builder.builder.WriteString(value)
234251
return
@@ -262,6 +279,9 @@ func applyLegacyByteBudgetToResult(result Result, maxBytes int, hint string) Res
262279

263280
func (builder *outputBudgetBuilder) Result() outputBudgetResult {
264281
output := builder.builder.String()
282+
if builder.capture != nil {
283+
output = builder.capture.retained()
284+
}
265285
result := outputBudgetResult{
266286
Output: output,
267287
RawBytes: builder.rawBytes,
@@ -276,7 +296,12 @@ func (builder *outputBudgetBuilder) Result() outputBudgetResult {
276296
if budget < 0 {
277297
budget = 0
278298
}
279-
result.Output = utf8Prefix(output, budget) + marker
299+
if builder.capture != nil {
300+
headBytes := budget / 2
301+
result.Output = utf8Prefix(output, headBytes) + marker + utf8Suffix(output, budget-headBytes)
302+
} else {
303+
result.Output = utf8Prefix(output, budget) + marker
304+
}
280305
result.Truncated = true
281306
result.EmittedBytes = len(result.Output)
282307
return result

‎internal/tools/output_ceiling.go‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,17 @@ const defaultOutputCeilingTokens = 16_000
2525
// disables the ceiling entirely; unset or unparsable keeps the default.
2626
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
2727

28-
// selfBudgeting marks a tool that enforces its own deliberate output budget —
29-
// possibly model-raisable (exec_command) — which the registry ceiling must not
30-
// second-guess. The method is unexported on purpose: only tools in this
31-
// package can opt out, so an MCP-served tool can never exempt itself.
28+
// selfBudgeting marks a tool with a deliberate capture-aware output budget —
29+
// possibly model-raisable (exec_command). The registry applies semantic
30+
// retention within that explicit budget rather than replacing it with the
31+
// default ceiling. The method is unexported so an MCP-served tool cannot opt
32+
// into this path.
3233
type selfBudgeting interface{ managesOutputBudget() }
3334

34-
// The exemption list, kept in one place. Shell/process tools retain their
35-
// established capture-aware budgets: bash (per-stream budget + spill) and
36-
// exec_command (model-raisable budget + spill). File/search tools now use the
37-
// shared post-redaction semantic boundary.
35+
// The list is kept in one place. Shell/process tools retain their established
36+
// capture-aware budgets: bash (per-stream bounded capture) and exec_command
37+
// (model-raisable bounded session output). File/search tools use the standard
38+
// shared post-redaction boundary.
3839
func (bashTool) managesOutputBudget() {}
3940
func (execCommandTool) managesOutputBudget() {}
4041

0 commit comments

Comments
 (0)