Skip to content

Commit 997b825

Browse files
authored
Merge pull request gi-dellav#223 from wowi42/feat/lsp
feat: LSP integration with diagnostics feedback to the agent
2 parents db411de + 17cff77 commit 997b825

18 files changed

Lines changed: 1253 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ multimodal = ["rig/image"]
3232
pdf = ["multimodal", "rig/pdf"]
3333
advisor = []
3434
hooks = []
35+
lsp = ["dep:lsp-types"]
3536

3637
[dependencies]
3738
rig = { version = "0.40", features = ["rmcp"] }
@@ -73,6 +74,7 @@ include_dir = "0.7"
7374
http = "1"
7475
agent-client-protocol = { version = "1.0.1", optional = true }
7576
blocking = { version = "1", optional = true }
77+
lsp-types = { version = "0.97", optional = true }
7678
mimalloc = { version = "0.1", default-features = false }
7779

7880
[dev-dependencies]

docs/CONFIG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,62 @@ The `/advisor` slash command provides runtime control:
10881088
/advisor context-limit <n> Set max kilobytes of conversation context
10891089
```
10901090

1091+
## LSP
1092+
1093+
zerostack can run language servers for the files the agent edits and feed
1094+
diagnostics (errors/warnings) back into `edit`/`write` tool results — the
1095+
agent sees type errors immediately instead of discovering them on the next
1096+
build. An `lsp_diagnostics` tool also lets the agent query one file or the
1097+
whole project on demand.
1098+
1099+
This integration is behind the non-default `lsp` Cargo feature — build with
1100+
`--features lsp` to enable it.
1101+
1102+
### TOML
1103+
1104+
```toml
1105+
[lsp]
1106+
enabled = true
1107+
1108+
[lsp.servers.rust] # override a built-in default
1109+
command = "rust-analyzer"
1110+
extensions = [".rs"]
1111+
1112+
[lsp.servers.myserver] # fully custom server
1113+
command = "my-ls"
1114+
args = ["--stdio"]
1115+
extensions = [".my"]
1116+
# env = { RUST_LOG = "debug" }
1117+
# initialization = { ... } # server-specific initializationOptions
1118+
# disabled = false # true removes a same-named built-in
1119+
```
1120+
1121+
### YAML
1122+
1123+
```yaml
1124+
lsp:
1125+
enabled: true
1126+
servers:
1127+
rust:
1128+
command: rust-analyzer
1129+
extensions: [".rs"]
1130+
```
1131+
1132+
Built-in server defaults (used only when the binary is on PATH):
1133+
rust-analyzer, gopls, typescript-language-server, pyright-langserver,
1134+
clangd, bash-language-server, lua-language-server.
1135+
1136+
Behavior notes:
1137+
1138+
- Servers are **PATH binaries only** — zerostack never auto-installs a
1139+
language server. A missing binary is skipped with a debug log.
1140+
- Servers start lazily on the first edit touching one of their extensions
1141+
(workspace root = session cwd) and stop when zerostack exits.
1142+
- Everything is fail-open: a hung or crashed server only means "no
1143+
diagnostics", never a failed edit.
1144+
- Post-edit diagnostics are capped (errors first, ~20 lines); nothing is
1145+
appended when the file is clean.
1146+
10911147
## Logging
10921148

10931149
zerostack uses the `tracing` framework for structured logging. By default, only

src/agent/builder.rs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,21 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
182182
additional_params: Option<serde_json::Value>,
183183
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
184184
) -> Agent<M> {
185-
let preamble = build_preamble(context, reasoning_enabled);
185+
#[cfg(feature = "lsp")]
186+
let lsp_manager = if cli.resolve_no_tools(cfg) {
187+
None
188+
} else {
189+
cfg.resolve_lsp().map(|c| {
190+
crate::extras::lsp::LspManager::new(c, std::env::current_dir().unwrap_or_default())
191+
})
192+
};
193+
194+
#[cfg_attr(not(feature = "lsp"), allow(unused_mut))]
195+
let mut preamble = build_preamble(context, reasoning_enabled);
196+
#[cfg(feature = "lsp")]
197+
if lsp_manager.is_some() {
198+
preamble.push_str(crate::agent::prompt::LSP_PROMPT);
199+
}
186200

187201
let mut builder = AgentBuilder::new(model).preamble(&preamble);
188202

@@ -209,19 +223,22 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
209223
let max_grep_results = cfg.resolve_max_grep_results();
210224
let max_find_results = cfg.resolve_max_find_results();
211225
let max_list_dir_entries = cfg.resolve_max_list_dir_entries();
226+
let write_tool =
227+
tools::WriteTool::new(permission.clone(), ask_tx.clone(), max_text_file_size);
228+
#[cfg(feature = "lsp")]
229+
let write_tool = write_tool.with_lsp(lsp_manager.clone());
230+
let edit_tool = tools::EditTool::new(permission.clone(), ask_tx.clone());
231+
#[cfg(feature = "lsp")]
232+
let edit_tool = edit_tool.with_lsp(lsp_manager.clone());
212233
let base_tools: SmallVec<[Box<dyn rig::tool::ToolDyn>; 8]> = SmallVec::from_buf([
213234
Box::new(tools::ReadTool::new(
214235
permission.clone(),
215236
ask_tx.clone(),
216237
max_text_file_size,
217238
max_read_lines,
218239
)),
219-
Box::new(tools::WriteTool::new(
220-
permission.clone(),
221-
ask_tx.clone(),
222-
max_text_file_size,
223-
)),
224-
Box::new(tools::EditTool::new(permission.clone(), ask_tx.clone())),
240+
Box::new(write_tool),
241+
Box::new(edit_tool),
225242
Box::new(tools::BashTool::new(
226243
permission.clone(),
227244
ask_tx.clone(),
@@ -254,7 +271,8 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
254271
feature = "subagents",
255272
feature = "memory",
256273
feature = "mcp",
257-
feature = "advisor"
274+
feature = "advisor",
275+
feature = "lsp"
258276
)),
259277
allow(unused_mut)
260278
)]
@@ -309,6 +327,11 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
309327
all_tools.push(Box::new(AdvisorTool::new()));
310328
}
311329

330+
#[cfg(feature = "lsp")]
331+
if let Some(lsp) = &lsp_manager {
332+
all_tools.push(Box::new(tools::lsp::LspTool::new(lsp.clone())));
333+
}
334+
312335
let all_tools = filter_tools_by_allowlist(all_tools, &cli.tools);
313336

314337
#[cfg(feature = "hooks")]

src/agent/prompt.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ You are an expert coding assistant. Read, write, edit files and run commands. Re
3838

3939
pub const TODO_TOOLS_PROMPT: &str = "";
4040

41+
/// Appended to the preamble when LSP integration is active (`[lsp]
42+
/// enabled = true`). Tells the model that diagnostics arrive automatically
43+
/// after edits and that it can query them on demand.
44+
#[cfg(feature = "lsp")]
45+
pub const LSP_PROMPT: &str = "\n\n## LSP diagnostics\n\
46+
Language servers are running for this project: after every successful edit or \
47+
write, fresh diagnostics (errors/warnings) are appended to the tool result \
48+
automatically. Trust them and fix what they report before moving on — no need \
49+
to run a manual typecheck just to confirm. Use the lsp_diagnostics tool to \
50+
query a file before editing it, or to list diagnostics across the project. \
51+
Files with no server configured simply return no diagnostics.";
52+
4153
pub const COMPACTION_PROMPT: &str = "\
4254
You are a conversation summarizer for a coding session. Distill the following conversation into a concise summary.
4355

