nika_engine/runtime/builtin/
router.rs1use super::media::{context::MediaToolContext, create_media_tool_adapters};
22use super::{
23 create_file_tool_adapters, AssertTool, BuiltinTool, CompleteTool, EmitTool, LogTool,
24 PromptTool, RunTool, SleepTool,
25};
26use crate::error::NikaError;
27use crate::tools::ToolContext;
28use rustc_hash::FxHashMap;
29use std::sync::Arc;
30
31pub struct BuiltinToolRouter {
47 tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>>,
48}
49
50impl BuiltinToolRouter {
51 pub fn new() -> Self {
55 let mut tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>> = FxHashMap::default();
56
57 tools.insert("sleep", Arc::new(SleepTool));
59 tools.insert("log", Arc::new(LogTool));
60 tools.insert("emit", Arc::new(EmitTool));
61 tools.insert("assert", Arc::new(AssertTool));
62 tools.insert("prompt", Arc::new(PromptTool::default()));
63 tools.insert("run", Arc::new(RunTool));
64 tools.insert("complete", Arc::new(CompleteTool));
65
66 Self { tools }
67 }
68
69 pub fn with_file_tools(ctx: Arc<ToolContext>) -> Self {
90 let mut router = Self::new();
91
92 for tool in create_file_tool_adapters(ctx) {
94 router.tools.insert(tool.name(), Arc::from(tool));
95 }
96
97 router
98 }
99
100 pub fn with_all_tools(file_ctx: Arc<ToolContext>, media_ctx: Arc<MediaToolContext>) -> Self {
104 let mut router = Self::with_file_tools(file_ctx);
105
106 for tool in create_media_tool_adapters(media_ctx) {
108 router.tools.insert(tool.name(), Arc::from(tool));
109 }
110
111 router
112 }
113
114 #[inline]
122 pub fn is_builtin(tool_name: &str) -> bool {
123 tool_name.starts_with("nika:")
124 }
125
126 #[inline]
136 pub fn extract_name(tool_name: &str) -> Option<&str> {
137 tool_name.strip_prefix("nika:")
138 }
139
140 pub fn has_tool(&self, name: &str) -> bool {
142 self.tools.contains_key(name)
143 }
144
145 pub fn tool_names(&self) -> Vec<&'static str> {
147 self.tools.keys().copied().collect()
148 }
149
150 pub fn register<T: BuiltinTool + 'static>(&mut self, tool: T) {
152 self.tools.insert(tool.name(), Arc::new(tool));
153 }
154
155 pub async fn dispatch(&self, tool_name: &str, args: String) -> Result<String, NikaError> {
165 let name = Self::extract_name(tool_name).ok_or_else(|| NikaError::BuiltinToolError {
166 tool: tool_name.into(),
167 reason: "Not a builtin tool (missing nika: prefix)".into(),
168 })?;
169
170 let tool = self
171 .tools
172 .get(name)
173 .ok_or_else(|| NikaError::BuiltinToolError {
174 tool: tool_name.into(),
175 reason: format!("Unknown builtin tool: {}", name),
176 })?;
177
178 tool.call(args).await
179 }
180}
181
182impl Default for BuiltinToolRouter {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::tools::PermissionMode;
192 use tempfile::TempDir;
193
194 fn setup_test_context() -> (TempDir, Arc<ToolContext>) {
195 let temp_dir = TempDir::new().unwrap();
196 let ctx = Arc::new(ToolContext::new(
197 temp_dir.path().to_path_buf(),
198 PermissionMode::YoloMode,
199 ));
200 (temp_dir, ctx)
201 }
202
203 #[test]
204 fn test_router_is_builtin() {
205 assert!(BuiltinToolRouter::is_builtin("nika:sleep"));
206 assert!(BuiltinToolRouter::is_builtin("nika:log"));
207 assert!(BuiltinToolRouter::is_builtin("nika:emit"));
208 assert!(BuiltinToolRouter::is_builtin("nika:assert"));
209 assert!(BuiltinToolRouter::is_builtin("nika:prompt"));
210 assert!(BuiltinToolRouter::is_builtin("nika:run"));
211 assert!(BuiltinToolRouter::is_builtin("nika:read"));
213 assert!(BuiltinToolRouter::is_builtin("nika:write"));
214 assert!(BuiltinToolRouter::is_builtin("nika:edit"));
215 assert!(BuiltinToolRouter::is_builtin("nika:glob"));
216 assert!(BuiltinToolRouter::is_builtin("nika:grep"));
217 assert!(!BuiltinToolRouter::is_builtin("novanet:describe"));
219 assert!(!BuiltinToolRouter::is_builtin("sleep"));
220 assert!(!BuiltinToolRouter::is_builtin(""));
221 }
222
223 #[test]
224 fn test_router_extract_name() {
225 assert_eq!(BuiltinToolRouter::extract_name("nika:sleep"), Some("sleep"));
226 assert_eq!(BuiltinToolRouter::extract_name("nika:log"), Some("log"));
227 assert_eq!(BuiltinToolRouter::extract_name("nika:emit"), Some("emit"));
228 assert_eq!(
229 BuiltinToolRouter::extract_name("nika:assert"),
230 Some("assert")
231 );
232 assert_eq!(
233 BuiltinToolRouter::extract_name("nika:prompt"),
234 Some("prompt")
235 );
236 assert_eq!(BuiltinToolRouter::extract_name("nika:run"), Some("run"));
237 assert_eq!(BuiltinToolRouter::extract_name("novanet:x"), None);
238 assert_eq!(BuiltinToolRouter::extract_name("sleep"), None);
239 assert_eq!(BuiltinToolRouter::extract_name(""), None);
240 }
241
242 #[test]
243 fn test_router_new_has_6_core_tools() {
244 let router = BuiltinToolRouter::new();
245 assert!(router.has_tool("sleep"));
246 assert!(router.has_tool("log"));
247 assert!(router.has_tool("emit"));
248 assert!(router.has_tool("assert"));
249 assert!(router.has_tool("prompt"));
250 assert!(router.has_tool("run"));
251 assert!(router.has_tool("complete"));
252 assert!(!router.has_tool("read"));
254 assert!(!router.has_tool("write"));
255 assert_eq!(router.tool_names().len(), 7); }
257
258 #[test]
259 fn test_router_with_file_tools_has_12_tools() {
260 let (_temp, ctx) = setup_test_context();
261 let router = BuiltinToolRouter::with_file_tools(ctx);
262
263 assert!(router.has_tool("sleep"));
265 assert!(router.has_tool("log"));
266 assert!(router.has_tool("emit"));
267 assert!(router.has_tool("assert"));
268 assert!(router.has_tool("prompt"));
269 assert!(router.has_tool("run"));
270 assert!(router.has_tool("complete"));
271
272 assert!(router.has_tool("read"));
274 assert!(router.has_tool("write"));
275 assert!(router.has_tool("edit"));
276 assert!(router.has_tool("glob"));
277 assert!(router.has_tool("grep"));
278
279 assert_eq!(router.tool_names().len(), 12); }
281
282 #[test]
283 fn test_router_register_tool() {
284 struct TestTool;
285
286 impl BuiltinTool for TestTool {
287 fn name(&self) -> &'static str {
288 "test"
289 }
290
291 fn call<'a>(
292 &'a self,
293 _args: String,
294 ) -> std::pin::Pin<
295 Box<dyn std::future::Future<Output = Result<String, NikaError>> + Send + 'a>,
296 > {
297 Box::pin(async { Ok("test result".to_string()) })
298 }
299 }
300
301 let mut router = BuiltinToolRouter::new();
302 router.register(TestTool);
303
304 assert!(router.has_tool("test"));
305 assert!(!router.has_tool("unknown"));
306 }
307
308 #[tokio::test]
309 async fn test_router_dispatch_registered_tool() {
310 struct TestTool;
311
312 impl BuiltinTool for TestTool {
313 fn name(&self) -> &'static str {
314 "test"
315 }
316
317 fn call<'a>(
318 &'a self,
319 args: String,
320 ) -> std::pin::Pin<
321 Box<dyn std::future::Future<Output = Result<String, NikaError>> + Send + 'a>,
322 > {
323 Box::pin(async move { Ok(format!("received: {}", args)) })
324 }
325 }
326
327 let mut router = BuiltinToolRouter::new();
328 router.register(TestTool);
329
330 let result = router
331 .dispatch("nika:test", r#"{"hello":"world"}"#.to_string())
332 .await;
333
334 assert!(result.is_ok());
335 assert_eq!(result.unwrap(), r#"received: {"hello":"world"}"#);
336 }
337
338 #[tokio::test]
339 async fn test_router_dispatch_unknown_tool() {
340 let router = BuiltinToolRouter::new();
341
342 let result = router.dispatch("nika:unknown", "{}".to_string()).await;
343
344 assert!(result.is_err());
345 let err = result.unwrap_err();
346 assert!(err.to_string().contains("Unknown builtin tool"));
347 }
348
349 #[tokio::test]
350 async fn test_router_dispatch_not_builtin() {
351 let router = BuiltinToolRouter::new();
352
353 let result = router.dispatch("novanet:describe", "{}".to_string()).await;
354
355 assert!(result.is_err());
356 let err = result.unwrap_err();
357 assert!(err.to_string().contains("Not a builtin tool"));
358 }
359
360 #[test]
361 fn test_router_default() {
362 let router = BuiltinToolRouter::default();
363 assert_eq!(router.tool_names().len(), 7);
365 }
366
367 #[tokio::test]
368 async fn test_router_dispatch_sleep() {
369 let router = BuiltinToolRouter::new();
370 let result = router
371 .dispatch("nika:sleep", r#"{"duration":"1ms"}"#.to_string())
372 .await;
373
374 assert!(result.is_ok());
375 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
376 assert_eq!(response["slept_for_ms"], 1);
377 }
378
379 #[tokio::test]
380 async fn test_router_dispatch_log() {
381 let router = BuiltinToolRouter::new();
382 let result = router
383 .dispatch(
384 "nika:log",
385 r#"{"level":"info","message":"test"}"#.to_string(),
386 )
387 .await;
388
389 assert!(result.is_ok());
390 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
391 assert_eq!(response["logged"], true);
392 }
393
394 #[tokio::test]
395 async fn test_router_dispatch_emit() {
396 let router = BuiltinToolRouter::new();
397 let result = router
398 .dispatch(
399 "nika:emit",
400 r#"{"name":"test_event","payload":{}}"#.to_string(),
401 )
402 .await;
403
404 assert!(result.is_ok());
405 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
406 assert_eq!(response["emitted"], true);
407 }
408
409 #[tokio::test]
410 async fn test_router_dispatch_assert_true() {
411 let router = BuiltinToolRouter::new();
412 let result = router
413 .dispatch("nika:assert", r#"{"condition":true}"#.to_string())
414 .await;
415
416 assert!(result.is_ok());
417 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
418 assert_eq!(response["passed"], true);
419 }
420
421 #[tokio::test]
422 async fn test_router_dispatch_assert_false() {
423 let router = BuiltinToolRouter::new();
424 let result = router
425 .dispatch("nika:assert", r#"{"condition":false}"#.to_string())
426 .await;
427
428 assert!(result.is_err());
429 let err = result.unwrap_err();
430 assert!(err.to_string().contains("Assertion failed"));
431 }
432
433 #[tokio::test]
434 async fn test_router_dispatch_prompt_headless() {
435 let router = BuiltinToolRouter::new();
436 let result = router
438 .dispatch(
439 "nika:prompt",
440 r#"{"message":"Test?","default":"yes"}"#.to_string(),
441 )
442 .await;
443
444 assert!(result.is_ok());
445 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
446 assert_eq!(response["response"], "yes");
447 assert_eq!(response["default_used"], true);
448 }
449
450 #[tokio::test]
451 async fn test_router_dispatch_run_nonexistent_file() {
452 let router = BuiltinToolRouter::new();
453 let result = router
454 .dispatch("nika:run", r#"{"workflow":"test.nika.yaml"}"#.to_string())
455 .await;
456
457 assert!(result.is_err());
459 let err = result.unwrap_err();
460 assert!(
461 err.to_string().contains("resolve workflow path")
462 || err.to_string().contains("not found")
463 );
464 }
465
466 #[tokio::test]
471 async fn test_router_dispatch_write_then_read() {
472 let (temp_dir, ctx) = setup_test_context();
473 let router = BuiltinToolRouter::with_file_tools(ctx);
474 let file_path = temp_dir.path().join("test.txt");
475
476 let write_args = serde_json::json!({
478 "file_path": file_path.to_string_lossy(),
479 "content": "Hello from router!"
480 })
481 .to_string();
482
483 let result = router.dispatch("nika:write", write_args).await;
484 assert!(result.is_ok(), "Write failed: {:?}", result);
485
486 let read_args = serde_json::json!({
488 "file_path": file_path.to_string_lossy()
489 })
490 .to_string();
491
492 let result = router.dispatch("nika:read", read_args).await;
493 assert!(result.is_ok(), "Read failed: {:?}", result);
494 assert!(result.unwrap().contains("Hello from router!"));
495 }
496
497 #[tokio::test]
498 async fn test_router_dispatch_glob() {
499 let (temp_dir, ctx) = setup_test_context();
500 let router = BuiltinToolRouter::with_file_tools(ctx);
501
502 std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
504 std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
505 std::fs::write(temp_dir.path().join("c.md"), "c").unwrap();
506
507 let glob_args = serde_json::json!({
508 "pattern": "*.txt",
509 "path": temp_dir.path().to_string_lossy()
510 })
511 .to_string();
512
513 let result = router.dispatch("nika:glob", glob_args).await;
514 assert!(result.is_ok());
515 let output = result.unwrap();
516 assert!(output.contains("a.txt"));
517 assert!(output.contains("b.txt"));
518 assert!(!output.contains("c.md"));
519 }
520
521 #[tokio::test]
522 async fn test_router_dispatch_grep() {
523 let (temp_dir, ctx) = setup_test_context();
524 let router = BuiltinToolRouter::with_file_tools(ctx);
525
526 std::fs::write(
528 temp_dir.path().join("search.txt"),
529 "Line 1: foo\nLine 2: bar\nLine 3: foo bar",
530 )
531 .unwrap();
532
533 let grep_args = serde_json::json!({
534 "pattern": "foo",
535 "path": temp_dir.path().to_string_lossy()
536 })
537 .to_string();
538
539 let result = router.dispatch("nika:grep", grep_args).await;
540 assert!(result.is_ok());
541 assert!(result.unwrap().contains("search.txt"));
542 }
543
544 #[tokio::test]
545 async fn test_router_dispatch_file_tool_not_found_without_context() {
546 let router = BuiltinToolRouter::new();
548
549 let result = router
550 .dispatch(
551 "nika:write",
552 r#"{"file_path":"x","content":"y"}"#.to_string(),
553 )
554 .await;
555
556 assert!(result.is_err());
557 let err = result.unwrap_err();
558 assert!(err.to_string().contains("Unknown builtin tool"));
559 }
560}