Skip to main content

oxi_agent/tools/
learn_tool.rs

1/// Learn tool — capture a reusable lesson to memory and optionally create a managed skill.
2///
3/// Persists a lesson to the memory backend and, given a `skill` payload,
4/// creates or updates a managed SKILL.md. Mirrors the omp `learn` tool.
5use super::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode, ToolTier};
6use async_trait::async_trait;
7use serde_json::{Value, json};
8use std::sync::LazyLock;
9use std::sync::Mutex;
10use tokio::sync::oneshot;
11
12/// Simple in-memory lesson store.
13#[derive(Clone, Debug)]
14#[allow(dead_code)]
15struct Lesson {
16    memory: String,
17    context: Option<String>,
18    skill_action: Option<String>,
19    skill_name: Option<String>,
20}
21
22static LESSONS: LazyLock<Mutex<Vec<Lesson>>> = LazyLock::new(|| Mutex::new(Vec::new()));
23
24/// LearnTool — capture lessons to memory.
25pub struct LearnTool;
26
27#[async_trait]
28impl AgentTool for LearnTool {
29    fn name(&self) -> &str {
30        "learn"
31    }
32
33    fn label(&self) -> &str {
34        "Learn"
35    }
36
37    fn description(&self) -> &str {
38        concat!(
39            "Capture a reusable lesson to long-term memory (and optionally a managed skill). ",
40            "Provide 'memory' for the lesson content, optional 'context' for source context, ",
41            "and optional 'skill' with action/name/description/body to also mint a managed skill."
42        )
43    }
44
45    fn essential(&self) -> bool {
46        false
47    }
48
49    fn parameters_schema(&self) -> Value {
50        json!({
51            "type": "object",
52            "properties": {
53                "memory": {
54                    "type": "string",
55                    "description": "The durable, self-contained lesson to remember (what, when, why)."
56                },
57                "context": {
58                    "type": "string",
59                    "description": "Optional source context for the lesson."
60                },
61                "skill": {
62                    "type": "object",
63                    "properties": {
64                        "action": {
65                            "type": "string",
66                            "enum": ["create", "update"],
67                            "description": "Create or update a managed skill."
68                        },
69                        "name": {
70                            "type": "string",
71                            "description": "Kebab-case skill name."
72                        },
73                        "description": {
74                            "type": "string",
75                            "description": "One-line description of when to use the skill."
76                        },
77                        "body": {
78                            "type": "string",
79                            "description": "The SKILL.md body in markdown (no frontmatter)."
80                        }
81                    },
82                    "required": ["action", "name", "description", "body"],
83                    "description": "Also create or enhance a managed skill in the same call."
84                }
85            },
86            "required": ["memory"]
87        })
88    }
89
90    fn intent(&self) -> Option<&str> {
91        Some("Capture a lesson to long-term memory")
92    }
93
94    fn execution_mode(&self) -> ToolExecutionMode {
95        ToolExecutionMode::SequentialOnly
96    }
97
98    fn tool_tier(&self) -> ToolTier {
99        ToolTier::Write
100    }
101
102    async fn execute(
103        &self,
104        _tool_call_id: &str,
105        params: Value,
106        _signal: Option<oneshot::Receiver<()>>,
107        ctx: &ToolContext,
108    ) -> Result<AgentToolResult, ToolError> {
109        let memory = params
110            .get("memory")
111            .and_then(|v| v.as_str())
112            .ok_or_else(|| "Missing required parameter: memory".to_string())?;
113
114        let context = params
115            .get("context")
116            .and_then(|v| v.as_str())
117            .map(String::from);
118        let skill = params.get("skill");
119
120        let mut lines = vec![format!("Lesson recorded: {}", memory)];
121        if let Some(ref ctx_str) = context {
122            lines.push(format!("Context: {}", ctx_str));
123        }
124
125        // Try to persist via MemoryBackend if available
126        if let Some(ref backend) = ctx.memory {
127            let result = backend
128                .put(memory, "lesson", context.as_deref().unwrap_or("general"))
129                .await;
130            match result {
131                Ok(id) => lines.push(format!("Persisted to memory backend (id: {}).", id)),
132                Err(e) => lines.push(format!("Warning: memory backend store failed: {}", e)),
133            }
134        } else {
135            // Fallback: in-memory store
136            let lesson = Lesson {
137                memory: memory.to_string(),
138                context: context.clone(),
139                skill_action: skill
140                    .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(String::from)),
141                skill_name: skill
142                    .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(String::from)),
143            };
144            let mut store = LESSONS
145                .lock()
146                .map_err(|e| format!("Lesson lock error: {}", e))?;
147            store.push(lesson);
148            lines.push(format!(
149                "Stored in-memory (lesson #{}). No MemoryBackend configured.",
150                store.len()
151            ));
152        }
153
154        // Handle skill sub-payload
155        if let Some(skill_val) = skill {
156            let action = skill_val
157                .get("action")
158                .and_then(|v| v.as_str())
159                .unwrap_or("create");
160            let name = skill_val
161                .get("name")
162                .and_then(|v| v.as_str())
163                .unwrap_or("unnamed");
164            let desc = skill_val
165                .get("description")
166                .and_then(|v| v.as_str())
167                .unwrap_or("");
168            let body = skill_val.get("body").and_then(|v| v.as_str()).unwrap_or("");
169
170            lines.push(format!(
171                "Skill {}: {} (description: {}, body: {} chars)",
172                action,
173                name,
174                desc,
175                body.len()
176            ));
177            lines.push(
178                "Note: Managed skill writing requires the `manage_skill` tool for full file I/O."
179                    .to_string(),
180            );
181        }
182
183        Ok(AgentToolResult::success(lines.join("\n")))
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[tokio::test]
192    async fn test_learn_basic() {
193        let tool = LearnTool;
194        let params = json!({"memory": "Always validate input before processing"});
195        let result = tool
196            .execute("id", params, None, &ToolContext::default())
197            .await
198            .unwrap();
199        assert!(result.success);
200        assert!(result.output.contains("Lesson recorded"));
201    }
202
203    #[tokio::test]
204    async fn test_learn_with_context() {
205        let tool = LearnTool;
206        let params = json!({
207            "memory": "Use parking_lot::RwLock not std::sync::RwLock",
208            "context": "oxi concurrency conventions"
209        });
210        let result = tool
211            .execute("id", params, None, &ToolContext::default())
212            .await
213            .unwrap();
214        assert!(result.success);
215        assert!(result.output.contains("Context:"));
216    }
217
218    #[tokio::test]
219    async fn test_learn_with_skill() {
220        let tool = LearnTool;
221        let params = json!({
222            "memory": "Debug with debug tool",
223            "skill": {
224                "action": "create",
225                "name": "debug-rust",
226                "description": "Debug Rust code with lldb",
227                "body": "Use `rust-lldb` for debugging Rust programs..."
228            }
229        });
230        let result = tool
231            .execute("id", params, None, &ToolContext::default())
232            .await
233            .unwrap();
234        assert!(result.success);
235        assert!(result.output.contains("Skill create"));
236    }
237}