Docs Inside Blacksite
LSP Code-Intelligence Layer — Design Spec
Status: shipped with reliability, safety, and orchestration hardening · Owner: Blacksite VS Code extension
1. Goal & thesis#
Give the agent the same code understanding a human gets from the IDE: jump to definitions, find all references, see types and signatures, rename symbols safely, run the language’s own quick-fixes, and read live language-server diagnostics as a feedback loop. This is the single capability a terminal CLI agent cannot replicate, and the extension is already sitting on top of the full VS Code language-feature surface — we just need to expose it as tools.
Today the agent navigates code with file_search (regex) and file_glob. It
greps, guesses, and re-reads. With LSP tools it reasons about code structurally:
find references before refactoring, go-to-definition instead of grep,
type-aware edits, repo-wide rename, and edit → diagnose → fix loops.
Non-goals#
- Inline ghost-text completions (Copilot-style) — different product surface.
- Reimplementing language servers. We only consume what VS Code exposes.
- Guaranteeing every language behaves identically — capability degrades gracefully to whatever providers the user’s installed extensions supply.
2. Architecture & wiring#
Reuses the exact provider pattern already used by editProvider /
diagnosticsProvider / memoryProvider.
tools/definitions.ts CODE_INTEL_TOOLS (schemas, runtimeType "lsp.*")
│
▼
agent-session.ts AgentSessionOptions.lspProvider?: LspProvider
_getTools(): gate CODE_INTEL_TOOLS on lspProvider
dispatch: runtimeType.startsWith("lsp.") → lspProvider.dispatch(op, payload, ctx)
│
▼
lsp-service.ts thin operation orchestration + output normalization
├── WorkspaceIdentity root/path/URI safety
├── TargetResolver exact target + document version
├── ProviderExecutor typed outcomes + total deadline
├── DiagnosticSnapshotter freshness + coverage + delta
├── ActionRegistry stable version-bound handles
└── MutationCoordinator prepare → approve → apply → verify queue
│
▼
WorkspaceEditApplier text/resource inspection, preview, approval, revalidation, save
│
▼
chat-provider.ts construct LspService(workspaceRoot); pass lspProvider
webview/react/lib/format.ts TOOL_GROUPS "Code Intelligence", labels, previews, result cards
Provider interface (keeps vscode types out of AgentSession)#
export interface LspContext {
autoApprove: boolean;
signal?: AbortSignal;
transactionId?: string;
}
export interface LspProvider {
dispatch(op: string, payload: Record<string, unknown>, ctx: LspContext): Promise<LspResult>;
}
export type LspResult =
| { ok: true; notice?: string; [k: string]: unknown; autoApproveAll?: boolean }
| { ok: false; error: string; ambiguous?: boolean; candidates?: SymbolRef[] };
op is the runtime type minus the lsp. prefix (e.g. navigate, symbols,
rename). The dispatch branch in agent-session.ts mirrors the editor.apply_edit
branch: it forwards this._autoApprove, honors autoApproveAll on the way back,
and strips that field before returning the result to the model.
Shared workspace-edit applier#
rename, actions (apply), format, insert, replace, batch replace, and
generic file edits share WorkspaceEditApplier. It:
- summarizes the edit (files touched, total edits),
- opens bounded native
vscode.diffpreviews for text resources, - inspects create/delete/rename and snippet operations where VS Code exposes them,
- requires explicit approval for resource-only, destructive, overwrite-capable, and otherwise opaque resource operations,
- validates every URI and rechecks document versions before preview and apply,
- applies, saves, and reports persistence failures as uncertain,
- returns structured counts, operation details, touched URIs, and approval state.
This unifies the approval UX across file_edit and every mutating LSP op.
3. Position targeting — the core UX problem#
LSP providers take (uri, Position). An LLM doesn’t reliably know character
offsets, so every tool accepts a flexible target and resolves it
server-side. Resolution is the heart of “as capable as possible.”
interface Target {
path: string; // absolute or workspace-relative
rootId?: string; // required when a relative path exists in multiple roots
symbol?: string; // e.g. "fetchModels" or "ChatProvider.send"
kind?: string; // optional disambiguation: "function" | "method" | "class" | ...
line?: number; // 1-based fallback
column?: number; // 1-based, optional
matchText?: string; // substring on `line` to locate the column robustly
}
Resolution order:
- symbol (preferred): load document symbols for
path, flatten the tree, match by name (supportingContainer.memberdotted form and optionalkind). Use the symbol’sselectionRange.start.- 0 matches →
{ ok:false, error:"symbol not found", candidates:<nearest names> }. -
1 match →
{ ok:false, ambiguous:true, candidates:[{id,qualifiedName,kind, path,rootId,line,endLine,container}] }so the model re-calls with an exact discriminator. Read-only navigations may opt intofirstMatch:true; every mutating operation rejects it.
- 0 matches →
- line + matchText: column = indexOf(matchText) on that line. A missing anchor fails with the current line text rather than silently moving the target.
- line + column: used directly.
- line only: first non-whitespace character on the line.
A single TargetResolver returns the URI, position, range, stable symbol
identity, confidence, and current document version. This makes the natural agent phrasing — “find
references to fetchModels in model-fetcher.ts” — work without the model ever
computing a column.
4. Output model & budgeting#
Normalize every VS Code shape to compact, agent-friendly JSON with relative
paths, 1-based line/column, and a source snippet so the agent rarely needs a
follow-up file_read.
interface CodeLocation {
path: string; // workspace-relative
line: number; column: number;
endLine?: number; endColumn?: number;
snippet: string; // the source line (trimmed), or N-line window
symbol?: string; kind?: string; container?: string;
}
Budgeting (results can be enormous — references to a popular symbol):
- Default cap 100 locations, configurable per call via
limit(max 500). - Group by file; include
{ truncated: true, totalFound }when capped. - Snippets default to the single matching line;
context(0–3) widens the window. - For definitions,
includeBody:truereturns the full symbol range (capped at ~200 lines) so the agent reads the implementation in one hop.
Hover/markdown is flattened to plain text (strip code-fence noise, keep the signature + first paragraph of docs).
5. Tool catalog#
Twelve tools. Navigation providers with identical I/O are consolidated under a
kind discriminator to avoid tool-list noise; distinct output shapes get their
own tool.
5.1 code_symbols — map the code (read)#
Document symbol tree, or workspace-wide symbol search.
{ path?: string, query?: string, limit?: number }
pathset →vscode.executeDocumentSymbolProvider→ nested tree (name, kind, line range, children).queryset →vscode.executeWorkspaceSymbolProvider→ flat matches across repo.- Returns
{ ok, symbols: [...], truncated? }. The agent’s “table of contents.”
5.2 code_navigate — jump around (read)#
{ target: Target, kind: "definition"|"typeDefinition"|"declaration"|"implementation"|"references",
includeBody?: boolean, limit?: number, context?: number }
Maps to vscode.execute{Definition|TypeDefinition|Declaration|Implementation|Reference}Provider.
Returns { ok, kind, target:<resolved>, locations: CodeLocation[], truncated? }.
Handles both Location and LocationLink results.
5.3 code_hierarchy — call & type graphs (read)#
{ target: Target, kind: "callers"|"callees"|"supertypes"|"subtypes",
depth?: 1|2|3|4, limit?: number }
- callers/callees →
vscode.prepareCallHierarchythenprovideIncomingCalls/provideOutgoingCalls. - supertypes/subtypes →
vscode.prepareTypeHierarchythenprovideSupertypes/provideSubtypes. Returns the compatible one-hop list plus a boundedgraphcontaining stable node IDs, ranges, traversal depth, directed edges, call-site ranges, failures, budgets, and truncation state. Nodes are deduplicated and cycles do not recurse.
5.4 code_hover — types, signatures, docs (read)#
{ target: Target }
vscode.executeHoverProvider, run in parallel with vscode.executeSignatureHelpProvider
(not warmup-wrapped — an empty signature-help result is a normal outcome, not a
cold-server symptom). Returns { ok, text, symbol?, kind?, range }, where text
is the hover content with the active parameter of any in-scope call signature
bolded ahead of it. The agent’s “what is this and what’s its type” without
re-reading the file.
5.5 code_diagnostics — the verification loop (read)#
{ path?: string, rootId?: string,
severity?: "error"|"warning"|"info"|"hint", limit?: number,
activateWorkspace?: boolean, activationLimit?: number }
Reads diagnostics published through vscode.languages.getDiagnostics. Results
are DiagnosticSnapshots: status, scope, filtered counts, allCounts,
problems, freshness, document versions, and measured coverage. A default
workspace read is always published_workspace/partial; explicit bounded
activation opens eligible source files but still does not replace compiler,
linter, or test verification. Post-edit snapshots additionally contain
introduced, resolved, and persisting diagnostics after a quiescence window.
5.6 code_rename — safe repo-wide rename (mutating)#
{ target: Target, newName: string }
vscode.prepareRename validates the position first — its rejection carries a
specific, actionable reason (e.g. “You cannot rename this element”), surfaced
as-is rather than swallowed to a generic error — then vscode.executeDocumentRenameProvider
produces a WorkspaceEdit. Routed through WorkspaceEditApplier for preview + approval.
Returns { ok, files, edits, newName }. Symbol-aware, language-correct — strictly
better than a find/replace.
5.7 code_actions — use the language’s quick-fixes & refactors (mutating)#
{ path: string, line: number, endLine?: number, only?: string, apply?: string }
- List:
vscode.executeCodeActionProvider(uri, range, only?)→ actions with an opaqueactionId, title, kind, preferred/disabled state, edit/command flags, and source document version. - Apply: pass the short-lived
actionId(preferred), or an exact unique title during the compatibility window. Prefixes are rejected rather than guessed. Handles are URI/version-bound and disabled actions cannot run. Returns the available actions, or the applied result. Text edits use the shared preview transaction. Command-only and edit-plus-command actions disclose that their complete diff is unavailable, require explicit approval, preserve command failure/timeout state, and observe document/create/ rename/delete events to expand touched-file diagnostics and receipts.
5.8 code_format — format on demand (mutating)#
{ path: string, range?: {startLine,endLine} }
vscode.executeFormatDocumentProvider / executeFormatRangeProvider → TextEdit[],
applied via WorkspaceEditApplier. Lets the agent format after editing instead
of hand-aligning whitespace.
5.9 code_insert — targeted insertion (mutating)#
{ target: Target, position: "before"|"after"|"start"|"end", text: string }
Inserts text relative to a resolved symbol or line, then routes through
WorkspaceEditApplier for preview + approval. Not an LSP wrapper — it reuses
the shared target-resolution machinery (§3) so the agent can add imports,
methods, or branches without a brittle full-file text match.
5.10 code_replace — exact target-range replacement (mutating)#
{ target: Target, text: string, endLine?: number }
Replaces the resolved symbol range, or an explicit validated line range. It does not claim language-specific body-only semantics. The document version is bound to preview/application and stale edits return a conflict.
5.11 code_replace_batch — coordinated target-range replacement (mutating)#
{ edits: [{ target: Target, text: string, endLine?: number }] }
Resolves all targets before mutation, rejects overlap, binds every document version, previews one atomic WorkspaceEdit, and returns per-target locations plus one transaction/diagnostic receipt.
5.12 code_inlay_hints — inferred types & parameter names (read)#
{ path: string, range?: {startLine,endLine}, limit?: number }
vscode.executeInlayHintProvider(uri, range) — unlike code_format, this
command has no whole-document overload, so a full-document range is
synthesized when range is omitted. Returns { ok, hints: [{line,column,label,kind}] }.
Most valuable for untyped or dynamically-typed code (Python, JS) where
inferred types aren’t visible in the source text.
Still open#
code_completions—executeCompletionItemProvider(“what members does this object expose”). Useful but largely covered by hover + symbols.code_signature— a standalone signature-help tool, if signature help ever needs to be queried independently of hover.
6. Mutating edits: preview & approval#
All mutating code_* operations run through one per-workspace
MutationCoordinator, which serializes target/version preparation, diagnostic
baseline, provider edit resolution, path validation, preview, approval,
revalidation, apply/save, and diagnostic collection. Read tools remain concurrent.
Two capability multipliers built into the edit results:
- Auto-attached post-edit diagnostics. After any mutating op (and after the
existing
file_edit), include freshcode_diagnosticsfor the touched files in the tool result. The agent instantly sees whether the change introduced an error — tightening the self-correction loop without an extra round-trip. - Auto-approve continuity. Reuse the session
_autoApproveflag so a refactor that renames + organizes imports + formats doesn’t prompt at every step once the user clicks “Apply All.” Resource operations and unpreviewable commands still require explicit review.
Every mutation result, including rejection/conflict/uncertainty, carries a transaction receipt with text-edit count, exact or opaque resource operations, command outcomes, touched files, save state, and a diagnostic snapshot.
7. Cross-cutting concerns#
- Provider outcomes and warm-up. All provider commands use
ProviderExecutorand returnok,timeout,error,cancelled, orunavailable. Only a successful empty read may warm up. One total nine-second deadline covers all attempts and abortable backoff, so retries cannot multiply the latency budget. Read results expose compact status, duration, attempt, and warm-up metadata. - Document loading. Always
openTextDocument(uri)(no need to reveal) so the owning language extension activates and providers run. Operate on in-memory content for dirty/unsaved buffers. - Multi-root.
WorkspaceIdentitysearches every root. Duplicate relative paths return candidates and requirerootId; model-facing paths are root-qualified. - Cancellation & timeouts. Cancellation stops local waiting, backoff, and retries immediately. VS Code’s public execute-provider commands do not accept a cancellation token, so an already-started command may finish internally; command-backed mutation timeouts are therefore reported as uncertain.
- Errors. An empty result whose language has a known, uninstalled recommended
extension gets an actionable explanation (
lsp-provider-hint.ts’snoProviderNotice, backed bygraph/language-support.ts’smissingExtensionFor— a cheap, synchronous check, not the Map’s expensive empirical probe). Ops that already treated empty as failure (code_hover,code_rename) get the hint in place of their generic message; ops where empty is often a legitimate correct answer (code_navigate,code_hierarchy,code_symbols,code_actions,code_format) stayok:trueand gain an optionalnoticefield alongside the empty result, rather than being reinterpreted as a failure. - Path safety. Inputs and every provider-produced text/resource URI must stay within an open workspace root. Traversal, non-file URIs, and external absolute paths fail closed.
- Operation caches and bounds. Symbol bodies share a URI/version cache within a navigation operation; location loading uses bounded concurrency; hierarchy traversal has total time, depth, node, and edge budgets.
- Telemetry. Structured logs retain provider state, duration/attempts, result/truncation counts, diagnostic readiness/coverage, and mutation outcome, but omit source text, diagnostic messages, command arguments, and paths.
8. Settings & toggles#
- Each
code_*tool appears in the existing tool-toggle list under a new Code Intelligence group (webviewTOOL_GROUPS), individually disableable via the existingdisabledToolsmechanism. - Not built:
blacksite.codeIntel.enabled/maxResultsconfig. Tools are always offered to the model whenlspProvideris present (gated in_getTools()), exactly likefile_edit/browser tools; individualcode_*tools remain disableable via the existingdisabledToolstoggle list, which was judged sufficient — no separate group-level switch has been needed.
9. Webview surfacing#
TOOL_GROUPS(format.ts):"Code Intel"includes all ten tools,code_inlay_hintsincluded.TOOL_LABELS:code_navigate:'Navigate',code_symbols:'Symbols',code_hover:'Hover',code_rename:'Rename Symbol',code_actions:'Code Action',code_diagnostics:'Diagnostics',code_hierarchy:'Hierarchy',code_format:'Format',code_inlay_hints:'Inlay Hints'.toolInputPreview/toolIntentPhrase(tool-presentation.ts): showtarget.symbol || path:line+kind;code_inlay_hintsshowspath+ range.toolResultPresentation: e.g. references →N references · M files; rename →renamed → newName · N files; diagnostics →E errors, W warnings. When a result carries anotice(§7) and its primary array is empty, the notice text replaces the usual empty-state preview instead of a bare “No results”.tool-icons.ts:code_inlay_hintsgrouped into the"code"bucket with the rest.- Locations in results are already clickable in chat via the
file:linemarkdown convention.
10. Phasing#
Phase 1 (MVP, highest value) — shipped: code_symbols, code_navigate,
code_hover, code_diagnostics. Pure read; no approval plumbing. Delivers
go-to-def / find-refs / types / verify-loop.
Phase 2 (mutating) — shipped: WorkspaceEditApplier, code_rename
(now with a prepareRename preflight), code_actions, code_format,
code_insert, plus auto-attached post-edit diagnostics.
Phase 3 (advanced) — shipped for the measured scope: code_hierarchy now
supports bounded graph depth, code_inlay_hints preserves tooltip/padding/edit
metadata, and hover is enriched with signature help. Completion/member discovery
and standalone signature tools remain intentionally deferred until usage shows a
gap that hover, symbols, and navigation do not cover.
11. Testing & verification#
npm run test:lsp:unit: provider outcomes/deadlines, multi-root identity, target ambiguity/versioning, action handles, diagnostic quiescence/deltas, resource inspection/approval, mutation serialization, hierarchy cycles, navigation caches, command touch observation, UI state, and private telemetry.npm run test:lsp:integration: production build plus a clean VS Code Extension Host/TypeScript fixture covering document symbols, definition, implementation, references, hover, signature help, diagnostic introduction and resolution, organize imports, changed/unchanged formatting, and cross-file rename.npm run test:lspruns both suites. The runner isolates the fixture and profile in temporary paths and sanitizesELECTRON_RUN_AS_NODEfor VS Code terminals.
12. Resolved decisions and deliberate limits#
- Read ambiguity returns candidates unless
firstMatch:true; mutations always require exact disambiguation. code_navigate(kind)remains consolidated because the request/result contract is identical across its five providers.- Code-action listing requests resolved items through VS Code’s supported
itemResolveCount; unresolved command portions remain explicit, approved, and observed rather than represented as previewable text edits. - Post-edit diagnostics follow planned and observed touched files. Whole-workspace published diagnostics remain a separate partial-coverage read.
- WorkspaceEdit resource-operation inspection uses VS Code’s Extension Host representation defensively. Unknown future shapes remain opaque, destructive, and approval-required rather than being guessed.