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