Skip to main content

deepstrike_core/memory/
synthesis.rs

1/// LLM-powered memory synthesis — the "dreaming" half of the idle pipeline.
2///
3/// The kernel is responsible for:
4///   1. Assembling a prompt from compressed session traces + rule-based seed insights
5///   2. Parsing the LLM's JSON response back into `TraceInsight` objects
6///
7/// The SDK is responsible for the actual LLM call between steps 1 and 2.
8/// This keeps the kernel pure-computation while enabling intelligent synthesis.
9use crate::memory::trace_analyzer::{InsightKind, TraceInsight};
10use crate::types::message::{Message, Role};
11
12#[derive(Debug, Clone)]
13pub struct SynthesisPolicy {
14    /// Max chars of session content included in the prompt. Default: 8_000.
15    pub max_session_chars: usize,
16    /// Max number of insights to request from the LLM. Default: 10.
17    pub max_insights: usize,
18    /// Prepend rule-based seed insights so the LLM can build on them. Default: true.
19    pub include_seed_insights: bool,
20}
21
22impl Default for SynthesisPolicy {
23    fn default() -> Self {
24        Self {
25            max_session_chars: 8_000,
26            max_insights: 10,
27            include_seed_insights: true,
28        }
29    }
30}
31
32/// Assembles the LLM prompt — pure computation, no I/O.
33pub struct SynthesisPromptBuilder {
34    pub policy: SynthesisPolicy,
35}
36
37impl SynthesisPromptBuilder {
38    pub fn new(policy: SynthesisPolicy) -> Self {
39        Self { policy }
40    }
41
42    /// Returns a two-message sequence `[System, User]` ready to send to the LLM.
43    pub fn build(
44        &self,
45        sessions: &[(String, Vec<Message>)],
46        seed_insights: &[TraceInsight],
47    ) -> Vec<Message> {
48        let mut user_content = String::new();
49
50        // --- session traces (compressed to budget) ---------------------------
51        user_content.push_str("## Recent Session Traces\n\n");
52        let mut chars_used = 0usize;
53        'outer: for (session_id, messages) in sessions {
54            user_content.push_str(&format!("### Session {}\n", session_id));
55            for msg in messages {
56                let remaining = self.policy.max_session_chars.saturating_sub(chars_used);
57                if remaining == 0 {
58                    user_content.push_str("...[truncated]\n");
59                    break 'outer;
60                }
61                let line = format_message(msg, remaining);
62                if !line.is_empty() {
63                    chars_used += line.len();
64                    user_content.push_str(&line);
65                    user_content.push('\n');
66                }
67            }
68            user_content.push('\n');
69        }
70
71        // --- seed insights (rule-based observations) -------------------------
72        if self.policy.include_seed_insights && !seed_insights.is_empty() {
73            user_content.push_str("## Rule-Based Observations (synthesize and elevate these)\n\n");
74            for insight in seed_insights {
75                user_content.push_str(&format!(
76                    "- [{}] {}\n",
77                    insight.kind.tag(),
78                    seed_hint(insight)
79                ));
80            }
81            user_content.push('\n');
82        }
83
84        // --- task instruction ------------------------------------------------
85        user_content.push_str(&format!(
86            "## Task\n\n\
87             Analyze the traces above and extract up to {} actionable, durable insights \
88             that will help this agent perform better in future sessions.\n\n\
89             Respond ONLY with valid JSON matching this exact schema — no prose, no fences:\n\
90             {{\"insights\":[{{\"text\":\"...\",\"confidence\":0.0}},...]}}\n\n\
91             Rules:\n\
92             - text: concise and actionable (max 200 chars)\n\
93             - confidence: 0.0–1.0 based on evidence strength\n\
94             - Focus on patterns, anti-patterns, and best practices\n\
95             - Do not copy seed observations verbatim; synthesize or elevate them",
96            self.policy.max_insights
97        ));
98
99        vec![Message::system(SYSTEM_PROMPT), Message::user(user_content)]
100    }
101}
102
103/// Parses the LLM's JSON response into `TraceInsight` objects — pure computation.
104pub struct SynthesisResponseParser;
105
106impl SynthesisResponseParser {
107    /// `synthetic_session_id` is a stable tag written into each insight's session_id
108    /// so curators downstream can distinguish synthesized vs rule-based insights.
109    pub fn parse(synthetic_session_id: &str, content: &str) -> Vec<TraceInsight> {
110        if let Some(insights) = try_parse_json(synthetic_session_id, content) {
111            if !insights.is_empty() {
112                return insights;
113            }
114        }
115        // Fallback: treat the entire response as a single synthesized insight.
116        vec![TraceInsight {
117            kind: InsightKind::Synthesized {
118                text: content.chars().take(300).collect(),
119            },
120            confidence: 0.5,
121            session_id: synthetic_session_id.to_string(),
122        }]
123    }
124}
125
126// --- private helpers ---------------------------------------------------------
127
128const SYSTEM_PROMPT: &str = "\
129You are a memory consolidation engine for an AI agent runtime. \
130Your role is to read recent agent session traces and extract durable, \
131actionable insights that will help the agent perform better in future sessions. \
132Think like a senior engineer running a retrospective: identify patterns, \
133anti-patterns, and best practices. \
134Respond only with structured JSON — no prose, no markdown fences.";
135
136fn format_message(msg: &Message, budget: usize) -> String {
137    match msg.role {
138        Role::System => String::new(), // system messages add noise, skip them
139        Role::User => {
140            let body = truncate(msg.content.as_text().unwrap_or(""), budget.min(400));
141            format!("[USER] {}", body)
142        }
143        Role::Assistant => {
144            if msg.tool_calls.is_empty() {
145                let body = truncate(msg.content.as_text().unwrap_or(""), budget.min(400));
146                format!("[ASST] {}", body)
147            } else {
148                let tools: Vec<_> = msg.tool_calls.iter().map(|tc| tc.name.as_str()).collect();
149                format!("[ASST] → tools: {}", tools.join(", "))
150            }
151        }
152        Role::Tool => "[TOOL] [tool results]".to_string(),
153    }
154}
155
156fn seed_hint(insight: &TraceInsight) -> String {
157    match &insight.kind {
158        InsightKind::RepeatedToolError {
159            tool_name,
160            error_count,
161            sample_error,
162        } => {
163            format!(
164                "'{}' errored {} times: {}",
165                tool_name, error_count, sample_error
166            )
167        }
168        InsightKind::SuccessfulToolSequence {
169            tools,
170            context_hint,
171        } => {
172            format!(
173                "Sequence [{}] succeeded for: {}",
174                tools.join("→"),
175                context_hint
176            )
177        }
178        InsightKind::LongReasoning { summary_hint } => summary_hint.chars().take(100).collect(),
179        InsightKind::Synthesized { text } => text.chars().take(100).collect(),
180    }
181}
182
183fn truncate(s: &str, max: usize) -> String {
184    let mut result: String = s.chars().take(max).collect();
185    if s.len() > max {
186        result.push_str("…");
187    }
188    result
189}
190
191fn try_parse_json(session_id: &str, content: &str) -> Option<Vec<TraceInsight>> {
192    // Strip markdown fences if the LLM added them despite instructions.
193    let cleaned = content
194        .trim()
195        .trim_start_matches("```json")
196        .trim_start_matches("```")
197        .trim_end_matches("```")
198        .trim();
199
200    let v: serde_json::Value = serde_json::from_str(cleaned).ok()?;
201    let arr = v.get("insights")?.as_array()?;
202
203    let insights: Vec<TraceInsight> = arr
204        .iter()
205        .filter_map(|item| {
206            let text = item.get("text")?.as_str()?.to_string();
207            if text.is_empty() {
208                return None;
209            }
210            let confidence = item
211                .get("confidence")?
212                .as_f64()
213                .unwrap_or(0.5)
214                .clamp(0.0, 1.0);
215            Some(TraceInsight {
216                kind: InsightKind::Synthesized {
217                    text: text.chars().take(300).collect(),
218                },
219                confidence,
220                session_id: session_id.to_string(),
221            })
222        })
223        .collect();
224
225    Some(insights)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::memory::trace_analyzer::TraceInsight;
232
233    fn seed(tool: &str) -> TraceInsight {
234        TraceInsight {
235            kind: InsightKind::RepeatedToolError {
236                tool_name: tool.to_string(),
237                error_count: 2,
238                sample_error: "permission denied".to_string(),
239            },
240            confidence: 0.8,
241            session_id: "s1".to_string(),
242        }
243    }
244
245    #[test]
246    fn parses_valid_json_response() {
247        let json =
248            r#"{"insights":[{"text":"Always check permissions before bash","confidence":0.9}]}"#;
249        let insights = SynthesisResponseParser::parse("synthetic", json);
250        assert_eq!(insights.len(), 1);
251        assert!(matches!(insights[0].kind, InsightKind::Synthesized { .. }));
252        assert!((insights[0].confidence - 0.9).abs() < 1e-9);
253    }
254
255    #[test]
256    fn strips_markdown_fences() {
257        let fenced =
258            "```json\n{\"insights\":[{\"text\":\"use read_file first\",\"confidence\":0.7}]}\n```";
259        let insights = SynthesisResponseParser::parse("synthetic", fenced);
260        assert_eq!(insights.len(), 1);
261        if let InsightKind::Synthesized { text } = &insights[0].kind {
262            assert_eq!(text, "use read_file first");
263        } else {
264            panic!("wrong kind");
265        }
266    }
267
268    #[test]
269    fn falls_back_on_invalid_json() {
270        let prose = "You should always check file permissions before running bash commands.";
271        let insights = SynthesisResponseParser::parse("synthetic", prose);
272        assert_eq!(insights.len(), 1);
273        assert!((insights[0].confidence - 0.5).abs() < 1e-9);
274    }
275
276    #[test]
277    fn clamps_confidence_above_one() {
278        let json = r#"{"insights":[{"text":"tip","confidence":1.5}]}"#;
279        let insights = SynthesisResponseParser::parse("synthetic", json);
280        assert_eq!(insights[0].confidence, 1.0);
281    }
282
283    #[test]
284    fn empty_insights_array_triggers_fallback() {
285        let json = r#"{"insights":[]}"#;
286        let insights = SynthesisResponseParser::parse("synthetic", json);
287        // empty array → fallback with the raw string
288        assert_eq!(insights.len(), 1);
289    }
290
291    #[test]
292    fn build_prompt_includes_session_content_and_seeds() {
293        let builder = SynthesisPromptBuilder::new(SynthesisPolicy::default());
294        let sessions = vec![(
295            "s1".to_string(),
296            vec![Message::user("fix the authentication bug")],
297        )];
298        let seeds = vec![seed("bash")];
299        let msgs = builder.build(&sessions, &seeds);
300
301        assert_eq!(msgs.len(), 2);
302        assert_eq!(msgs[0].role, Role::System);
303        let user_text = msgs[1].content.as_text().unwrap();
304        assert!(user_text.contains("fix the authentication bug"));
305        assert!(user_text.contains("bash"));
306        assert!(user_text.contains("permission denied"));
307    }
308
309    #[test]
310    fn build_prompt_respects_session_char_budget() {
311        let policy = SynthesisPolicy {
312            max_session_chars: 20,
313            ..Default::default()
314        };
315        let builder = SynthesisPromptBuilder::new(policy);
316        let long_msg = Message::user("x".repeat(1000));
317        let sessions = vec![("s1".to_string(), vec![long_msg])];
318        let msgs = builder.build(&sessions, &[]);
319        let user_text = msgs[1].content.as_text().unwrap();
320        // Session content portion should be capped; prompt itself may be longer.
321        assert!(user_text.contains("truncated") || user_text.len() < 2000);
322    }
323}