Skip to main content

symbi_runtime/reasoning/
knowledge_bridge.rs

1//! Bridge between the knowledge/context system and the reasoning loop.
2//!
3//! `KnowledgeBridge` lets the reasoning loop access and update the agent's
4//! knowledge store. It is opt-in: when provided to `ReasoningLoopRunner`,
5//! it injects relevant context before each reasoning step and exposes
6//! `recall_knowledge` / `store_knowledge` as LLM-callable tools.
7
8use std::sync::Arc;
9use std::time::SystemTime;
10
11use serde::Deserialize;
12
13use crate::context::manager::ContextManager as KnowledgeContextManager;
14use crate::context::types::*;
15use crate::reasoning::conversation::{Conversation, MessageRole};
16use crate::reasoning::inference::ToolDefinition;
17use crate::types::AgentId;
18
19/// Configuration for knowledge integration with the reasoning loop.
20#[derive(Debug, Clone)]
21pub struct KnowledgeConfig {
22    /// Max knowledge items to inject per iteration.
23    pub max_context_items: usize,
24    /// Relevance threshold for knowledge retrieval (0.0–1.0).
25    pub relevance_threshold: f32,
26    /// Whether to auto-store learnings after loop completion.
27    pub auto_persist: bool,
28}
29
30impl Default for KnowledgeConfig {
31    fn default() -> Self {
32        Self {
33            max_context_items: 5,
34            relevance_threshold: 0.3,
35            auto_persist: true,
36        }
37    }
38}
39
40/// Bridges the knowledge/context system into the reasoning loop.
41pub struct KnowledgeBridge {
42    context_manager: Arc<dyn KnowledgeContextManager>,
43    config: KnowledgeConfig,
44}
45
46impl KnowledgeBridge {
47    pub fn new(context_manager: Arc<dyn KnowledgeContextManager>, config: KnowledgeConfig) -> Self {
48        Self {
49            context_manager,
50            config,
51        }
52    }
53
54    /// Retrieve relevant knowledge and inject as a system-level context message.
55    /// Called BEFORE each reasoning step.
56    ///
57    /// Returns the number of knowledge items injected.
58    pub async fn inject_context(
59        &self,
60        agent_id: &AgentId,
61        conversation: &mut Conversation,
62    ) -> Result<usize, ContextError> {
63        // Extract search terms from recent user/tool messages
64        let search_terms = extract_search_terms(conversation);
65        if search_terms.is_empty() {
66            return Ok(0);
67        }
68
69        // Query the context manager for relevant items
70        let query = ContextQuery {
71            query_type: QueryType::Hybrid,
72            search_terms: search_terms.clone(),
73            time_range: None,
74            memory_types: vec![],
75            relevance_threshold: self.config.relevance_threshold,
76            max_results: self.config.max_context_items,
77            include_embeddings: false,
78        };
79
80        let context_items = self.context_manager.query_context(*agent_id, query).await?;
81
82        // Also search the knowledge base
83        let search_query = search_terms.join(" ");
84        let knowledge_items = self
85            .context_manager
86            .search_knowledge(*agent_id, &search_query, self.config.max_context_items)
87            .await?;
88
89        // Combine and format results
90        let mut lines = Vec::new();
91
92        for item in &context_items {
93            lines.push(format!(
94                "- [memory, relevance={:.2}] {}",
95                item.relevance_score, item.content
96            ));
97        }
98
99        for item in &knowledge_items {
100            lines.push(format!(
101                "- [knowledge/{:?}, confidence={:.2}] {}",
102                item.knowledge_type, item.confidence, item.content
103            ));
104        }
105
106        let total_items = context_items.len() + knowledge_items.len();
107
108        if !lines.is_empty() {
109            let context_text = format!(
110                "The following relevant knowledge and context was retrieved for this conversation:\n{}",
111                lines.join("\n")
112            );
113            conversation.inject_knowledge_context(context_text);
114        }
115
116        Ok(total_items)
117    }
118
119    /// Persist learnings from the completed conversation.
120    /// Called AFTER loop completion if auto_persist is true.
121    pub async fn persist_learnings(
122        &self,
123        agent_id: &AgentId,
124        conversation: &Conversation,
125    ) -> Result<(), ContextError> {
126        // Extract assistant responses as episodic memory
127        let assistant_messages: Vec<&str> = conversation
128            .messages()
129            .iter()
130            .filter(|m| m.role == MessageRole::Assistant && !m.content.is_empty())
131            .map(|m| m.content.as_str())
132            .collect();
133
134        if assistant_messages.is_empty() {
135            return Ok(());
136        }
137
138        let summary = if assistant_messages.len() == 1 {
139            assistant_messages[0].to_string()
140        } else {
141            // Combine into a summary, truncating if very long
142            let combined = assistant_messages.join("\n---\n");
143            if combined.len() > 2000 {
144                format!("{}...", &combined[..2000])
145            } else {
146                combined
147            }
148        };
149
150        let memory_update = MemoryUpdate {
151            operation: UpdateOperation::Add,
152            target: MemoryTarget::Working("last_conversation_summary".to_string()),
153            data: serde_json::Value::String(summary),
154        };
155
156        self.context_manager
157            .update_memory(*agent_id, vec![memory_update])
158            .await
159    }
160
161    /// Return tool definitions for knowledge tools.
162    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
163        vec![recall_tool_def(), store_tool_def()]
164    }
165
166    /// Handle a knowledge tool call. Returns the tool result content.
167    pub async fn handle_tool_call(
168        &self,
169        agent_id: &AgentId,
170        tool_name: &str,
171        arguments: &str,
172    ) -> Result<String, String> {
173        match tool_name {
174            "recall_knowledge" => self.handle_recall(agent_id, arguments).await,
175            "store_knowledge" => self.handle_store(agent_id, arguments).await,
176            _ => Err(format!("Unknown knowledge tool: {}", tool_name)),
177        }
178    }
179
180    /// Returns true if the given tool name is a knowledge tool handled by this bridge.
181    pub fn is_knowledge_tool(tool_name: &str) -> bool {
182        matches!(tool_name, "recall_knowledge" | "store_knowledge")
183    }
184
185    async fn handle_recall(&self, agent_id: &AgentId, arguments: &str) -> Result<String, String> {
186        #[derive(Deserialize)]
187        struct RecallArgs {
188            query: String,
189            #[serde(default = "default_limit")]
190            limit: usize,
191            #[cfg(feature = "orga-adaptive")]
192            #[serde(default)]
193            directory: Option<String>,
194            #[cfg(feature = "orga-adaptive")]
195            #[serde(default)]
196            scope: Option<String>,
197        }
198        fn default_limit() -> usize {
199            5
200        }
201
202        let args: RecallArgs =
203            serde_json::from_str(arguments).map_err(|e| format!("Invalid arguments: {}", e))?;
204
205        // With orga-adaptive feature: route to scoped conventions if directory + scope=conventions
206        #[cfg(feature = "orga-adaptive")]
207        {
208            if let (Some(ref dir), Some(ref scope)) = (&args.directory, &args.scope) {
209                if scope == "conventions" {
210                    return self
211                        .retrieve_scoped_conventions(agent_id, &args.query, dir, args.limit)
212                        .await;
213                }
214            }
215        }
216
217        let items = self
218            .context_manager
219            .search_knowledge(*agent_id, &args.query, args.limit)
220            .await
221            .map_err(|e| format!("Knowledge search failed: {}", e))?;
222
223        if items.is_empty() {
224            return Ok("No relevant knowledge found.".to_string());
225        }
226
227        let mut lines = Vec::new();
228        for item in &items {
229            lines.push(format!(
230                "- [{:?}, confidence={:.2}] {}",
231                item.knowledge_type, item.confidence, item.content
232            ));
233        }
234        Ok(lines.join("\n"))
235    }
236
237    /// Retrieve conventions scoped to a directory, walking up to parent directories
238    /// and falling back to language-level conventions.
239    #[cfg(feature = "orga-adaptive")]
240    async fn retrieve_scoped_conventions(
241        &self,
242        agent_id: &AgentId,
243        language: &str,
244        directory: &str,
245        limit: usize,
246    ) -> Result<String, String> {
247        let mut all_items = Vec::new();
248        let mut seen_content = std::collections::HashSet::new();
249
250        // Walk up directory hierarchy: directory -> parent -> grandparent -> ...
251        let mut current_dir = std::path::PathBuf::from(directory);
252        loop {
253            let dir_query = format!("{} conventions {}", language, current_dir.display());
254            if let Ok(items) = self
255                .context_manager
256                .search_knowledge(*agent_id, &dir_query, limit)
257                .await
258            {
259                for item in items {
260                    if seen_content.insert(item.content.clone()) {
261                        all_items.push(item);
262                    }
263                }
264            }
265
266            if !current_dir.pop() {
267                break;
268            }
269        }
270
271        // Language-level fallback
272        let lang_query = format!("{} conventions", language);
273        if let Ok(items) = self
274            .context_manager
275            .search_knowledge(*agent_id, &lang_query, limit)
276            .await
277        {
278            for item in items {
279                if seen_content.insert(item.content.clone()) {
280                    all_items.push(item);
281                }
282            }
283        }
284
285        // Truncate to limit
286        all_items.truncate(limit);
287
288        if all_items.is_empty() {
289            return Ok("No relevant conventions found.".to_string());
290        }
291
292        let mut lines = Vec::new();
293        for item in &all_items {
294            lines.push(format!(
295                "- [{:?}, confidence={:.2}] {}",
296                item.knowledge_type, item.confidence, item.content
297            ));
298        }
299        Ok(lines.join("\n"))
300    }
301
302    async fn handle_store(&self, agent_id: &AgentId, arguments: &str) -> Result<String, String> {
303        #[derive(Deserialize)]
304        struct StoreArgs {
305            subject: String,
306            predicate: String,
307            object: String,
308            #[serde(default = "default_confidence")]
309            confidence: f32,
310        }
311        fn default_confidence() -> f32 {
312            0.8
313        }
314
315        let args: StoreArgs =
316            serde_json::from_str(arguments).map_err(|e| format!("Invalid arguments: {}", e))?;
317
318        let fact = KnowledgeFact {
319            id: KnowledgeId::new(),
320            subject: args.subject.clone(),
321            predicate: args.predicate.clone(),
322            object: args.object.clone(),
323            confidence: args.confidence,
324            source: KnowledgeSource::Experience,
325            created_at: SystemTime::now(),
326            verified: false,
327        };
328
329        let knowledge_id = self
330            .context_manager
331            .add_knowledge(*agent_id, Knowledge::Fact(fact))
332            .await
333            .map_err(|e| format!("Failed to store knowledge: {}", e))?;
334
335        Ok(format!(
336            "Stored fact: {} {} {} (id: {})",
337            args.subject, args.predicate, args.object, knowledge_id.0
338        ))
339    }
340}
341
342#[cfg(not(feature = "orga-adaptive"))]
343fn recall_tool_def() -> ToolDefinition {
344    ToolDefinition {
345        name: "recall_knowledge".to_string(),
346        description: "Search the agent's knowledge base for relevant information. Use this to recall facts, procedures, or patterns that may help with the current task.".to_string(),
347        parameters: serde_json::json!({
348            "type": "object",
349            "properties": {
350                "query": {
351                    "type": "string",
352                    "description": "The search query to find relevant knowledge"
353                },
354                "limit": {
355                    "type": "integer",
356                    "description": "Maximum number of results to return (default: 5)",
357                    "default": 5
358                }
359            },
360            "required": ["query"]
361        }),
362    }
363}
364
365#[cfg(feature = "orga-adaptive")]
366fn recall_tool_def() -> ToolDefinition {
367    ToolDefinition {
368        name: "recall_knowledge".to_string(),
369        description: "Search the agent's knowledge base for relevant information. Use this to recall facts, procedures, conventions, or patterns that may help with the current task. Use scope='conventions' with a directory to retrieve directory-scoped conventions.".to_string(),
370        parameters: serde_json::json!({
371            "type": "object",
372            "properties": {
373                "query": {
374                    "type": "string",
375                    "description": "The search query to find relevant knowledge (or language name when scope='conventions')"
376                },
377                "limit": {
378                    "type": "integer",
379                    "description": "Maximum number of results to return (default: 5)",
380                    "default": 5
381                },
382                "directory": {
383                    "type": "string",
384                    "description": "Directory path for scoped convention retrieval. Walks up parent directories for convention inheritance."
385                },
386                "scope": {
387                    "type": "string",
388                    "description": "Set to 'conventions' to retrieve directory-scoped coding conventions instead of general knowledge.",
389                    "enum": ["conventions"]
390                }
391            },
392            "required": ["query"]
393        }),
394    }
395}
396
397fn store_tool_def() -> ToolDefinition {
398    ToolDefinition {
399        name: "store_knowledge".to_string(),
400        description: "Store a new fact in the agent's knowledge base for future reference. Use this to remember important information learned during the conversation.".to_string(),
401        parameters: serde_json::json!({
402            "type": "object",
403            "properties": {
404                "subject": {
405                    "type": "string",
406                    "description": "The subject of the fact (e.g., 'Rust')"
407                },
408                "predicate": {
409                    "type": "string",
410                    "description": "The relationship (e.g., 'is_a')"
411                },
412                "object": {
413                    "type": "string",
414                    "description": "The object of the fact (e.g., 'systems programming language')"
415                },
416                "confidence": {
417                    "type": "number",
418                    "description": "Confidence level 0.0-1.0 (default: 0.8)",
419                    "default": 0.8
420                }
421            },
422            "required": ["subject", "predicate", "object"]
423        }),
424    }
425}
426
427/// Extract search terms from the most recent user and tool messages in the conversation.
428fn extract_search_terms(conversation: &Conversation) -> Vec<String> {
429    let messages = conversation.messages();
430    let mut terms = Vec::new();
431
432    // Look at the last few messages for search context
433    for msg in messages.iter().rev().take(5) {
434        match msg.role {
435            MessageRole::User | MessageRole::Tool => {
436                // Extract meaningful words (skip very short words and common stop words)
437                let words: Vec<&str> = msg
438                    .content
439                    .split_whitespace()
440                    .filter(|w| w.len() > 3)
441                    .take(10)
442                    .collect();
443                for word in words {
444                    let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
445                    if !cleaned.is_empty() && !terms.contains(&cleaned.to_string()) {
446                        terms.push(cleaned.to_string());
447                    }
448                }
449            }
450            _ => {}
451        }
452        // Limit total terms
453        if terms.len() >= 15 {
454            break;
455        }
456    }
457
458    terms
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::reasoning::conversation::ConversationMessage;
465
466    #[test]
467    fn test_knowledge_config_default() {
468        let config = KnowledgeConfig::default();
469        assert_eq!(config.max_context_items, 5);
470        assert!((config.relevance_threshold - 0.3).abs() < f32::EPSILON);
471        assert!(config.auto_persist);
472    }
473
474    #[test]
475    fn test_extract_search_terms_from_user_message() {
476        let mut conv = Conversation::new();
477        conv.push(ConversationMessage::user(
478            "What is the weather forecast for tomorrow?",
479        ));
480
481        let terms = extract_search_terms(&conv);
482        assert!(!terms.is_empty());
483        assert!(terms.contains(&"weather".to_string()));
484        assert!(terms.contains(&"forecast".to_string()));
485        assert!(terms.contains(&"tomorrow".to_string()));
486    }
487
488    #[test]
489    fn test_extract_search_terms_skips_short_words() {
490        let mut conv = Conversation::new();
491        conv.push(ConversationMessage::user("I am at the big house"));
492
493        let terms = extract_search_terms(&conv);
494        // "I", "am", "at", "the" are all <= 3 chars, should be skipped
495        assert!(terms.contains(&"house".to_string()));
496        assert!(!terms.iter().any(|t| t.len() <= 3));
497    }
498
499    #[test]
500    fn test_extract_search_terms_empty_conversation() {
501        let conv = Conversation::new();
502        let terms = extract_search_terms(&conv);
503        assert!(terms.is_empty());
504    }
505
506    #[test]
507    fn test_extract_search_terms_ignores_assistant() {
508        let mut conv = Conversation::new();
509        conv.push(ConversationMessage::assistant(
510            "Here is some information about databases",
511        ));
512
513        let terms = extract_search_terms(&conv);
514        assert!(terms.is_empty());
515    }
516
517    #[test]
518    fn test_tool_definitions() {
519        let recall = recall_tool_def();
520        assert_eq!(recall.name, "recall_knowledge");
521        assert!(recall.parameters["required"]
522            .as_array()
523            .unwrap()
524            .contains(&serde_json::json!("query")));
525
526        let store = store_tool_def();
527        assert_eq!(store.name, "store_knowledge");
528        assert!(store.parameters["required"]
529            .as_array()
530            .unwrap()
531            .contains(&serde_json::json!("subject")));
532    }
533
534    #[test]
535    fn test_is_knowledge_tool() {
536        assert!(KnowledgeBridge::is_knowledge_tool("recall_knowledge"));
537        assert!(KnowledgeBridge::is_knowledge_tool("store_knowledge"));
538        assert!(!KnowledgeBridge::is_knowledge_tool("web_search"));
539        assert!(!KnowledgeBridge::is_knowledge_tool(""));
540    }
541
542    #[cfg(feature = "orga-adaptive")]
543    #[test]
544    fn test_recall_tool_def_has_directory_and_scope() {
545        let def = recall_tool_def();
546        let props = &def.parameters["properties"];
547        assert!(props.get("directory").is_some());
548        assert!(props.get("scope").is_some());
549        // query is still required
550        assert!(def.parameters["required"]
551            .as_array()
552            .unwrap()
553            .contains(&serde_json::json!("query")));
554    }
555
556    #[cfg(feature = "orga-adaptive")]
557    #[test]
558    fn test_recall_tool_backward_compatible() {
559        let def = recall_tool_def();
560        let required = def.parameters["required"].as_array().unwrap();
561        // directory and scope are NOT required
562        assert!(!required.contains(&serde_json::json!("directory")));
563        assert!(!required.contains(&serde_json::json!("scope")));
564    }
565}