behest_runtime/compaction/
prompt.rs1use std::fmt::Write;
12
13use crate::token::estimate_record_tokens;
14use behest_store::MessageRecord;
15
16const TOOL_OUTPUT_MAX_CHARS: usize = 2_000;
20
21const COMPACTION_PROMPT_TEMPLATE: &str = "\
27You are a conversation summarizer. Your task is to produce a structured, \
28dense summary of the conversation history below so another instance of \
29the same model can pick up where it left off.
30
31{previous_instruction}
32
33Output ONLY the summary — no preamble, no commentary, no markdown code fences.
34
35## Goal
36<!-- Single-sentence description of the task the user is trying to accomplish -->
37
38## Constraints & Preferences
39<!-- Explicit constraints, preferences, style guides, or rules mentioned -->
40
41## Progress
42### Done
43<!-- What has been completed so far -->
44### In Progress
45<!-- What is currently being worked on -->
46### Blocked
47<!-- Anything that is blocked and why -->
48
49## Key Decisions
50<!-- Important technical or design decisions made during the conversation -->
51
52## Next Steps
53<!-- What the model should do next, in priority order -->
54
55## Critical Context
56<!-- Any context the model MUST know to continue (file paths, error messages, \
57API responses, etc.) -->
58
59## Relevant Files
60<!-- Files that were created, modified, or discussed. Format: path:line_number -->
61";
62
63#[must_use]
66pub fn build_prompt(
67 messages_to_compact: &[MessageRecord],
68 previous_summary: Option<&str>,
69) -> String {
70 let previous_instruction = match previous_summary {
71 Some(prev) => format!(
72 "You are updating an existing summary. \
73Below is the previous summary — keep what is still relevant, \
74update what has changed, and add new information since the last compaction.\n\n\
75## Previous Summary\n```\n{prev}\n```"
76 ),
77 None => "Create a new anchored summary from the conversation below.".to_owned(),
78 };
79
80 let prompt =
81 COMPACTION_PROMPT_TEMPLATE.replace("{previous_instruction}", &previous_instruction);
82
83 let messages_text = serialize_messages(messages_to_compact);
84
85 format!("{prompt}\n## Messages to Summarize\n{messages_text}")
86}
87
88fn serialize_messages(messages: &[MessageRecord]) -> String {
91 let mut buf = String::new();
92
93 for msg in messages {
94 let role_label = match msg.role {
95 behest_store::MessageRole::System => "[System]",
96 behest_store::MessageRole::User => "[User]",
97 behest_store::MessageRole::Assistant => "[Assistant]",
98 behest_store::MessageRole::Tool => "[Tool Result]",
99 _ => "[Message]",
100 };
101
102 buf.push_str(role_label);
103 buf.push_str(": ");
104
105 for part in &msg.content {
106 match part {
107 behest_provider::ContentPart::Text { text, .. } => {
108 let text = truncate_if_too_long(text, TOOL_OUTPUT_MAX_CHARS);
109 buf.push_str(&text);
110 }
111 behest_provider::ContentPart::Json { value, .. } => {
112 let json_str = value.to_string();
113 let json_str = truncate_if_too_long(&json_str, TOOL_OUTPUT_MAX_CHARS);
114 buf.push_str(&json_str);
115 }
116 behest_provider::ContentPart::ImageUrl { url, .. } => {
117 let _ = write!(buf, "[Image: {url}]");
118 }
119 _ => {}
120 }
121 }
122
123 for tc in &msg.tool_calls {
124 let _ = write!(
125 buf,
126 "\n [Tool Call: {}({})]",
127 tc.name,
128 truncate_if_too_long(&tc.arguments.to_string(), 500)
129 );
130 }
131
132 buf.push('\n');
133 if estimate_record_tokens(msg) > 0 && crate::token::estimate_tokens(&buf) > 50_000 {
137 buf.push_str(
138 "\n[... further messages truncated to stay within compaction model context ...]\n",
139 );
140 break;
141 }
142 }
143
144 buf
145}
146
147fn truncate_if_too_long(text: &str, max_chars: usize) -> String {
148 if text.len() <= max_chars {
149 text.to_owned()
150 } else {
151 let truncated: String = text.chars().take(max_chars).collect();
152 format!(
153 "{truncated}\n[truncated: omitted {} chars]",
154 text.len() - max_chars
155 )
156 }
157}
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used)]
161mod tests {
162 use super::*;
163 use behest_provider::ContentPart;
164 use uuid::Uuid;
165
166 fn make_user_record(text: &str) -> MessageRecord {
167 MessageRecord::new(
168 Uuid::now_v7(),
169 behest_store::MessageRole::User,
170 vec![ContentPart::text(text)],
171 )
172 }
173
174 #[test]
175 fn build_prompt_without_previous_summary() {
176 let messages = vec![make_user_record("Hello, can you help me write a function?")];
177 let prompt = build_prompt(&messages, None);
178 assert!(prompt.contains("Create a new anchored summary"));
179 assert!(prompt.contains("## Goal"));
180 assert!(prompt.contains("Hello, can you help me write a function?"));
181 }
182
183 #[test]
184 fn build_prompt_with_previous_summary() {
185 let messages = vec![make_user_record("Now add error handling.")];
186 let prev = "## Goal\nWrite a function\n## Progress\n### Done\nCreated function";
187 let prompt = build_prompt(&messages, Some(prev));
188 assert!(prompt.contains("updating an existing summary"));
189 assert!(prompt.contains("## Previous Summary"));
190 assert!(prompt.contains("Created function"));
191 }
192
193 #[test]
194 fn serialize_includes_role_labels() {
195 let records = vec![
196 MessageRecord::new(
197 Uuid::now_v7(),
198 behest_store::MessageRole::User,
199 vec![ContentPart::text("Hi")],
200 ),
201 MessageRecord::new(
202 Uuid::now_v7(),
203 behest_store::MessageRole::Assistant,
204 vec![ContentPart::text("Hello!")],
205 ),
206 ];
207 let text = serialize_messages(&records);
208 assert!(text.contains("[User]: Hi"));
209 assert!(text.contains("[Assistant]: Hello!"));
210 }
211
212 #[test]
213 fn truncate_long_content() {
214 let long = "x".repeat(3_000);
215 let result = truncate_if_too_long(&long, 2_000);
216 assert!(result.len() < 3_000);
217 assert!(result.contains("[truncated"));
218 }
219
220 #[test]
221 fn truncate_empty() {
222 assert_eq!(truncate_if_too_long("", 100), "");
223 }
224}