src/agent/tools/edit.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,32 @@ use crate::agent::tools::{
66
levenshtein_similarity, normalize_whitespace,
77
};
88
use crate::config::types::EditSystem;
9+
#[cfg(feature = "lsp")]
10+
use crate::extras::lsp::LspManager;
911

1012
pub struct EditTool {
1113
pub permission: Option<PermCheck>,
1214
pub ask_tx: Option<AskSender>,
15+
/// When `Some`, edited files are synced to their language server and
16+
/// fresh diagnostics are appended to the tool result.
17+
#[cfg(feature = "lsp")]
18+
pub lsp: Option<LspManager>,
1319
}
1420

1521
impl EditTool {
1622
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
17-
EditTool { permission, ask_tx }
23+
EditTool {
24+
permission,
25+
ask_tx,
26+
#[cfg(feature = "lsp")]
27+
lsp: None,
28+
}
29+
}
30+
31+
#[cfg(feature = "lsp")]
32+
pub fn with_lsp(mut self, lsp: Option<LspManager>) -> Self {
33+
self.lsp = lsp;
34+
self
1835
}
1936
}
2037

@@ -607,6 +624,15 @@ impl Tool for EditTool {
607624
result = format!("{}\n\n{}", msg, result);
608625
}
609626

627+
#[cfg(feature = "lsp")]
628+
if let Some(lsp) = &self.lsp {
629+
let file = std::path::Path::new(&path);
630+
lsp.notify_changed(file).await;
631+
if let Some(block) = lsp.diagnostics_block_for_edit(file).await {
632+
result.push_str(&block);
633+
}
634+
}
635+
610636
Ok(result)
611637
}
612638
}

