Files
zero/internal/tools/lsp_navigate.go
Vasanth T b2bb6c4b41 feat: five capability features from the reference-agent comparison (#276)
* feat(commands): user-defined slash commands from markdown files

Drop a markdown file at `.zero/commands/<name>.md` (project, shared with the
team) or `<userConfigDir>/zero/commands/<name>.md` (personal); typing
`/<name> args` expands its body template and submits it as a normal prompt.
Optional YAML frontmatter (`description:`, `model:`, `agent:`) feeds help text
and routing. This turns Zero's model-pulled skills into user-invokable,
repo-checked-in team workflows (`/release v1.2`, `/fix-flaky <test>`).

New internal/usercommands package: Load() scans the project + user dirs
(project shadows user on name collision, mirroring specialist scope
precedence), validates names to a safe slash-command shape, and parses
frontmatter with a self-contained parser. Expand() substitutes $ARGUMENTS,
$1..$9, and $$ (literal $); a placeholder-free template gets the raw args
appended so a trivial command still receives input.

Wiring: model loads commands at construction; the commandUnknown dispatch
checks the user set before reporting "unknown"; autocomplete lists user
commands alongside builtins. Builtins win on collision (listed first, shadow at
dispatch). Tests cover load/precedence/name-validation, the placeholder
substitution table, and the TUI dispatch + autocomplete + still-unknown paths.

* feat(init): guided /init that generates AGENTS.md seeded with repo facts

`/init` (TUI) and `zero init` (headless) investigate the repo and write a
concise AGENTS.md so future runs start with project context. Zero already has
the AGENTS.md loader and the repoinfo scanner, but the file only existed if a
user hand-authored it — this activates the machinery in one command.

New internal/agentinit.BuildPrompt seeds the bootstrap prompt with
repoinfo.Collect() output (primary language, build/test tools, CI, workspace
layout, git) formatted as a fact block, then instructs the agent to investigate
the gaps and write a tight AGENTS.md (build/test commands, conventions, things
to avoid). Collection failure is non-fatal — the agent investigates from
scratch. This is one better than a pure-prompt /init: the agent starts from
known facts instead of re-deriving them.

TUI: /init command + handleInitCommand reuse launchPrompt (standard run, tools,
AGENTS.md write path). Headless: `zero init` builds the prompt and forwards it
through the existing exec machinery, so all exec flags (--model, -w, --add-dir)
work unchanged. Tests cover FormatFacts (rendered/omitted fields), BuildPrompt,
and the TUI dispatch.

* feat(tools): lsp_navigate — semantic code navigation via the language server

A read-only, model-callable tool exposing LSP navigation: jump-to-definition,
find-all-references, find-implementations, and workspace_symbol search. The
agent can answer "where is X defined / who calls X / what implements this
interface" precisely, where grep collides on common names or misses indirect
references — cutting wasted read/grep turns and wrong-edit risk on large repos.

Zero already runs language servers for diagnostics, so this adds the
request/response navigation methods on top: Manager.Navigate syncs the file
(didOpen) then issues textDocument/definition|references|implementation or
workspace/symbol via the existing client.Call, decoding all three result shapes
(Location, []Location, []LocationLink). 1-based file:line:col in and out
(converting LSP's 0-based positions); the tool has its own lazily-started
manager (servers start on first use, reused across calls).

Opportunistic: a file type with no installed server, or a server lacking the
capability, degrades to a clear "unavailable — fall back to grep" message, not
an error. Registered in CoreReadOnlyToolsScoped. Tests cover the response-shape
decoding (incl. LocationLink + null), symbol decoding/kinds, unknown-op and
unsupported-extension degradation, and the tool's arg validation + read-only
safety.

* feat(agent): prune stale tool output before paid summarization

Long, dump-heavy sessions accumulate large read_file/grep/glob/bash tool
results the model has already acted on; their bodies are dead weight the loop
otherwise keeps paying an LLM summarizer to compress. Add a zero-cost prune
stage inside maybeCompact, before the summarizer: when over threshold, replace
the bodies of OLD, large tool results with a compact placeholder, recompute
size, and only fall through to the paid Compact() if still over.

pruneStaleToolOutput walks newest-first, protects the last preserveLast
messages plus a trailing 40k-token window of tool output (most likely still in
use), and prunes only tool results older than that and larger than 200 tokens —
gated so the whole pass is a no-op unless reclaimable output exceeds 20k tokens.
It keeps the tool message and its ToolCallID (provider replay stays valid; the
model knows what was there and can re-run the tool), never touches non-tool
messages, never re-prunes a placeholder (idempotent), and copies-on-write so it
never mutates the caller's slice. Recent turns are preserved verbatim — no
paraphrase loss, no token or latency cost.

Tests cover the reclaim gate, recent-window protection, idempotence,
non-tool-message safety, ToolCallID/role preservation, and no-input-mutation.

* feat(agent): reconnect on a transient model disconnect instead of ending the run

A long autonomous task (a big refactor, a swarm member, a headless/cron run)
should survive a single transient upstream hiccup rather than dying and
re-burning every token on a restart. When the initial StreamCompletion connect
fails with a disconnect-shaped error, streamWithReconnect re-issues the SAME
request with exponential backoff up to 2 times.

It retries only the connect — before any content is forwarded — so no
already-streamed OnText is ever duplicated. A context-limit error (the
compactor's job), an image rejection, a non-disconnect error, or a cancelled
context is returned immediately without retry, so the reconnect never fights
the existing handlers. Disconnect classification matches EOF / connection
reset|refused|closed / broken pipe / timeout / 502 / 503 / server-closed.
Reconnect attempts surface through OnReasoning ("connection lost —
reconnecting N/max"), a non-content channel that never corrupts the answer
text; silent when there's no reasoning sink.

Tests: recover-from-transient, give-up-after-max, no-retry-on-context-limit,
stop-on-context-cancel, the disconnect-vs-not classifier, backoff growth, and
the notice routing.

* fix(specialist): add lsp_navigate to knownToolNames

The new lsp_navigate tool was added to the core read-only registry but not to
specialist.knownToolNames, which mirrors the core tool set — so
TestKnownToolNamesMatchCoreRegistry failed (the list drifted from the registry).
Add it to the read-only group to match.

* fix(tools): confine lsp_navigate to the workspace (close arbitrary-file-read)

Review caught that lsp_navigate dropped the PathScope every sibling read-only
tool (read_file/glob/grep) carries, and its readFile joined or used the
model-supplied path verbatim — so `path:"/etc/passwd"` (absolute) or
`path:"../../etc/..."` (escaping) read arbitrary files off disk AND opened them
into the language-server subprocess, reachable via indirect prompt injection.

Add NewScopedLSPNavigateTool(workspaceRoot, scope) (NewLSPNavigateTool now
delegates with nil scope), resolve the path through resolveScopedReadPath BEFORE
reading or building the LSP request — the same confinement the neighbouring
tools enforce — and hand the LSP manager the resolved absolute path so it never
opens an out-of-workspace file. Errors echo only the relative path, never an
absolute one. Registered via the scoped constructor in CoreReadOnlyToolsScoped.

Regression test asserts `/etc/passwd` and `../../` are rejected for both the
position and workspace_symbol ops; verified by mutation (bypassing the scope
resolution makes the test fail).
2026-06-20 23:12:15 +05:30

171 lines
7.0 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/lsp"
)
// lspNavigateTool exposes LSP code navigation (jump-to-definition, find-all-
// references, find-implementations, workspace symbol search) as a read-only,
// model-callable tool. It answers "where is X defined / who calls X / what
// implements this interface" semantically, where grep collides on names or
// misses indirect references. Degrades to a clear "unavailable" message when no
// language server is installed for the file's type — LSP is opportunistic.
type lspNavigateTool struct {
baseTool
workspaceRoot string
scope PathScope
manager *lsp.Manager
}
// NewLSPNavigateTool builds the tool with workspace-only path confinement.
func NewLSPNavigateTool(workspaceRoot string) Tool {
return NewScopedLSPNavigateTool(workspaceRoot, nil)
}
// NewScopedLSPNavigateTool builds the tool with its own lazily-started LSP
// manager (servers spin up on first use and are reused across calls within a
// session). The model-supplied path is resolved through the same scoped
// confinement the sibling read-only tools use, so a `..` or absolute path
// cannot read or open a file outside the workspace (or an extra granted root).
func NewScopedLSPNavigateTool(workspaceRoot string, scope PathScope) Tool {
root := normalizeWorkspaceRoot(workspaceRoot)
return lspNavigateTool{
baseTool: baseTool{
name: "lsp_navigate",
description: "Navigate code semantically via the language server: jump to a symbol's " +
"definition, find all references, find implementations of an interface/method, or " +
"search workspace symbols by name. Use this instead of grep when you need precise " +
"definition/reference/implementation results. Returns file:line:col locations.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"op": {
Type: "string",
Description: "Operation: definition, references, implementation, or workspace_symbol.",
Enum: []string{"definition", "references", "implementation", "workspace_symbol"},
},
"path": {Type: "string", Description: "File to anchor the query (required for all ops; for workspace_symbol it selects the language server)."},
"line": {Type: "integer", Description: "1-based line of the symbol (definition/references/implementation).", Minimum: intPtr(1)},
"character": {Type: "integer", Description: "1-based column of the symbol (definition/references/implementation).", Minimum: intPtr(1)},
"query": {Type: "string", Description: "Symbol name to search for (workspace_symbol)."},
},
Required: []string{"op", "path"},
AdditionalProperties: false,
},
safety: readOnlySafety("Queries the language server for code navigation; reads files, modifies nothing."),
},
workspaceRoot: root,
scope: scope,
manager: lsp.NewManager(root),
}
}
func (tool lspNavigateTool) Run(ctx context.Context, args map[string]any) Result {
opRaw, err := stringArg(args, "op", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for lsp_navigate: " + err.Error())
}
op := lsp.NavOp(strings.ToLower(strings.TrimSpace(opRaw)))
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for lsp_navigate: " + err.Error())
}
// Confine the model-supplied path to the workspace (or an extra granted root)
// BEFORE reading it or handing it to the language server — a `..` or absolute
// path must not read/open a file outside the boundary the sibling read-only
// tools enforce. Errors echo only the relative path, never an absolute one.
absPath, relPath, scopeErr := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if scopeErr != nil {
return errorResult("Error: lsp_navigate " + scopeErr.Error())
}
text, readErr := readWorkspaceFile(absPath)
if readErr != nil && op != lsp.NavWorkspaceSymbol {
return errorResult("Error: lsp_navigate could not read " + relPath + ": " + readErr.Error())
}
// Hand the LSP manager the resolved absolute path so it opens only the
// confined file (not the raw, possibly-escaping, model input).
req := lsp.NavRequest{Op: op, Path: absPath, Text: text}
switch op {
case lsp.NavWorkspaceSymbol:
query, qErr := stringArg(args, "query", "", true)
if qErr != nil {
return errorResult("Error: lsp_navigate workspace_symbol requires a query.")
}
req.Query = strings.TrimSpace(query)
case lsp.NavDefinition, lsp.NavReferences, lsp.NavImplementation:
line, lErr := intArg(args, "line", 0, 1, 0)
col, cErr := intArg(args, "character", 1, 1, 0)
if lErr != nil || cErr != nil || line == 0 {
return errorResult("Error: lsp_navigate " + string(op) + " requires 1-based line and character.")
}
req.Line = line
req.Character = col
req.IncludeDeclaration = true
default:
return errorResult("Error: lsp_navigate unknown op " + string(op) + " (use definition, references, implementation, or workspace_symbol).")
}
locations, symbols, ok, navErr := tool.manager.Navigate(ctx, req)
if navErr != nil {
return errorResult("Error: lsp_navigate failed: " + navErr.Error())
}
if !ok {
return okResult(fmt.Sprintf("No language server available for %s — lsp_navigate is unavailable for this file type. Fall back to grep/read_file.", filepath.Base(relPath)))
}
return okResult(tool.formatResult(op, locations, symbols))
}
// readWorkspaceFile reads an already-confined absolute path (resolved by
// resolveScopedReadPath). Returns "" + error when the file can't be read.
func readWorkspaceFile(absPath string) (string, error) {
data, err := os.ReadFile(absPath)
if err != nil {
return "", err
}
return string(data), nil
}
func (tool lspNavigateTool) formatResult(op lsp.NavOp, locations []lsp.Location, symbols []lsp.SymbolResult) string {
if op == lsp.NavWorkspaceSymbol {
if len(symbols) == 0 {
return "No matching symbols."
}
lines := make([]string, 0, len(symbols)+1)
lines = append(lines, fmt.Sprintf("%d symbol(s):", len(symbols)))
for _, s := range symbols {
lines = append(lines, fmt.Sprintf(" %s %s — %s", s.Kind, s.Name, tool.formatLocation(s.Location)))
}
return strings.Join(lines, "\n")
}
if len(locations) == 0 {
return "No " + string(op) + " results."
}
lines := make([]string, 0, len(locations)+1)
lines = append(lines, fmt.Sprintf("%d %s result(s):", len(locations), op))
for _, l := range locations {
lines = append(lines, " "+tool.formatLocation(l))
}
return strings.Join(lines, "\n")
}
// formatLocation renders a location as a workspace-relative file:line:col
// (1-based for the user, converting from LSP's 0-based positions).
func (tool lspNavigateTool) formatLocation(l lsp.Location) string {
path := lsp.URIToPath(l.URI)
if rel, err := filepath.Rel(tool.workspaceRoot, path); err == nil && !strings.HasPrefix(rel, "..") {
path = filepath.ToSlash(rel)
}
return fmt.Sprintf("%s:%d:%d", path, l.Range.Start.Line+1, l.Range.Start.Character+1)
}