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