1use async_trait::async_trait;
8
9use pulsedb::{AgentId, ExperienceType, NewExperience, Severity};
10use pulsehive_core::agent::{AgentOutcome, ExperienceExtractor, ExtractionContext};
11use pulsehive_core::llm::Message;
12
13#[derive(Debug, Clone, Default)]
23pub struct DefaultExperienceExtractor;
24
25#[async_trait]
26impl ExperienceExtractor for DefaultExperienceExtractor {
27 async fn extract(
28 &self,
29 conversation: &[Message],
30 outcome: &AgentOutcome,
31 context: &ExtractionContext,
32 ) -> Vec<NewExperience> {
33 let base = || NewExperience {
34 collective_id: context.collective_id,
35 content: String::new(),
36 experience_type: ExperienceType::Generic { category: None },
37 embedding: None, importance: 0.5,
39 confidence: 0.5,
40 domain: vec![],
41 source_agent: AgentId(context.agent_id.clone()),
42 source_task: None,
43 related_files: vec![],
44 };
45
46 match outcome {
47 AgentOutcome::Complete { response } => {
48 if response.is_empty() {
49 return vec![];
50 }
51 let mut exp = base();
52 exp.content = format!(
53 "Task: {}\n\nResult: {}",
54 context.task_description,
55 truncate(response, 8192)
56 );
57 exp.experience_type = ExperienceType::Generic {
58 category: Some("task_completion".into()),
59 };
60 exp.importance = 0.7;
61 exp.confidence = 0.8;
62 vec![exp]
63 }
64 AgentOutcome::Error { error } => {
65 let mut experiences = Vec::new();
66
67 let tool_results = extract_tool_summaries(conversation);
70 if !tool_results.is_empty() {
71 let mut partial = base();
72 let summaries: String = tool_results
73 .iter()
74 .map(|s| format!("- {s}"))
75 .collect::<Vec<_>>()
76 .join("\n");
77 partial.content = format!(
78 "Task: {}\n\nPartial progress ({} tool calls completed):\n{}\n\nFailed with: {}",
79 context.task_description,
80 tool_results.len(),
81 summaries,
82 error,
83 );
84 partial.experience_type = ExperienceType::Generic {
85 category: Some("partial_completion".into()),
86 };
87 partial.importance = 0.6;
88 partial.confidence = 0.6;
89 experiences.push(partial);
90 }
91
92 let mut exp = base();
93 exp.content = format!("Task: {}\n\nError: {}", context.task_description, error);
94 exp.experience_type = ExperienceType::ErrorPattern {
95 signature: truncate(error, 500),
96 fix: String::new(),
97 prevention: String::new(),
98 };
99 exp.importance = 0.5;
100 exp.confidence = 0.5;
101 experiences.push(exp);
102
103 experiences
104 }
105 AgentOutcome::MaxIterationsReached => {
106 let mut exp = base();
107 exp.content = format!(
108 "Task: {}\n\nAgent reached maximum iterations without completing.",
109 context.task_description
110 );
111 exp.experience_type = ExperienceType::Difficulty {
112 description: "Agent reached max iterations".into(),
113 severity: Severity::Medium,
114 };
115 exp.importance = 0.6;
116 exp.confidence = 0.7;
117 vec![exp]
118 }
119 }
120 }
121}
122
123fn extract_tool_summaries(conversation: &[Message]) -> Vec<String> {
128 conversation
129 .iter()
130 .filter_map(|msg| {
131 if let Message::ToolResult { content, .. } = msg {
132 if content.starts_with("Error:") {
134 None
135 } else {
136 Some(truncate(content, 200))
137 }
138 } else {
139 None
140 }
141 })
142 .collect()
143}
144
145fn truncate(s: &str, max_len: usize) -> String {
147 if s.len() <= max_len {
148 s.to_string()
149 } else {
150 format!("{}...", &s[..max_len])
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use pulsedb::CollectiveId;
158
159 fn test_context() -> ExtractionContext {
160 ExtractionContext {
161 agent_id: "agent-1".into(),
162 collective_id: CollectiveId::new(),
163 task_description: "Analyze the codebase".into(),
164 }
165 }
166
167 #[tokio::test]
168 async fn test_extract_complete_outcome() {
169 let extractor = DefaultExperienceExtractor;
170 let outcome = AgentOutcome::Complete {
171 response: "Found 3 issues in the code.".into(),
172 };
173
174 let experiences = extractor.extract(&[], &outcome, &test_context()).await;
175 assert_eq!(experiences.len(), 1);
176 assert!(experiences[0].content.contains("Found 3 issues"));
177 assert!(experiences[0].content.contains("Analyze the codebase"));
178 assert!((experiences[0].importance - 0.7).abs() < f32::EPSILON);
179 assert!(matches!(
180 &experiences[0].experience_type,
181 ExperienceType::Generic { category: Some(c) } if c == "task_completion"
182 ));
183 }
184
185 #[tokio::test]
186 async fn test_extract_error_outcome() {
187 let extractor = DefaultExperienceExtractor;
188 let outcome = AgentOutcome::Error {
189 error: "LLM timeout".into(),
190 };
191
192 let experiences = extractor.extract(&[], &outcome, &test_context()).await;
193 assert_eq!(experiences.len(), 1);
194 assert!(experiences[0].content.contains("LLM timeout"));
195 assert!(matches!(
196 &experiences[0].experience_type,
197 ExperienceType::ErrorPattern { signature, .. } if signature == "LLM timeout"
198 ));
199 }
200
201 #[tokio::test]
202 async fn test_extract_max_iterations() {
203 let extractor = DefaultExperienceExtractor;
204 let outcome = AgentOutcome::MaxIterationsReached;
205
206 let experiences = extractor.extract(&[], &outcome, &test_context()).await;
207 assert_eq!(experiences.len(), 1);
208 assert!(matches!(
209 &experiences[0].experience_type,
210 ExperienceType::Difficulty {
211 severity: Severity::Medium,
212 ..
213 }
214 ));
215 }
216
217 #[tokio::test]
218 async fn test_extract_empty_response_skipped() {
219 let extractor = DefaultExperienceExtractor;
220 let outcome = AgentOutcome::Complete {
221 response: "".into(),
222 };
223
224 let experiences = extractor.extract(&[], &outcome, &test_context()).await;
225 assert!(experiences.is_empty());
226 }
227
228 #[tokio::test]
229 async fn test_extract_sets_context_fields() {
230 let extractor = DefaultExperienceExtractor;
231 let ctx = test_context();
232 let outcome = AgentOutcome::Complete {
233 response: "result".into(),
234 };
235
236 let experiences = extractor.extract(&[], &outcome, &ctx).await;
237 assert_eq!(experiences[0].collective_id, ctx.collective_id);
238 assert_eq!(experiences[0].source_agent.0, "agent-1");
239 assert!(experiences[0].embedding.is_none()); }
241
242 #[tokio::test]
245 async fn test_extract_error_with_partial_progress() {
246 let extractor = DefaultExperienceExtractor;
247 let conversation = vec![
248 Message::user("Do the task"),
249 Message::assistant_with_tool_calls(vec![]),
250 Message::tool_result("call_1", "Search results: found 3 items"),
251 Message::tool_result("call_2", "Processed 2 of 3 items"),
252 ];
253 let outcome = AgentOutcome::Error {
254 error: "LLM timeout on third call".into(),
255 };
256
257 let experiences = extractor
258 .extract(&conversation, &outcome, &test_context())
259 .await;
260
261 assert_eq!(
263 experiences.len(),
264 2,
265 "Expected 2 experiences (partial + error)"
266 );
267
268 assert!(matches!(
270 &experiences[0].experience_type,
271 ExperienceType::Generic { category: Some(c) } if c == "partial_completion"
272 ));
273 assert!(experiences[0].content.contains("2 tool calls completed"));
274 assert!(experiences[0].content.contains("Search results"));
275 assert!(experiences[0].content.contains("Processed 2"));
276 assert!(experiences[0].content.contains("LLM timeout"));
277 assert!((experiences[0].importance - 0.6).abs() < f32::EPSILON);
278
279 assert!(matches!(
281 &experiences[1].experience_type,
282 ExperienceType::ErrorPattern { signature, .. } if signature == "LLM timeout on third call"
283 ));
284 }
285
286 #[tokio::test]
287 async fn test_extract_error_no_prior_tools_backward_compatible() {
288 let extractor = DefaultExperienceExtractor;
289 let outcome = AgentOutcome::Error {
290 error: "Immediate failure".into(),
291 };
292
293 let experiences = extractor.extract(&[], &outcome, &test_context()).await;
295
296 assert_eq!(experiences.len(), 1);
298 assert!(matches!(
299 &experiences[0].experience_type,
300 ExperienceType::ErrorPattern { .. }
301 ));
302 }
303
304 #[tokio::test]
305 async fn test_extract_error_skips_error_tool_results() {
306 let extractor = DefaultExperienceExtractor;
307 let conversation = vec![
308 Message::tool_result("call_1", "Error: Tool execution denied: restricted"),
309 Message::tool_result("call_2", "Successfully fetched data"),
310 ];
311 let outcome = AgentOutcome::Error {
312 error: "Failed after partial work".into(),
313 };
314
315 let experiences = extractor
316 .extract(&conversation, &outcome, &test_context())
317 .await;
318
319 assert_eq!(experiences.len(), 2);
321 assert!(experiences[0].content.contains("1 tool calls completed"));
322 assert!(experiences[0].content.contains("Successfully fetched"));
323 assert!(!experiences[0].content.contains("denied"));
324 }
325}