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.messages.iter().any(|message| {
14 message.content.iter().any(|block| match block {
15 ContentBlock::Text { text } => {
16 let text = text.to_lowercase();
17 text.contains("session is being continued")
18 || (message.role == Role::User && text.trim_start().starts_with(CODEX_SUMMARY))
19 || has_hermes_marker(&text)
20 }
21 _ => false,
22 })
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(CODEX_SUMMARY)
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 CODEX_SUMMARY: &str = "another language model started to solve this problem and produced a summary of its thinking process.";
82
83const HERMES_REPLAY: &str = "[still in progress \u{2014} this is the active request, restated after the compaction boundary";
84
85fn is_hermes_summary(text: &str) -> bool {
86 const END: &str = "--- end of context summary \u{2014} respond to the message below, not the summary above ---";
87 if let Some((_, tail)) = text.rsplit_once(END)
88 && !tail.trim().is_empty()
89 && !tail.trim_start().starts_with(HERMES_REPLAY)
90 {
91 return false;
92 }
93 has_hermes_marker(text)
94 || text
95 .lines()
96 .any(|line| line.trim_start().starts_with("[context summary]:"))
97}
98
99fn has_hermes_marker(text: &str) -> bool {
100 text.lines().any(|line| {
101 let line = line.trim_start();
102 line.starts_with("[context compaction \u{2014} reference only]")
103 || line.starts_with(HERMES_REPLAY)
104 })
105}
106
107#[cfg(test)]
108mod tests {
109 use crate::{WireFormat, decode_request};
110 use serde_json::json;
111
112 #[test]
113 fn codex_summary_remains_in_history_after_a_new_user_turn() {
114 let summary = " Another language model started to solve this problem and produced a summary of its thinking process. Continue the task.";
115 for format in [
116 WireFormat::OpenAiChat,
117 WireFormat::OpenAiResponses,
118 WireFormat::AnthropicMessages,
119 ] {
120 let field = if format == WireFormat::OpenAiResponses {
121 "input"
122 } else {
123 "messages"
124 };
125 let mut body = json!({"model": "model", "max_tokens": 32});
126 body[field] = json!([{"role": "user", "content": summary}]);
127 let request = decode_request(format, &body).unwrap();
128 assert!(
129 request.is_compaction && request.has_compaction,
130 "{format:?}"
131 );
132 body[field]
133 .as_array_mut()
134 .unwrap()
135 .push(json!({"role": "user", "content": "Now fix the parser"}));
136 let request = decode_request(format, &body).unwrap();
137 assert!(
138 !request.is_compaction && request.has_compaction,
139 "{format:?}"
140 );
141 }
142 }
143
144 #[test]
145 fn compaction_request_and_quoted_summary_do_not_mark_history() {
146 let summary = "Another language model started to solve this problem and produced a summary of its thinking process.";
147 for message in [
148 json!({"role": "user", "content": "Summarize existing work."}),
149 json!({"role": "user", "content": format!("Explain this phrase: {summary}")}),
150 json!({"role": "assistant", "content": summary}),
151 json!({"role": "system", "content": summary}),
152 json!({"role": "developer", "content": summary}),
153 json!({"role": "tool", "tool_call_id": "read", "content": summary}),
154 ] {
155 let body = json!({"messages": [message]});
156 let mut request = decode_request(WireFormat::OpenAiChat, &body).unwrap();
157 assert!(!request.is_compaction && !request.has_compaction);
158 let mut headers = http::HeaderMap::new();
159 headers.insert(
160 "x-codex-turn-metadata",
161 r#"{"request_kind":"compaction"}"#.parse().unwrap(),
162 );
163 super::normalize_compaction(&mut request, Some(&headers));
164 assert!(request.is_compaction && !request.has_compaction);
165 }
166 }
167
168 #[test]
169 fn summary_continues_but_fresh_user_input_does_not() {
170 let mut body = json!({"messages": [
171 {"role": "user", "content": [
172 {"type": "text", "text": "prior context\n[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] summary"},
173 {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}
174 ]},
175 {"role": "user", "content": "<environment_context>updated</environment_context>"}
176 ]});
177 let request = decode_request(WireFormat::OpenAiChat, &body).unwrap();
178 assert!(request.is_compaction && request.has_compaction);
179 for role in [super::Role::System, super::Role::Developer] {
180 let mut with_instruction = request.clone();
181 with_instruction
182 .messages
183 .push(super::Message::text(role, "Follow repository instructions"));
184 with_instruction.is_compaction = false;
185 super::normalize_compaction(&mut with_instruction, None);
186 assert!(with_instruction.is_compaction);
187 }
188 body["messages"]
189 .as_array_mut()
190 .unwrap()
191 .push(json!({"role": "user", "content": "Now fix the parser"}));
192 let followup = decode_request(WireFormat::OpenAiChat, &body).unwrap();
193 assert!(!followup.is_compaction && followup.has_compaction);
194 }
195}