mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -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.
205 lines
7.1 KiB
Go
205 lines
7.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// probeTool records execution overlap and ordering so tests can assert what
|
|
// actually ran concurrently.
|
|
type probeTool struct {
|
|
name string
|
|
sideEffect tools.SideEffect
|
|
delay time.Duration
|
|
// shared, when set, receives this probe's start/end entries too, so a test
|
|
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
|
|
shared *probeLog
|
|
|
|
mu sync.Mutex
|
|
active int
|
|
maxActive int
|
|
log []string
|
|
}
|
|
|
|
// probeLog is a mutex-guarded event log shared between probes.
|
|
type probeLog struct {
|
|
mu sync.Mutex
|
|
entries []string
|
|
}
|
|
|
|
func (log *probeLog) append(entry string) {
|
|
log.mu.Lock()
|
|
defer log.mu.Unlock()
|
|
log.entries = append(log.entries, entry)
|
|
}
|
|
|
|
func (log *probeLog) snapshot() []string {
|
|
log.mu.Lock()
|
|
defer log.mu.Unlock()
|
|
return append([]string(nil), log.entries...)
|
|
}
|
|
|
|
func (tool *probeTool) Name() string { return tool.name }
|
|
func (tool *probeTool) Description() string { return "test probe tool" }
|
|
func (tool *probeTool) Parameters() tools.Schema {
|
|
return tools.Schema{
|
|
Type: "object",
|
|
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
|
AdditionalProperties: false,
|
|
}
|
|
}
|
|
func (tool *probeTool) Safety() tools.Safety {
|
|
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
|
}
|
|
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
|
id, _ := args["id"].(string)
|
|
tool.mu.Lock()
|
|
tool.active++
|
|
if tool.active > tool.maxActive {
|
|
tool.maxActive = tool.active
|
|
}
|
|
tool.log = append(tool.log, "start:"+id)
|
|
tool.mu.Unlock()
|
|
if tool.shared != nil {
|
|
tool.shared.append("start:" + id)
|
|
}
|
|
time.Sleep(tool.delay)
|
|
if tool.shared != nil {
|
|
tool.shared.append("end:" + id)
|
|
}
|
|
tool.mu.Lock()
|
|
tool.active--
|
|
tool.log = append(tool.log, "end:"+id)
|
|
tool.mu.Unlock()
|
|
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
|
}
|
|
|
|
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
|
return []zeroruntime.StreamEvent{
|
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
|
}
|
|
}
|
|
|
|
func TestParallelSafeToolCall(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
|
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
|
|
|
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
|
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
|
t.Fatal("auto-allowed read tool must be parallel-safe")
|
|
}
|
|
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
|
t.Fatal("mutating tool must not be parallel-safe")
|
|
}
|
|
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
|
t.Fatal("unknown tool must not be parallel-safe")
|
|
}
|
|
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
|
t.Fatal("undecodable arguments must not be parallel-safe")
|
|
}
|
|
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
|
t.Fatal("loop-intercepted tools must stay sequential")
|
|
}
|
|
}
|
|
|
|
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
|
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
|
registry := tools.NewRegistry()
|
|
registry.Register(probe)
|
|
|
|
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
|
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
|
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
|
provider := &mockProvider{
|
|
turns: [][]zeroruntime.StreamEvent{
|
|
turnOne,
|
|
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
|
},
|
|
}
|
|
|
|
var results []ToolResult
|
|
_, err := Run(context.Background(), "probe", provider, Options{
|
|
Registry: registry,
|
|
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if probe.maxActive < 2 {
|
|
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
|
}
|
|
// Results must still be recorded in original call order.
|
|
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
|
t.Fatalf("tool results out of order: %#v", results)
|
|
}
|
|
messages := provider.requests[1].Messages
|
|
var toolOrder []string
|
|
for _, message := range messages {
|
|
if message.Role == zeroruntime.MessageRoleTool {
|
|
toolOrder = append(toolOrder, message.ToolCallID)
|
|
}
|
|
}
|
|
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
|
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
|
}
|
|
}
|
|
|
|
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
|
shared := &probeLog{}
|
|
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
|
|
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
|
|
registry := tools.NewRegistry()
|
|
registry.Register(read)
|
|
registry.Register(write)
|
|
|
|
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
|
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
|
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
|
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
|
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
|
provider := &mockProvider{
|
|
turns: [][]zeroruntime.StreamEvent{
|
|
turnOne,
|
|
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
|
},
|
|
}
|
|
|
|
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Cross-probe ordering on the SHARED log: the write must start only after
|
|
// both first-batch reads finished, and both second-batch reads must start
|
|
// only after the write finished — batches never cross a mutating call.
|
|
log := shared.snapshot()
|
|
index := func(entry string) int {
|
|
for i, e := range log {
|
|
if e == entry {
|
|
return i
|
|
}
|
|
}
|
|
t.Fatalf("entry %q missing from shared log: %v", entry, log)
|
|
return -1
|
|
}
|
|
writeStart, writeEnd := index("start:w"), index("end:w")
|
|
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
|
|
t.Fatalf("write started before the first read batch finished: %v", log)
|
|
}
|
|
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
|
t.Fatalf("second read batch started before the write finished: %v", log)
|
|
}
|
|
if read.maxActive < 2 {
|
|
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
|
}
|
|
}
|