Skip to main content

switchyard_translation/
compaction.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use http::HeaderMap;
5use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role};
6
7/// Normalizes current and historical compaction signals, preserving explicit IR annotations.
8pub 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}