switchyard_translation/
compaction.rs1use http::HeaderMap;
5use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role};
6
7pub fn normalize_compaction(request: &mut LlmRequest, headers: Option<&HeaderMap>) {
9 let is_compaction = headers
10 .and_then(|headers| headers.get("x-codex-turn-metadata"))
11 .and_then(|value| serde_json::from_slice::<serde_json::Value>(value.as_bytes()).ok())
12 .is_some_and(|metadata| metadata["request_kind"] == "compaction");
13 request.has_compaction |= request
14 .messages
15 .iter()
16 .flat_map(|message| &message.content)
17 .any(|block| match block {
18 ContentBlock::Text { text } => {
19 let text = text.to_lowercase();
20 text.contains("session is being continued") || has_hermes_marker(&text)
21 }
22 _ => false,
23 });
24 request.is_compaction |= is_compaction
25 || request
26 .messages
27 .iter()
28 .rev()
29 .find(|message| {
30 !matches!(message.role, Role::System | Role::Developer)
31 && !is_environment_update(message)
32 })
33 .is_some_and(|message| message.role == Role::User && is_compaction_message(message));
34}
35
36fn is_environment_update(message: &Message) -> bool {
37 message.role == Role::User
38 && !message.content.is_empty()
39 && message.content.iter().all(|block| {
40 matches!(block, ContentBlock::Text { text } if text.trim()
41 .strip_prefix("<environment_context>")
42 .and_then(|context| context.split_once("</environment_context>"))
43 .is_some_and(|(_, rest)| rest.trim().is_empty()))
44 })
45}
46
47fn is_compaction_message(message: &Message) -> bool {
48 let mut has_summary = false;
49 for block in &message.content {
50 match block {
51 ContentBlock::Image { .. } => {}
52 _ if is_compaction_block(block) => has_summary = true,
53 _ => return false,
54 }
55 }
56 has_summary
57}
58
59fn is_compaction_block(block: &ContentBlock) -> bool {
60 match block {
61 ContentBlock::Unknown { raw, .. } => matches!(
62 raw.get("type").and_then(serde_json::Value::as_str),
63 Some("compaction" | "context_compaction" | "compaction_trigger")
64 ),
65 ContentBlock::Text { text } => {
66 let text = text.trim().to_ascii_lowercase();
67 text.starts_with("another language model started to solve this problem and produced a summary of its thinking process.")
68 || text.starts_with("this session is being continued from a previous conversation that ran out of context.")
69 || (text.starts_with("the conversation history before this point was compacted into the following summary:")
70 && text.ends_with("</summary>"))
71 || (text.starts_with("prior model work/tool state available.")
72 && text.ends_with("</summary>"))
73 || (text.starts_with("<conversation-checkpoint>")
74 && text.ends_with("</conversation-checkpoint>"))
75 || is_hermes_summary(&text)
76 }
77 _ => false,
78 }
79}
80
81const HERMES_REPLAY: &str = "[still in progress \u{2014} this is the active request, restated after the compaction boundary";
82
83fn is_hermes_summary(text: &str) -> bool {
84 const END: &str = "--- end of context summary \u{2014} respond to the message below, not the summary above ---";
85 if let Some((_, tail)) = text.rsplit_once(END)
86 && !tail.trim().is_empty()
87 && !tail.trim_start().starts_with(HERMES_REPLAY)
88 {
89 return false;
90 }
91 has_hermes_marker(text)
92 || text
93 .lines()
94 .any(|line| line.trim_start().starts_with("[context summary]:"))
95}
96
97fn has_hermes_marker(text: &str) -> bool {
98 text.lines().any(|line| {
99 let line = line.trim_start();
100 line.starts_with("[context compaction \u{2014} reference only]")
101 || line.starts_with(HERMES_REPLAY)
102 })
103}
104
105#[cfg(test)]
106mod tests {
107 use crate::{WireFormat, decode_request};
108 use serde_json::json;
109
110 #[test]
111 fn summary_continues_but_fresh_user_input_does_not() {
112 let mut body = json!({"messages": [
113 {"role": "user", "content": [
114 {"type": "text", "text": "prior context\n[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] summary"},
115 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
116 ]},
117 {"role": "user", "content": "<environment_context>updated</environment_context>"}
118 ]});
119 let request = decode_request(WireFormat::OpenAiChat, &body).unwrap();
120 assert!(request.is_compaction && request.has_compaction);
121 for role in [super::Role::System, super::Role::Developer] {
122 let mut with_instruction = request.clone();
123 with_instruction
124 .messages
125 .push(super::Message::text(role, "Follow repository instructions"));
126 with_instruction.is_compaction = false;
127 super::normalize_compaction(&mut with_instruction, None);
128 assert!(with_instruction.is_compaction);
129 }
130 body["messages"]
131 .as_array_mut()
132 .unwrap()
133 .push(json!({"role": "user", "content": "Now fix the parser"}));
134 let followup = decode_request(WireFormat::OpenAiChat, &body).unwrap();
135 assert!(!followup.is_compaction && followup.has_compaction);
136 }
137}