LSP Diagnostic Feedback Loop into Adapters — T436 Design Note
Status: shipped (T436) — see src/core/config.ts:946-966,1516-1536 (diagFeedback, diagFeedbackInterval, diagSeverity, diagMaxErrors) and src/adapters/LoopAgentAdapter.ts:1255 (runDiagnostics, per-edit and end-of-loop injection).
Source cross-checked: src/adapters/LoopAgentAdapter.ts, src/adapters/CliAgentAdapter.ts, src/core/lspClient.ts, src/core/lspRegistry.ts, src/tools/lspQuery.ts, src/tools/syntaxGate.ts, src/orchestrator/buildAdapters.ts, src/core/router.ts, src/core/workerProfiles.ts, src/core/config.ts.
1. Problem Statement and opencode Parity Gap
opencode's most impactful quality multiplier is a tight edit-then-check loop: after the model writes a file, the CLI immediately runs the language server against the changed file and feeds any type errors back as context before the next model turn. The result is that opencode workers self-correct type errors within the same session rather than delivering broken output to the judge.
Cyberine's router already has LSP-awareness: lspAwareReorder in src/core/router.ts (lines 434-467) prefers workers whose WorkerProfile.lspCapable flag is true, and buildAdapters.ts pushes lspQueryTool into both implementerTools and readOnlyTools when isLspAvailable() returns true (lines 121-129). The lsp_query tool exposes a diagnostics operation backed by LspClient.diagnostics() in src/core/lspClient.ts (lines 372-383), which calls textDocument/diagnostic on the running language server.
What is absent is any automatic feedback loop. Today the model must proactively call lsp_query with operation: "diagnostics" on each file it edits. Models rarely do this without explicit prompting, so type errors accumulate until the DiffJudge typecheck catches them at evaluation time, costing rounds and often an outright failure.
2. Existing Infrastructure That Is Reusable
The following components already exist and require no new work to exploit:
src/core/lspClient.ts — a minimal, zero-external-dep LSP client over stdio JSON-RPC. It implements textDocument/diagnostic (pull-based, LSP 3.17) at line 372. The client is shared across workers via src/core/lspRegistry.ts, which keeps one warm server process per (cmd, projectRoot) pair and evicts dead clients automatically (lines 77-131). The isLspAvailable() synchronous guard (line 153) checks $PATH for typescript-language-server or deno lsp, or reads CYBERINE_LSP_CMD.
src/tools/lspQuery.ts — a Tool-interface wrapper around the client. The diagnostics operation (lines 192-215) already formats file-level diagnostics into a human-readable string. It is already in implementerTools when LSP is available (buildAdapters.ts lines 126-128).
src/tools/syntaxGate.ts — an in-process fast-path syntax-only check using the bundled TypeScript compiler API. It operates on a single file's text content without spawning a server and returns errors in line:col message format. This is a cheap pre-check that can gate whether a full LSP diagnostic pull is even needed.
src/adapters/LoopAgentAdapter.ts FILE_MUTATING_TOOLS (line 46) — the existing set {'write_file', 'edit_file', 'multi_edit'} already identifies file-mutating tool calls. The shadow-git snapshot path already branches on FILE_MUTATING_TOOLS.has(toolName) at line 351, which is the exact hook location where diagnostic feedback would fire.
3. Recommended Diagnostic Source
Recommendation: shared LSP client (lsp_query / LspClient.diagnostics()) as the primary source, with syntaxGate as a fast pre-filter.
Rationale follows.
Full-project tsc --noEmit (spawning the tsc binary) is authoritative for cross-file type errors but takes 5-30 seconds on a non-trivial TypeScript project because it re-analyses the entire project graph. Running it after every file edit would make the diagnostic loop slower than the model's round-trip latency, destroying any benefit. It also produces noise from pre-existing errors in files the model never touched.
Persistent LSP/tsserver session (LspClient.diagnostics()) is incremental: tsserver in LSP mode maintains an in-memory project graph and re-checks only the changed file and its transitive dependents. A textDocument/diagnostic pull on a single recently-changed file returns in tens to hundreds of milliseconds once the server is warm. The lspRegistry.ts singleton means the server is already warm after the first lsp_query tool call — the diagnostic pull adds virtually no startup cost. A cold start (first diagnostic pull in a run) costs 1-5 seconds but amortises across all rounds.
Language-server-agnostic fallback matters because Cyberine orchestrates tasks in any language, not just TypeScript. The CYBERINE_LSP_CMD override already lets operators point to any stdio LSP server (e.g. pyright --stdio, gopls, rust-analyzer). The design must degrade gracefully when isLspAvailable() returns false — diagnostics are silently skipped and the loop runs exactly as today.
syntaxGate is useful as an in-process pre-filter for .ts/.tsx/.js files because it operates synchronously on the file text the tool result already has, before any LSP round-trip. If syntaxGateError returns a non-null string, that error is fed back immediately without waiting for the LSP. If it returns null, the LSP pull proceeds. For non-TypeScript files (Python, Go, Rust) there is no syntaxGate shortcut; the LSP is the only path.
The combined approach: syntaxGate (synchronous, zero latency) for TS/JS files, then LspClient.diagnostics() (incremental, warm) for all files when isLspAvailable() is true, then silent skip when LSP is unavailable.
4. Scope: LoopAgentAdapter Only
The feedback loop targets LoopAgentAdapter exclusively. CLI workers (CliAgentAdapter factories: claude, codex, opencode-deepseek, opencode-kimi, gemini, aider, qwen-code, crush, cursor-agent, amp, goose, openhands) run headless subprocesses that manage their own tool harness and LSP integration internally. OpenCode's LSP loop is the specific feature this design seeks parity with — and opencode workers already have it via their internal harness. Claude Code CLI (makeClaudeAdapter) runs its own file edit/check cycle. Injecting a Cyberine-side diagnostic pass into a CLI worker's output would be double-checking with a different LSP session, producing confusing or contradictory error messages.
LoopAgentAdapter workers — all loop-*-build profiles (loop-deepseek-build, loop-kimi-build, loop-openai-build, loop-gemini-build, loop-claude-native-build, etc.) and loop-copilot-build — have lspCapable: true in their WorkerProfile entries (src/core/workerProfiles.ts lines 139, 185, 201, 215, 248, etc.) and receive implementerTools which already includes lspQueryTool. These workers have no built-in LSP loop of their own. They are the target population.
5. Exact Injection Point in the LoopAgentAdapter Loop
The runStreaming method in src/adapters/LoopAgentAdapter.ts drives the tool-calling loop at lines 916-1333. The relevant control flow after tool calls resolve is at lines 1154-1333: after resolveToolCallResults returns and the tool-result messages are pushed to messages (lines 1171-1178), the adapter polls for steer messages (lines 1179-1200) and model-switch requests (lines 1201-1235), then runs stuck-detection (lines 1236-1279), then the capacity-wall check (lines 1282-1313), then history compression (lines 1316-1330), and finally continues to the next round (line 1333).
The diagnostic injection point is immediately after tool-result messages are pushed (after line 1178) and before the steer-message poll (before line 1179). This ordering is correct because:
- Tool results are already in
messages, so the model's next turn sees both what the tool did and what the diagnostics say about the result. - The diagnostic check is logically a reaction to the edit just performed, not an operator action, so it precedes the steer-message slot (which injects operator redirections).
- Inserting after steer messages but before stuck-detection would be equally correct but less semantically clean.
The injected feedback takes the form of a user-role message, mirroring the steer-message pattern at line 1190:
messages.push({
role: 'user',
content: `[Diagnostic feedback after edit to ${relPath}]:\n${diagnosticText}`,
});This approach is correct for all OpenAI-compatible providers in the fleet because the tool-role messages have already closed out the tool-call sequence at this point; appending a user turn after them is a well-formed continuation under the OpenAI Chat Completions protocol. The Kimi reasoning_content replay contract (replayReasoning, lines 1138-1150) is not affected because it only governs the assistant turn, not user turns inserted between rounds.
6. Trigger Condition: When to Run Diagnostics
Not every tool-call round should trigger a diagnostic pull. The trigger is: at least one resolved tool call in the current round used a name in FILE_MUTATING_TOOLS ({'write_file', 'edit_file', 'multi_edit'}, defined at line 46).
The mutated file paths must be extracted from the tool call arguments. write_file and edit_file carry a path field in their args; multi_edit carries an edits array each with a path. These paths are already parsed in executeSingleToolCall via JSON.parse(call.args) (line 293). The diagnostic runner needs a list of distinct absolute paths touched in the current round.
For each path, the diagnostic sequence is:
- Run
syntaxGateError(absPath, fileContent)if the file is a TypeScript or JavaScript file.fileContentis obtained viareadFile(absPath)— a single async read per edited file. IfsyntaxGateErrorreturns a non-null string, that is the diagnostic message; skip the LSP pull for this file. - If
isLspAvailable()and the syntaxGate returned null, callLspClient.diagnostics(absPath)via thegetLspClient(cwd)singleton. Format results using the same logic aslspQueryTool'sdiagnosticscase (lines 205-213). - Collect all error-severity diagnostics across all edited files into a single block. If the block is empty (no errors, only warnings or clean), inject nothing (skip the feedback user-turn entirely, so clean edits do not waste tokens). If there are warnings only, a flag controls whether they are included (see section 8).
The diagnostic pull is gated on CYBERINE_DIAG_FEEDBACK=1 (opt-in for MVP; opt-out in a later phase) and on isLspAvailable(). When either gate fails, the loop runs exactly as today, byte-for-byte.
7. Performance Analysis
Each diagnostic pull on a warm language server costs approximately 50-300 ms depending on file size and project graph depth. For a typical implement task with LOOP_MAX_TOOL_ROUNDS of 25 (the default from config.ts line 734), a diagnostic pull fires only on tool rounds that include at least one file-mutating call. Tasks that do many read-only rounds before any write (exploration phases) incur zero diagnostic overhead.
Per-edit mode (default): diagnostics fire on every round containing a file edit. This is the opencode-equivalent behaviour and maximises self-correction opportunity. Total overhead is at most N_write_rounds * 300ms, where N_write_rounds is typically 3-8 for a real implement task.
Per-N-edits mode (CYBERINE_DIAG_FEEDBACK_INTERVAL=N): diagnostics fire only every N write rounds. This is a trade-off for tasks with many rapid small edits (e.g. a scaffold step writing many files).
End-of-loop mode (CYBERINE_DIAG_FEEDBACK=end): diagnostics fire once after the last tool round, before the final answer turn. This adds a forced diagnostic summary but does not allow mid-task self-correction. This is the lowest-cost mode and is appropriate when token budget is the primary constraint.
The feedback user-turn itself costs tokens. A typical diagnostic block (3-5 errors) is 100-300 tokens. On models billed at $0.28/M input tokens (DeepSeek v4-flash), 10 diagnostic turns in a run costs $0.0003 — negligible. On expensive models (Claude Sonnet 4.6 at roughly $3/M), the cost is ~$0.009 per run, still negligible relative to the task cost.
The interaction with LOOP_MAX_TOOL_ROUNDS: a diagnostic feedback turn that triggers the model to make another file edit consumes an additional round. Operators running tight loops should raise LOOP_MAX_TOOL_ROUNDS slightly (e.g. from 25 to 30) when enabling diagnostic feedback on complex tasks. The adapter already allows this via options.maxToolRounds and the LOOP_MAX_TOOL_ROUNDS env var.
8. Config Surface
All new env vars follow the CYBERINE_* naming convention established in src/core/config.ts.
CYBERINE_DIAG_FEEDBACK — primary toggle. Values: 0 (off, default for MVP), 1 or on (per-edit mode), end (end-of-loop-only mode). Default is 0 in the MVP so existing runs are byte-for-byte unchanged. Planned default change to 1 in the full release once the feature is validated.
CYBERINE_DIAG_FEEDBACK_INTERVAL — integer N >= 1; diagnostics fire every N write rounds. Only consulted when CYBERINE_DIAG_FEEDBACK=1. Default 1 (every write round). Set to 3 or 5 for scaffold-heavy tasks.
CYBERINE_DIAG_SEVERITY — minimum LSP severity level to include in feedback. Values: error (default, errors only), warning (errors and warnings), all (all diagnostics including hints). Keeping error as the default avoids warning noise that leads the model to over-refactor.
CYBERINE_LSP_CMD — already exists in src/core/lspRegistry.ts line 86. No new entry needed. This is the multi-language hook: operators point it at pyright --stdio, gopls serve, or rust-analyzer to get diagnostics for non-TypeScript projects.
CYBERINE_DIAG_FEEDBACK_MAX_ERRORS — cap on how many errors to include in the feedback turn. Default 10. Prevents extremely large diagnostic blocks from consuming the entire context on first-write into a pre-broken file.
These knobs are added to the Config interface in src/core/config.ts following the existing patterns (boolean parsed via truthy(env(...)), integer via Number(env(...)), string via env(..., default)).
9. Concrete Code Touch-Points
src/adapters/LoopAgentAdapter.ts is the primary implementation site.
The class acquires two new private fields: diagFeedback: 'off' | 'per-edit' | 'end' and diagFeedbackInterval: number, both read from config at construction time (consistent with how stuckIdenticalRounds, historyCompressionBudget, etc. are handled at lines 563-598).
A new private async method runDiagnostics(editedPaths: string[], cwd: string, emit: EmitFn): Promise<string> collects diagnostics for a list of file paths, applies severity filtering, and returns a formatted string or an empty string when there is nothing to report. It calls syntaxGateError (from src/tools/syntaxGate.ts) for TypeScript files and getLspClient(cwd).then(c => c.diagnostics(absPath)) for all files when LSP is available. It swallows all errors (LSP unavailable, file unreadable) so a failing diagnostic pass never aborts the tool loop.
In runStreaming, after the tool-result messages are pushed (after line 1178), a new block:
// Diagnostic feedback: when enabled and at least one edit was made this round,
// pull diagnostics for edited files and inject them as a user turn.
if (this.diagFeedback === 'per-edit' && editedPathsThisRound.length > 0) {
const diagText = await this.runDiagnostics(editedPathsThisRound, toolCtx.cwd, emit);
if (diagText) {
messages.push({ role: 'user', content: `[Diagnostic feedback]:\n${diagText}` });
emit({ type: 'text', textDelta: `[diag: ${diagText.slice(0, 120)}]\n` });
}
}The local variable editedPathsThisRound: string[] is reset to [] at the start of each round and populated inside executeSingleToolCall when FILE_MUTATING_TOOLS.has(toolName) is true (alongside the existing shadowGit.snapshot call at lines 351-360). Since executeSingleToolCall is a module-level function that receives a SpeculativeDispatchContext, adding editedPaths: string[] to that context struct (or passing it as a shared mutable array via the SpeculativeDispatchContext) is the cleanest threading approach, consistent with how shadowGitRef is already threaded at lines 83-93 of the same file.
For the end-of-loop mode, an additional block runs after the tool-round budget is exhausted (before the fallback completion at line 1348).
src/orchestrator/buildAdapters.ts is touched only to pass the new config fields into LoopAgentAdapter constructor options when constructing each loop-*-build adapter. No new adapter cases; no changes to CliAgentAdapter instantiation.
src/core/config.ts adds the four new Config interface fields and their initialization from env vars.
src/tools/syntaxGate.ts is used as-is; no changes required.
src/core/lspClient.ts and src/core/lspRegistry.ts are used as-is; no changes required.
src/core/Tool.ts is not changed. The diagnostic injection is not a tool call; it is a user-turn injection in the loop, analogous to steer messages.
10. Phased Build Plan
Phase 1 — MVP (target: single implementation session)
Implement per-edit diagnostic feedback for LoopAgentAdapter for TypeScript/JavaScript files only. Gate entirely on CYBERINE_DIAG_FEEDBACK=1 (default off). Use syntaxGate only (no LSP startup cost, no async complexity) so the MVP has zero async risk. Deliverables: new config fields, editedPathsThisRound tracking, runDiagnostics stub that calls only syntaxGateError, injection in the loop. npm run typecheck must pass. Verify manually by running a loop-deepseek-build task that writes a TypeScript file with a deliberate type error and confirming the next model turn acknowledges the error.
Phase 2 — LSP integration (target: following session)
Wire LspClient.diagnostics() into runDiagnostics via getLspClient(cwd), behind isLspAvailable(). Add CYBERINE_DIAG_SEVERITY filtering. Add CYBERINE_DIAG_FEEDBACK_INTERVAL support. Add progress event emission (emit({ type: 'text', textDelta: '[diag: ...]' })) so the TUI peek view shows that a diagnostic pass ran. Verify with a live TypeScript project by confirming that a cross-file type error introduced by an edit is surfaced in the next model turn without the model having explicitly called lsp_query.
Phase 3 — multi-language and end-of-loop mode (target: polish)
Document CYBERINE_LSP_CMD as the multi-language hook in USAGE.md (pointing operators at pyright --stdio for Python, gopls serve for Go, etc.). Implement CYBERINE_DIAG_FEEDBACK=end mode. Implement CYBERINE_DIAG_FEEDBACK_MAX_ERRORS cap. Change the default from 0 to 1 once Phase 2 is validated. Add the new env vars to the env-var table in USAGE.md (required by feature-docs.md project rule). Add an entry to docs/providers/direct-api.md noting that loop workers now self-correct via LSP.
11. Risks and Open Questions
Risk: diagnostic false positives on partially-written files. When a model uses multi_edit to modify multiple locations in one tool call, the file is in a syntactically correct intermediate state after the edit but may have type errors that resolve once the model's next edit arrives. Injecting diagnostics between these two related edits produces noise that may cause the model to re-edit the first file before finishing the planned second edit, creating churn. Mitigation: the CYBERINE_DIAG_FEEDBACK_INTERVAL option lets operators dial down feedback frequency for tasks that write files in multiple coordinated steps. A smarter mitigation (deferred diagnosis) is out of scope for Phase 1.
Risk: LSP server start-up latency on cold runs. The first getLspClient(cwd) call in a run spawns and initializes typescript-language-server, which can take 1-5 seconds on a large project. In Phase 1 (syntaxGate only) this risk does not exist. In Phase 2, the first diagnostic pull may add perceptible latency to the first write round. Mitigation: lspRegistry.ts already pre-warms the client on the first lsp_query tool call; since lspQueryTool is in implementerTools and models frequently call it before writing, the LSP is often already warm by the time a file edit occurs. A further mitigation is an explicit pre-warm call in runStreaming's preamble (gated on isLspAvailable() && this.diagFeedback !== 'off'), but this is an optimization, not a correctness requirement.
Risk: LSP pull-diagnostic protocol support. textDocument/diagnostic is a LSP 3.17 pull-based protocol. Not all language servers implement it. LspClient.diagnostics() already returns [] on failure (line 380: return result?.items ?? []). The adapter treats an empty diagnostic response as "no errors" and injects nothing, which is safe. Operators whose language server does not support pull diagnostics should use the syntaxGate-only path by setting CYBERINE_LSP_CMD to an empty string.
Open question: should diagnostic feedback count against LOOP_MAX_TOOL_ROUNDS? Currently, a diagnostic user-turn that prompts the model to make another edit consumes one of the LOOP_MAX_TOOL_ROUNDS budget. An alternative is to maintain a separate diagRounds counter so diagnostic-triggered rounds do not starve the task's actual tool budget. This is a product decision that affects how operators set LOOP_MAX_TOOL_ROUNDS. For Phase 1 (MVP) the simpler approach — count against the existing budget — is correct; revisit if operators report budget exhaustion from diagnostic churn.
Open question: should the diagnostic feedback be visible in the run record? The steer-message pattern emits a text progress event (line 1191) that surfaces in the TUI peek view. Diagnostic feedback should follow the same pattern (already included in the Phase 1 spec above). Whether diagnostic injections should also be recorded in events.jsonl (as a new event type diagnostic) is a persistence question for Phase 2. A diagnostic event type would allow the run UI to display the error timeline alongside tool calls.
12. Cross-Check Statement
Every architectural claim in this note was verified directly against source, not from memory.
The FILE_MUTATING_TOOLS set at src/adapters/LoopAgentAdapter.ts line 46: confirmed {'write_file', 'edit_file', 'multi_edit'}. The steer-message injection pattern at lines 1179-1200: confirmed as the insertion-point analogue. The SpeculativeDispatchContext struct at lines 83-93: confirmed that shadowGitRef is threaded as a shared mutable ref, establishing the pattern for threading editedPathsThisRound. The lspQueryTool.execute diagnostics case at src/tools/lspQuery.ts lines 192-215: confirmed as reusable formatting logic. LspClient.diagnostics() at src/core/lspClient.ts lines 372-383: confirmed as a pull-based call with graceful empty-array fallback. isLspAvailable() at src/core/lspRegistry.ts line 153: confirmed as a synchronous PATH check, safe to call in the hot loop before each diagnostic attempt. syntaxGateError at src/tools/syntaxGate.ts lines 14-36: confirmed as a synchronous, in-process TypeScript-only syntax check returning string | null. LOOP_MAX_TOOL_ROUNDS read from src/core/config.ts line 734: confirmed default of 25.
All WorkerProfile.lspCapable: true entries in src/core/workerProfiles.ts: confirmed across the loop-*-build profiles. No CLI workers (claude, codex, opencode-deepseek, opencode-kimi, gemini, etc.) carry lspCapable: true — they are not in scope for this feature. implementerTools push of lspQueryTool at src/orchestrator/buildAdapters.ts lines 126-128: confirmed gated on isLspAvailable(). CLI-worker adapter factories do not appear in any diagnostic injection code path: confirmed by the absence of LSP-related imports in src/adapters/CliAgentAdapter.ts.
Long-term memory for Cyberine — survey + design
Agent memory is organized into five cognitive categories — episodic (experiences situated in time), semantic (abstract facts), procedural (skills, policies), working (in-context manipulation),...
Fleet Config — Design Draft
fleet.json declares policy (who is on, which model for which task). It never holds API keys or secrets. Keys stay exactly where they are today: