Skip to main content

microclaw_engine/tools/
knowledge_graph.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use tracing::info;
5
6use crate::internal::storage::db::{call_blocking, Database};
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10
11pub struct KnowledgeGraphQueryTool {
12    db: Arc<Database>,
13}
14
15impl KnowledgeGraphQueryTool {
16    pub fn new(db: Arc<Database>) -> Self {
17        Self { db }
18    }
19}
20
21#[async_trait]
22impl Tool for KnowledgeGraphQueryTool {
23    fn name(&self) -> &str {
24        "knowledge_graph_query"
25    }
26
27    fn definition(&self) -> ToolDefinition {
28        ToolDefinition {
29            name: "knowledge_graph_query".into(),
30            description: "Query the temporal knowledge graph for entity relationships. Returns triples (subject-predicate-object) with temporal validity. Use 'as_of' for point-in-time queries. Use 'timeline' mode to see how facts changed over time.".into(),
31            input_schema: schema_object(
32                json!({
33                    "entity": {
34                        "type": "string",
35                        "description": "The entity (subject or object) to query"
36                    },
37                    "mode": {
38                        "type": "string",
39                        "enum": ["subject", "object", "timeline", "stats"],
40                        "description": "Query mode: 'subject' (find facts about entity), 'object' (reverse lookup), 'timeline' (all historical facts), 'stats' (graph statistics)"
41                    },
42                    "as_of": {
43                        "type": "string",
44                        "description": "Optional ISO 8601 timestamp for point-in-time query (only for subject mode)"
45                    },
46                    "chat_id": {
47                        "type": "integer",
48                        "description": "Optional chat_id to scope the query"
49                    }
50                }),
51                &["entity", "mode"],
52            ),
53        }
54    }
55
56    async fn execute(&self, input: serde_json::Value) -> ToolResult {
57        let entity = match input.get("entity").and_then(|v| v.as_str()) {
58            Some(e) if !e.trim().is_empty() => e.trim().to_string(),
59            _ => return ToolResult::error("Missing required parameter: entity".into()),
60        };
61        let mode = input
62            .get("mode")
63            .and_then(|v| v.as_str())
64            .unwrap_or("subject");
65        let chat_id = input.get("chat_id").and_then(|v| v.as_i64());
66        let as_of = input
67            .get("as_of")
68            .and_then(|v| v.as_str())
69            .map(String::from);
70
71        let db = self.db.clone();
72        match mode {
73            "subject" => {
74                let entity_clone = entity.clone();
75                let as_of_clone = as_of.clone();
76                let triples = call_blocking(db, move |db| {
77                    db.kg_query_subject(&entity_clone, chat_id, as_of_clone.as_deref())
78                })
79                .await
80                .map_err(|e| e.to_string());
81                match triples {
82                    Ok(rows) if rows.is_empty() => {
83                        ToolResult::success(format!("No facts found for entity '{entity}'."))
84                    }
85                    Ok(rows) => {
86                        let mut out = format!("Facts about '{entity}'");
87                        if let Some(ts) = &as_of {
88                            out.push_str(&format!(" (as of {ts})"));
89                        }
90                        out.push_str(":\n");
91                        for t in &rows {
92                            out.push_str(&format!(
93                                "  [{}] {} -> {} (since {}, confidence: {:.2})\n",
94                                t.predicate, t.subject, t.object, t.valid_from, t.confidence
95                            ));
96                        }
97                        ToolResult::success(out)
98                    }
99                    Err(e) => ToolResult::error(format!("Query failed: {e}")),
100                }
101            }
102            "object" => {
103                let entity_clone = entity.clone();
104                let triples =
105                    call_blocking(db, move |db| db.kg_query_object(&entity_clone, chat_id))
106                        .await
107                        .map_err(|e| e.to_string());
108                match triples {
109                    Ok(rows) if rows.is_empty() => ToolResult::success(format!(
110                        "No relationships found pointing to '{entity}'."
111                    )),
112                    Ok(rows) => {
113                        let mut out = format!("Entities related to '{entity}' (as object):\n");
114                        for t in &rows {
115                            out.push_str(&format!(
116                                "  {} -[{}]-> {} (since {})\n",
117                                t.subject, t.predicate, t.object, t.valid_from
118                            ));
119                        }
120                        ToolResult::success(out)
121                    }
122                    Err(e) => ToolResult::error(format!("Query failed: {e}")),
123                }
124            }
125            "timeline" => {
126                let entity_clone = entity.clone();
127                let triples = call_blocking(db, move |db| db.kg_timeline(&entity_clone, chat_id))
128                    .await
129                    .map_err(|e| e.to_string());
130                match triples {
131                    Ok(rows) if rows.is_empty() => ToolResult::success(format!(
132                        "No historical facts found for entity '{entity}'."
133                    )),
134                    Ok(rows) => {
135                        let mut out = format!("Timeline for '{entity}':\n");
136                        for t in &rows {
137                            let status = if t.valid_to.is_some() {
138                                format!("invalidated at {}", t.valid_to.as_deref().unwrap_or("?"))
139                            } else {
140                                "current".to_string()
141                            };
142                            out.push_str(&format!(
143                                "  {} | {} -> {} [{}] ({})\n",
144                                t.valid_from, t.subject, t.object, t.predicate, status
145                            ));
146                        }
147                        ToolResult::success(out)
148                    }
149                    Err(e) => ToolResult::error(format!("Query failed: {e}")),
150                }
151            }
152            "stats" => {
153                let stats = call_blocking(db, move |db| db.kg_stats(chat_id))
154                    .await
155                    .map_err(|e| e.to_string());
156                match stats {
157                    Ok((total, active, invalidated)) => ToolResult::success(format!(
158                        "Knowledge graph stats: {total} total triples, {active} active, {invalidated} invalidated"
159                    )),
160                    Err(e) => ToolResult::error(format!("Stats failed: {e}")),
161                }
162            }
163            _ => ToolResult::error(format!(
164                "Unknown mode: {mode}. Use subject, object, timeline, or stats."
165            )),
166        }
167    }
168}
169
170pub struct KnowledgeGraphAddTool {
171    db: Arc<Database>,
172}
173
174impl KnowledgeGraphAddTool {
175    pub fn new(db: Arc<Database>) -> Self {
176        Self { db }
177    }
178}
179
180#[async_trait]
181impl Tool for KnowledgeGraphAddTool {
182    fn name(&self) -> &str {
183        "knowledge_graph_add"
184    }
185
186    fn definition(&self) -> ToolDefinition {
187        ToolDefinition {
188            name: "knowledge_graph_add".into(),
189            description: "Add a fact (triple) to the temporal knowledge graph. Facts are subject-predicate-object with a valid_from timestamp. Use this to record structured relationships between entities discovered during conversation.".into(),
190            input_schema: schema_object(
191                json!({
192                    "subject": {
193                        "type": "string",
194                        "description": "The subject entity (e.g., 'User', 'ProjectX', 'production-db')"
195                    },
196                    "predicate": {
197                        "type": "string",
198                        "description": "The relationship (e.g., 'uses', 'prefers', 'located_at', 'version_is')"
199                    },
200                    "object": {
201                        "type": "string",
202                        "description": "The object entity or value (e.g., 'Rust', 'PostgreSQL 16', 'us-east-1')"
203                    },
204                    "chat_id": {
205                        "type": "integer",
206                        "description": "Chat ID to scope this fact to (default: current chat)"
207                    },
208                    "invalidates_id": {
209                        "type": "integer",
210                        "description": "Optional: ID of a previous triple this fact supersedes (will set its valid_to)"
211                    }
212                }),
213                &["subject", "predicate", "object"],
214            ),
215        }
216    }
217
218    async fn execute(&self, input: serde_json::Value) -> ToolResult {
219        let subject = match input.get("subject").and_then(|v| v.as_str()) {
220            Some(s) if !s.trim().is_empty() => s.trim().to_string(),
221            _ => return ToolResult::error("Missing required parameter: subject".into()),
222        };
223        let predicate = match input.get("predicate").and_then(|v| v.as_str()) {
224            Some(p) if !p.trim().is_empty() => p.trim().to_string(),
225            _ => return ToolResult::error("Missing required parameter: predicate".into()),
226        };
227        let object = match input.get("object").and_then(|v| v.as_str()) {
228            Some(o) if !o.trim().is_empty() => o.trim().to_string(),
229            _ => return ToolResult::error("Missing required parameter: object".into()),
230        };
231        let chat_id = input.get("chat_id").and_then(|v| v.as_i64());
232        let invalidates_id = input.get("invalidates_id").and_then(|v| v.as_i64());
233
234        let now = chrono::Utc::now().to_rfc3339();
235
236        // Invalidate the old triple if specified
237        if let Some(old_id) = invalidates_id {
238            let db = self.db.clone();
239            let now_clone = now.clone();
240            let _ = call_blocking(db, move |db| db.kg_invalidate_triple(old_id, &now_clone)).await;
241        }
242
243        let db = self.db.clone();
244        let now_clone = now.clone();
245        let subject_clone = subject.clone();
246        let predicate_clone = predicate.clone();
247        let object_clone = object.clone();
248        match call_blocking(db, move |db| {
249            db.kg_insert_triple(
250                &subject_clone,
251                &predicate_clone,
252                &object_clone,
253                chat_id,
254                &now_clone,
255                0.80,
256                "agent",
257                None,
258            )
259        })
260        .await
261        {
262            Ok(id) => {
263                info!(
264                    "KG: added triple #{} ({} -> {} -> {})",
265                    id, subject, predicate, object
266                );
267                let mut msg = format!(
268                    "Added to knowledge graph: {subject} -[{predicate}]-> {object} (id={id})"
269                );
270                if let Some(old_id) = invalidates_id {
271                    msg.push_str(&format!(", invalidated previous triple #{old_id}"));
272                }
273                ToolResult::success(msg)
274            }
275            Err(e) => ToolResult::error(format!("Failed to add triple: {e}")),
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    fn test_db() -> (Arc<Database>, std::path::PathBuf) {
286        let dir = std::env::temp_dir().join(format!("microclaw_kg_test_{}", uuid::Uuid::new_v4()));
287        let db = Database::new(dir.to_str().unwrap()).unwrap();
288        (Arc::new(db), dir)
289    }
290
291    fn cleanup(dir: &std::path::Path) {
292        let _ = std::fs::remove_dir_all(dir);
293    }
294
295    #[tokio::test]
296    async fn test_kg_add_and_query() {
297        let (db, dir) = test_db();
298        let add_tool = KnowledgeGraphAddTool::new(db.clone());
299        let query_tool = KnowledgeGraphQueryTool::new(db.clone());
300
301        // Add a triple
302        let result = add_tool
303            .execute(json!({
304                "subject": "User",
305                "predicate": "prefers",
306                "object": "Rust"
307            }))
308            .await;
309        assert!(!result.is_error, "Error: {}", result.content);
310        assert!(result.content.contains("Added to knowledge graph"));
311
312        // Query it
313        let result = query_tool
314            .execute(json!({
315                "entity": "User",
316                "mode": "subject"
317            }))
318            .await;
319        assert!(!result.is_error, "Error: {}", result.content);
320        assert!(result.content.contains("prefers"));
321        assert!(result.content.contains("Rust"));
322
323        cleanup(&dir);
324    }
325
326    #[tokio::test]
327    async fn test_kg_invalidate_and_timeline() {
328        let (db, dir) = test_db();
329
330        // Insert first triple directly with a fixed timestamp to avoid timing races
331        let id1 = crate::internal::storage::db::call_blocking(db.clone(), |db| {
332            db.kg_insert_triple(
333                "db-port",
334                "is",
335                "5432",
336                None,
337                "2026-01-01T00:00:00Z",
338                0.80,
339                "test",
340                None,
341            )
342        })
343        .await
344        .unwrap();
345
346        // Invalidate the first and add the second
347        let now = "2026-01-02T00:00:00Z";
348        let _ = crate::internal::storage::db::call_blocking(db.clone(), {
349            let now = now.to_string();
350            move |db| db.kg_invalidate_triple(id1, &now)
351        })
352        .await;
353
354        let _ = crate::internal::storage::db::call_blocking(db.clone(), {
355            let now = now.to_string();
356            move |db| db.kg_insert_triple("db-port", "is", "5433", None, &now, 0.80, "test", None)
357        })
358        .await
359        .unwrap();
360
361        let query_tool = KnowledgeGraphQueryTool::new(db.clone());
362
363        // Current query should only show 5433 (5432 is invalidated)
364        let current = query_tool
365            .execute(json!({
366                "entity": "db-port",
367                "mode": "subject"
368            }))
369            .await;
370        assert!(
371            current.content.contains("5433"),
372            "Expected 5433 in current: {}",
373            current.content
374        );
375        assert!(
376            !current.content.contains("5432"),
377            "Expected no 5432 in current: {}",
378            current.content
379        );
380
381        // Timeline should show both
382        let timeline = query_tool
383            .execute(json!({
384                "entity": "db-port",
385                "mode": "timeline"
386            }))
387            .await;
388        assert!(timeline.content.contains("5432"));
389        assert!(timeline.content.contains("5433"));
390        assert!(timeline.content.contains("invalidated"));
391        assert!(timeline.content.contains("current"));
392
393        cleanup(&dir);
394    }
395
396    #[tokio::test]
397    async fn test_kg_stats() {
398        let (db, dir) = test_db();
399        let add_tool = KnowledgeGraphAddTool::new(db.clone());
400        let query_tool = KnowledgeGraphQueryTool::new(db.clone());
401
402        add_tool
403            .execute(json!({"subject": "A", "predicate": "knows", "object": "B"}))
404            .await;
405        add_tool
406            .execute(json!({"subject": "C", "predicate": "knows", "object": "D"}))
407            .await;
408
409        let result = query_tool
410            .execute(json!({"entity": "_", "mode": "stats"}))
411            .await;
412        assert!(result.content.contains("2 total"));
413        assert!(result.content.contains("2 active"));
414
415        cleanup(&dir);
416    }
417}