oxi_agent/tools/
learn_tool.rs1use 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#[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
24pub 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 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 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 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}