Skip to main content

behest_runtime/compaction/
prompt.rs

1//! Anchored summary prompt template for compaction.
2//!
3//! The compaction LLM is asked to produce a structured summary using
4//! the anchored summary format. When a previous compaction exists, the
5//! model is instructed to update it rather than regenerate from scratch.
6//!
7//! Ported from OpenCode V1/V2:
8//! - `packages/core/src/session/compaction.ts` (V2 anchored summary template)
9//! - `packages/opencode/src/agent/prompt/compaction.txt` (V1 compaction agent prompt)
10
11use std::fmt::Write;
12
13use crate::token::estimate_record_tokens;
14use behest_store::MessageRecord;
15
16/// The maximum number of characters to include from tool outputs in the
17/// compaction prompt. Longer outputs are truncated to avoid bloating the
18/// compaction request.
19const TOOL_OUTPUT_MAX_CHARS: usize = 2_000;
20
21/// Anchored summary template instructing the compaction LLM.
22///
23/// The placeholder `{previous_summary}` is replaced with the prior
24/// compaction's summary text (for incremental updates). If absent, the
25/// model is instructed to create a new summary.
26const 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/// Builds the full compaction prompt from messages to summarise and
64/// an optional previous summary for incremental updates.
65#[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
88/// Serialises a slice of messages into a readable text format for the
89/// compaction LLM. Tool outputs are truncated to `TOOL_OUTPUT_MAX_CHARS`.
90fn 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        // Estimate token count and stop if the prompt is getting too long.
134        // The compaction model itself has a context limit; we stop building
135        // the prompt at ~80% of a reasonable compaction model context (64K).
136        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}