src/agent/tools/lsp.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//! `lsp_diagnostics` agent tool: on-demand language-server diagnostics.
2+
//! Post-edit diagnostics are appended to edit/write results automatically;
3+
//! this tool is for querying a file before editing it, or surveying the
4+
//! whole project.
5+
6+
use std::path::Path;
7+
use std::time::Duration;
8+
9+
use rig::tool::Tool;
10+
use serde::Deserialize;
11+
12+
use crate::agent::tools::ToolError;
13+
use crate::extras::lsp::LspManager;
14+
15+
/// Longer than the post-edit wait: an explicit query justifies giving the
16+
/// server more time to catch up.
17+
const QUERY_WAIT: Duration = Duration::from_secs(3);
18+
19+
pub struct LspTool {
20+
pub manager: LspManager,
21+
}
22+
23+
#[derive(Deserialize)]
24+
pub struct LspArgs {
25+
/// File to inspect. Omit to list diagnostics for every file.
26+
pub path: Option<String>,
27+
}
28+
29+
impl LspTool {
30+
pub fn new(manager: LspManager) -> Self {
31+
Self { manager }
32+
}
33+
}
34+
35+
impl Tool for LspTool {
36+
const NAME: &'static str = "lsp_diagnostics";
37+
38+
type Error = ToolError;
39+
type Args = LspArgs;
40+
type Output = String;
41+
42+
fn description(&self) -> String {
43+
"Get language-server diagnostics (errors/warnings). With `path`: diagnostics for that file, synced from disk first. Without: every file that currently has diagnostics.".to_string()
44+
}
45+
46+
fn parameters(&self) -> serde_json::Value {
47+
serde_json::json!({
48+
"type": "object",
49+
"properties": {
50+
"path": { "type": "string", "description": "File to inspect (optional; omit for all files)" }
51+
}
52+
})
53+
}
54+
55+
async fn call(&self, args: LspArgs) -> Result<String, ToolError> {
56+
match args.path {
57+
Some(path) => {
58+
let expanded = crate::fs::expand_tilde(&path);
59+
let path = Path::new(&expanded);
60+
if !path.exists() {
61+
return Err(ToolError::Msg(format!("File '{expanded}' does not exist.")));
62+
}
63+
self.manager.notify_changed(path).await;
64+
Ok(self
65+
.manager
66+
.diagnostics_block(path, QUERY_WAIT)
67+
.await
68+
.map(|block| block.trim_start().to_string())
69+
.unwrap_or_else(|| format!("No diagnostics for {expanded}.")))
70+
}
71+
None => Ok(self
72+
.manager
73+
.all_diagnostics_block()
74+
.map(|block| format!("Files with diagnostics:\n{block}"))
75+
.unwrap_or_else(|| "No diagnostics.".to_string())),
76+
}
77+
}
78+
}

src/agent/tools/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pub(crate) mod edit;
44
pub(crate) mod find_files;
55
pub(crate) mod grep;
66
pub(crate) mod list_dir;
7+
#[cfg(feature = "lsp")]
8+
pub(crate) mod lsp;
79
pub(crate) mod normalize;
810
pub(crate) mod read;
911
pub(crate) mod todo;

0 commit comments

Comments
 (0)