Skip to main content

nika_engine/runtime/builtin/
router.rs

1//! BuiltinToolRouter for nika:* tool dispatch.
2//!
3//! Provides routing for 12 builtin tools:
4//!
5//! **Core tools (7):**
6//! - `nika:sleep` - Pause execution for duration
7//! - `nika:log` - Emit log event at level
8//! - `nika:emit` - Emit custom event to EventLog
9//! - `nika:assert` - Validate condition, fail if false
10//! - `nika:prompt` - HITL - request user input
11//! - `nika:run` - Execute nested workflow
12//! - `nika:complete` - Signal agent task completion
13//!
14//! **File tools (5) - requires ToolContext:**
15//! - `nika:read` - Read file with line numbers
16//! - `nika:write` - Create/overwrite file
17//! - `nika:edit` - Modify file (old_string → new_string)
18//! - `nika:glob` - Find files by pattern
19//! - `nika:grep` - Search content with regex
20
21use 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
31/// Router for builtin nika:* tools.
32///
33/// Dispatches tool calls to appropriate builtin implementations based on
34/// the nika: prefix.
35///
36/// # Example
37///
38/// ```ignore
39/// let router = BuiltinToolRouter::new();
40///
41/// // Check if tool is builtin
42/// if BuiltinToolRouter::is_builtin("nika:sleep") {
43///     let result = router.dispatch("nika:sleep", r#"{"duration":"1s"}"#).await?;
44/// }
45/// ```
46pub struct BuiltinToolRouter {
47    tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>>,
48}
49
50impl BuiltinToolRouter {
51    /// Create a new router with 7 core builtin tools (no file tools).
52    ///
53    /// For file tools (read, write, edit, glob, grep), use `with_file_tools()`.
54    pub fn new() -> Self {
55        let mut tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>> = FxHashMap::default();
56
57        // Register 7 core builtin tools
58        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    /// Create a router with all 12 builtin tools (7 core + 5 file tools).
70    ///
71    /// File tools require a `ToolContext` for working directory and permissions.
72    ///
73    /// # Example
74    ///
75    /// ```ignore
76    /// use std::sync::Arc;
77    /// use nika::tools::{ToolContext, PermissionMode};
78    ///
79    /// let ctx = Arc::new(ToolContext::new(
80    ///     std::env::current_dir().unwrap(),
81    ///     PermissionMode::YoloMode,
82    /// ));
83    /// let router = BuiltinToolRouter::with_file_tools(ctx);
84    ///
85    /// // Now supports nika:read, nika:write, etc.
86    /// assert!(router.has_tool("read"));
87    /// assert!(router.has_tool("write"));
88    /// ```
89    pub fn with_file_tools(ctx: Arc<ToolContext>) -> Self {
90        let mut router = Self::new();
91
92        // Register 5 file tools via adapter
93        for tool in create_file_tool_adapters(ctx) {
94            router.tools.insert(tool.name(), Arc::from(tool));
95        }
96
97        router
98    }
99
100    /// Create a router with all builtin tools (7 core + 5 file + N media).
101    ///
102    /// Media tools require a `MediaToolContext` for CAS access, budget, and compute pool.
103    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        // Register media tools via adapter
107        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    /// Check if a tool name is a builtin (has nika: prefix).
115    ///
116    /// # Example
117    /// ```ignore
118    /// assert!(BuiltinToolRouter::is_builtin("nika:sleep"));
119    /// assert!(!BuiltinToolRouter::is_builtin("novanet:describe"));
120    /// ```
121    #[inline]
122    pub fn is_builtin(tool_name: &str) -> bool {
123        tool_name.starts_with("nika:")
124    }
125
126    /// Extract the tool name from a nika: prefixed string.
127    ///
128    /// Returns None if the string doesn't start with "nika:".
129    ///
130    /// # Example
131    /// ```ignore
132    /// assert_eq!(BuiltinToolRouter::extract_name("nika:sleep"), Some("sleep"));
133    /// assert_eq!(BuiltinToolRouter::extract_name("novanet:x"), None);
134    /// ```
135    #[inline]
136    pub fn extract_name(tool_name: &str) -> Option<&str> {
137        tool_name.strip_prefix("nika:")
138    }
139
140    /// Check if the router has a specific tool registered.
141    pub fn has_tool(&self, name: &str) -> bool {
142        self.tools.contains_key(name)
143    }
144
145    /// Get all registered tool names.
146    pub fn tool_names(&self) -> Vec<&'static str> {
147        self.tools.keys().copied().collect()
148    }
149
150    /// Register a builtin tool.
151    pub fn register<T: BuiltinTool + 'static>(&mut self, tool: T) {
152        self.tools.insert(tool.name(), Arc::new(tool));
153    }
154
155    /// Dispatch a tool call to the appropriate builtin tool.
156    ///
157    /// # Arguments
158    /// * `tool_name` - Full tool name with nika: prefix (e.g., "nika:sleep")
159    /// * `args` - JSON-encoded arguments
160    ///
161    /// # Returns
162    /// * `Ok(String)` - JSON-encoded result from the tool
163    /// * `Err(NikaError)` - If tool not found or execution fails
164    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        // File tools
212        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        // Non-builtin
218        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        // new() does NOT include file tools
253        assert!(!router.has_tool("read"));
254        assert!(!router.has_tool("write"));
255        assert_eq!(router.tool_names().len(), 7); // 6 core + complete
256    }
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        // 7 core tools (6 original + complete)
264        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        // 5 file tools
273        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); // 7 core + 5 file
280    }
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        // Default router has all 7 core tools (6 original + complete)
364        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        // In headless mode with default, should use default
437        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        // Path canonicalization gives "resolve workflow path" error
458        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    // ═══════════════════════════════════════════════════════════════════════════
467    // FILE TOOL DISPATCH TESTS (via with_file_tools router)
468    // ═══════════════════════════════════════════════════════════════════════════
469
470    #[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        // Write file via router
477        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        // Read file via router
487        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        // Create test files
503        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        // Create test file
527        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        // Router without file tools
547        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}