Skip to main content

zeroclaw_tools/
knowledge_tool.rs

1//! Knowledge management tool for capturing, searching, and reusing expertise.
2//!
3//! Exposes the knowledge graph to the agent via the `Tool` trait with actions:
4//! capture, search, relate, suggest, expert_find, lessons_extract, graph_stats.
5
6use async_trait::async_trait;
7use serde_json::json;
8use std::sync::Arc;
9use zeroclaw_api::tool::{Tool, ToolResult};
10use zeroclaw_memory::knowledge_graph::{KnowledgeGraph, NodeType, Relation};
11
12/// Tool for managing a knowledge graph of patterns, decisions, lessons, and experts.
13pub struct KnowledgeTool {
14    graph: Arc<KnowledgeGraph>,
15}
16
17impl KnowledgeTool {
18    pub fn new(graph: Arc<KnowledgeGraph>) -> Self {
19        Self { graph }
20    }
21}
22
23#[async_trait]
24impl Tool for KnowledgeTool {
25    fn name(&self) -> &str {
26        "knowledge"
27    }
28
29    fn description(&self) -> &str {
30        "Manage a knowledge graph of architecture decisions, solution patterns, lessons learned, and experts. Actions: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats."
31    }
32
33    fn parameters_schema(&self) -> serde_json::Value {
34        json!({
35            "type": "object",
36            "properties": {
37                "action": {
38                    "type": "string",
39                    "enum": ["capture", "search", "relate", "suggest", "expert_find", "lessons_extract", "graph_stats"],
40                    "description": "The action to perform"
41                },
42                "node_type": {
43                    "type": "string",
44                    "enum": ["pattern", "decision", "lesson", "expert", "technology"],
45                    "description": "Type of knowledge node (for capture)"
46                },
47                "title": {
48                    "type": "string",
49                    "description": "Title for the knowledge item (for capture)"
50                },
51                "content": {
52                    "type": "string",
53                    "description": "Content body (for capture) or text to extract lessons from (for lessons_extract)"
54                },
55                "tags": {
56                    "type": "array",
57                    "items": { "type": "string" },
58                    "description": "Tags for filtering and categorization"
59                },
60                "source_project": {
61                    "type": "string",
62                    "description": "Source project identifier (for capture)"
63                },
64                "query": {
65                    "type": "string",
66                    "description": "Search query text (for search, suggest)"
67                },
68                "from_id": {
69                    "type": "string",
70                    "description": "Source node ID (for relate)"
71                },
72                "to_id": {
73                    "type": "string",
74                    "description": "Target node ID (for relate)"
75                },
76                "relation": {
77                    "type": "string",
78                    "enum": ["uses", "replaces", "extends", "authored_by", "applies_to"],
79                    "description": "Relationship type (for relate)"
80                },
81                "filters": {
82                    "type": "object",
83                    "properties": {
84                        "node_type": { "type": "string" },
85                        "tags": { "type": "array", "items": { "type": "string" } },
86                        "project": { "type": "string" }
87                    },
88                    "description": "Optional search filters"
89                }
90            },
91            "required": ["action"]
92        })
93    }
94
95    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
96        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
97            ::zeroclaw_log::record!(
98                WARN,
99                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
100                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
101                    .with_attrs(::serde_json::json!({"param": "action"})),
102                "knowledge_tool: missing action parameter"
103            );
104            anyhow::Error::msg("missing 'action' parameter")
105        })?;
106
107        match action {
108            "capture" => self.handle_capture(&args),
109            "search" => self.handle_search(&args),
110            "relate" => self.handle_relate(&args),
111            "suggest" => self.handle_suggest(&args),
112            "expert_find" => self.handle_expert_find(&args),
113            "lessons_extract" => self.handle_lessons_extract(&args),
114            "graph_stats" => self.handle_graph_stats(),
115            other => Ok(ToolResult {
116                success: false,
117                output: String::new(),
118                error: Some(format!("unknown action: {other}")),
119            }),
120        }
121    }
122}
123
124impl KnowledgeTool {
125    fn handle_capture(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
126        let node_type_str = args
127            .get("node_type")
128            .and_then(|v| v.as_str())
129            .ok_or_else(|| {
130                ::zeroclaw_log::record!(
131                    WARN,
132                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
133                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
134                        .with_attrs(::serde_json::json!({
135                            "action": "capture",
136                            "param": "node_type",
137                        })),
138                    "knowledge_tool: capture missing node_type"
139                );
140                anyhow::Error::msg("missing 'node_type' for capture")
141            })?;
142        let title = args.get("title").and_then(|v| v.as_str()).ok_or_else(|| {
143            ::zeroclaw_log::record!(
144                WARN,
145                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
146                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
147                    .with_attrs(::serde_json::json!({
148                        "action": "capture",
149                        "param": "title",
150                    })),
151                "knowledge_tool: capture missing title"
152            );
153            anyhow::Error::msg("missing 'title' for capture")
154        })?;
155        let content = args
156            .get("content")
157            .and_then(|v| v.as_str())
158            .ok_or_else(|| {
159                ::zeroclaw_log::record!(
160                    WARN,
161                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
162                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
163                        .with_attrs(::serde_json::json!({
164                            "action": "capture",
165                            "param": "content",
166                        })),
167                    "knowledge_tool: capture missing content"
168                );
169                anyhow::Error::msg("missing 'content' for capture")
170            })?;
171
172        let node_type = NodeType::parse(node_type_str).map_err(|e| {
173            ::zeroclaw_log::record!(
174                WARN,
175                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
176                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
177                    .with_attrs(::serde_json::json!({
178                        "node_type": node_type_str,
179                        "error": format!("{}", e),
180                    })),
181                "knowledge_tool: invalid node_type"
182            );
183            anyhow::Error::msg(format!("{e}"))
184        })?;
185
186        let tags: Vec<String> = args
187            .get("tags")
188            .and_then(|v| v.as_array())
189            .map(|arr| {
190                arr.iter()
191                    .filter_map(|v| v.as_str().map(String::from))
192                    .collect()
193            })
194            .unwrap_or_default();
195
196        let source_project = args.get("source_project").and_then(|v| v.as_str());
197
198        match self
199            .graph
200            .add_node(node_type, title, content, &tags, source_project)
201        {
202            Ok(id) => Ok(ToolResult {
203                success: true,
204                output: json!({ "node_id": id }).to_string(),
205                error: None,
206            }),
207            Err(e) => Ok(ToolResult {
208                success: false,
209                output: String::new(),
210                error: Some(format!("capture failed: {e}")),
211            }),
212        }
213    }
214
215    fn handle_search(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
216        let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
217
218        // Apply optional filters.
219        let filter_tags: Vec<String> = args
220            .get("filters")
221            .and_then(|f| f.get("tags"))
222            .and_then(|v| v.as_array())
223            .map(|arr| {
224                arr.iter()
225                    .filter_map(|v| v.as_str().map(String::from))
226                    .collect()
227            })
228            .unwrap_or_default();
229
230        let filter_type = args
231            .get("filters")
232            .and_then(|f| f.get("node_type"))
233            .and_then(|v| v.as_str());
234
235        let filter_project = args
236            .get("filters")
237            .and_then(|f| f.get("project"))
238            .and_then(|v| v.as_str());
239
240        // Parse the node_type filter once so it applies in all code paths.
241        let parsed_filter_type = filter_type.and_then(|ft| NodeType::parse(ft).ok());
242
243        let results = if query.is_empty() && !filter_tags.is_empty() {
244            // Tag-only search -- apply node_type and project filters consistently.
245            let mut nodes = self.graph.query_by_tags(&filter_tags)?;
246            if let Some(ref nt) = parsed_filter_type {
247                nodes.retain(|n| &n.node_type == nt);
248            }
249            if let Some(proj) = filter_project {
250                nodes.retain(|n| n.source_project.as_deref() == Some(proj));
251            }
252            nodes
253                .into_iter()
254                .map(|node| json!({ "id": node.id, "type": node.node_type, "title": node.title, "score": 1.0 }))
255                .collect::<Vec<_>>()
256        } else if !query.is_empty() {
257            let mut search_results = self.graph.query_by_similarity(query, 20)?;
258
259            // Post-filter by type if specified.
260            if let Some(ref nt) = parsed_filter_type {
261                search_results.retain(|r| &r.node.node_type == nt);
262            }
263            // Post-filter by project if specified.
264            if let Some(proj) = filter_project {
265                search_results.retain(|r| r.node.source_project.as_deref() == Some(proj));
266            }
267            // Post-filter by tags if specified.
268            if !filter_tags.is_empty() {
269                search_results.retain(|r| filter_tags.iter().all(|t| r.node.tags.contains(t)));
270            }
271
272            search_results
273                .into_iter()
274                .map(|r| {
275                    json!({
276                        "id": r.node.id,
277                        "type": r.node.node_type,
278                        "title": r.node.title,
279                        "score": r.score
280                    })
281                })
282                .collect::<Vec<_>>()
283        } else {
284            Vec::new()
285        };
286
287        Ok(ToolResult {
288            success: true,
289            output: json!({ "results": results, "count": results.len() }).to_string(),
290            error: None,
291        })
292    }
293
294    fn handle_relate(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
295        let from_id = args
296            .get("from_id")
297            .and_then(|v| v.as_str())
298            .ok_or_else(|| {
299                ::zeroclaw_log::record!(
300                    WARN,
301                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
302                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
303                        .with_attrs(::serde_json::json!({
304                            "action": "relate",
305                            "param": "from_id",
306                        })),
307                    "knowledge_tool: relate missing from_id"
308                );
309                anyhow::Error::msg("missing 'from_id' for relate")
310            })?;
311        let to_id = args.get("to_id").and_then(|v| v.as_str()).ok_or_else(|| {
312            ::zeroclaw_log::record!(
313                WARN,
314                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
315                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
316                    .with_attrs(::serde_json::json!({
317                        "action": "relate",
318                        "param": "to_id",
319                    })),
320                "knowledge_tool: relate missing to_id"
321            );
322            anyhow::Error::msg("missing 'to_id' for relate")
323        })?;
324        let relation_str = args
325            .get("relation")
326            .and_then(|v| v.as_str())
327            .ok_or_else(|| {
328                ::zeroclaw_log::record!(
329                    WARN,
330                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
331                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
332                        .with_attrs(::serde_json::json!({
333                            "action": "relate",
334                            "param": "relation",
335                        })),
336                    "knowledge_tool: relate missing relation"
337                );
338                anyhow::Error::msg("missing 'relation' for relate")
339            })?;
340
341        let relation = Relation::parse(relation_str).map_err(|e| {
342            ::zeroclaw_log::record!(
343                WARN,
344                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
345                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
346                    .with_attrs(::serde_json::json!({
347                        "relation": relation_str,
348                        "error": format!("{}", e),
349                    })),
350                "knowledge_tool: invalid relation"
351            );
352            anyhow::Error::msg(format!("{e}"))
353        })?;
354
355        match self.graph.add_edge(from_id, to_id, relation) {
356            Ok(()) => Ok(ToolResult {
357                success: true,
358                output: "relationship created".to_string(),
359                error: None,
360            }),
361            Err(e) => Ok(ToolResult {
362                success: false,
363                output: String::new(),
364                error: Some(format!("relate failed: {e}")),
365            }),
366        }
367    }
368
369    fn handle_suggest(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
370        let query = args
371            .get("query")
372            .or_else(|| args.get("content"))
373            .and_then(|v| v.as_str())
374            .ok_or_else(|| {
375                ::zeroclaw_log::record!(
376                    WARN,
377                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
378                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
379                        .with_attrs(::serde_json::json!({
380                            "action": "suggest",
381                            "missing": "query_or_content",
382                        })),
383                    "knowledge_tool: suggest missing query/content"
384                );
385                anyhow::Error::msg("missing 'query' or 'content' for suggest")
386            })?;
387
388        let results = self.graph.query_by_similarity(query, 10)?;
389        let suggestions: Vec<serde_json::Value> = results
390            .into_iter()
391            .map(|r| {
392                json!({
393                    "id": r.node.id,
394                    "type": r.node.node_type,
395                    "title": r.node.title,
396                    "content_preview": truncate_str(&r.node.content, 200),
397                    "tags": r.node.tags,
398                    "relevance_score": r.score,
399                })
400            })
401            .collect();
402
403        Ok(ToolResult {
404            success: true,
405            output: json!({ "suggestions": suggestions, "count": suggestions.len() }).to_string(),
406            error: None,
407        })
408    }
409
410    fn handle_expert_find(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
411        let tags: Vec<String> = args
412            .get("tags")
413            .and_then(|v| v.as_array())
414            .map(|arr| {
415                arr.iter()
416                    .filter_map(|v| v.as_str().map(String::from))
417                    .collect()
418            })
419            .unwrap_or_default();
420
421        if tags.is_empty() {
422            return Ok(ToolResult {
423                success: false,
424                output: String::new(),
425                error: Some("missing 'tags' for expert_find".into()),
426            });
427        }
428
429        let experts = self.graph.find_experts(&tags)?;
430        let output: Vec<serde_json::Value> = experts
431            .into_iter()
432            .map(|r| {
433                json!({
434                    "id": r.node.id,
435                    "name": r.node.title,
436                    "contribution_score": r.score,
437                    "tags": r.node.tags,
438                })
439            })
440            .collect();
441
442        Ok(ToolResult {
443            success: true,
444            output: json!({ "experts": output, "count": output.len() }).to_string(),
445            error: None,
446        })
447    }
448
449    fn handle_lessons_extract(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
450        let text = args
451            .get("content")
452            .and_then(|v| v.as_str())
453            .ok_or_else(|| {
454                ::zeroclaw_log::record!(
455                    WARN,
456                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
457                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
458                        .with_attrs(::serde_json::json!({
459                            "action": "lessons_extract",
460                            "param": "content",
461                        })),
462                    "knowledge_tool: lessons_extract missing content"
463                );
464                anyhow::Error::msg("missing 'content' for lessons_extract")
465            })?;
466
467        // Simple keyword-based extraction: split on sentence boundaries, score by
468        // signal keywords that commonly indicate lessons.
469        let signal_words = [
470            "learned",
471            "lesson",
472            "mistake",
473            "should have",
474            "next time",
475            "improvement",
476            "better",
477            "avoid",
478            "risk",
479            "issue",
480            "root cause",
481            "takeaway",
482            "insight",
483            "recommendation",
484            "decision",
485        ];
486
487        let sentences: Vec<&str> = text
488            .split(&['.', '!', '?', '\n'][..])
489            .map(str::trim)
490            .filter(|s| s.len() > 10)
491            .collect();
492
493        let mut lessons: Vec<serde_json::Value> = Vec::new();
494        for sentence in &sentences {
495            let lower = sentence.to_ascii_lowercase();
496            let score: f64 = signal_words.iter().filter(|w| lower.contains(**w)).count() as f64;
497            if score > 0.0 {
498                lessons.push(json!({
499                    "text": sentence,
500                    "confidence": (score / signal_words.len() as f64).min(1.0),
501                }));
502            }
503        }
504
505        lessons.sort_by(|a, b| {
506            let sa = a["confidence"].as_f64().unwrap_or(0.0);
507            let sb = b["confidence"].as_f64().unwrap_or(0.0);
508            sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
509        });
510        lessons.truncate(10);
511
512        Ok(ToolResult {
513            success: true,
514            output: json!({ "lessons": lessons, "count": lessons.len() }).to_string(),
515            error: None,
516        })
517    }
518
519    fn handle_graph_stats(&self) -> anyhow::Result<ToolResult> {
520        match self.graph.stats() {
521            Ok(stats) => Ok(ToolResult {
522                success: true,
523                output: serde_json::to_string(&stats).unwrap_or_default(),
524                error: None,
525            }),
526            Err(e) => Ok(ToolResult {
527                success: false,
528                output: String::new(),
529                error: Some(format!("failed to get stats: {e}")),
530            }),
531        }
532    }
533}
534
535fn truncate_str(s: &str, max_len: usize) -> String {
536    if s.chars().count() <= max_len {
537        s.to_string()
538    } else {
539        let truncated: String = s.chars().take(max_len).collect();
540        format!("{truncated}...")
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use tempfile::TempDir;
548    use zeroclaw_memory::knowledge_graph::KnowledgeGraph;
549
550    fn test_tool() -> (TempDir, KnowledgeTool) {
551        let tmp = TempDir::new().unwrap();
552        let db_path = tmp.path().join("knowledge.db");
553        let graph = Arc::new(KnowledgeGraph::new(&db_path, 10000).unwrap());
554        (tmp, KnowledgeTool::new(graph))
555    }
556
557    #[tokio::test]
558    async fn capture_returns_node_id() {
559        let (_tmp, tool) = test_tool();
560        let result = tool
561            .execute(json!({
562                "action": "capture",
563                "node_type": "pattern",
564                "title": "Circuit Breaker",
565                "content": "Use circuit breaker for external calls",
566                "tags": ["resilience", "microservices"]
567            }))
568            .await
569            .unwrap();
570
571        assert!(result.success);
572        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
573        assert!(output["node_id"].is_string());
574    }
575
576    #[tokio::test]
577    async fn search_returns_results() {
578        let (_tmp, tool) = test_tool();
579        tool.execute(json!({
580            "action": "capture",
581            "node_type": "decision",
582            "title": "Use Kubernetes",
583            "content": "Kubernetes for container orchestration",
584            "tags": ["infrastructure"]
585        }))
586        .await
587        .unwrap();
588
589        let result = tool
590            .execute(json!({
591                "action": "search",
592                "query": "Kubernetes container"
593            }))
594            .await
595            .unwrap();
596
597        assert!(result.success);
598        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
599        assert!(output["count"].as_u64().unwrap() > 0);
600    }
601
602    #[tokio::test]
603    async fn relate_creates_edge() {
604        let (_tmp, tool) = test_tool();
605
606        let r1 = tool
607            .execute(json!({
608                "action": "capture",
609                "node_type": "pattern",
610                "title": "CQRS",
611                "content": "Command Query Responsibility Segregation"
612            }))
613            .await
614            .unwrap();
615        let id1: serde_json::Value = serde_json::from_str(&r1.output).unwrap();
616
617        let r2 = tool
618            .execute(json!({
619                "action": "capture",
620                "node_type": "technology",
621                "title": "Event Sourcing",
622                "content": "Event sourcing pattern"
623            }))
624            .await
625            .unwrap();
626        let id2: serde_json::Value = serde_json::from_str(&r2.output).unwrap();
627
628        let result = tool
629            .execute(json!({
630                "action": "relate",
631                "from_id": id1["node_id"],
632                "to_id": id2["node_id"],
633                "relation": "uses"
634            }))
635            .await
636            .unwrap();
637
638        assert!(result.success);
639    }
640
641    #[tokio::test]
642    async fn graph_stats_reports_counts() {
643        let (_tmp, tool) = test_tool();
644        tool.execute(json!({
645            "action": "capture",
646            "node_type": "lesson",
647            "title": "Test lesson",
648            "content": "Testing matters"
649        }))
650        .await
651        .unwrap();
652
653        let result = tool
654            .execute(json!({ "action": "graph_stats" }))
655            .await
656            .unwrap();
657
658        assert!(result.success);
659        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
660        assert_eq!(output["total_nodes"].as_u64().unwrap(), 1);
661    }
662
663    #[tokio::test]
664    async fn lessons_extract_finds_signal_sentences() {
665        let (_tmp, tool) = test_tool();
666        let result = tool
667            .execute(json!({
668                "action": "lessons_extract",
669                "content": "The project went well overall. We learned that caching is critical. Next time we should avoid tight coupling. The weather was nice."
670            }))
671            .await
672            .unwrap();
673
674        assert!(result.success);
675        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
676        assert!(output["count"].as_u64().unwrap() >= 1);
677    }
678
679    #[tokio::test]
680    async fn unknown_action_returns_error() {
681        let (_tmp, tool) = test_tool();
682        let result = tool
683            .execute(json!({ "action": "delete_all" }))
684            .await
685            .unwrap();
686        assert!(!result.success);
687        assert!(result.error.unwrap().contains("unknown action"));
688    }
689
690    #[test]
691    fn name_and_schema_are_valid() {
692        let tmp = TempDir::new().unwrap();
693        let db_path = tmp.path().join("knowledge.db");
694        let graph = Arc::new(KnowledgeGraph::new(&db_path, 100).unwrap());
695        let tool = KnowledgeTool::new(graph);
696
697        assert_eq!(tool.name(), "knowledge");
698        let schema = tool.parameters_schema();
699        assert!(schema["properties"]["action"].is_object());
700    }
701}