1use kiss_agent::{AgentMessage, convert_to_llm};
5use kiss_ai::{ContentBlock, Message, Model, StreamOptions, ThinkingLevel, UserContent};
6use serde_json::json;
7
8const TOOL_RESULT_SERIALIZE_CAP: usize = 2000;
9
10pub fn estimate_tokens_text(text: &str) -> u64 {
12 (text.len() as u64).div_ceil(4)
13}
14
15pub fn estimate_message_tokens(message: &AgentMessage) -> u64 {
16 let text = match message {
17 AgentMessage::User(u) => u.content.as_text(),
18 AgentMessage::Assistant(a) => a
19 .content
20 .iter()
21 .map(|c| match c {
22 ContentBlock::Text { text, .. } => text.len(),
23 ContentBlock::Thinking { thinking, .. } => thinking.len(),
24 ContentBlock::ToolCall(tc) => tc.arguments.to_string().len() + tc.name.len(),
25 ContentBlock::Image { .. } => 1600,
26 })
27 .sum::<usize>()
28 .to_string(),
29 AgentMessage::ToolResult(t) => t
30 .content
31 .iter()
32 .map(|c| match c {
33 ContentBlock::Text { text, .. } => text.len(),
34 _ => 1600,
35 })
36 .sum::<usize>()
37 .to_string(),
38 AgentMessage::BashExecution(b) => (b.command.len() + b.output.len()).to_string(),
39 AgentMessage::Custom(c) => c.content.as_text(),
40 AgentMessage::BranchSummary(b) => b.summary.clone(),
41 AgentMessage::CompactionSummary(c) => c.summary.clone(),
42 };
43 match message {
44 AgentMessage::Assistant(_) | AgentMessage::ToolResult(_) => text
45 .parse::<u64>()
46 .map(|chars| chars.div_ceil(4))
47 .unwrap_or_else(|_| estimate_tokens_text(&text)),
48 _ => estimate_tokens_text(&text),
49 }
50}
51
52pub fn estimate_context_tokens(messages: &[AgentMessage]) -> u64 {
55 let last_assistant = messages
56 .iter()
57 .rposition(|m| matches!(m, AgentMessage::Assistant(_)));
58 match last_assistant {
59 Some(pos) => {
60 let AgentMessage::Assistant(a) = &messages[pos] else {
61 unreachable!()
62 };
63 let base = a.usage.input + a.usage.output + a.usage.cache_read + a.usage.cache_write;
64 let tail: u64 = messages[pos + 1..]
65 .iter()
66 .map(estimate_message_tokens)
67 .sum();
68 base + tail
69 }
70 None => messages.iter().map(estimate_message_tokens).sum(),
71 }
72}
73
74pub fn serialize_conversation(messages: &[Message]) -> String {
77 let mut out = String::new();
78 for message in messages {
79 match message {
80 Message::User(u) => {
81 out.push_str(&format!("[User]: {}\n", u.content.as_text()));
82 }
83 Message::Assistant(a) => {
84 let mut tool_calls: Vec<String> = Vec::new();
85 for block in &a.content {
86 match block {
87 ContentBlock::Thinking { thinking, .. } => {
88 if !thinking.is_empty() {
89 out.push_str(&format!("[Assistant thinking]: {thinking}\n"));
90 }
91 }
92 ContentBlock::Text { text, .. } => {
93 if !text.is_empty() {
94 out.push_str(&format!("[Assistant]: {text}\n"));
95 }
96 }
97 ContentBlock::ToolCall(tc) => {
98 let args = tc
99 .arguments
100 .as_object()
101 .map(|o| {
102 o.iter()
103 .map(|(k, v)| format!("{k}={v}"))
104 .collect::<Vec<_>>()
105 .join(", ")
106 })
107 .unwrap_or_else(|| tc.arguments.to_string());
108 tool_calls.push(format!("{}({args})", tc.name));
109 }
110 ContentBlock::Image { .. } => {}
111 }
112 }
113 if !tool_calls.is_empty() {
114 out.push_str(&format!(
115 "[Assistant tool calls]: {}\n",
116 tool_calls.join("; ")
117 ));
118 }
119 }
120 Message::ToolResult(t) => {
121 let text: String = t
122 .content
123 .iter()
124 .filter_map(|c| match c {
125 ContentBlock::Text { text, .. } => Some(text.as_str()),
126 _ => None,
127 })
128 .collect::<Vec<_>>()
129 .join("\n");
130 let capped = if text.chars().count() > TOOL_RESULT_SERIALIZE_CAP {
131 let kept: String = text.chars().take(TOOL_RESULT_SERIALIZE_CAP).collect();
132 let dropped = text.chars().count() - TOOL_RESULT_SERIALIZE_CAP;
133 format!("{kept}\n[... {dropped} characters truncated]")
134 } else {
135 text
136 };
137 out.push_str(&format!("[Tool result]: {capped}\n"));
138 }
139 }
140 }
141 out
142}
143
144pub fn extract_file_ops(messages: &[AgentMessage]) -> (Vec<String>, Vec<String>) {
146 let mut read: Vec<String> = Vec::new();
147 let mut modified: Vec<String> = Vec::new();
148 for message in messages {
149 let AgentMessage::Assistant(a) = message else {
150 continue;
151 };
152 for tc in a.tool_calls() {
153 let Some(path) = tc.arguments["path"].as_str() else {
154 continue;
155 };
156 match tc.name.as_str() {
157 "read" => read.push(path.to_string()),
158 "edit" | "write" => modified.push(path.to_string()),
159 _ => {}
160 }
161 }
162 }
163 read.sort();
164 read.dedup();
165 modified.sort();
166 modified.dedup();
167 (read, modified)
168}
169
170pub const SUMMARY_FORMAT: &str = "## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements mentioned by user]\n\n## Progress\n### Done\n- [x] [Completed tasks]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues, if any]\n\n## Key Decisions\n- **[Decision]**: [Rationale]\n\n## Next Steps\n1. [What should happen next]\n\n## Critical Context\n- [Data needed to continue]";
171
172#[derive(Debug, Clone, PartialEq)]
174pub struct CompactionPlan {
175 pub to_summarize: Vec<AgentMessage>,
177 pub kept: Vec<AgentMessage>,
179 pub turn_prefix: Vec<AgentMessage>,
181 pub is_split_turn: bool,
182 pub tokens_before: u64,
183}
184
185fn is_valid_cut(message: &AgentMessage) -> bool {
186 matches!(
188 message,
189 AgentMessage::User(_)
190 | AgentMessage::Assistant(_)
191 | AgentMessage::BashExecution(_)
192 | AgentMessage::Custom(_)
193 | AgentMessage::BranchSummary(_)
194 )
195}
196
197fn is_turn_start(message: &AgentMessage) -> bool {
198 matches!(
199 message,
200 AgentMessage::User(_) | AgentMessage::Custom(_) | AgentMessage::BashExecution(_)
201 )
202}
203
204pub fn plan_compaction(messages: &[AgentMessage], keep_recent_tokens: u64) -> CompactionPlan {
207 let tokens_before = estimate_context_tokens(messages);
208 let mut budget = 0u64;
209 let mut cut = messages.len();
210 for (i, message) in messages.iter().enumerate().rev() {
211 budget += estimate_message_tokens(message);
212 if budget > keep_recent_tokens {
213 break;
214 }
215 if is_valid_cut(message) {
216 cut = i;
217 }
218 }
219 if cut == messages.len() && !messages.is_empty() {
220 cut = messages.len() - 1;
222 while cut > 0 && !is_valid_cut(&messages[cut]) {
223 cut -= 1;
224 }
225 }
226
227 let turn_start = messages[..cut].iter().rposition(is_turn_start);
229 match turn_start {
230 Some(_) if is_turn_start(&messages[cut]) => CompactionPlan {
231 to_summarize: messages[..cut].to_vec(),
232 kept: messages[cut..].to_vec(),
233 turn_prefix: Vec::new(),
234 is_split_turn: false,
235 tokens_before,
236 },
237 Some(start) => CompactionPlan {
238 to_summarize: messages[..start].to_vec(),
239 kept: messages[cut..].to_vec(),
240 turn_prefix: messages[start..cut].to_vec(),
241 is_split_turn: true,
242 tokens_before,
243 },
244 None => CompactionPlan {
245 to_summarize: Vec::new(),
246 kept: messages[cut..].to_vec(),
247 turn_prefix: messages[..cut].to_vec(),
248 is_split_turn: cut > 0,
249 tokens_before,
250 },
251 }
252}
253
254pub fn should_compact(context_tokens: u64, context_window: u64, reserve_tokens: u64) -> bool {
256 context_tokens > context_window.saturating_sub(reserve_tokens)
257}
258
259pub struct SummaryOutcome {
260 pub summary: String,
261 pub usage: Option<kiss_ai::Usage>,
262}
263
264fn summary_failure(message: &kiss_ai::AssistantMessage) -> Option<String> {
265 match message.stop_reason {
266 kiss_ai::StopReason::Error => Some(format!(
267 "summary generation failed: {}",
268 message.error_message.as_deref().unwrap_or("unknown error")
269 )),
270 kiss_ai::StopReason::Length => {
271 Some("summary generation failed: response reached the token limit".into())
272 }
273 _ if message.tool_calls().next().is_some() => {
274 Some("summary generation failed: response contained a tool call".into())
275 }
276 _ => None,
277 }
278}
279
280fn summary_prompt(
281 conversation_text: &str,
282 previous_summary: Option<&str>,
283 custom_instructions: Option<&str>,
284 continuation: bool,
285) -> String {
286 let mut prompt = format!("# Conversation\n{conversation_text}\n\n# Instructions\n");
287 if continuation {
288 prompt.push_str("The conversation above is earlier context from an ongoing task. Later messages are retained separately. Create a concise checkpoint that can be placed before them so work can continue; do not infer or recreate later messages. Use exactly this structure:\n\n");
289 } else {
290 prompt.push_str("Summarize the conversation so a coding agent can pick up where it left off. Use exactly this structure:\n\n");
291 }
292 prompt.push_str(SUMMARY_FORMAT);
293 prompt.push_str("\n\nAlso include, at the end, a <read-files> block listing files that were read and a <modified-files> block listing files that were changed, one path per line, when known.");
294 if let Some(prev) = previous_summary {
295 prompt.push_str("\n\nA previous summary of earlier context exists. Fold it in:\n\n");
296 prompt.push_str(prev);
297 }
298 if let Some(custom) = custom_instructions {
299 prompt.push_str("\n\nAdditional focus requested by the user: ");
300 prompt.push_str(custom);
301 }
302 prompt
303}
304
305pub async fn generate_summary(
308 model: &Model,
309 credential: Option<kiss_ai::ResolvedCredential>,
310 conversation_text: &str,
311 previous_summary: Option<&str>,
312 custom_instructions: Option<&str>,
313 continuation: bool,
314 cancel: tokio_util::sync::CancellationToken,
315) -> anyhow::Result<SummaryOutcome> {
316 let prompt = summary_prompt(
317 conversation_text,
318 previous_summary,
319 custom_instructions,
320 continuation,
321 );
322 let context = kiss_ai::Context {
323 system_prompt: None,
324 openai_responses_input: None,
325 messages: vec![Message::User(kiss_ai::UserMessage {
326 content: UserContent::Text(prompt),
327 timestamp: kiss_ai::now_ms(),
328 })],
329 tools: vec![],
330 };
331 let options = StreamOptions {
332 credential,
333 reasoning: ThinkingLevel::Off,
334 cancel,
335 ..Default::default()
336 };
337 let message = kiss_ai::stream_simple(model, &context, &options)
338 .result()
339 .await;
340 if let Some(error) = summary_failure(&message) {
341 anyhow::bail!(error);
342 }
343 Ok(SummaryOutcome {
344 summary: message.text(),
345 usage: Some(message.usage),
346 })
347}
348
349pub fn file_ops_details(read: &[String], modified: &[String]) -> serde_json::Value {
351 json!({"readFiles": read, "modifiedFiles": modified})
352}
353
354pub fn serialize_agent_messages(messages: &[AgentMessage]) -> String {
356 serialize_conversation(&convert_to_llm(messages))
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use kiss_ai::{AssistantMessage, StopReason, ToolCall, ToolResultMessage};
363
364 fn user(text: &str) -> AgentMessage {
365 AgentMessage::user(text)
366 }
367
368 fn assistant(text: &str) -> AgentMessage {
369 let mut m = AssistantMessage::empty("fake", "fake", "fake");
370 m.content.push(ContentBlock::text(text));
371 m.stop_reason = StopReason::Stop;
372 AgentMessage::Assistant(m)
373 }
374
375 fn tool_result(text: &str) -> AgentMessage {
376 AgentMessage::ToolResult(ToolResultMessage {
377 tool_call_id: "c".into(),
378 tool_name: "bash".into(),
379 content: vec![ContentBlock::text(text)],
380 details: None,
381 usage: None,
382 is_error: false,
383 timestamp: 0,
384 })
385 }
386
387 #[test]
388 fn cut_at_turn_boundary_never_tool_result() {
389 let messages = vec![
391 user(&"a".repeat(400)),
392 assistant(&"b".repeat(400)),
393 tool_result(&"c".repeat(400)),
394 user(&"d".repeat(400)),
395 assistant(&"e".repeat(400)),
396 ];
397 let plan = plan_compaction(&messages, 250);
398 assert!(!plan.is_split_turn);
399 assert!(matches!(plan.kept.first().unwrap(), AgentMessage::User(_)));
400 assert_eq!(plan.to_summarize.len(), 3);
401 }
402
403 #[test]
404 fn split_turn_detected() {
405 let messages = vec![
407 user("start"),
408 assistant(&"x".repeat(2000)),
409 tool_result(&"y".repeat(2000)),
410 assistant(&"z".repeat(2000)),
411 ];
412 let plan = plan_compaction(&messages, 600);
413 assert!(plan.is_split_turn);
414 assert!(plan.to_summarize.is_empty());
415 assert!(!plan.turn_prefix.is_empty());
416 assert!(is_valid_cut(plan.kept.first().unwrap()));
418 }
419
420 #[test]
421 fn oversized_trailing_tool_result_keeps_its_assistant_call() {
422 let messages = vec![
423 user("start"),
424 assistant("tool call"),
425 tool_result(&"x".repeat(8_000)),
426 ];
427 let plan = plan_compaction(&messages, 10);
428 assert!(matches!(
429 plan.kept.first(),
430 Some(AgentMessage::Assistant(_))
431 ));
432 assert!(matches!(
433 plan.kept.get(1),
434 Some(AgentMessage::ToolResult(_))
435 ));
436 }
437
438 #[test]
439 fn serialization_labels_and_caps() {
440 let big = "L".repeat(3000);
441 let messages = vec![user("do it"), assistant("on it"), tool_result(&big)];
442 let text = serialize_agent_messages(&messages);
443 assert!(text.contains("[User]: do it"));
444 assert!(text.contains("[Assistant]: on it"));
445 assert!(text.contains("characters truncated"));
446 }
447
448 #[test]
449 fn file_ops_extraction() {
450 let mut a = AssistantMessage::empty("f", "f", "f");
451 a.content.push(ContentBlock::ToolCall(ToolCall {
452 id: "1".into(),
453 name: "read".into(),
454 arguments: json!({"path": "src/a.rs"}),
455 thought_signature: None,
456 }));
457 a.content.push(ContentBlock::ToolCall(ToolCall {
458 id: "2".into(),
459 name: "edit".into(),
460 arguments: json!({"path": "src/b.rs"}),
461 thought_signature: None,
462 }));
463 let (read, modified) = extract_file_ops(&[AgentMessage::Assistant(a)]);
464 assert_eq!(read, vec!["src/a.rs"]);
465 assert_eq!(modified, vec!["src/b.rs"]);
466 }
467
468 #[test]
469 fn threshold() {
470 assert!(should_compact(190_000, 200_000, 16_384));
471 assert!(!should_compact(100_000, 200_000, 16_384));
472 }
473
474 #[test]
475 fn split_turn_prompt_separates_conversation_from_continuation_instructions() {
476 let prompt = summary_prompt("user work", None, None, true);
477 assert!(prompt.starts_with("# Conversation\nuser work\n\n# Instructions\n"));
478 assert!(prompt.contains("Later messages are retained separately"));
479 assert!(prompt.contains("do not infer or recreate later messages"));
480 }
481
482 #[test]
483 fn incomplete_summary_is_rejected() {
484 let mut message = AssistantMessage::empty("fake", "fake", "fake");
485 message.stop_reason = StopReason::Length;
486 assert_eq!(
487 summary_failure(&message).as_deref(),
488 Some("summary generation failed: response reached the token limit")
489 );
490 }
491
492 #[test]
493 fn summary_with_tool_call_is_rejected() {
494 let mut message = AssistantMessage::empty("fake", "fake", "fake");
495 message.stop_reason = StopReason::ToolUse;
496 message.content.push(ContentBlock::ToolCall(ToolCall {
497 id: "call-1".into(),
498 name: "read".into(),
499 arguments: json!({"path": "src/lib.rs"}),
500 thought_signature: None,
501 }));
502 assert_eq!(
503 summary_failure(&message).as_deref(),
504 Some("summary generation failed: response contained a tool call")
505 );
506 }
507}