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 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 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 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 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 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 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 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}