Skip to main content

pulsehive_runtime/
experience.rs

1//! Default experience extraction from agent conversations.
2//!
3//! [`DefaultExperienceExtractor`] provides simple rule-based extraction
4//! for the Record phase. Products can implement [`ExperienceExtractor`]
5//! for custom logic (e.g., LLM-based summarization).
6
7use 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/// Default experience extractor using simple rule-based logic.
14///
15/// Extraction rules:
16/// - `Complete` → `Generic` experience with the agent's final response
17/// - `Error` → `ErrorPattern` with the error description
18/// - `MaxIterationsReached` → `Difficulty` noting the iteration limit
19///
20/// For richer extraction (e.g., extracting patterns, decisions, insights
21/// from the full conversation), implement a custom [`ExperienceExtractor`].
22#[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, // Builtin computes
38            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                // Check conversation for successful tool results before the error.
68                // This captures partial progress even when the agent ultimately fails.
69                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
123/// Extract summaries of successful tool results from the conversation.
124///
125/// Filters out error results (prefixed with "Error:") and truncates each
126/// summary for inclusion in partial completion experiences.
127fn extract_tool_summaries(conversation: &[Message]) -> Vec<String> {
128    conversation
129        .iter()
130        .filter_map(|msg| {
131            if let Message::ToolResult { content, .. } = msg {
132                // Skip error results (from denied tools, tool failures, etc.)
133                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
145/// Truncate a string to max_len, appending "..." if truncated.
146fn 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()); // Builtin computes
240    }
241
242    // ── Partial experience recording tests ───────────────────────────
243
244    #[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        // Should produce 2 experiences: partial_completion + error
262        assert_eq!(
263            experiences.len(),
264            2,
265            "Expected 2 experiences (partial + error)"
266        );
267
268        // First: partial completion
269        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        // Second: error pattern (unchanged behavior)
280        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        // Empty conversation — no tool results
294        let experiences = extractor.extract(&[], &outcome, &test_context()).await;
295
296        // Should produce exactly 1 experience (error only), same as before
297        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        // Partial should only include the non-error tool result
320        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}