1use crate::memory::trace_analyzer::{InsightKind, TraceInsight};
10use crate::types::message::{Message, Role};
11
12#[derive(Debug, Clone)]
13pub struct SynthesisPolicy {
14 pub max_session_chars: usize,
16 pub max_insights: usize,
18 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
32pub struct SynthesisPromptBuilder {
34 pub policy: SynthesisPolicy,
35}
36
37impl SynthesisPromptBuilder {
38 pub fn new(policy: SynthesisPolicy) -> Self {
39 Self { policy }
40 }
41
42 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 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 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 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
103pub struct SynthesisResponseParser;
105
106impl SynthesisResponseParser {
107 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 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
126const 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(), 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 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 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 assert!(user_text.contains("truncated") || user_text.len() < 2000);
322 }
323}