synwire_agent/tools/
index.rs1use synwire_core::error::SynwireError;
7use synwire_core::tools::{
8 StaticToolProvider, StructuredTool, Tool, ToolOutput, ToolProvider, ToolSchema,
9};
10
11pub 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
32fn 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}