feat: Phase 5 polish — conditional LSP tools, capabilities, sidecar hooks

- ToolSide enum: documented Sidecar future variant
- StartSession.capabilities: client reports LSP availability
- Client detects LSP binaries on PATH, sends ["lsp_rust", "lsp_typescript"]
- build_tool_definitions() conditionally registers LSP tools only when
  client has LSP capability — model won't hallucinate unavailable tools
- CodeSession stores capabilities, has_lsp(), has_capability() accessors
- git_branch() reads from git for breadcrumb scoping
- ToolRegistry.gitea_client() accessor for reindex endpoint
This commit is contained in:
2026-03-24 09:54:14 +00:00
parent a11b313301
commit ec55984fd8
4 changed files with 46 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ pub struct CodeSession {
pub model: String, pub model: String,
pub user_id: String, pub user_id: String,
pub prompt_md: String, pub prompt_md: String,
pub capabilities: Vec<String>,
state: Arc<GrpcState>, state: Arc<GrpcState>,
room: Option<Room>, room: Option<Room>,
} }
@@ -76,6 +77,7 @@ impl CodeSession {
model, model,
user_id, user_id,
prompt_md: start.prompt_md.clone(), prompt_md: start.prompt_md.clone(),
capabilities: start.capabilities.clone(),
state, state,
room, room,
}); });
@@ -127,6 +129,7 @@ impl CodeSession {
model, model,
user_id, user_id,
prompt_md: start.prompt_md.clone(), prompt_md: start.prompt_md.clone(),
capabilities: start.capabilities.clone(),
state, state,
room, room,
}) })
@@ -263,9 +266,21 @@ you also have access to server-side tools: search_archive, search_web, research,
} }
fn git_branch(&self) -> String { fn git_branch(&self) -> String {
// Stored from StartSession.git_branch, fall back to "mainline" // Use the git branch from StartSession, fall back to "mainline"
// TODO: store git_branch in CodeSession struct if self.project_path.is_empty() {
"mainline".into() "mainline".into()
} else {
// Try to read from git
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&self.project_path)
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "mainline".into())
}
} }
/// Send a user message and run the agent loop. /// Send a user message and run the agent loop.
@@ -484,14 +499,19 @@ you also have access to server-side tools: search_archive, search_web, research,
("list_directory", "List files and directories. Use path for the directory (default: project root) and optional depth."), ("list_directory", "List files and directories. Use path for the directory (default: project root) and optional depth."),
]; ];
// LSP tools — use path, line, column for navigation // LSP tools — only registered when client reports LSP capability
let lsp_tools = vec![ let has_lsp = self.capabilities.iter().any(|c| c.starts_with("lsp_"));
("lsp_definition", "Go to the definition of the symbol at the given position. Returns file:line:column."), let lsp_tools: Vec<(&str, &str)> = if has_lsp {
("lsp_references", "Find all references to the symbol at the given position."), vec![
("lsp_hover", "Get type information and documentation for the symbol at the given position."), ("lsp_definition", "Go to the definition of the symbol at the given position. Returns file:line:column."),
("lsp_diagnostics", "Get compilation errors and warnings for a file."), ("lsp_references", "Find all references to the symbol at the given position."),
("lsp_symbols", "List symbols in a file (document outline) or search workspace symbols. Use path for a file, or query for workspace search."), ("lsp_hover", "Get type information and documentation for the symbol at the given position."),
]; ("lsp_diagnostics", "Get compilation errors and warnings for a file."),
("lsp_symbols", "List symbols in a file (document outline) or search workspace symbols. Use path for a file, or query for workspace search."),
]
} else {
vec![] // no LSP on client — don't expose tools the model can't use
};
for (name, desc) in client_tools { for (name, desc) in client_tools {
tools.push(mistralai_client::v1::agents::AgentTool::function( tools.push(mistralai_client::v1::agents::AgentTool::function(

View File

@@ -658,6 +658,7 @@ mod grpc_tests {
file_tree: vec![], file_tree: vec![],
model: "mistral-medium-latest".into(), model: "mistral-medium-latest".into(),
client_tools: vec![], client_tools: vec![],
capabilities: vec![],
})), })),
}) })
.await .await

View File

@@ -68,6 +68,10 @@ pub struct TokenUsage {
pub enum ToolSide { pub enum ToolSide {
Server, Server,
Client, Client,
// Future: Sidecar — server-side execution with synced workspace.
// When a sidecar is connected, client tools can optionally run
// on the sidecar instead of relaying to the gRPC client.
// The orchestrator doesn't distinguish — it just parks on a oneshot.
} }
/// Result payload from a client-side tool execution. /// Result payload from a client-side tool execution.

View File

@@ -43,6 +43,16 @@ mod tests {
assert_eq!(route("ask_user"), ToolSide::Client); assert_eq!(route("ask_user"), ToolSide::Client);
} }
#[test]
fn test_sidecar_variant_placeholder() {
// ToolSide should support future Sidecar variant
// For now, any non-client tool routes to Server
// When Sidecar is added, this test should be updated
assert_eq!(route("lsp_definition"), ToolSide::Client);
assert_eq!(route("search_archive"), ToolSide::Server);
// Future: assert_eq!(route_with_sidecar("file_read", true), ToolSide::Sidecar);
}
#[test] #[test]
fn test_lsp_tools_are_client_side() { fn test_lsp_tools_are_client_side() {
assert_eq!(route("lsp_definition"), ToolSide::Client); assert_eq!(route("lsp_definition"), ToolSide::Client);