mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tools): universal token-based output ceiling with spill-to-disk
Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.
This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all
Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.
bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.
* perf(agent): collect post-edit LSP diagnostics off the tool-call path
Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.
Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.
A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.
The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.
* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening
- Drain background diagnostics before the max-turns final-answer request
so an error introduced by the LAST turn's edit is reported in the
summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
marker when boundedBuffer dropped the middle of a stream, so a spilled
log never reads as contiguous when it is not; junction position is
asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
spill file content; exemption-list test gains read_minified_file.
* fix(test): use a real workspace root in async diagnostics tests
filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.
* fix: pattern-scrub spill files and gate finalization on pending diagnostics
Review follow-ups (Vasanthdev2004):
- Spill files now pass the pattern-based secret scanner (AWS keys,
GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
configured-key scrub. A bash spill runs before formatBashOutput's
scan, so without this a spilled file held in cleartext exactly the
credentials the transcript redacts. Applied centrally in
spillTruncatedOutput, covering bash, exec_command, and ceiling spills.
- Finalization diagnostics gate: a no-tool-call final answer now drains
pending post-edit diagnostics with the full inline-era budget (10s,
vs the 3s per-turn wait) before the run is accepted; introduced
errors append the nudge and give the model one more turn instead of
being silently dropped when no later turn exists. The max-turns
summary path uses the same finalization budget. Free for runs that
never edited — an idle collector returns immediately.
141 lines
4.9 KiB
Go
141 lines
4.9 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ceilingFakeTool is a minimal tool with no output budget of its own — the
|
|
// stand-in for an MCP-served tool or any future builtin that forgets to cap
|
|
// its output.
|
|
type ceilingFakeTool struct {
|
|
baseTool
|
|
output string
|
|
}
|
|
|
|
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
|
|
return okResult(tool.output)
|
|
}
|
|
|
|
// ceilingExemptTool is the same fake but marked self-budgeting.
|
|
type ceilingExemptTool struct{ ceilingFakeTool }
|
|
|
|
func (ceilingExemptTool) managesOutputBudget() {}
|
|
|
|
func newCeilingFakeTool(name, output string) ceilingFakeTool {
|
|
return ceilingFakeTool{
|
|
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
|
|
output: output,
|
|
}
|
|
}
|
|
|
|
// An unbudgeted tool's oversized output is capped at the universal ceiling,
|
|
// head and tail survive, and the full output is spilled to a re-readable file.
|
|
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
|
|
setTestTempDir(t)
|
|
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
|
|
|
|
registry := NewRegistry()
|
|
registry.Register(newCeilingFakeTool("fake_big", big))
|
|
result := registry.Run(context.Background(), "fake_big", map[string]any{})
|
|
|
|
if !result.Truncated {
|
|
t.Fatal("over-ceiling output must report truncated=true")
|
|
}
|
|
maxBytes := defaultOutputCeilingTokens * 4
|
|
if len(result.Output) > maxBytes+512 {
|
|
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
|
|
}
|
|
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
|
|
t.Fatal("head/tail lost at the ceiling")
|
|
}
|
|
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
|
|
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
|
|
}
|
|
if !strings.Contains(result.Output, "full output saved to ") {
|
|
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
|
|
}
|
|
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
|
|
end := strings.Index(result.Output[start:], " (grep")
|
|
content, err := os.ReadFile(result.Output[start : start+end])
|
|
if err != nil {
|
|
t.Fatalf("spill file unreadable: %v", err)
|
|
}
|
|
if string(content) != big {
|
|
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
|
|
}
|
|
}
|
|
|
|
// A self-budgeting tool's output passes the boundary untouched even when it
|
|
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
|
|
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
|
|
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
|
|
registry := NewRegistry()
|
|
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
|
|
|
|
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
|
|
if result.Truncated || result.Output != big {
|
|
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
|
|
}
|
|
}
|
|
|
|
// Under-ceiling output is untouched and gains no budget metadata.
|
|
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.Register(newCeilingFakeTool("fake_small", "hello"))
|
|
|
|
result := registry.Run(context.Background(), "fake_small", map[string]any{})
|
|
if result.Truncated || result.Output != "hello" {
|
|
t.Fatalf("small output altered: %+v", result)
|
|
}
|
|
if _, ok := result.Meta["raw_bytes"]; ok {
|
|
t.Fatal("small output must not gain budget metadata")
|
|
}
|
|
}
|
|
|
|
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
|
|
func TestOutputCeilingEnvOverride(t *testing.T) {
|
|
setTestTempDir(t)
|
|
big := strings.Repeat("z", 100*1024)
|
|
|
|
t.Setenv(outputCeilingEnv, "1000")
|
|
registry := NewRegistry()
|
|
registry.Register(newCeilingFakeTool("fake_env", big))
|
|
result := registry.Run(context.Background(), "fake_env", map[string]any{})
|
|
if !result.Truncated || len(result.Output) > 1000*4+512 {
|
|
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
|
}
|
|
|
|
t.Setenv(outputCeilingEnv, "0")
|
|
result = registry.Run(context.Background(), "fake_env", map[string]any{})
|
|
if result.Truncated || len(result.Output) != len(big) {
|
|
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
|
}
|
|
}
|
|
|
|
// The exemption list matches the tools that really do budget themselves, and
|
|
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
|
|
func TestSelfBudgetingExemptionList(t *testing.T) {
|
|
dir := t.TempDir()
|
|
exempt := []Tool{
|
|
NewBashTool(dir),
|
|
NewExecCommandTool(dir, newExecSessionManager()),
|
|
NewReadFileTool(dir),
|
|
NewReadMinifiedFileTool(dir),
|
|
NewGrepTool(dir),
|
|
NewGlobTool(dir),
|
|
NewListDirectoryTool(dir),
|
|
}
|
|
for _, tool := range exempt {
|
|
if _, ok := tool.(selfBudgeting); !ok {
|
|
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
|
|
}
|
|
}
|
|
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
|
|
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
|
|
}
|
|
}
|