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.
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
|
var diagnostics *asyncDiagnostics
|
|
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
|
t.Fatal("nil check must yield a nil collector")
|
|
}
|
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
|
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
|
}
|
|
}
|
|
|
|
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
|
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
|
// (no drive letter), which is exactly what the assertions below check.
|
|
root := t.TempDir()
|
|
var mu sync.Mutex
|
|
var checked []string
|
|
check := func(_ context.Context, absPath string) string {
|
|
mu.Lock()
|
|
checked = append(checked, absPath)
|
|
mu.Unlock()
|
|
if filepath.Base(absPath) == "clean.go" {
|
|
return ""
|
|
}
|
|
return "ERR " + filepath.Base(absPath)
|
|
}
|
|
diagnostics := newAsyncDiagnostics(check, root)
|
|
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
|
|
|
nudge := diagnostics.drain(context.Background())
|
|
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
|
t.Fatalf("nudge missing prefix: %q", nudge)
|
|
}
|
|
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
|
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
|
}
|
|
mu.Lock()
|
|
for _, path := range checked {
|
|
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
|
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
if second := diagnostics.drain(context.Background()); second != "" {
|
|
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
|
}
|
|
}
|
|
|
|
// A check still running at drain time defers delivery to the next drain
|
|
// instead of blocking the turn.
|
|
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
|
previous := asyncDiagnosticsDrainTimeout
|
|
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
|
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
|
|
|
release := make(chan struct{})
|
|
check := func(_ context.Context, absPath string) string {
|
|
<-release
|
|
return "ERR " + filepath.Base(absPath)
|
|
}
|
|
diagnostics := newAsyncDiagnostics(check, "/ws")
|
|
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
|
|
|
start := time.Now()
|
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
|
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > time.Second {
|
|
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
|
}
|
|
|
|
close(release)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
|
if !strings.Contains(nudge, "ERR slow.go") {
|
|
t.Fatalf("deferred nudge = %q", nudge)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("deferred diagnostics never delivered")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Re-enqueueing a file after its check completed replaces the stale result.
|
|
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
|
var mu sync.Mutex
|
|
response := "ERR old"
|
|
check := func(context.Context, string) string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return response
|
|
}
|
|
diagnostics := newAsyncDiagnostics(check, "/ws")
|
|
|
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
|
waitForIdle(t, diagnostics)
|
|
mu.Lock()
|
|
response = "ERR new"
|
|
mu.Unlock()
|
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
|
|
|
nudge := diagnostics.drain(context.Background())
|
|
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
|
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
|
}
|
|
}
|
|
|
|
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
diagnostics.mu.Lock()
|
|
busy := diagnostics.working
|
|
diagnostics.mu.Unlock()
|
|
if busy == nil {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("worker never went idle")
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// changedFilesTool is a fake mutating tool reporting a changed file without
|
|
// touching the filesystem.
|
|
type changedFilesTool struct{}
|
|
|
|
func (changedFilesTool) Name() string { return "fake_edit" }
|
|
func (changedFilesTool) Description() string { return "test mutating tool" }
|
|
func (changedFilesTool) Parameters() tools.Schema {
|
|
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
|
}
|
|
func (changedFilesTool) Safety() tools.Safety {
|
|
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
|
}
|
|
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
|
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
|
}
|
|
|
|
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
|
// result (the old inline path), but the next request contains the background
|
|
// diagnostics nudge, before the model finalizes.
|
|
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(changedFilesTool{})
|
|
provider := &mockProvider{
|
|
turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
},
|
|
}
|
|
root := t.TempDir()
|
|
var checkedPath string
|
|
var mu sync.Mutex
|
|
|
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
|
Registry: registry,
|
|
Cwd: root,
|
|
FileDiagnostics: func(_ context.Context, absPath string) string {
|
|
mu.Lock()
|
|
checkedPath = absPath
|
|
mu.Unlock()
|
|
return "main.go:1:1 error: boom"
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "done" {
|
|
t.Fatalf("final answer = %q", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
|
}
|
|
|
|
// The tool result itself must NOT block on / embed diagnostics.
|
|
for _, message := range provider.requests[1].Messages {
|
|
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
|
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
|
}
|
|
}
|
|
// The nudge must be present in the second request as a user message.
|
|
found := false
|
|
for _, message := range provider.requests[1].Messages {
|
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
|
if !strings.Contains(message.Content, "boom") {
|
|
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
|
}
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("no async diagnostics nudge in the follow-up request")
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
|
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
|
}
|
|
}
|
|
|
|
// When the run hits the maxTurns ceiling right after an edit turn, the
|
|
// final-answer request must still carry the diagnostics nudge — otherwise an
|
|
// error introduced by the last edit would go unreported in the summary.
|
|
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(changedFilesTool{})
|
|
provider := &mockProvider{
|
|
turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
|
Registry: registry,
|
|
Cwd: t.TempDir(),
|
|
MaxTurns: 1,
|
|
FileDiagnostics: func(context.Context, string) string {
|
|
return "main.go:1:1 error: boom"
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "summary" {
|
|
t.Fatalf("final answer = %q", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 2 {
|
|
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
|
}
|
|
found := false
|
|
for _, message := range provider.requests[1].Messages {
|
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
|
}
|
|
}
|
|
|
|
// When the model finalizes while a slow check is still in flight (the
|
|
// per-turn drain missed it), the finalization gate must wait it out and give
|
|
// the model one more turn with the nudge instead of dropping the errors.
|
|
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
|
previous := asyncDiagnosticsDrainTimeout
|
|
asyncDiagnosticsDrainTimeout = time.Millisecond
|
|
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
|
|
|
registry := tools.NewRegistry()
|
|
registry.Register(changedFilesTool{})
|
|
provider := &mockProvider{
|
|
turns: [][]zeroruntime.StreamEvent{
|
|
{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
{
|
|
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
|
{Type: zeroruntime.StreamEventDone},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
|
Registry: registry,
|
|
Cwd: t.TempDir(),
|
|
FileDiagnostics: func(context.Context, string) string {
|
|
// Slower than the (shortened) per-turn drain, well under the
|
|
// finalization budget.
|
|
time.Sleep(100 * time.Millisecond)
|
|
return "main.go:1:1 error: boom"
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.FinalAnswer != "done after fix" {
|
|
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
|
}
|
|
if len(provider.requests) != 3 {
|
|
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
|
}
|
|
found := false
|
|
for _, message := range provider.requests[2].Messages {
|
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
|
if !strings.Contains(message.Content, "boom") {
|
|
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
|
}
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
|
}
|
|
}
|