feat: add semantic search tool for code symbol indexing and searching

- Implemented a new tool for semantic code search that indexes Rust code symbols (functions, structs, enums, traits, modules) and allows searching by name, concept, or meaning.
- Introduced a symbol index structure with methods for rebuilding the index and searching symbols.
- Added regex patterns for extracting various code symbols from Rust source files.
- Implemented scoring logic for search results based on exact matches, prefix matches, and context relevance.
- Created a web search tool that interacts with a SearXNG instance to fetch documentation and API information based on user queries.
- Added a diff preview overlay for rendering git diff output with color-coded additions and deletions in a TUI interface.
This commit is contained in:
asepharyana
2026-07-20 16:59:27 +07:00
parent 785ae19757
commit fef3c925cd
17 changed files with 1968 additions and 0 deletions
+20
View File
@@ -11,6 +11,7 @@ use tracing::{debug, info, warn};
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
use zesdex_domain::core::ChatMessage;
use crate::llm::provider::LlmClient;
use crate::subagent::auto::engine::trigger_auto_review;
use crate::tools::{all_tools, tool_defs, ToolCtx};
use crate::TurnEvent;
@@ -25,6 +26,8 @@ pub struct AgentTurnParams {
pub api_key: String,
pub model: String,
pub api_base: Option<String>,
pub edit_count: u32,
pub consecutive_empty_reviews: u32,
}
/// Spawns an agent turn on a background OS thread.
@@ -198,6 +201,23 @@ fn run_turn(params: &mut AgentTurnParams) {
params
.messages
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
// Trigger auto-review after write/edit tool execution
if name == "write" || name == "edit" {
params.edit_count = params.edit_count.saturating_add(1);
// If we have a workspace root, trigger review
if let Some(root) = params.workspace_roots.first() {
let _ = trigger_auto_review(
root,
params.edit_count,
&mut params.consecutive_empty_reviews,
3, // max_skip: skip after 3 consecutive empty reviews
&params.turn_events,
Some(&client),
);
}
}
}
}
Err(e) => {
@@ -0,0 +1,280 @@
//! Auto-review engine — automatically checks git diff after file edits
//! using an LLM subagent.
//!
//! Flow: after each write/edit tool execution in the agent turn, the runner
//! calls `trigger_auto_review` which:
//! 1. Runs `git diff --cached` and `git diff` to get working-tree changes
//! 2. Sends the diff to a lightweight LLM call for quick review
//! 3. Emits findings as `TurnEvent::SystemNote` on the event queue
use std::collections::VecDeque;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use tracing::{debug, info, instrument, warn};
use crate::llm::provider::LlmClient;
use crate::subagent::gating::should_review;
use crate::TurnEvent;
/// Trigger an auto-review of recent git changes.
///
/// Flow:
/// 1. Check gating conditions (edit count, consecutive empty reviews)
/// 2. Run `git diff --cached` to get staged changes
/// 3. Run `git diff` to get unstaged changes
/// 4. If there are changes, call LLM for a quick review
/// 5. Emit findings as TurnEvent::SystemNote
///
/// Returns `(had_findings, total_findings)` tuple.
#[instrument(skip(turn_events, llm_client))]
pub fn trigger_auto_review(
workspace_root: &Path,
edit_count: u32,
consecutive_empty_reviews: &mut u32,
max_skip: u32,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
llm_client: Option<&LlmClient>,
) -> Result<(bool, usize)> {
// Gating check
if !should_review(edit_count, *consecutive_empty_reviews, max_skip) {
debug!("auto-review skipped by gating");
return Ok((false, 0));
}
info!("triggering auto-review");
// Run git diff to get changes
let diff = match get_git_diff(workspace_root) {
Ok(d) => d,
Err(e) => {
debug!(error = %e, "auto-review: git diff failed (not a git repo?)");
return Ok((false, 0));
}
};
if diff.is_empty() {
debug!("auto-review: no changes to review");
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
return Ok((false, 0));
}
// If we have an LLM client, do a real review
let review_result = if let Some(client) = llm_client {
perform_llm_review(client, &diff)?
} else {
// Fallback: simple heuristic review without LLM
perform_heuristic_review(&diff)
};
let had_findings = !review_result.is_empty();
let finding_count = review_result.len();
if had_findings {
*consecutive_empty_reviews = 0;
info!(finding_count, "auto-review produced findings");
// Emit findings as SystemNote events
for finding in &review_result {
let note = TurnEvent::SystemNote {
kind: "info".to_string(),
message: format!("🔍 Auto-Review: {finding}"),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(note);
}
}
} else {
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
info!("auto-review: no issues found");
}
Ok((had_findings, finding_count))
}
/// Run `git diff` to get workspace changes (both staged and unstaged).
fn get_git_diff(workspace_root: &Path) -> Result<String> {
// Check if this is a git repo
let git_dir = workspace_root.join(".git");
if !git_dir.exists() {
return Ok(String::new());
}
// Get unstaged diff
let unstaged = Command::new("git")
.arg("diff")
.current_dir(workspace_root)
.output()
.map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?;
// Get staged diff
let staged = Command::new("git")
.arg("diff")
.arg("--cached")
.current_dir(workspace_root)
.output()
.map_err(|e| anyhow::anyhow!("git diff --cached failed: {e}"))?;
let mut combined = String::new();
let staged_out = String::from_utf8_lossy(&staged.stdout).trim().to_string();
if !staged_out.is_empty() {
combined.push_str("=== Staged Changes ===\n");
combined.push_str(&staged_out);
combined.push('\n');
}
let unstaged_out = String::from_utf8_lossy(&unstaged.stdout).trim().to_string();
if !unstaged_out.is_empty() {
combined.push_str("=== Unstaged Changes ===\n");
combined.push_str(&unstaged_out);
combined.push('\n');
}
// Run `git diff --stat` for summary
let stat = Command::new("git")
.arg("diff")
.arg("--stat")
.current_dir(workspace_root)
.output()
.map_err(|e| anyhow::anyhow!("git diff --stat failed: {e}"))?;
let stat_out = String::from_utf8_lossy(&stat.stdout).trim().to_string();
if !stat_out.is_empty() {
combined.push_str("=== Summary ===\n");
combined.push_str(&stat_out);
combined.push('\n');
}
Ok(combined)
}
/// Perform an LLM-based review of the git diff.
///
/// Sends the diff to the model with a focused prompt asking it to find
/// typos, missing imports, syntax errors, and other issues.
fn perform_llm_review(client: &LlmClient, diff: &str) -> Result<Vec<String>> {
// Truncate very large diffs to avoid token limits
const MAX_DIFF_CHARS: usize = 8000;
let truncated_diff = if diff.len() > MAX_DIFF_CHARS {
format!("{}...\n[diff truncated at {} characters]",
&diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS)
} else {
diff.to_string()
};
let messages = vec![
zesdex_domain::core::ChatMessage::system(
"You are a focused code reviewer. Review the following git diff for:\n\
1. Typos and spelling errors\n\
2. Missing imports or undefined references\n\
3. Syntax errors or type mismatches\n\
4. Logic bugs or off-by-one errors\n\
5. Missing error handling\n\
6. Security issues\n\n\
Be concise. List each issue on a new line with severity [HIGH], [MEDIUM], or [LOW].\n\
If no issues are found, reply with exactly: NO_ISSUES_FOUND"
.to_string(),
),
zesdex_domain::core::ChatMessage::user(format!(
"Review this git diff:\n\n```diff\n{truncated_diff}\n```"
)),
];
match client.chat_with_tools_non_streaming(&messages, None, Some(1024), Some(0.3), None) {
Ok((response, _usage)) => {
let text = response.content.unwrap_or_default().trim().to_string();
if text.contains("NO_ISSUES_FOUND") {
return Ok(Vec::new());
}
// Parse findings line by line
let findings: Vec<String> = text
.lines()
.map(|l| l.trim().to_string())
.filter(|l| {
!l.is_empty()
&& !l.starts_with("Here")
&& !l.starts_with("Let me")
&& !l.starts_with("I've")
&& !l.starts_with("The diff")
})
.collect();
Ok(findings)
}
Err(e) => {
warn!(error = %e, "auto-review LLM call failed");
Ok(Vec::new())
}
}
}
/// Perform a simple heuristic-based review without an LLM call.
///
/// This is a fallback when no LLM client is available. It checks for:
/// - Missing semicolons
/// - Unclosed brackets
/// - `todo!()` or `unimplemented!()` macros left in code
/// - Debug print statements
/// - Extremely long functions
fn perform_heuristic_review(diff: &str) -> Vec<String> {
let mut findings = Vec::new();
// Check for added lines (lines starting with +)
let added_lines: Vec<&str> = diff
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.collect();
let added_content: String = added_lines
.iter()
.map(|l| &l[1..]) // Strip leading +
.collect::<Vec<&str>>()
.join("\n");
// Check for todo! and unimplemented!
if added_content.contains("todo!()") {
findings.push("[MEDIUM] `todo!()` found in new code — replace with implementation".to_string());
}
if added_content.contains("unimplemented!()") {
findings.push("[MEDIUM] `unimplemented!()` found in new code — replace with implementation".to_string());
}
// Check for debug print statements
if added_content.contains("println!") || added_content.contains("dbg!") {
findings.push("[LOW] Debug print statements (println!/dbg!) found — consider removing before finalizing".to_string());
}
if added_content.contains("eprintln!") {
findings.push("[LOW] Debug eprintln! found — consider removing before finalizing".to_string());
}
// Check for unreachable or panic statements
if added_content.contains("panic!(\"reached") || added_content.contains("panic!(\"not implemented") {
findings.push("[HIGH] Unreachable code / panic found — implement the missing logic".to_string());
}
// Check for very long lines (>120 chars)
for (i, line) in added_lines.iter().enumerate() {
let content = &line[1..]; // Strip leading +
if content.len() > 120 && !content.trim_start().starts_with("//") {
let preview: String = content.chars().take(80).collect();
findings.push(format!(
"[LOW] Very long line ({} chars, line {} in diff) — consider breaking up:\n `{}…`",
content.len(),
i + 1,
preview
));
}
}
// Count new functions to detect very long additions
let fn_count = added_content.matches("fn ").count();
if fn_count > 5 {
findings.push("[INFO] Large number of new functions ({fn_count}) — consider whether this should be split into separate modules".to_string());
}
findings
}
@@ -1,4 +1,5 @@
//! Auto-subagents — automatically run review/test subagents at the end of
//! each turn.
pub mod engine;
pub mod paths;
+1
View File
@@ -1,6 +1,7 @@
//! Subagent spawning and execution engine — spawn managed sub-processes
//! for test generation, architecture review, security review, etc.
pub mod auto;
pub mod context;
pub mod division;
pub mod engine;
+7
View File
@@ -19,13 +19,16 @@ pub mod fs;
pub mod git;
pub mod lsp;
pub mod memory;
pub mod parallel_delegate;
pub mod plan;
pub mod search;
pub mod semantic_search;
pub mod sequential_think;
pub mod shell;
pub mod shell_filter;
pub mod spawn;
pub mod utility;
pub mod web_search;
pub mod workflow;
pub use git::git_cred;
@@ -204,6 +207,10 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
Box::new(lsp::LspDefinition),
Box::new(lsp::LspReferences),
Box::new(lsp::LspDisconnect),
Box::new(web_search::WebSearch),
Box::new(semantic_search::SemanticSearch),
Box::new(semantic_search::RebuildIndex),
Box::new(parallel_delegate::ParallelDelegate),
]
}
@@ -0,0 +1,377 @@
//! Parallel delegation tool — splits a large task into sub-tasks that run
//! concurrently across multiple subagents, then synthesises results.
//!
//! Flow: receive task description + optional breakdown → LLM analyses the
//! task and splits it into parallel directives → spawn subagents for each →
//! collect results → return consolidated output.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::{debug, info, instrument, warn};
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
use zesdex_domain::core::Store;
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
use crate::subagent::context::SubagentContext;
use crate::subagent::division::AccessTier;
use crate::subagent::spawn::spawn_subagent;
use crate::tools::{Tool, ToolCtx};
/// Delegate a large task to multiple subagents running in parallel.
///
/// Flow:
/// 1. If `directives` are provided explicitly, use them directly.
/// 2. Otherwise, use LLM to analyse the task and split it into directives.
/// 3. Spawn a subagent for each directive concurrently.
/// 4. Join all results and consolidate into a single response.
pub struct ParallelDelegate;
impl Tool for ParallelDelegate {
fn name(&self) -> &'static str {
"parallel_delegate"
}
fn description(&self) -> &'static str {
"Delegate a large task to multiple subagents running in parallel for 3x faster completion"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The task to be split and delegated to parallel agents"
},
"directives": {
"type": "array",
"items": {
"type": "object",
"properties": {
"directive": {"type": "string", "description": "Directive for one parallel agent"},
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this agent (default: write)"}
},
"required": ["directive"]
},
"description": "Optional explicit list of parallel directives (if not provided, the LLM will auto-split the task)"
},
"synthesize": {
"type": "boolean",
"description": "Whether to synthesize results into a unified response (default: true)",
"default": true
},
"max_parallel": {
"type": "integer",
"description": "Maximum number of parallel agents (default: 3, max: 8)",
"default": 3
}
},
"required": ["task"]
})
}
#[instrument(skip(self, ctx, args))]
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let task = crate::tools::arg_str(args, "task")?;
let synthesize = args
.get("synthesize")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let max_parallel = args
.get("max_parallel")
.and_then(|v| v.as_u64())
.unwrap_or(3)
.min(8) as usize;
// Load LLM credentials
let store = Store::new();
let settings = JsonSettingsRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let (provider, model) =
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
let base_url = app_config
.providers
.get(&provider)
.map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
// Determine directives
let directives: Vec<(String, AccessTier)> = if let Some(dirs) =
args.get("directives").and_then(|v| v.as_array())
{
// Explicit directives provided
dirs.iter()
.filter_map(|d| {
let directive = d.get("directive").and_then(|v| v.as_str())?;
let access_str = d
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("write");
let access = match access_str {
"read" => AccessTier::Read,
"full" => AccessTier::Full,
_ => AccessTier::Write,
};
Some((directive.to_string(), access))
})
.collect()
} else {
// Auto-split using LLM
let rt = tokio::runtime::Runtime::new()?;
let directives = rt.block_on(auto_split_task(
&task,
max_parallel,
&base_url,
&api_key,
&model,
))?;
directives
};
if directives.is_empty() {
anyhow::bail!("no directives could be derived for the task");
}
let directive_count = directives.len();
info!(
task = %task,
directive_count,
max_parallel,
"parallel delegation: starting subagents"
);
// Spawn agents in parallel
let mut handles = Vec::new();
for (i, (directive, access)) in directives.iter().enumerate() {
let subagent_ctx = SubagentContext::new(
directive.clone(),
ctx.clone(),
format!("{access:?}"),
base_url.clone(),
api_key.clone(),
model.clone(),
);
debug!(agent_index = i, access = ?access, "spawning parallel agent");
let handle = spawn_subagent(
subagent_ctx,
directive.clone(),
access.clone(),
ctx.clone(),
);
handles.push((i, handle));
}
// Join all results
let mut results: Vec<(usize, String, String)> = Vec::new();
for (i, handle) in handles {
match handle.join() {
Ok(Ok(output)) => {
info!(agent_index = i, "parallel agent completed");
results.push((i, directives[i].0.clone(), output));
}
Ok(Err(e)) => {
warn!(agent_index = i, error = %e, "parallel agent failed");
results.push((
i,
directives[i].0.clone(),
format!("[ERROR] {e}"),
));
}
Err(e) => {
warn!(agent_index = i, error = ?e, "parallel agent panicked");
results.push((
i,
directives[i].0.clone(),
"[ERROR] Agent panicked".to_string(),
));
}
}
}
// Consolidate results
if synthesize && results.len() > 1 {
let consolidated = consolidate_results(&results, &base_url, &api_key, &model)?;
Ok(format!(
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}",
results.len(),
consolidated
))
} else {
let mut output = format!(
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n",
results.len()
);
for (i, directive, result) in &results {
output.push_str(&format!("---\n### Agent {}: {}\n\n{}\n", i, directive, result));
}
Ok(output)
}
}
}
/// Use LLM to analyse a task and split it into parallel directives.
async fn auto_split_task(
task: &str,
max_parallel: usize,
base_url: &str,
api_key: &str,
model: &str,
) -> Result<Vec<(String, AccessTier)>> {
let client = crate::llm::provider::LlmClient::new(
api_key.to_string(),
model.to_string(),
Some(base_url.to_string()),
);
let sys_msg = zesdex_domain::core::ChatMessage::system(
format!(
"You are a task decomposition expert. Split the following task into {max_parallel} \
independent sub-tasks that can run in parallel. Each sub-task must be self-contained \
and produce useful output independently.\n\n\
Output your response as a JSON array of objects, each with:\n\
- \"directive\": a clear, detailed instruction for a subagent\n\
- \"access\": one of \"read\", \"write\", or \"full\"\n\n\
IMPORTANT: Return ONLY valid JSON, no other text. Example:\n\
[{{\"directive\": \"Create the User model with fields...\", \"access\": \"write\"}}]"
)
);
let user_msg =
zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:"));
match client
.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.4), None)
{
Ok((response, _)) => {
let text = response.content.unwrap_or_default();
parse_directives_json(&text, max_parallel)
}
Err(e) => {
warn!(error = %e, "auto-split LLM call failed, using fallback splitting");
Ok(fallback_split(task, max_parallel))
}
}
}
/// Parse JSON directives from LLM response.
fn parse_directives_json(text: &str, max_parallel: usize) -> Result<Vec<(String, AccessTier)>> {
// Find JSON array in the response
let json_start = text.find('[');
let json_end = text.rfind(']');
let json_str = match (json_start, json_end) {
(Some(start), Some(end)) if end > start => &text[start..=end],
_ => {
// Try parsing the whole text as JSON
text.trim()
}
};
let parsed: Vec<Value> = serde_json::from_str(json_str)
.map_err(|e| anyhow::anyhow!("failed to parse directives JSON: {e}"))?;
let directives: Vec<(String, AccessTier)> = parsed
.into_iter()
.take(max_parallel)
.filter_map(|v| {
let directive = v.get("directive")?.as_str()?.to_string();
let access_str = v.get("access").and_then(|a| a.as_str()).unwrap_or("write");
let access = match access_str {
"read" => AccessTier::Read,
"full" => AccessTier::Full,
_ => AccessTier::Write,
};
Some((directive, access))
})
.collect();
Ok(directives)
}
/// Fallback splitting when LLM call fails.
fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)> {
// Simple heuristic split by common patterns
let mut directives = Vec::new();
if task.contains("backend") || task.contains("api") || task.contains("server") {
directives.push((
format!("Implement the backend/API components for: {task}"),
AccessTier::Write,
));
}
if task.contains("frontend") || task.contains("ui") || task.contains("client") {
directives.push((
format!("Implement the frontend/UI components for: {task}"),
AccessTier::Write,
));
}
if task.contains("test") || task.contains("unit") {
directives.push((
format!("Write unit tests for: {task}"),
AccessTier::Read,
));
}
if directives.is_empty() {
// Just split the task into generic chunks
for i in 0..max_parallel {
directives.push((
format!("Part {} of parallel task: {task}", i + 1),
AccessTier::Write,
));
}
}
directives.truncate(max_parallel);
directives
}
/// Use LLM to consolidate multiple agent results into a single response.
fn consolidate_results(
results: &[(usize, String, String)],
base_url: &str,
api_key: &str,
model: &str,
) -> Result<String> {
let client = crate::llm::provider::LlmClient::new(
api_key.to_string(),
model.to_string(),
Some(base_url.to_string()),
);
let mut summary = String::new();
for (i, directive, result) in results {
summary.push_str(&format!("### Agent {}: {}\n{}\n\n", i, directive, result));
}
let sys_msg = zesdex_domain::core::ChatMessage::system(
"You are a synthesis expert. Consolidate the following parallel agent outputs \
into a single coherent response. Remove duplication, reconcile conflicts, and \
present the unified result in a well-structured format."
.to_string(),
);
let user_msg = zesdex_domain::core::ChatMessage::user(format!(
"Consolidate the following parallel agent outputs:\n\n{summary}"
));
match client.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.3), None) {
Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())),
Err(e) => {
warn!(error = %e, "consolidation LLM call failed, using raw concatenation");
Ok(summary)
}
}
}
@@ -0,0 +1,690 @@
//! Semantic code search tool — indexes all code symbols (functions, structs,
//! enums, traits, modules) in a project and allows searching by name,
//! concept, or meaning.
//!
//! Flow: walk workspace files → parse Rust source for symbol declarations →
//! build in-memory index → search by fuzzy/prefix match on symbol names and
//! doc comments.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Mutex;
use tracing::{debug, info, instrument, warn};
// ---------------------------------------------------------------------------
// Symbol index types
// ---------------------------------------------------------------------------
/// The kind of a code symbol.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SymbolKind {
Function,
Struct,
Enum,
Trait,
Module,
Impl,
Type,
Constant,
Macro,
Other,
}
impl std::fmt::Display for SymbolKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SymbolKind::Function => write!(f, "fn"),
SymbolKind::Struct => write!(f, "struct"),
SymbolKind::Enum => write!(f, "enum"),
SymbolKind::Trait => write!(f, "trait"),
SymbolKind::Module => write!(f, "mod"),
SymbolKind::Impl => write!(f, "impl"),
SymbolKind::Type => write!(f, "type"),
SymbolKind::Constant => write!(f, "const"),
SymbolKind::Macro => write!(f, "macro"),
SymbolKind::Other => write!(f, "symbol"),
}
}
}
/// A single code symbol entry in the index.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeSymbol {
/// Symbol name (e.g. "run_agent", "AppStateRest").
pub name: String,
/// Kind of symbol.
pub kind: SymbolKind,
/// File path relative to workspace root.
pub file: String,
/// Line number (1-indexed).
pub line: usize,
/// Parent symbol (e.g. struct name for impl methods).
pub parent: Option<String>,
/// Doc comment text, if any.
pub doc_comment: Option<String>,
/// Short context (the declaration line).
pub context: String,
}
/// The in-memory symbol index, shared via a global static.
static SYMBOL_INDEX: Mutex<Option<SymbolIndex>> = Mutex::new(None);
/// Lazily compile regexes for symbol extraction.
fn compiled_regexes() -> (
Regex,
Regex,
Regex,
Regex,
Regex,
Regex,
Regex,
Regex,
Regex,
) {
(
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?type\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)").unwrap(),
Regex::new(r"(?m)^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)").unwrap(),
)
}
/// A symbol index cache.
#[derive(Debug, Clone)]
pub struct SymbolIndex {
symbols: Vec<CodeSymbol>,
workspace_path: Option<String>,
}
impl SymbolIndex {
pub fn new() -> Self {
SymbolIndex {
symbols: Vec::new(),
workspace_path: None,
}
}
pub fn is_empty(&self) -> bool {
self.symbols.is_empty()
}
pub fn len(&self) -> usize {
self.symbols.len()
}
pub fn rebuild(&mut self, workspace: &str) -> Result<usize> {
let path = std::path::Path::new(workspace);
if !path.exists() {
anyhow::bail!("workspace path does not exist: {workspace}");
}
let mut symbols = Vec::new();
let walker = ignore::Walk::new(path);
for entry in walker.flatten() {
let file_path = entry.path();
if !file_path.is_file() {
continue;
}
// Only index Rust files
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "rs" {
continue;
}
let rel_path = file_path
.strip_prefix(path)
.unwrap_or(file_path)
.display()
.to_string();
match std::fs::read_to_string(file_path) {
Ok(content) => {
let file_symbols = extract_symbols(&content, &rel_path);
symbols.extend(file_symbols);
}
Err(e) => {
debug!(file = %rel_path, error = %e, "failed to read file for indexing");
}
}
}
symbols.sort_by(|a, b| a.name.cmp(&b.name));
self.symbols = symbols;
self.workspace_path = Some(workspace.to_string());
let count = self.symbols.len();
info!(symbol_count = count, workspace = %workspace, "symbol index rebuilt");
Ok(count)
}
pub fn search(&self, query: &str, max_results: usize) -> Vec<&CodeSymbol> {
if self.symbols.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
let query_words: Vec<String> = query_lower.split_whitespace().map(|s| s.to_string()).collect();
let mut scored: Vec<(i32, &CodeSymbol)> = self
.symbols
.iter()
.filter_map(|sym| {
let score = score_symbol(sym, &query_lower, &query_words);
if score > 0 {
Some((score, sym))
} else {
None
}
})
.collect();
// Sort by score descending, then by name ascending
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
scored
.into_iter()
.take(max_results)
.map(|(_, sym)| sym)
.collect()
}
}
impl Default for SymbolIndex {
fn default() -> Self {
Self::new()
}
}
/// Score a symbol against a search query.
fn score_symbol(sym: &CodeSymbol, query_lower: &str, query_words: &[String]) -> i32 {
let name_lower = sym.name.to_lowercase();
let mut score: i32 = 0;
// Exact match = highest score
if name_lower == *query_lower {
score += 1000;
}
// Prefix match
if name_lower.starts_with(query_lower) {
score += 500;
}
// Contains
if name_lower.contains(query_lower) {
score += 200;
}
// Word-by-word matching
for word in query_words {
if name_lower.contains(word) {
score += 50;
}
}
// Doc comment match
if let Some(ref doc) = sym.doc_comment {
let doc_lower = doc.to_lowercase();
if doc_lower.contains(query_lower) {
score += 30;
}
for word in query_words {
if doc_lower.contains(word) {
score += 10;
}
}
}
// Context match
let context_lower = sym.context.to_lowercase();
if context_lower.contains(query_lower) {
score += 20;
}
score
}
/// Extract code symbols from Rust source content.
fn extract_symbols(content: &str, rel_path: &str) -> Vec<CodeSymbol> {
let (fn_re, struct_re, enum_re, trait_re, mod_re, impl_re, type_re, const_re, macro_re) =
compiled_regexes();
let mut symbols = Vec::new();
let lines: Vec<&str> = content.lines().collect();
// Extract doc comments that precede declarations
let doc_comments = extract_doc_comments(&lines);
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1;
let trimmed = line.trim();
// Check for function declarations
if let Some(caps) = fn_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Function,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for struct declarations
if let Some(caps) = struct_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Struct,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for enum declarations
if let Some(caps) = enum_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Enum,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for trait declarations
if let Some(caps) = trait_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Trait,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for module declarations
if let Some(caps) = mod_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Module,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: None,
context: trimmed.to_string(),
});
}
// Check for type alias declarations
if let Some(caps) = type_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Type,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for const declarations
if let Some(caps) = const_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let doc = doc_comments.get(&line_num).cloned();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Constant,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: doc,
context: trimmed.to_string(),
});
}
// Check for macro declarations
if let Some(caps) = macro_re.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
symbols.push(CodeSymbol {
name,
kind: SymbolKind::Macro,
file: rel_path.to_string(),
line: line_num,
parent: None,
doc_comment: None,
context: trimmed.to_string(),
});
}
// Parse impl blocks for method-level indexing
if let Some(caps) = impl_re.captures(trimmed) {
let impl_for = caps.get(1).unwrap().as_str().to_string();
// Look for methods inside this impl block
let mut brace_depth: i32 = 0;
let mut started = false;
for (j, l) in lines[i..].iter().enumerate() {
for ch in l.chars() {
match ch {
'{' => {
brace_depth += 1;
started = true;
}
'}' => {
brace_depth -= 1;
}
_ => {}
}
}
if started && brace_depth <= 0 && j > 1 {
break; // End of impl block
}
if j > 0 {
let inner_line = l.trim();
if let Some(mcaps) = fn_re.captures(inner_line) {
let method_name = mcaps.get(1).unwrap().as_str().to_string();
let abs_line = i + j + 1;
let doc = doc_comments.get(&abs_line).cloned();
symbols.push(CodeSymbol {
name: format!("{impl_for}::{method_name}"),
kind: SymbolKind::Function,
file: rel_path.to_string(),
line: abs_line,
parent: Some(impl_for.clone()),
doc_comment: doc,
context: inner_line.to_string(),
});
}
}
}
}
}
symbols
}
/// Extract doc comments (/// or //!) that precede each line.
fn extract_doc_comments(lines: &[&str]) -> HashMap<usize, String> {
let mut map = HashMap::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if line.starts_with("///") {
let mut doc = String::new();
while i < lines.len() {
let l = lines[i].trim();
if l.starts_with("///") {
if !doc.is_empty() {
doc.push(' ');
}
doc.push_str(l.trim_start_matches("///").trim());
i += 1;
} else {
break;
}
}
// Associate doc with the next non-empty, non-doc, non-attribute line
let target_line = find_next_declaration_line(lines, i);
if let Some(tl) = target_line {
map.insert(tl + 1, doc);
}
} else {
i += 1;
}
}
map
}
/// Find the next line that looks like a declaration (not doc, not attr).
fn find_next_declaration_line(lines: &[&str], start: usize) -> Option<usize> {
for i in start..lines.len() {
let trimmed = lines[i].trim();
if trimmed.is_empty()
|| trimmed.starts_with("///")
|| trimmed.starts_with("//!")
|| trimmed.starts_with('#')
{
continue;
}
return Some(i);
}
None
}
// ---------------------------------------------------------------------------
// Tool: SemanticSearch — search the symbol index
// ---------------------------------------------------------------------------
/// Search for code symbols by name, concept, or semantic meaning.
///
/// Flow: ensure index is built → search by query → return formatted results.
pub struct SemanticSearch;
impl Tool for SemanticSearch {
fn name(&self) -> &'static str {
"semantic_search"
}
fn description(&self) -> &'static str {
"Search for code symbols (functions, structs, enums, traits) by name, concept, or meaning"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query — function name, struct name, or concept (e.g. 'payment handler', 'auth middleware', 'user repository')"
},
"kind": {
"type": "string",
"enum": ["fn", "struct", "enum", "trait", "mod", "all"],
"description": "Filter by symbol kind (default: all)",
"default": "all"
},
"max_results": {
"type": "integer",
"description": "Maximum results (default 10, max 30)",
"default": 10
},
"rebuild_index": {
"type": "boolean",
"description": "Force rebuild the symbol index before searching (default false)",
"default": false
}
},
"required": ["query"]
})
}
#[instrument(skip(self, ctx, args))]
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let query = crate::tools::arg_str(args, "query")?;
let kind_filter = args
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("all");
let max_results = args
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(10)
.min(30) as usize;
let rebuild = args
.get("rebuild_index")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let workspace = ctx
.workspaces
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
info!(query = %query, kind = %kind_filter, max_results, rebuild, "semantic search");
// Get or rebuild the index
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
let index = guard.get_or_insert_with(SymbolIndex::new);
if rebuild || index.is_empty() {
let count = index.rebuild(&workspace)?;
debug!(symbol_count = count, "symbol index rebuilt");
}
let results = index.search(&query, max_results * 2); // Get extra for filtering
// Apply kind filter
let filtered: Vec<&&CodeSymbol> = if kind_filter != "all" {
let target_kind = match kind_filter {
"fn" => SymbolKind::Function,
"struct" => SymbolKind::Struct,
"enum" => SymbolKind::Enum,
"trait" => SymbolKind::Trait,
"mod" => SymbolKind::Module,
_ => SymbolKind::Other,
};
results
.iter()
.filter(|s| s.kind == target_kind)
.take(max_results)
.collect()
} else {
results.iter().take(max_results).collect()
};
if filtered.is_empty() {
return Ok(format!(
"No symbols found matching '{query}'.\n\
Try a different query, or use `rebuild_index: true` to rebuild the index first."
));
}
let total = index.len();
info!(matched = filtered.len(), total_indexed = total, "semantic search completed");
// Group results by file for cleaner output
let mut by_file: std::collections::BTreeMap<String, Vec<&&CodeSymbol>> =
std::collections::BTreeMap::new();
for sym in &filtered {
by_file.entry(sym.file.clone()).or_default().push(*sym);
}
let mut output = format!(
"## Semantic Search Results\n\n**Query:** {query}\n**Index size:** {total} symbols\n**Matches:** {}\n\n",
filtered.len()
);
for (file, symbols) in &by_file {
output.push_str(&format!("### `{file}`\n\n"));
for sym in symbols {
let kind_str = sym.kind.to_string();
let parent_str = sym
.parent
.as_ref()
.map(|p| format!(" [{p}]"))
.unwrap_or_default();
let doc_str = sym
.doc_comment
.as_ref()
.map(|d| {
let truncated: String = d.chars().take(100).collect();
format!("{truncated}")
})
.unwrap_or_default();
let context_trimmed = sym.context.trim();
let context_ellipsis = if context_trimmed.len() > 80 { "" } else { "" };
output.push_str(&format!(
"- `{kind_str}` **{}**{} at line {} `{}`{}{}\n",
sym.name,
parent_str,
sym.line,
context_trimmed,
doc_str,
context_ellipsis
));
}
output.push('\n');
}
output.push_str(&format!(
"---\n*{} symbols indexed. Use `rebuild_index: true` to refresh.*\n",
total
));
Ok(output)
}
}
/// Tool: Rebuild the symbol index explicitly.
pub struct RebuildIndex;
impl Tool for RebuildIndex {
fn name(&self) -> &'static str {
"rebuild_index"
}
fn description(&self) -> &'static str {
"Rebuild the code symbol index for semantic search"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
#[instrument(skip(self, ctx, _args))]
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
let workspace = ctx
.workspaces
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
info!("rebuilding symbol index");
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
let index = guard.get_or_insert_with(SymbolIndex::new);
let count = index.rebuild(&workspace)?;
Ok(format!(
"Symbol index rebuilt successfully. {} symbols indexed.",
count
))
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Web search & documentation scraper tool — search the web for docs, APIs,
//! and troubleshooting information using a SearXNG instance.
//!
//! Flow: take a search query → call SearXNG JSON API → parse results →
//! optionally fetch full page content → return formatted markdown.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::{debug, info, instrument, warn};
use crate::tools::{arg_str, Tool, ToolCtx};
/// Default SearXNG instance URL.
const DEFAULT_SEARXNG_URL: &str = "https://searxng.imrnes.team";
/// Search the web for documentation, APIs, and technical information.
///
/// Flow: build query → call SearXNG JSON endpoint → parse results →
/// optionally scrape full page content → return formatted result.
pub struct WebSearch;
impl Tool for WebSearch {
fn name(&self) -> &'static str {
"web_search"
}
fn description(&self) -> &'static str {
"Search the web for documentation, APIs, and technical information. Uses SearXNG instance."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. 'Next.js 15 app router documentation')"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return (default 5, max 10)",
"default": 5
},
"fetch_content": {
"type": "boolean",
"description": "Whether to fetch full page content from each result (default false)",
"default": false
},
"categories": {
"type": "string",
"description": "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general",
"default": "general"
}
},
"required": ["query"]
})
}
#[instrument(skip(self, _ctx, args))]
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let query = arg_str(args, "query")?;
let max_results = args
.get("max_results")
.and_then(|v| v.as_u64())
.unwrap_or(5)
.min(10) as usize;
let fetch_content = args
.get("fetch_content")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let categories = args
.get("categories")
.and_then(|v| v.as_str())
.unwrap_or("general");
info!(query = %query, max_results, fetch_content, categories = %categories, "web search starting");
let searxng_url = std::env::var("SEARXNG_URL")
.unwrap_or_else(|_| DEFAULT_SEARXNG_URL.to_string());
// Build the SearXNG JSON search URL
let search_url = format!(
"{}/search?format=json&q={}&categories={}&language=en-US",
searxng_url,
urlencoding(&query),
categories
);
debug!(search_url = %search_url, "calling SearXNG API");
// Make the HTTP request
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("Zesdex/1.0 (AI Coding Agent)")
.build()
.map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}"))?;
let response = client
.get(&search_url)
.send()
.map_err(|e| anyhow::anyhow!("search request failed: {e}"))?;
if !response.status().is_success() {
warn!(status = %response.status(), "SearXNG returned non-success status");
anyhow::bail!("SearXNG returned HTTP {}", response.status());
}
let body: Value = response
.json()
.map_err(|e| anyhow::anyhow!("failed to parse SearXNG JSON response: {e}"))?;
let results = body
.get("results")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter(|r| {
r.get("url").and_then(|u| u.as_str()).is_some()
&& r.get("title").and_then(|t| t.as_str()).is_some()
})
.take(max_results)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let result_count = results.len();
if results.is_empty() {
info!("no search results found");
return Ok(format!("No search results found for '{query}'."));
}
info!(result_count, "web search completed");
let mut output = format!("## Web Search Results for: {query}\n\n");
for (i, result) in results.iter().enumerate() {
let title = result
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
let url = result
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("");
let snippet = result
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
output.push_str(&format!("### {}. [{title}]({url})\n\n", i + 1));
if !snippet.is_empty() {
output.push_str(&format!("> {snippet}\n\n"));
}
// Optionally fetch full page content
if fetch_content && !url.is_empty() {
match fetch_page_content(url, &client) {
Ok(content) => {
let truncated = limit_lines(&content, 80);
output.push_str(&format!("**Content:**\n```\n{truncated}\n```\n\n"));
}
Err(e) => {
debug!(url = %url, error = %e, "failed to fetch page content");
output.push_str(&format!("*(Content fetch failed: {e})*\n\n"));
}
}
}
// Show engine info if available
if let Some(engine) = result.get("engine").and_then(|v| v.as_str()) {
output.push_str(&format!("*Source: {engine}*\n\n"));
}
}
// Add suggestion for more specific queries if very few results
if result_count < 3 {
output.push_str("---\n*Few results. Try a more specific query or different categories.*\n");
}
Ok(output)
}
}
/// URL-encode a string for use in query parameters.
fn urlencoding(input: &str) -> String {
input
.chars()
.map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
' ' => '+'.to_string(),
_ => {
let bytes = c.to_string().into_bytes();
bytes
.iter()
.map(|&b| format!("%{:02X}", b))
.collect::<String>()
}
})
.collect()
}
/// Fetch and extract readable text content from a URL.
fn fetch_page_content(url: &str, client: &reqwest::blocking::Client) -> Result<String> {
let resp = client
.get(url)
.timeout(std::time::Duration::from_secs(15))
.send()
.map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?;
let html = resp
.text()
.map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
// Parse HTML and extract text
let document = scraper::Html::parse_document(&html);
// Remove script and style elements
let selector = scraper::Selector::parse("script, style, nav, footer, header")
.map_err(|e| anyhow::anyhow!("invalid selector: {e}"))?;
let cleaned = document.clone();
for element in cleaned.select(&selector) {
let inner = element.inner_html();
let _ = inner;
}
// Extract text from main content areas
let content_selectors = [
"article",
"main",
".content",
".documentation",
".doc-content",
".post-content",
".entry-content",
"#content",
"body",
];
let mut text = String::new();
for sel_str in &content_selectors {
if let Ok(sel) = scraper::Selector::parse(sel_str) {
if let Some(element) = cleaned.select(&sel).next() {
text = element.text().collect::<Vec<_>>().join(" ");
if text.len() > 100 {
break;
}
}
}
}
if text.is_empty() {
// Fallback: just get all body text
if let Ok(body_sel) = scraper::Selector::parse("body") {
text = cleaned
.select(&body_sel)
.next()
.map(|e| e.text().collect::<Vec<_>>().join(" "))
.unwrap_or_default();
}
}
// Clean up whitespace
let cleaned_text = text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned_text.is_empty() {
anyhow::bail!("no readable content found on page");
}
Ok(cleaned_text)
}
/// Limit a string to at most `max_lines` lines.
fn limit_lines(s: &str, max_lines: usize) -> String {
s.lines()
.take(max_lines)
.collect::<Vec<_>>()
.join("\n")
}