Skip to main content

synwire_agent/tools/
index.rs

1//! `index.*` tool provider for semantic indexing operations.
2//!
3//! These tools control the indexing pipeline (walk, chunk, embed, store)
4//! and provide document-level search.
5
6use synwire_core::error::SynwireError;
7use synwire_core::tools::{
8    StaticToolProvider, StructuredTool, Tool, ToolOutput, ToolProvider, ToolSchema,
9};
10
11/// Build a tool provider for `index.*` tools.
12///
13/// The returned provider includes:
14/// - `index.build` (trigger indexing pipeline)
15/// - `index.status` (check indexing progress)
16/// - `index.search_docs` (semantic document search)
17/// - `index.search_docs_hybrid` (combined semantic + keyword document search)
18///
19/// # Errors
20///
21/// Returns [`SynwireError`] if any tool fails validation.
22pub fn index_tool_provider() -> Result<Box<dyn ToolProvider>, SynwireError> {
23    let tools: Vec<Box<dyn Tool>> = vec![
24        Box::new(build_index_build()?),
25        Box::new(build_index_status()?),
26        Box::new(build_index_search_docs()?),
27        Box::new(build_index_search_docs_hybrid()?),
28    ];
29    Ok(Box::new(StaticToolProvider::new(tools)))
30}
31
32/// Create a stub tool that returns a "not configured" message.
33fn stub_response(tool_name: &str) -> ToolOutput {
34    ToolOutput {
35        content: format!(
36            "{tool_name}: not configured. This tool requires the indexing daemon. \
37             Configure the daemon to enable this tool."
38        ),
39        ..Default::default()
40    }
41}
42
43fn build_index_build() -> Result<StructuredTool, SynwireError> {
44    StructuredTool::builder()
45        .name("index.build")
46        .description(
47            "Trigger or resume the indexing pipeline for the current project. \
48             Walks files, chunks with tree-sitter, embeds, and stores vectors.",
49        )
50        .schema(ToolSchema {
51            name: "index.build".into(),
52            description: "Trigger the indexing pipeline".into(),
53            parameters: serde_json::json!({
54                "type": "object",
55                "properties": {
56                    "force": {
57                        "type": "boolean",
58                        "description": "Force full re-index (default: false, incremental)"
59                    },
60                    "paths": {
61                        "type": "array",
62                        "items": { "type": "string" },
63                        "description": "Restrict indexing to specific paths"
64                    }
65                },
66                "additionalProperties": false,
67            }),
68        })
69        .func(|_input| Box::pin(async { Ok(stub_response("index.build")) }))
70        .build()
71}
72
73fn build_index_status() -> Result<StructuredTool, SynwireError> {
74    StructuredTool::builder()
75        .name("index.status")
76        .description(
77            "Check the current indexing progress and statistics: files indexed, \
78             chunks stored, last update time, and any errors.",
79        )
80        .schema(ToolSchema {
81            name: "index.status".into(),
82            description: "Check indexing progress and statistics".into(),
83            parameters: serde_json::json!({
84                "type": "object",
85                "properties": {},
86                "additionalProperties": false,
87            }),
88        })
89        .func(|_input| Box::pin(async { Ok(stub_response("index.status")) }))
90        .build()
91}
92
93fn build_index_search_docs() -> Result<StructuredTool, SynwireError> {
94    StructuredTool::builder()
95        .name("index.search_docs")
96        .description(
97            "Search indexed documents using semantic similarity (embedding-based). \
98             Returns ranked document chunks with file paths and relevance scores.",
99        )
100        .schema(ToolSchema {
101            name: "index.search_docs".into(),
102            description: "Semantic document search".into(),
103            parameters: serde_json::json!({
104                "type": "object",
105                "properties": {
106                    "query": {
107                        "type": "string",
108                        "description": "Natural language search query"
109                    },
110                    "limit": {
111                        "type": "integer",
112                        "description": "Maximum number of results (default: 10)"
113                    },
114                    "file_filter": {
115                        "type": "string",
116                        "description": "Glob pattern to restrict search to matching files"
117                    }
118                },
119                "required": ["query"],
120                "additionalProperties": false,
121            }),
122        })
123        .func(|_input| Box::pin(async { Ok(stub_response("index.search_docs")) }))
124        .build()
125}
126
127fn build_index_search_docs_hybrid() -> Result<StructuredTool, SynwireError> {
128    StructuredTool::builder()
129        .name("index.search_docs_hybrid")
130        .description(
131            "Search indexed documents using combined semantic and keyword matching. \
132             Merges embedding similarity with BM25 text relevance.",
133        )
134        .schema(ToolSchema {
135            name: "index.search_docs_hybrid".into(),
136            description: "Hybrid semantic + keyword document search".into(),
137            parameters: serde_json::json!({
138                "type": "object",
139                "properties": {
140                    "query": {
141                        "type": "string",
142                        "description": "Natural language search query"
143                    },
144                    "limit": {
145                        "type": "integer",
146                        "description": "Maximum number of results (default: 10)"
147                    },
148                    "file_filter": {
149                        "type": "string",
150                        "description": "Glob pattern to restrict search to matching files"
151                    },
152                    "semantic_weight": {
153                        "type": "number",
154                        "description": "Weight for semantic score (0.0-1.0, default: 0.6)"
155                    }
156                },
157                "required": ["query"],
158                "additionalProperties": false,
159            }),
160        })
161        .func(|_input| Box::pin(async { Ok(stub_response("index.search_docs_hybrid")) }))
162        .build()
163}
164
165#[cfg(test)]
166#[allow(clippy::unwrap_used)]
167mod tests {
168    use super::*;
169
170    #[tokio::test]
171    async fn index_provider_discovers_all_tools() {
172        let provider = index_tool_provider().unwrap();
173        let tools = provider.discover_tools().await.unwrap();
174        assert_eq!(tools.len(), 4);
175    }
176
177    #[tokio::test]
178    async fn index_provider_get_by_name() {
179        let provider = index_tool_provider().unwrap();
180        let tool = provider.get_tool("index.build").await.unwrap();
181        assert!(tool.is_some());
182        let missing = provider.get_tool("index.nonexistent").await.unwrap();
183        assert!(missing.is_none());
184    }
185
186    #[tokio::test]
187    async fn stub_tools_return_not_configured() {
188        let provider = index_tool_provider().unwrap();
189        let tool = provider.get_tool("index.status").await.unwrap().unwrap();
190        let output = tool.invoke(serde_json::json!({})).await.unwrap();
191        assert!(output.content.contains("not configured"));
192    }
193}