Skip to main content

oxios_kernel/tools/builtin/
knowledge_tool.rs

1//! Knowledge tool — agent-facing tool for markdown note management.
2//!
3//! Provides a single `knowledge` tool with action-based dispatch, following
4//! the same pattern as `memory_tools.rs`. Actions: read, write, delete,
5//! move, tree, search, backlinks.
6
7use async_trait::async_trait;
8use std::sync::Arc;
9
10use chrono::Datelike;
11use oxi_sdk::{AgentTool as OxiAgentTool, AgentToolResult, ToolContext};
12use serde_json::{Value, json};
13
14use crate::KernelHandle;
15use oxios_markdown::KnowledgeBase;
16use oxios_markdown::types::{NoteMeta, NoteQuality, NoteSource};
17
18/// Tool for reading, writing, and managing markdown knowledge notes.
19///
20/// Uses action-based dispatch: `read`, `write`, `delete`, `move`, `tree`,
21/// `search`, `backlinks`.
22///
23/// Delegates directly to [`KnowledgeBase`] for all operations.
24pub struct KnowledgeTool {
25    kb: Arc<KnowledgeBase>,
26}
27
28impl KnowledgeTool {
29    /// Create from a [`KernelHandle`], extracting KnowledgeBase directly.
30    pub fn from_kernel(kernel: &KernelHandle) -> Self {
31        Self {
32            kb: kernel.knowledge.clone(),
33        }
34    }
35
36    /// Create with explicit KnowledgeBase (for testing).
37    pub fn new(kb: Arc<KnowledgeBase>) -> Self {
38        Self { kb }
39    }
40}
41
42impl std::fmt::Debug for KnowledgeTool {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("KnowledgeTool").finish()
45    }
46}
47
48#[async_trait]
49
50impl OxiAgentTool for KnowledgeTool {
51    fn name(&self) -> &str {
52        "knowledge"
53    }
54
55    fn label(&self) -> &str {
56        "Knowledge"
57    }
58
59    fn description(&self) -> &'static str {
60        "Personal markdown vault — documents, articles, notes, journal entries. File-based with backlinks, full-text search, and directory structure. Read, write, search, and organize user content as markdown files."
61    }
62
63    fn parameters_schema(&self) -> Value {
64        json!({
65            "type": "object",
66            "properties": {
67                "action": {
68                    "type": "string",
69                    "enum": [
70                    "read", "write", "delete", "move", "tree", "search", "backlinks",
71                    "checklist_items", "checklist_add", "checklist_complete", "checklist_remove",
72                    "chat_append", "chat_messages", "chat_delete", "chat_move",
73                    "journal_add", "journal_emoji", "journal_today",
74                    "habits", "habits_last_week",
75                    "today_report", "done_today",
76                    "config_read", "config_write",
77                    "nightly_cleanup", "run_scheduled",
78                    "markdown_to_html", "auto_emoji"
79                ],
80                    "description": "The action to perform"
81                },
82                "path": {
83                    "type": "string",
84                    "description": "Note path (e.g., 'brain/Rust.md' or 'Chat.md')"
85                },
86                "content": {
87                    "type": "string",
88                    "description": "Content for write action"
89                },
90                "old_path": {
91                    "type": "string",
92                    "description": "Old path for move action"
93                },
94                "new_path": {
95                    "type": "string",
96                    "description": "New path for move action"
97                },
98                "dir": {
99                    "type": "string",
100                    "description": "Directory for tree action (default: root)"
101                },
102                "query": {
103                    "type": "string",
104                    "description": "Search query for search action"
105                },
106                "limit": {
107                    "type": "integer",
108                    "description": "Max results for search/tree (default: 20)"
109                },
110                "item": {
111                    "type": "string",
112                    "description": "Checklist item text (for checklist_add)"
113                },
114                "checked": {
115                    "type": "boolean",
116                    "description": "Whether the checklist item is checked (for checklist_add, default: false)"
117                },
118                "item_hash": {
119                    "type": "string",
120                    "description": "Hash identifying a checklist or chat item (for checklist_complete, chat_delete, chat_move)"
121                },
122                "item_or_hash": {
123                    "type": "string",
124                    "description": "Checklist item text or hash (for checklist_remove)"
125                },
126                "message": {
127                    "type": "string",
128                    "description": "Chat message text (for chat_append)"
129                },
130                "msg_hash": {
131                    "type": "string",
132                    "description": "Hash identifying a chat message (for chat_delete, chat_move)"
133                },
134                "target_path": {
135                    "type": "string",
136                    "description": "Target note path (for chat_move)"
137                },
138                "record": {
139                    "type": "string",
140                    "description": "Journal record text (for journal_add)"
141                },
142                "emoji": {
143                    "type": "string",
144                    "description": "Emoji string (for journal_emoji)"
145                },
146                "year": {
147                    "type": "integer",
148                    "description": "Year number (for habits action)"
149                },
150                "config": {
151                    "type": "object",
152                    "description": "KnowledgeConfig JSON object (for config_write)"
153                },
154                "md": {
155                    "type": "string",
156                    "description": "Markdown text to convert (for markdown_to_html)"
157                },
158                "text": {
159                    "type": "string",
160                    "description": "Text to find emoji for (for auto_emoji)"
161                }
162            },
163            "required": ["action"]
164        })
165    }
166
167    async fn execute(
168        &self,
169        _tool_call_id: &str,
170        params: Value,
171        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
172        _ctx: &ToolContext,
173    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
174        let action = params["action"].as_str().unwrap_or("");
175        if action.is_empty() {
176            return Ok(AgentToolResult::error("action is required"));
177        }
178
179        match action {
180            "read" => {
181                let path = params["path"].as_str().unwrap_or("");
182                if path.is_empty() {
183                    return Ok(AgentToolResult::error("path is required for read"));
184                }
185                match self.kb.note_read(path) {
186                    Ok(Some(content)) => Ok(AgentToolResult::success(&content)),
187                    Ok(None) => Ok(AgentToolResult::error(format!("Note '{path}' not found"))),
188                    Err(e) => Ok(AgentToolResult::error(format!("Failed to read note: {e}"))),
189                }
190            }
191            "write" => {
192                let path = params["path"].as_str().unwrap_or("");
193                let content = params["content"].as_str().unwrap_or("");
194                if path.is_empty() {
195                    return Ok(AgentToolResult::error("path is required for write"));
196                }
197                if content.is_empty() {
198                    return Ok(AgentToolResult::error("content is required for write"));
199                }
200                let meta = NoteMeta {
201                    author: "agent".to_string(),
202                    source: NoteSource::Tool,
203                    quality: NoteQuality::Raw,
204                    needs_review: true,
205                    session_id: None,
206                    message_index: None,
207                    saved_at: Some(chrono::Utc::now().to_rfc3339()),
208                };
209                match self.kb.note_write_with_meta(path, content, &meta) {
210                    Ok(true) => Ok(AgentToolResult::success(format!(
211                        "Note '{path}' written successfully"
212                    ))),
213                    Ok(false) => Ok(AgentToolResult::error(format!(
214                        "Note '{path}' is a user-authored file, not overwriting"
215                    ))),
216                    Err(e) => Ok(AgentToolResult::error(format!("Failed to write note: {e}"))),
217                }
218            }
219            "delete" => {
220                let path = params["path"].as_str().unwrap_or("");
221                if path.is_empty() {
222                    return Ok(AgentToolResult::error("path is required for delete"));
223                }
224                match self.kb.note_delete(path) {
225                    Ok(()) => Ok(AgentToolResult::success(format!("Note '{path}' deleted"))),
226                    Err(e) => Ok(AgentToolResult::error(format!(
227                        "Failed to delete note: {e}"
228                    ))),
229                }
230            }
231            "move" => {
232                let old_path = params["old_path"]
233                    .as_str()
234                    .or_else(|| {
235                        // Also accept "path" as old_path if old_path not provided
236                        if params["new_path"].as_str().is_some() {
237                            params["path"].as_str()
238                        } else {
239                            None
240                        }
241                    })
242                    .unwrap_or("");
243                let new_path = params["new_path"].as_str().unwrap_or("");
244                if old_path.is_empty() || new_path.is_empty() {
245                    return Ok(AgentToolResult::error(
246                        "old_path and new_path are required for move",
247                    ));
248                }
249                match self.kb.note_move(old_path, new_path) {
250                    Ok(()) => Ok(AgentToolResult::success(format!(
251                        "Note moved from '{old_path}' to '{new_path}'"
252                    ))),
253                    Err(e) => Ok(AgentToolResult::error(format!("Failed to move note: {e}"))),
254                }
255            }
256            "tree" => {
257                let dir = params["dir"].as_str().unwrap_or("/");
258                let limit = params["limit"].as_u64().unwrap_or(50) as usize;
259                match self.kb.note_tree(dir) {
260                    Ok(entries) => {
261                        let count = entries.len();
262                        let entries: Vec<_> = entries.into_iter().take(limit).collect();
263                        if entries.is_empty() {
264                            return Ok(AgentToolResult::success("Directory is empty"));
265                        }
266                        let mut output =
267                            format!("Found {} entries (showing {}):\n\n", count, entries.len());
268                        for entry in &entries {
269                            let kind = if entry.is_dir { "📁" } else { "📄" };
270                            output.push_str(&format!(
271                                "{} {} ({})\n",
272                                kind, entry.display_name, entry.name
273                            ));
274                        }
275                        Ok(AgentToolResult::success(&output))
276                    }
277                    Err(e) => Ok(AgentToolResult::error(format!("Failed to list notes: {e}"))),
278                }
279            }
280            "search" => {
281                let query = params["query"].as_str().unwrap_or("");
282                if query.is_empty() {
283                    return Ok(AgentToolResult::error("query is required for search"));
284                }
285                let limit = params["limit"].as_u64().unwrap_or(10) as usize;
286                match self.kb.search(query, limit) {
287                    Ok(hits) => {
288                        if hits.is_empty() {
289                            return Ok(AgentToolResult::success("No matching notes found"));
290                        }
291                        let mut output = format!("Found {} matching notes:\n\n", hits.len());
292                        for hit in &hits {
293                            output.push_str(&format!(
294                                "- {} (path: {}, backlinks: {}, name_sim: {}%)\n",
295                                hit.name, hit.path, hit.backlink_count, hit.name_similarity,
296                            ));
297                        }
298                        Ok(AgentToolResult::success(&output))
299                    }
300                    Err(e) => Ok(AgentToolResult::error(format!(
301                        "Failed to search notes: {e}"
302                    ))),
303                }
304            }
305            "backlinks" => {
306                let path = params["path"].as_str().unwrap_or("");
307                if path.is_empty() {
308                    return Ok(AgentToolResult::error("path is required for backlinks"));
309                }
310                let backlinks = self.kb.backlinks_for(path);
311                if backlinks.is_empty() {
312                    return Ok(AgentToolResult::success(format!(
313                        "No backlinks for '{path}'"
314                    )));
315                }
316                let mut output = format!("Backlinks for '{}' ({}):\n\n", path, backlinks.len());
317                for bl in &backlinks {
318                    output.push_str(&format!(
319                        "- {} → {} (line {})\n",
320                        bl.source_path, bl.target_path, bl.line_number
321                    ));
322                }
323                Ok(AgentToolResult::success(&output))
324            }
325            // ── Checklist ─────────────────────────────────────────
326            "checklist_items" => {
327                let path = params["path"].as_str().unwrap_or("");
328                if path.is_empty() {
329                    return Ok(AgentToolResult::error(
330                        "path is required for checklist_items",
331                    ));
332                }
333                match self.kb.checklist_items(path) {
334                    Ok((items, checked_map)) => {
335                        if items.is_empty() {
336                            return Ok(AgentToolResult::success("No checklist items found"));
337                        }
338                        let mut output =
339                            format!("Checklist items for '{}' ({}):\n\n", path, items.len());
340                        for item in &items {
341                            let status = checked_map
342                                .get(item)
343                                .map(|b| if *b { "✅" } else { "⬜" })
344                                .unwrap_or("⬜");
345                            output.push_str(&format!("{status} {item}\n"));
346                        }
347                        Ok(AgentToolResult::success(&output))
348                    }
349                    Err(e) => Ok(AgentToolResult::error(format!(
350                        "Failed to get checklist items: {e}"
351                    ))),
352                }
353            }
354
355            "checklist_add" => {
356                let path = params["path"].as_str().unwrap_or("");
357                let item = params["item"].as_str().unwrap_or("");
358                let checked = params["checked"].as_bool().unwrap_or(false);
359                if path.is_empty() {
360                    return Ok(AgentToolResult::error("path is required for checklist_add"));
361                }
362                if item.is_empty() {
363                    return Ok(AgentToolResult::error("item is required for checklist_add"));
364                }
365                match self.kb.checklist_add(path, item, checked) {
366                    Ok(()) => Ok(AgentToolResult::success(format!(
367                        "Checklist item added to '{path}'"
368                    ))),
369                    Err(e) => Ok(AgentToolResult::error(format!(
370                        "Failed to add checklist item: {e}"
371                    ))),
372                }
373            }
374
375            "checklist_complete" => {
376                let path = params["path"].as_str().unwrap_or("");
377                let item_hash = params["item_hash"].as_str().unwrap_or("");
378                if path.is_empty() {
379                    return Ok(AgentToolResult::error(
380                        "path is required for checklist_complete",
381                    ));
382                }
383                if item_hash.is_empty() {
384                    return Ok(AgentToolResult::error(
385                        "item_hash is required for checklist_complete",
386                    ));
387                }
388                match self.kb.checklist_complete(path, item_hash) {
389                    Ok(true) => Ok(AgentToolResult::success(format!(
390                        "Checklist item completed in '{path}'"
391                    ))),
392                    Ok(false) => Ok(AgentToolResult::error(format!(
393                        "Checklist item '{item_hash}' not found in '{path}'"
394                    ))),
395                    Err(e) => Ok(AgentToolResult::error(format!(
396                        "Failed to complete checklist item: {e}"
397                    ))),
398                }
399            }
400
401            "checklist_remove" => {
402                let path = params["path"].as_str().unwrap_or("");
403                let item_or_hash = params["item_or_hash"].as_str().unwrap_or("");
404                if path.is_empty() {
405                    return Ok(AgentToolResult::error(
406                        "path is required for checklist_remove",
407                    ));
408                }
409                if item_or_hash.is_empty() {
410                    return Ok(AgentToolResult::error(
411                        "item_or_hash is required for checklist_remove",
412                    ));
413                }
414                match self.kb.checklist_remove(path, item_or_hash) {
415                    Ok(true) => Ok(AgentToolResult::success(format!(
416                        "Checklist item removed from '{path}'"
417                    ))),
418                    Ok(false) => Ok(AgentToolResult::error(format!(
419                        "Checklist item '{item_or_hash}' not found in '{path}'"
420                    ))),
421                    Err(e) => Ok(AgentToolResult::error(format!(
422                        "Failed to remove checklist item: {e}"
423                    ))),
424                }
425            }
426
427            // ── Chat ────────────────────────────────────────────────
428            "chat_append" => {
429                let message = params["message"].as_str().unwrap_or("");
430                if message.is_empty() {
431                    return Ok(AgentToolResult::error(
432                        "message is required for chat_append",
433                    ));
434                }
435                match self.kb.chat_append(message) {
436                    Ok(()) => Ok(AgentToolResult::success("Message appended to chat")),
437                    Err(e) => Ok(AgentToolResult::error(format!(
438                        "Failed to append chat message: {e}"
439                    ))),
440                }
441            }
442
443            "chat_messages" => match self.kb.chat_messages() {
444                Ok(messages) => {
445                    if messages.is_empty() {
446                        return Ok(AgentToolResult::success("No chat messages found"));
447                    }
448                    let mut output = format!("Chat messages ({}):\n\n", messages.len());
449                    for (i, msg) in messages.iter().enumerate() {
450                        output.push_str(&format!("{}. {}\n", i + 1, msg));
451                    }
452                    Ok(AgentToolResult::success(&output))
453                }
454                Err(e) => Ok(AgentToolResult::error(format!(
455                    "Failed to get chat messages: {e}"
456                ))),
457            },
458
459            "chat_delete" => {
460                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
461                if msg_hash.is_empty() {
462                    return Ok(AgentToolResult::error(
463                        "msg_hash is required for chat_delete",
464                    ));
465                }
466                match self.kb.chat_delete(msg_hash) {
467                    Ok(true) => Ok(AgentToolResult::success(format!(
468                        "Chat message '{msg_hash}' deleted"
469                    ))),
470                    Ok(false) => Ok(AgentToolResult::error(format!(
471                        "Chat message '{msg_hash}' not found"
472                    ))),
473                    Err(e) => Ok(AgentToolResult::error(format!(
474                        "Failed to delete chat message: {e}"
475                    ))),
476                }
477            }
478
479            "chat_move" => {
480                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
481                let target_path = params["target_path"].as_str().unwrap_or("");
482                if msg_hash.is_empty() {
483                    return Ok(AgentToolResult::error("msg_hash is required for chat_move"));
484                }
485                if target_path.is_empty() {
486                    return Ok(AgentToolResult::error(
487                        "target_path is required for chat_move",
488                    ));
489                }
490                match self.kb.chat_move_to(msg_hash, target_path) {
491                    Ok(true) => Ok(AgentToolResult::success(format!(
492                        "Chat message moved to '{target_path}'"
493                    ))),
494                    Ok(false) => Ok(AgentToolResult::error(format!(
495                        "Chat message '{msg_hash}' not found"
496                    ))),
497                    Err(e) => Ok(AgentToolResult::error(format!(
498                        "Failed to move chat message: {e}"
499                    ))),
500                }
501            }
502
503            // ── Journal ─────────────────────────────────────────────
504            "journal_add" => {
505                let record = params["record"].as_str().unwrap_or("");
506                if record.is_empty() {
507                    return Ok(AgentToolResult::error("record is required for journal_add"));
508                }
509                match self.kb.journal_add_record(record) {
510                    Ok(()) => Ok(AgentToolResult::success("Journal record added")),
511                    Err(e) => Ok(AgentToolResult::error(format!(
512                        "Failed to add journal record: {e}"
513                    ))),
514                }
515            }
516
517            "journal_emoji" => {
518                let emoji = params["emoji"].as_str().unwrap_or("");
519                if emoji.is_empty() {
520                    return Ok(AgentToolResult::error(
521                        "emoji is required for journal_emoji",
522                    ));
523                }
524                match self.kb.journal_add_emoji(emoji) {
525                    Ok(()) => Ok(AgentToolResult::success(format!(
526                        "Journal emoji set to '{emoji}'"
527                    ))),
528                    Err(e) => Ok(AgentToolResult::error(format!(
529                        "Failed to set journal emoji: {e}"
530                    ))),
531                }
532            }
533
534            "journal_today" => {
535                let path = self.kb.journal_today_path();
536                Ok(AgentToolResult::success(&path))
537            }
538
539            // ── Habits ──────────────────────────────────────────────
540            "habits" => {
541                let year = params["year"]
542                    .as_i64()
543                    .unwrap_or_else(|| chrono::Local::now().year() as i64)
544                    as i32;
545                match self.kb.habits(year) {
546                    Ok(habits) => {
547                        let json = serde_json::to_string_pretty(&habits)
548                            .unwrap_or_else(|_| "{}".to_string());
549                        Ok(AgentToolResult::success(&json))
550                    }
551                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get habits: {e}"))),
552                }
553            }
554
555            "habits_last_week" => match self.kb.habits_last_week() {
556                Ok(habits) => {
557                    let json =
558                        serde_json::to_string_pretty(&habits).unwrap_or_else(|_| "{}".to_string());
559                    Ok(AgentToolResult::success(&json))
560                }
561                Err(e) => Ok(AgentToolResult::error(format!(
562                    "Failed to get last week habits: {e}"
563                ))),
564            },
565
566            // ── Stats ───────────────────────────────────────────────
567            "today_report" => match self.kb.today_report() {
568                Ok(report) => {
569                    let json =
570                        serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string());
571                    Ok(AgentToolResult::success(&json))
572                }
573                Err(e) => Ok(AgentToolResult::error(format!(
574                    "Failed to get today report: {e}"
575                ))),
576            },
577
578            "done_today" => match self.kb.done_today() {
579                Ok(entries) => {
580                    if entries.is_empty() {
581                        return Ok(AgentToolResult::success("No completed items today"));
582                    }
583                    let mut output = format!("Done today ({}):\n\n", entries.len());
584                    for entry in &entries {
585                        let kind = if entry.is_dir { "📁" } else { "📄" };
586                        output.push_str(&format!(
587                            "{} {} ({})\n",
588                            kind, entry.display_name, entry.name
589                        ));
590                    }
591                    Ok(AgentToolResult::success(&output))
592                }
593                Err(e) => Ok(AgentToolResult::error(format!(
594                    "Failed to get done today: {e}"
595                ))),
596            },
597
598            // ── Config ──────────────────────────────────────────────
599            "config_read" => match self.kb.config() {
600                Ok(config) => {
601                    let json =
602                        serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".to_string());
603                    Ok(AgentToolResult::success(&json))
604                }
605                Err(e) => Ok(AgentToolResult::error(format!(
606                    "Failed to read config: {e}"
607                ))),
608            },
609
610            "config_write" => {
611                let config_val = params.get("config").cloned().unwrap_or(json!({}));
612                match serde_json::from_value::<oxios_markdown::types::KnowledgeConfig>(config_val) {
613                    Ok(config) => match self.kb.set_config(&config) {
614                        Ok(()) => Ok(AgentToolResult::success("Config updated successfully")),
615                        Err(e) => Ok(AgentToolResult::error(format!(
616                            "Failed to write config: {e}"
617                        ))),
618                    },
619                    Err(e) => Ok(AgentToolResult::error(format!(
620                        "Invalid config object: {e}"
621                    ))),
622                }
623            }
624
625            // ── Automation ──────────────────────────────────────────
626            "nightly_cleanup" => match self.kb.run_nightly_cleanup() {
627                Ok(report) => {
628                    let json =
629                        serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string());
630                    Ok(AgentToolResult::success(&json))
631                }
632                Err(e) => Ok(AgentToolResult::error(format!(
633                    "Failed to run nightly cleanup: {e}"
634                ))),
635            },
636
637            "run_scheduled" => match self.kb.run_scheduled_tasks() {
638                Ok(moved) => {
639                    if moved.is_empty() {
640                        Ok(AgentToolResult::success("No scheduled tasks due"))
641                    } else {
642                        let mut output =
643                            format!("Moved {} scheduled tasks to chat:\n\n", moved.len());
644                        for task in &moved {
645                            output.push_str(&format!("- {task}\n"));
646                        }
647                        Ok(AgentToolResult::success(&output))
648                    }
649                }
650                Err(e) => Ok(AgentToolResult::error(format!(
651                    "Failed to run scheduled tasks: {e}"
652                ))),
653            },
654
655            // ── Utils ───────────────────────────────────────────────
656            "markdown_to_html" => {
657                let md = params["md"].as_str().unwrap_or("");
658                if md.is_empty() {
659                    return Ok(AgentToolResult::error(
660                        "md is required for markdown_to_html",
661                    ));
662                }
663                let html = self.kb.markdown_to_html(md);
664                Ok(AgentToolResult::success(&html))
665            }
666
667            "auto_emoji" => {
668                let text = params["text"].as_str().unwrap_or("");
669                if text.is_empty() {
670                    return Ok(AgentToolResult::error("text is required for auto_emoji"));
671                }
672                let emoji = self.kb.auto_emoji(text);
673                Ok(AgentToolResult::success(&emoji))
674            }
675
676            _ => Ok(AgentToolResult::error(format!(
677                "Unknown action '{action}'. Must be one of: read, write, delete, move, tree, search, backlinks, \
678             checklist_items, checklist_add, checklist_complete, checklist_remove, \
679             chat_append, chat_messages, chat_delete, chat_move, \
680             journal_add, journal_emoji, journal_today, \
681             habits, habits_last_week, today_report, done_today, \
682             config_read, config_write, nightly_cleanup, run_scheduled, \
683             markdown_to_html, auto_emoji"
684            ))),
685        }
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn test_knowledge_tool_schema() {
695        let dir = std::env::temp_dir().join(format!("test-kb-tool-{}", uuid::Uuid::new_v4()));
696        let kb = Arc::new(oxios_markdown::KnowledgeBase::new(dir).unwrap());
697        let tool = KnowledgeTool::new(kb);
698        assert_eq!(tool.name(), "knowledge");
699        let schema = tool.parameters_schema();
700        assert!(schema["required"].is_array());
701        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
702        assert_eq!(actions.len(), 28);
703    }
704}