mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* stage 01: web_search tool Adds a web_search tool alongside web_fetch: discovers URLs via a pluggable searchBackend (env-configured generic JSON backend; swappable for Exa/You/ Tavily/Brave) and formats ranked results as a numbered list. Registered in CoreNetworkTools(). Deviation from the stage spec: the spec said readOnlySafety, but the repo enforces (TestCoreNetworkToolsExposePromptMetadata) that every core network tool is prompt-gated network egress — so web_search matches web_fetch's posture instead of being auto-allowed. Necessary fallout from adding the tool: knownToolNames (+web_search), the network-tools metadata test (now covers both), and a stale 'unknown tool' fixture that had used web_search. * stage 02: LSP client foundation New internal/lsp package: a direct (self-spawned, stdio) Language Server Protocol client so unattended runs see real compiler diagnostics without an open editor. - client.go: JSON-RPC 2.0 with LSP Content-Length framing; concurrent Call/Notify, id-matched responses, server->client request auto-reply, notification handler, context cancellation, read-loop teardown. - server.go: process lifecycle (spawn via os/exec, initialize/initialized handshake, graceful shutdown+exit with kill fallback). Handshake factored out (performInitialize) for pipe-based testing. - registry.go: extension -> server command (.go/.ts/.tsx/.js/.jsx/.py/.rs), languageId map, PATH availability via exec.LookPath. - types.go: minimal LSP types + PathToURI/URIToPath (Windows-drive aware). Stdlib-only; tested over in-memory pipes with a stub server (no real gopls), including -race for the concurrent id router. * stage 03: LSP document sync + diagnostics Makes the LSP client produce actionable signal so ZERO can ask 'did my edit compile?' headlessly. - documents.go: per-server session with document lifecycle (didOpen/didChange full-sync/didClose), a URI->diagnostics store fed by publishDiagnostics, and WaitForDiagnostics with a publish-baseline + 300ms quiet debounce (servers never signal 'analysis complete'). - diagnostics.go: FormatDiagnostics (path:line:col: severity: message, 1-based), HasErrors, FilterBySeverity, severity labels. - manager.go: Manager — one lazily-started, reused server per language (keyed by binary), routes a file to the right server, Check(ctx,path,text) -> settled diagnostics, graceful Shutdown; unknown extension -> (nil,nil), not an error. Concurrency-safe; tested under -race over in-memory pipes with a stub server (no real gopls). FormatDiagnostics takes the path explicitly since a Diagnostic carries only a range, not its document. * wedge: address review on #175 (LSP robustness + web_search provider) - lsp/client.go: Close() now disables the client — Call/Notify reject after close instead of leaking a pending entry / writing to a dead conn. - lsp/documents.go: serialize same-document syncs through the Notify write via a per-URI lock and commit version/open only on success; drop delayed publishDiagnostics for a superseded (older positive) version; WaitForDiagnostics now reports whether a fresh publish arrived. - lsp/manager.go: Check returns (nil,nil) when no publish newer than baseline arrives (don't return a stale prior result for new text); Shutdown joins and returns per-server shutdown errors instead of always reporting success. - lsp/diagnostics.go: FormatDiagnostics collapses embedded whitespace so each diagnostic stays one physical line. - tools/web_search.go: forward ZERO_WEBSEARCH_PROVIDER in the request so the config knob isn't inert. Adds tests: close-disables-client, stale-version drop, multi-line message collapse, Shutdown error propagation, httptest backend (provider+parse). * wedge: degrade gracefully when an LSP server binary is missing (#175 review) Manager.Check translated a missing-binary start error (exec.ErrNotFound) into (nil,nil) so a configured extension on a machine without gopls/ pyright/rust-analyzer degrades exactly like an unsupported extension — LSP diagnostics are an opportunistic layer, not a hard dependency. Other start failures still surface. Adds a test injecting an exec.ErrNotFound starter. * wedge stage 04: self-correct loop (#176) * stage 04: self-correct loop After a successful mutating tool call, verify the changed files (LSP diagnostics + project tests) and feed failures back to the model to fix — bounded and autonomy-gated, so an unattended run fixes its own mistakes without looping forever. - internal/agent/selfcorrect.go: SelfCorrector with injectable diagnosticsChecker (lsp.Manager adapter) and projectVerifier (verify.DetectPlan+Run); AfterEdit produces a redacted, model-facing corrective message; per-run attempt ceiling; low autonomy reports without auto-attempting; medium/high auto-correct. - internal/agent/loop.go: invoke AfterEdit after a successful mutating tool call (ChangedFiles non-empty), appending feedback to messages. nil SelfCorrect (the default) is a no-op — read-only tools never trigger it. - internal/agent/types.go: add Options.SelfCorrect *SelfCorrector (the explicit options-struct change). Single-pass verify per call (the agent loop + model edits provide the multi- attempt cycle, not selfverify's same-command re-runs). Tested with fakes (no gopls/shell): fail-then-pass, max-attempts abort, read-only skip, low-autonomy report-only, LSP-only failure, secret redaction; -race clean. CLI activation (a --self-correct flag building the SelfCorrector in exec.go) is intentionally left out to honor this stage's file scope — the engine is exported and ready to wire. * wedge: propagate file-read error in self-correct LSP checker (#176 review) CodeRabbit/golangci-lint (nilerr) flagged lspDiagnosticsChecker.Check returning (nil, nil) after os.ReadFile fails. Propagate the error instead; the sole caller (inspect) already skips files whose Check errors, so a deleted/unreadable file still degrades quietly while future callers can decide. Adds a regression test. * wedge: surface non-degradable verify errors in self-correct (#175 review) SelfCorrector.inspect silently continued on every checker.Check error and ignored verifier errors, so a real LSP startup/sync failure, an unreadable changed file, or a failed plan detection produced report.Failed=false -> OutcomePassed with no signal. Manager.Check already degrades missing servers to (nil,nil) and DetectPlan returns an empty plan (not an error) when nothing is detected, so a non-nil error from either is a genuine failure. Record such errors in CorrectionReport.InspectErrors, fail the pass, and surface them in the model feedback. Adds regression tests for both paths. * wedge: drop third-party search-provider names from web_search comments Keep code/comments provider-neutral: describe the backend interface as 'any hosted search API' rather than naming specific services. * wedge: defer self-correct feedback until after the tool-call batch (#175 review) Self-correct feedback was appended as a user message inside the per-tool-call loop, so a multi-tool-call turn whose first (mutating) call failed verification produced: assistant tool_use batch -> tool_result 1 -> user feedback -> tool_result 2. A user message interleaved between tool_results breaks strict provider replay and later session replay/compaction. Accumulate feedback during the batch and append it once after the loop (same after-batch pattern already used for mid-run model escalation), so every advertised tool call keeps its tool_result contiguous. Adds a two-tool-call regression test asserting the feedback lands after both tool_results. * wedge: batch self-correct AfterEdit once per turn over union of changes (#175 review) Deferring only the feedback emission still ran SelfCorrect.AfterEdit once per mutating tool call, which (a) consumes the per-run attempt budget multiple times in a single turn and (b) verifies an intermediate edit that a later call in the same batch already superseded — AfterEdit is documented as one per-run instance holding attempt state, so this is a correctness issue, not just transcript shape. Aggregate every mutating tool's ChangedFiles during the batch and call AfterEdit once afterward over the deduped union, appending its feedback once (still after all tool_results, preserving contiguity). Adds dedupeStrings and a regression test asserting two writes in one turn consume exactly one attempt and emit one feedback message.
233 lines
7.9 KiB
Go
233 lines
7.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/lsp"
|
|
"github.com/Gitlawb/zero/internal/verify"
|
|
)
|
|
|
|
type fakeVerifier struct {
|
|
reports []verify.Report
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeVerifier) Verify(context.Context) (verify.Report, error) {
|
|
report := verify.Report{OK: true}
|
|
switch {
|
|
case f.calls < len(f.reports):
|
|
report = f.reports[f.calls]
|
|
case len(f.reports) > 0:
|
|
report = f.reports[len(f.reports)-1]
|
|
}
|
|
f.calls++
|
|
return report, nil
|
|
}
|
|
|
|
type fakeChecker struct {
|
|
byPath map[string][]lsp.Diagnostic
|
|
}
|
|
|
|
func (f fakeChecker) Check(_ context.Context, path string) ([]lsp.Diagnostic, error) {
|
|
return f.byPath[path], nil
|
|
}
|
|
|
|
type erroringChecker struct{ err error }
|
|
|
|
func (e erroringChecker) Check(context.Context, string) ([]lsp.Diagnostic, error) {
|
|
return nil, e.err
|
|
}
|
|
|
|
type erroringVerifier struct{ err error }
|
|
|
|
func (e erroringVerifier) Verify(context.Context) (verify.Report, error) {
|
|
return verify.Report{}, e.err
|
|
}
|
|
|
|
func TestSelfCorrectSurfacesCheckerErrorInsteadOfPassing(t *testing.T) {
|
|
// Manager.Check degrades missing servers to (nil, nil); a real error (LSP
|
|
// startup/sync failure or unreadable file) must fail the pass, not be swallowed
|
|
// into OutcomePassed.
|
|
sc := NewSelfCorrector("/root", erroringChecker{err: errors.New("lsp server crashed")}, nil, SelfCorrectConfig{
|
|
Enabled: true,
|
|
IncludeLSP: true,
|
|
Autonomy: "high",
|
|
})
|
|
feedback, outcome := sc.AfterEdit(context.Background(), []string{"a.go"})
|
|
if outcome != OutcomeCorrecting {
|
|
t.Fatalf("outcome = %q, want %q (a checker error must not pass)", outcome, OutcomeCorrecting)
|
|
}
|
|
if !strings.Contains(feedback, "lsp server crashed") {
|
|
t.Fatalf("feedback should surface the checker error, got: %q", feedback)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectSurfacesVerifierErrorInsteadOfPassing(t *testing.T) {
|
|
// DetectPlan returns an empty plan (not an error) when no tests exist, so a
|
|
// non-nil Verify error means verification could not run — it must fail the pass.
|
|
sc := NewSelfCorrector("/root", nil, erroringVerifier{err: errors.New("plan detection failed")}, SelfCorrectConfig{
|
|
Enabled: true,
|
|
IncludeTests: true,
|
|
Autonomy: "high",
|
|
})
|
|
feedback, outcome := sc.AfterEdit(context.Background(), []string{"a.go"})
|
|
if outcome != OutcomeCorrecting {
|
|
t.Fatalf("outcome = %q, want %q (a verifier error must not pass)", outcome, OutcomeCorrecting)
|
|
}
|
|
if !strings.Contains(feedback, "plan detection failed") {
|
|
t.Fatalf("feedback should surface the verifier error, got: %q", feedback)
|
|
}
|
|
}
|
|
|
|
func TestLSPDiagnosticsCheckerPropagatesReadError(t *testing.T) {
|
|
// A read failure (e.g. the edit deleted the file) must propagate so callers
|
|
// can decide how to handle it, rather than being swallowed as "no diagnostics".
|
|
// manager is never reached on a read error, so a nil manager is fine here.
|
|
c := lspDiagnosticsChecker{}
|
|
if _, err := c.Check(context.Background(), "/nonexistent/zero-selfcorrect/missing.go"); err == nil {
|
|
t.Fatal("expected Check to propagate the file-read error, got nil")
|
|
}
|
|
}
|
|
|
|
func failingReport(stderr string) verify.Report {
|
|
return verify.Report{
|
|
OK: false,
|
|
Results: []verify.Result{{
|
|
Name: "go test",
|
|
Command: []string{"go", "test", "./..."},
|
|
Status: verify.StatusFail,
|
|
Stderr: stderr,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func newTestCorrector(checker diagnosticsChecker, verifier projectVerifier, autonomy string, maxAttempts int) *SelfCorrector {
|
|
return NewSelfCorrector("", checker, verifier, SelfCorrectConfig{
|
|
Enabled: true,
|
|
IncludeLSP: checker != nil,
|
|
IncludeTests: verifier != nil,
|
|
Autonomy: autonomy,
|
|
MaxAttempts: maxAttempts,
|
|
})
|
|
}
|
|
|
|
func TestSelfCorrectPassesWithNoFailures(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{{OK: true}}}
|
|
sc := newTestCorrector(nil, v, "high", 3)
|
|
if fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"}); fb != "" || oc != OutcomePassed {
|
|
t.Fatalf("expected passed/no-feedback, got %q / %s", fb, oc)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectFailsThenPasses(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("--- FAIL: TestX"), {OK: true}}}
|
|
sc := newTestCorrector(nil, v, "high", 3)
|
|
|
|
fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if oc != OutcomeCorrecting || !strings.Contains(fb, "Fix these and continue") || !strings.Contains(fb, "go test") {
|
|
t.Fatalf("round 1 = %q / %s", fb, oc)
|
|
}
|
|
fb2, oc2 := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if oc2 != OutcomePassed || fb2 != "" {
|
|
t.Fatalf("round 2 should pass cleanly, got %q / %s", fb2, oc2)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectAbortsAtMaxAttempts(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("boom")}} // always fails
|
|
sc := newTestCorrector(nil, v, "high", 2)
|
|
|
|
for i := 1; i <= 2; i++ {
|
|
if _, oc := sc.AfterEdit(context.Background(), []string{"main.go"}); oc != OutcomeCorrecting {
|
|
t.Fatalf("attempt %d outcome = %s, want correcting", i, oc)
|
|
}
|
|
}
|
|
fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if oc != OutcomeAborted {
|
|
t.Fatalf("third round outcome = %s, want aborted", oc)
|
|
}
|
|
if !strings.Contains(fb, "stopping auto-correction") {
|
|
t.Fatalf("abort notice = %q", fb)
|
|
}
|
|
if sc.attempts != 2 {
|
|
t.Fatalf("correction attempts exceeded the ceiling: %d", sc.attempts)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectSkipsWhenNoChangedFiles(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("x")}}
|
|
sc := newTestCorrector(nil, v, "high", 3)
|
|
if fb, oc := sc.AfterEdit(context.Background(), nil); fb != "" || oc != OutcomeDisabled {
|
|
t.Fatalf("read-only (no changes) should skip, got %q / %s", fb, oc)
|
|
}
|
|
if v.calls != 0 {
|
|
t.Fatal("verification must not run when nothing changed")
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectLowAutonomyReportsButDoesNotAttempt(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("FAIL")}}
|
|
sc := newTestCorrector(nil, v, "low", 3)
|
|
fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if oc != OutcomeReported {
|
|
t.Fatalf("low autonomy outcome = %s, want reported", oc)
|
|
}
|
|
if !strings.Contains(fb, "reporting only") {
|
|
t.Fatalf("expected a reporting-only message, got %q", fb)
|
|
}
|
|
if sc.attempts != 0 {
|
|
t.Fatalf("low autonomy must not consume a correction attempt, got %d", sc.attempts)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectLSPOnlyFailureFeedsDiagnostics(t *testing.T) {
|
|
checker := fakeChecker{byPath: map[string][]lsp.Diagnostic{
|
|
"main.go": {{
|
|
Severity: lsp.SeverityError,
|
|
Message: "undefined: foo",
|
|
Range: lsp.Range{Start: lsp.Position{Line: 4, Character: 1}},
|
|
}},
|
|
}}
|
|
v := &fakeVerifier{reports: []verify.Report{{OK: true}}} // tests pass; only LSP fails
|
|
sc := newTestCorrector(checker, v, "high", 3)
|
|
|
|
fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if oc != OutcomeCorrecting {
|
|
t.Fatalf("LSP error should drive a correction, got %s", oc)
|
|
}
|
|
if !strings.Contains(fb, "main.go:5:2: error: undefined: foo") {
|
|
t.Fatalf("feedback missing formatted diagnostic: %q", fb)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectRedactsSecretsInFeedback(t *testing.T) {
|
|
secret := "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("auth failed with " + secret)}}
|
|
sc := newTestCorrector(nil, v, "high", 3)
|
|
fb, _ := sc.AfterEdit(context.Background(), []string{"main.go"})
|
|
if strings.Contains(fb, secret) {
|
|
t.Fatalf("secret leaked into self-correct feedback: %q", fb)
|
|
}
|
|
}
|
|
|
|
func TestSelfCorrectDisabledIsNoop(t *testing.T) {
|
|
v := &fakeVerifier{reports: []verify.Report{failingReport("x")}}
|
|
sc := NewSelfCorrector("", nil, v, SelfCorrectConfig{Enabled: false, IncludeTests: true, Autonomy: "high"})
|
|
if fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"}); fb != "" || oc != OutcomeDisabled {
|
|
t.Fatalf("disabled corrector should no-op, got %q / %s", fb, oc)
|
|
}
|
|
if v.calls != 0 {
|
|
t.Fatal("disabled corrector must not verify")
|
|
}
|
|
}
|
|
|
|
func TestNilSelfCorrectorIsSafe(t *testing.T) {
|
|
var sc *SelfCorrector
|
|
if fb, oc := sc.AfterEdit(context.Background(), []string{"main.go"}); fb != "" || oc != OutcomeDisabled {
|
|
t.Fatalf("nil corrector should no-op, got %q / %s", fb, oc)
|
|
}
|
|
}
|