mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat: agent quality, caching, retry, and tooling upgrades Ten self-contained improvements to the core agent engine and tools: - edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness protection and a disproportion guard) when old_string does not match byte-for-byte; exact match remains the fast path - edit_file/write_file: inline error-severity LSP diagnostics in tool output so the model sees a break it introduced in the same turn - lsp: expand server registry from 4 to 28 languages; doctor grades only tier-1 servers and lists the long tail informationally - agent: consecutive read-only tool calls execute concurrently (bounded at 8), consumed in original order, never spanning a mutating call - anthropic: cache_control breakpoints on the last two conversation messages so the transcript prefix stays a prompt-cache hit - providerio: retry schedule raised to 6 attempts with exponential 2s-30s backoff when the server sends no Retry-After - tools: truncated exec/bash output spills to a redacted temp file referenced in the truncation notice; bash output (previously unbounded) now shares exec_command's token budget - web_fetch: dependency-free HTML-to-markdown conversion (format: auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s) - modelregistry: live models.dev overlay refreshes context limits and base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint - tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard formatters across 27 extensions, ordered before the file-tracker re-baseline so conflict detection is preserved * fix: address PR review feedback on fuzzy edits, diagnostics, spill - edit_file: adapt the replacement to the fuzzy-resolved span — when the span is a uniformly deeper-indented copy of old_string (line-trimmed / indentation-flexible matches), the same indentation delta is applied to new_string, and a span ending in a bare CR keeps its CR so CRLF files never gain a mixed line ending; block-anchor matches with drifted interiors are deliberately left untouched - diagnostics: render workspace-relative paths in inline post-edit diagnostics (base name outside the workspace) so absolute paths never leak the local username or directory layout into the model prompt or session transcript - spill: harden the spill directory for shared temp dirs — per-uid name, reject symlinked or non-directory paths (MkdirAll follows symlinks), and on Unix require current-user ownership - bash: the branch's output cap and truncation-flag threading are superseded by main's capture-time bounding (boundedBuffer + budgetBashCapture), which already sets Result.Truncated; this rebase adopts that design and keeps spill-to-disk on exec_command, where the full output is still buffered and a spill is genuinely complete * fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability - agent: serialize OnPermission behind the same mutex as OnPermissionRequest in the parallel read batch. buildPermissionEvent fires for batched reads with a non-trivial sandbox decision (e.g. a grant-matched extra root), and front-end handlers append to shared session-recording state without their own locking — two batched reads emitting concurrently was a data race the pre-batch serial loop never allowed. - edit_file: reject two or more DISTINCT candidate spans from one fuzzy strategy as ambiguous instead of silently editing the first. Duplicate blocks at different indentation each resolve to a unique literal, so the previous occurrence-uniqueness check passed and wrote the wrong span. A single candidate occurring at multiple positions still cascades to stricter strategies as before. - tools: make spill files actually reachable by the recovery path the truncation notice advertises — resolveScopedReadPath now recognizes files inside the per-uid spill dir (containment verified after symlink resolution, so a planted link cannot smuggle an out-of-scope file through the gate); read_file/grep can follow the notice instead of re-running the command. * fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order - edit_file: blockAnchorReplacer now returns every span clearing the similarity threshold (best first) instead of silently picking the single most-similar block. Hiding competitors from the cascade's distinct-candidate check could edit the wrong block when two blocks share anchors and both interiors are plausible; one clear winner still resolves, two plausible blocks now report ambiguity (covered by a two-block test that asserts the file is untouched). - agent tests: the parallel write-barrier test now records both probes into one mutex-guarded shared log and asserts the cross-probe ordering it always claimed to (write starts after the first read batch ends; second read batch starts after the write ends). The previous version only compared indices within the read probe's own log, which a regression letting the write overlap a batch could still pass; the stray no-op mutex assignment is gone.
98 lines
3.8 KiB
Go
98 lines
3.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
// Parallel read-ahead for tool batches. When a turn requests several
|
|
// independent lookups (read_file + grep + glob is the common shape), executing
|
|
// them one after another serializes pure I/O waits. A consecutive run of
|
|
// auto-allowed read-only calls is executed concurrently instead; results are
|
|
// then consumed in the original call order, so guard counters, message
|
|
// ordering, abort semantics, and the surface's call/result event pairing are
|
|
// byte-identical to sequential execution. Runs never span a mutating call: a
|
|
// read that follows a write must observe the write, so eligibility is decided
|
|
// per consecutive run, not per batch.
|
|
|
|
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
|
const maxParallelReadTools = 8
|
|
|
|
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
|
// its batch index by the caller.
|
|
type precomputedToolResult struct {
|
|
result ToolResult
|
|
abortErr error
|
|
}
|
|
|
|
// parallelSafeToolCall reports whether call may run concurrently with its
|
|
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
|
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
|
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
|
// and tool_search (mutates the deferred-tool set) stay sequential.
|
|
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
|
switch call.Name {
|
|
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
|
return false
|
|
}
|
|
tool, found := registry.Get(call.Name)
|
|
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
|
return false
|
|
}
|
|
args := map[string]any{}
|
|
if call.Arguments != "" {
|
|
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
return effectivePermission(tool, args) == tools.PermissionAllow
|
|
}
|
|
|
|
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
|
// maxParallelReadTools) and returns results indexed relative to start. All
|
|
// execution-side callbacks that can fire inside executeToolCall are serialized
|
|
// behind one mutex: a permission prompt (a sandbox preflight can demand one
|
|
// even for an auto-allowed read) must never appear twice at once on an
|
|
// interactive front-end, and OnPermission event handlers append to shared
|
|
// session-recording state without their own locking — two batched reads under
|
|
// a granted extra root would otherwise race (the pre-batch serial loop never
|
|
// had two callbacks in flight at once).
|
|
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
|
batchOptions := options
|
|
var callbackMutex sync.Mutex
|
|
if options.OnPermissionRequest != nil {
|
|
inner := options.OnPermissionRequest
|
|
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
|
callbackMutex.Lock()
|
|
defer callbackMutex.Unlock()
|
|
return inner(ctx, request)
|
|
}
|
|
}
|
|
if options.OnPermission != nil {
|
|
inner := options.OnPermission
|
|
batchOptions.OnPermission = func(event PermissionEvent) {
|
|
callbackMutex.Lock()
|
|
defer callbackMutex.Unlock()
|
|
inner(event)
|
|
}
|
|
}
|
|
|
|
results := make([]precomputedToolResult, end-start)
|
|
semaphore := make(chan struct{}, maxParallelReadTools)
|
|
var waitGroup sync.WaitGroup
|
|
for index := start; index < end; index++ {
|
|
waitGroup.Add(1)
|
|
go func(index int) {
|
|
defer waitGroup.Done()
|
|
semaphore <- struct{}{}
|
|
defer func() { <-semaphore }()
|
|
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
|
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
|
}(index)
|
|
}
|
|
waitGroup.Wait()
|
|
return results
|
|
}
|