mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01: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.
186 lines
6.4 KiB
Go
186 lines
6.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
|
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
|
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
|
// the largest recurring latency tax in an edit-heavy session. Collection is
|
|
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
|
// single worker checks it while the rest of the tool batch (and any
|
|
// self-correct verification) runs, and the loop drains completed results just
|
|
// before building the NEXT provider request, appending errors as a user-role
|
|
// nudge. The model always issues another request after an edit turn — it has
|
|
// to read its tool results — so it still sees introduced errors at the same
|
|
// decision point as before, without any tool call stalling 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); a file re-edited after its check completed
|
|
// is re-enqueued and the newer result wins. When the worker is still busy at
|
|
// drain time, the drain gives up after a short wait and the results are
|
|
// delivered on the following turn instead — they stay accurate because any
|
|
// further edit re-enqueues the file.
|
|
|
|
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
|
// check before deferring delivery to the next turn. Most checks finish well
|
|
// under it (debounce floor + analysis); a cold language server on a large
|
|
// repo does not get to stall the loop the way the old inline 10s cap could.
|
|
// A var so tests can shorten the wait.
|
|
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
|
|
|
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
|
// FINALIZE (natural completion or the max-turns summary): there is no later
|
|
// turn to defer to, so the gate waits out the old inline-era budget rather
|
|
// than silently dropping an error the last edit introduced. A var so tests
|
|
// can shorten the wait.
|
|
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
|
|
|
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
|
// the old inline block so the model reacts the same way.
|
|
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
|
|
|
type asyncDiagnostics struct {
|
|
check func(context.Context, string) string
|
|
workspaceRoot string
|
|
|
|
mu sync.Mutex
|
|
queue []string
|
|
queued map[string]bool
|
|
results map[string]string
|
|
// working is non-nil while the worker goroutine runs and is closed when it
|
|
// exits, so drain can wait for quiescence without polling.
|
|
working chan struct{}
|
|
}
|
|
|
|
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
|
// receiver is a no-op — callers wire it unconditionally.
|
|
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
|
if check == nil {
|
|
return nil
|
|
}
|
|
return &asyncDiagnostics{
|
|
check: check,
|
|
workspaceRoot: workspaceRoot,
|
|
queued: map[string]bool{},
|
|
results: map[string]string{},
|
|
}
|
|
}
|
|
|
|
// enqueue schedules changed files for a background check. Paths are
|
|
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
|
// extra write root), mirroring SelfCorrector's resolution.
|
|
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
|
if diagnostics == nil || len(files) == 0 {
|
|
return
|
|
}
|
|
diagnostics.mu.Lock()
|
|
defer diagnostics.mu.Unlock()
|
|
for _, file := range files {
|
|
path := diagnostics.absPath(file)
|
|
if diagnostics.queued[path] {
|
|
continue
|
|
}
|
|
diagnostics.queued[path] = true
|
|
// A completed result for this file describes a pre-edit state now.
|
|
delete(diagnostics.results, path)
|
|
diagnostics.queue = append(diagnostics.queue, path)
|
|
}
|
|
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
|
done := make(chan struct{})
|
|
diagnostics.working = done
|
|
go diagnostics.work(ctx, done)
|
|
}
|
|
}
|
|
|
|
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
|
defer close(done)
|
|
for {
|
|
diagnostics.mu.Lock()
|
|
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
|
diagnostics.working = nil
|
|
diagnostics.mu.Unlock()
|
|
return
|
|
}
|
|
path := diagnostics.queue[0]
|
|
diagnostics.queue = diagnostics.queue[1:]
|
|
// Un-mark before checking so an edit that lands DURING the check
|
|
// re-enqueues the file and it is checked again in its final state.
|
|
delete(diagnostics.queued, path)
|
|
diagnostics.mu.Unlock()
|
|
|
|
block := diagnostics.check(ctx, path)
|
|
|
|
diagnostics.mu.Lock()
|
|
if block != "" {
|
|
diagnostics.results[path] = block
|
|
} else {
|
|
delete(diagnostics.results, path)
|
|
}
|
|
diagnostics.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// drain waits briefly for the worker to go quiet, then formats and consumes
|
|
// all completed error blocks as one nudge. Returns "" when there is nothing
|
|
// to report or the worker is still busy (results then surface next turn).
|
|
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
|
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
|
}
|
|
|
|
// drainFinal is drain with the finalization budget: called before the run's
|
|
// last model request, where an undelivered result would otherwise be lost.
|
|
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
|
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
|
}
|
|
|
|
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
|
if diagnostics == nil {
|
|
return ""
|
|
}
|
|
diagnostics.mu.Lock()
|
|
busy := diagnostics.working
|
|
diagnostics.mu.Unlock()
|
|
if busy != nil {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-busy:
|
|
case <-timer.C:
|
|
return ""
|
|
case <-ctx.Done():
|
|
return ""
|
|
}
|
|
}
|
|
diagnostics.mu.Lock()
|
|
defer diagnostics.mu.Unlock()
|
|
if len(diagnostics.results) == 0 {
|
|
return ""
|
|
}
|
|
paths := make([]string, 0, len(diagnostics.results))
|
|
for path := range diagnostics.results {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
blocks := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
blocks = append(blocks, diagnostics.results[path])
|
|
}
|
|
diagnostics.results = map[string]string{}
|
|
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
|
}
|
|
|
|
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
|
if filepath.IsAbs(path) {
|
|
return path
|
|
}
|
|
return filepath.Join(diagnostics.workspaceRoot, path)
|
|
}
|