Skip to main content

everruns_provider/
compact.rs

1//! Transport-neutral native compaction request and response contracts.
2
3use crate::driver_registry::{LlmContentPart, LlmMessage, LlmMessageContent, LlmMessageRole};
4use serde::{Deserialize, Serialize};
5
6/// Request body for the Open Responses `/v1/responses/compact` endpoint.
7#[derive(Debug, Clone, Serialize)]
8pub struct CompactRequest {
9    /// Explicit Responses compaction, required for configuration-update history.
10    /// This field is local routing state and never sent to the provider.
11    #[serde(skip)]
12    pub reasoning_state: Option<crate::reasoning_updates::ReasoningState>,
13    /// Model used for compaction.
14    pub model: String,
15    /// Current conversation items to compact.
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub input: Vec<CompactInputItem>,
18    /// Previous response identifier, as an alternative to a complete input.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub previous_response_id: Option<String>,
21    /// Optional system instructions for this compaction request.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub instructions: Option<String>,
24}
25
26/// Transport-neutral input item accepted by native conversation compaction.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type")]
29pub enum CompactInputItem {
30    #[serde(rename = "configuration_update")]
31    ConfigurationUpdate { reasoning: ConfigurationReasoning },
32    /// User, assistant, or developer message.
33    #[serde(rename = "message")]
34    Message {
35        /// Protocol role name.
36        role: String,
37        /// Message content.
38        content: CompactContent,
39    },
40    /// Function call emitted by the assistant.
41    #[serde(rename = "function_call")]
42    FunctionCall {
43        /// Provider-visible call identifier.
44        call_id: String,
45        /// Function name.
46        name: String,
47        /// JSON-encoded arguments.
48        arguments: String,
49    },
50    /// Output corresponding to a function call.
51    #[serde(rename = "function_call_output")]
52    FunctionCallOutput {
53        /// Provider-visible call identifier.
54        call_id: String,
55        /// String-encoded function output.
56        output: String,
57    },
58    /// Opaque output from an earlier compaction pass.
59    #[serde(rename = "compaction")]
60    Compaction {
61        /// Provider-produced encrypted latent context.
62        encrypted_content: String,
63    },
64    /// Provider-native reasoning or other retained items, replayed verbatim.
65    #[serde(untagged)]
66    ProviderItem(serde_json::Value),
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ConfigurationReasoning {
71    pub effort: crate::model::ReasoningEffort,
72}
73
74impl From<&CompactOutputItem> for CompactInputItem {
75    fn from(item: &CompactOutputItem) -> Self {
76        match item {
77            CompactOutputItem::Message { role, content } => Self::Message {
78                role: role.clone(),
79                content: content.clone(),
80            },
81            CompactOutputItem::Compaction { encrypted_content } => Self::Compaction {
82                encrypted_content: encrypted_content.clone(),
83            },
84            CompactOutputItem::ProviderItem(item) => Self::ProviderItem(item.clone()),
85        }
86    }
87}
88
89/// Text or multipart content carried by a compact message.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(untagged)]
92pub enum CompactContent {
93    /// Plain text content.
94    Text(String),
95    /// Ordered text and image parts.
96    Parts(Vec<CompactContentPart>),
97}
98
99/// One multipart content item in a compact message.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "type")]
102pub enum CompactContentPart {
103    /// Text input.
104    #[serde(rename = "input_text")]
105    InputText {
106        /// Text value.
107        text: String,
108    },
109    /// Image input.
110    #[serde(rename = "input_image")]
111    InputImage {
112        /// Image URL or data URL.
113        image_url: String,
114    },
115    /// File (e.g. PDF) input.
116    #[serde(rename = "input_file")]
117    InputFile {
118        /// File data URL (base64) or file URL.
119        file_data: String,
120        /// Original filename, when known.
121        #[serde(skip_serializing_if = "Option::is_none")]
122        filename: Option<String>,
123    },
124}
125
126/// Decoded response from native conversation compaction.
127#[derive(Debug, Clone, Deserialize)]
128pub struct CompactResponse {
129    /// Ordered compacted output items.
130    pub output: Vec<CompactOutputItem>,
131    /// Optional provider token and cost accounting.
132    pub usage: Option<CompactUsage>,
133}
134
135/// Output item returned by native conversation compaction.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137#[serde(tag = "type")]
138pub enum CompactOutputItem {
139    /// User message preserved verbatim.
140    #[serde(rename = "message")]
141    Message {
142        /// Protocol role name.
143        role: String,
144        /// Message content.
145        content: CompactContent,
146    },
147    /// Opaque replacement for earlier assistant/tool context.
148    #[serde(rename = "compaction")]
149    Compaction {
150        /// Provider-produced encrypted latent context.
151        encrypted_content: String,
152    },
153    /// Preserve complete explicit-compaction output, including reasoning and
154    /// any retained native items, instead of dropping unknown semantic state.
155    #[serde(untagged)]
156    ProviderItem(serde_json::Value),
157}
158
159impl<'de> Deserialize<'de> for CompactOutputItem {
160    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
161        use serde::de::Error;
162        let value = serde_json::Value::deserialize(deserializer)?;
163        let kind = value
164            .get("type")
165            .and_then(serde_json::Value::as_str)
166            .ok_or_else(|| D::Error::custom("compact output requires a string type"))?;
167        match kind {
168            "compaction" => {
169                let encrypted_content = value
170                    .get("encrypted_content")
171                    .and_then(serde_json::Value::as_str)
172                    .ok_or_else(|| D::Error::custom("compaction requires encrypted_content"))?;
173                Ok(Self::Compaction {
174                    encrypted_content: encrypted_content.to_owned(),
175                })
176            }
177            "message" => {
178                let role = value
179                    .get("role")
180                    .and_then(serde_json::Value::as_str)
181                    .ok_or_else(|| D::Error::custom("compact message requires role"))?;
182                let content = value
183                    .get("content")
184                    .filter(|content| content.is_string() || content.is_array())
185                    .ok_or_else(|| {
186                        D::Error::custom("compact message requires text or multipart content")
187                    })?;
188                // The standalone shape is intentionally narrow. Native Responses
189                // messages may carry phase, identity and output-only content;
190                // checkpoint reload must preserve that complete provider item.
191                if value.as_object().is_some_and(|object| object.len() == 3)
192                    && let Ok(content) = serde_json::from_value::<CompactContent>(content.clone())
193                {
194                    return Ok(Self::Message {
195                        role: role.to_owned(),
196                        content,
197                    });
198                }
199                Ok(Self::ProviderItem(value))
200            }
201            _ => Ok(Self::ProviderItem(value)),
202        }
203    }
204}
205
206/// Provider-reported accounting for one compact request.
207#[derive(Debug, Clone, Deserialize)]
208pub struct CompactUsage {
209    /// Input tokens processed.
210    pub input_tokens: Option<u32>,
211    /// Output tokens produced.
212    pub output_tokens: Option<u32>,
213    /// Total tokens billed.
214    pub total_tokens: Option<u32>,
215    /// Authoritative provider-reported per-request cost in USD, when supplied.
216    #[serde(default)]
217    pub cost: Option<f64>,
218}
219
220impl CompactInputItem {
221    /// Convert one provider-neutral message to its ordered compact input items.
222    ///
223    /// Assistant tool calls expand to function-call items and tool messages
224    /// become function-call outputs.
225    pub fn from_llm_message(msg: &LlmMessage) -> Vec<Self> {
226        let mut items = Vec::new();
227        if let Some(effort) = msg.configuration_update {
228            items.push(Self::ConfigurationUpdate {
229                reasoning: ConfigurationReasoning { effort },
230            });
231        }
232        for part in &msg.reasoning {
233            if part.provider == "openai"
234                && let (Some(id), Some(encrypted)) = (&part.item_id, &part.encrypted)
235            {
236                let summary = match &part.text {
237                    Some(crate::reasoning::ReasoningText::Summary { parts }) => parts
238                        .iter()
239                        .map(|text| serde_json::json!({"type": "summary_text", "text": text}))
240                        .collect::<Vec<_>>(),
241                    _ => Vec::new(),
242                };
243                items.push(Self::ProviderItem(serde_json::json!({
244                    "type": "reasoning", "id": id, "encrypted_content": encrypted,
245                    "summary": summary,
246                })));
247            }
248        }
249        let role = match msg.role {
250            LlmMessageRole::System => "developer",
251            LlmMessageRole::User => "user",
252            LlmMessageRole::Assistant => "assistant",
253            LlmMessageRole::Tool => "tool",
254        };
255
256        if msg.role == LlmMessageRole::Tool
257            && let Some(tool_call_id) = &msg.tool_call_id
258        {
259            let output = match &msg.content {
260                LlmMessageContent::Text(text) => text.clone(),
261                LlmMessageContent::Parts(parts) => parts
262                    .iter()
263                    .filter_map(|part| match part {
264                        LlmContentPart::Text { text } => Some(text.clone()),
265                        _ => None,
266                    })
267                    .collect::<Vec<_>>()
268                    .join(""),
269            };
270            items.push(Self::FunctionCallOutput {
271                call_id: tool_call_id.clone(),
272                output,
273            });
274            return items;
275        }
276
277        let content = Self::content_from_llm_message(msg);
278        let has_content = match &content {
279            CompactContent::Text(text) => !text.is_empty(),
280            CompactContent::Parts(parts) => !parts.is_empty(),
281        };
282        if has_content || msg.tool_calls.is_none() {
283            let message = Self::Message {
284                role: role.to_string(),
285                content,
286            };
287            if msg.role == LlmMessageRole::Assistant
288                && let Some(phase) = msg.phase
289            {
290                let mut value = serde_json::json!(message);
291                value["phase"] = serde_json::json!(phase.as_provider_str());
292                items.push(Self::ProviderItem(value));
293            } else {
294                items.push(message);
295            }
296        }
297
298        if msg.role == LlmMessageRole::Assistant
299            && let Some(tool_calls) = &msg.tool_calls
300        {
301            items.extend(tool_calls.iter().map(|call| Self::FunctionCall {
302                call_id: call.id.clone(),
303                name: call.name.clone(),
304                arguments: call.arguments.to_string(),
305            }));
306        }
307        items
308    }
309
310    pub fn is_assistant_item(&self) -> bool {
311        match self {
312            Self::FunctionCall { .. } => true,
313            Self::Message { role, .. } => role == "assistant",
314            Self::ProviderItem(value) => {
315                value["role"] == "assistant"
316                    || value["type"] == "reasoning"
317                    || value["type"] == "function_call"
318            }
319            _ => false,
320        }
321    }
322
323    fn content_from_llm_message(msg: &LlmMessage) -> CompactContent {
324        match &msg.content {
325            LlmMessageContent::Text(text) => CompactContent::Text(text.clone()),
326            LlmMessageContent::Parts(parts) => {
327                let compact_parts = parts
328                    .iter()
329                    .filter_map(|part| match part {
330                        LlmContentPart::Text { text } => {
331                            Some(CompactContentPart::InputText { text: text.clone() })
332                        }
333                        LlmContentPart::Image { url } => Some(CompactContentPart::InputImage {
334                            image_url: url.clone(),
335                        }),
336                        LlmContentPart::File { url, filename } => {
337                            Some(CompactContentPart::InputFile {
338                                file_data: url.clone(),
339                                filename: filename.clone(),
340                            })
341                        }
342                        LlmContentPart::Audio { .. } => None,
343                    })
344                    .collect::<Vec<_>>();
345                if compact_parts.len() == 1
346                    && let CompactContentPart::InputText { text } = &compact_parts[0]
347                {
348                    return CompactContent::Text(text.clone());
349                }
350                CompactContent::Parts(compact_parts)
351            }
352        }
353    }
354}
355
356/// Convert provider-neutral messages into ordered native compact input items.
357pub fn messages_to_compact_input(messages: &[LlmMessage]) -> Vec<CompactInputItem> {
358    messages
359        .iter()
360        .flat_map(CompactInputItem::from_llm_message)
361        .collect()
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use serde_json::json;
368
369    #[test]
370    fn request_wire_covers_every_item_and_omits_absent_continuation_fields() {
371        let request = CompactRequest {
372            reasoning_state: Some(crate::reasoning_updates::ReasoningState {
373                epoch: "local-only-epoch".into(),
374                baseline: Some(crate::model::ReasoningEffort::Low),
375                effective: Some(crate::model::ReasoningEffort::High),
376                pending: Some(crate::model::ReasoningEffort::High),
377            }),
378            model: "model".into(),
379            input: vec![
380                CompactInputItem::Message {
381                    role: "user".into(),
382                    content: CompactContent::Text("hello".into()),
383                },
384                CompactInputItem::Message {
385                    role: "assistant".into(),
386                    content: CompactContent::Parts(vec![
387                        CompactContentPart::InputText {
388                            text: "image".into(),
389                        },
390                        CompactContentPart::InputImage {
391                            image_url: "data:image/png;base64,abc".into(),
392                        },
393                    ]),
394                },
395                CompactInputItem::FunctionCall {
396                    call_id: "call-1".into(),
397                    name: "lookup".into(),
398                    arguments: r#"{"city":"NYC"}"#.into(),
399                },
400                CompactInputItem::FunctionCallOutput {
401                    call_id: "call-1".into(),
402                    output: "result".into(),
403                },
404                CompactInputItem::Compaction {
405                    encrypted_content: "opaque".into(),
406                },
407                CompactInputItem::ConfigurationUpdate {
408                    reasoning: ConfigurationReasoning {
409                        effort: crate::model::ReasoningEffort::High,
410                    },
411                },
412                CompactInputItem::ProviderItem(
413                    json!({"type":"reasoning","id":"rs-native","encrypted_content":"native-opaque"}),
414                ),
415            ],
416            previous_response_id: None,
417            instructions: Some("rules".into()),
418        };
419        assert_eq!(
420            serde_json::to_value(request).unwrap(),
421            json!({"model":"model","instructions":"rules","input":[
422                {"type":"message","role":"user","content":"hello"},
423                {"type":"message","role":"assistant","content":[{"type":"input_text","text":"image"},{"type":"input_image","image_url":"data:image/png;base64,abc"}]},
424                {"type":"function_call","call_id":"call-1","name":"lookup","arguments":"{\"city\":\"NYC\"}"},
425                {"type":"function_call_output","call_id":"call-1","output":"result"},
426                {"type":"compaction","encrypted_content":"opaque"},
427                {"type":"configuration_update","reasoning":{"effort":"high"}},
428                {"type":"reasoning","id":"rs-native","encrypted_content":"native-opaque"}
429            ]})
430        );
431        assert_eq!(
432            serde_json::to_value(CompactRequest {
433                reasoning_state: None,
434                model: "model".into(),
435                input: vec![],
436                previous_response_id: Some("resp-previous".into()),
437                instructions: None
438            })
439            .unwrap(),
440            json!({"model":"model","previous_response_id":"resp-previous"})
441        );
442    }
443
444    #[test]
445    fn response_decoding_preserves_opaque_and_multipart_replay_with_optional_usage() {
446        let output = json!([
447            {"type":"message","role":"user","content":"hello"},
448            {"type":"message","role":"user","content":[{"type":"input_text","text":"see"},{"type":"input_image","image_url":"https://images.example/a.png"}]},
449            {"type":"compaction","encrypted_content":"opaque-secret"}
450        ]);
451        let response: CompactResponse = serde_json::from_value(json!({"output":output,"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150,"cost":0.04}})).unwrap();
452        assert_eq!(serde_json::to_value(&response.output).unwrap(), output);
453        let replay: Vec<_> = response.output.iter().map(CompactInputItem::from).collect();
454        assert_eq!(serde_json::to_value(replay).unwrap(), output);
455        let usage = response.usage.unwrap();
456        assert_eq!(
457            (
458                usage.input_tokens,
459                usage.output_tokens,
460                usage.total_tokens,
461                usage.cost
462            ),
463            (Some(100), Some(50), Some(150), Some(0.04))
464        );
465        // Native Responses output must survive checkpoint JSON without losing
466        // phase/identity fields or unfamiliar provider-owned semantic items.
467        for native in [
468            json!({"type":"message","role":"assistant","content":[],"phase":"final_answer","id":"msg-1"}),
469            json!({"type":"reasoning","encrypted_content":"opaque-reasoning","id":"rs-1"}),
470            json!({"type":"unknown","encrypted_content":"opaque-future"}),
471        ] {
472            let decoded: CompactOutputItem = serde_json::from_value(native.clone()).unwrap();
473            assert_eq!(
474                serde_json::to_value(CompactInputItem::from(&decoded)).unwrap(),
475                native
476            );
477            assert_eq!(serde_json::to_value(decoded).unwrap(), native);
478        }
479        let minimal: CompactResponse = serde_json::from_value(json!({"output":[]})).unwrap();
480        assert!(minimal.output.is_empty());
481        assert!(minimal.usage.is_none());
482        let sparse: CompactResponse =
483            serde_json::from_value(json!({"output":[],"usage":{"input_tokens":9}})).unwrap();
484        let usage = sparse.usage.unwrap();
485        assert_eq!(
486            (
487                usage.input_tokens,
488                usage.output_tokens,
489                usage.total_tokens,
490                usage.cost
491            ),
492            (Some(9), None, None, None)
493        );
494        for invalid in [
495            json!({"type":"compaction"}),
496            json!({"encrypted_content":"x"}),
497            json!({"type":"message","role":"user"}),
498        ] {
499            assert!(
500                serde_json::from_value::<CompactOutputItem>(invalid.clone()).is_err(),
501                "{invalid}"
502            );
503        }
504    }
505
506    #[test]
507    fn message_conversion_keeps_roles_call_order_and_supported_content() {
508        let mut assistant = LlmMessage::text(LlmMessageRole::Assistant, "checking");
509        assistant.configuration_update = Some(crate::model::ReasoningEffort::High);
510        assistant.phase = Some(crate::execution_phase::ExecutionPhase::Commentary);
511        assistant.reasoning = vec![
512            crate::reasoning::ReasoningContentPart::opaque("openai")
513                .with_item_id("rs-1")
514                .with_encrypted("private-replay")
515                .with_text(crate::reasoning::ReasoningText::Summary {
516                    parts: vec!["first".into(), "second".into()],
517                }),
518            crate::reasoning::ReasoningContentPart::opaque("anthropic")
519                .with_item_id("foreign")
520                .with_encrypted("foreign-secret"),
521            crate::reasoning::ReasoningContentPart::opaque("openai")
522                .with_item_id("missing-encrypted"),
523        ];
524        assistant.tool_calls = Some(vec![crate::tool_types::ToolCall {
525            id: "call-1".into(),
526            name: "lookup".into(),
527            arguments: json!({"q":1}),
528        }]);
529        let mut result = LlmMessage::parts(
530            LlmMessageRole::Tool,
531            vec![
532                LlmContentPart::text("do"),
533                LlmContentPart::image("https://images.example/ignored.png"),
534                LlmContentPart::text("ne"),
535            ],
536        );
537        result.tool_call_id = Some("call-1".into());
538        let input = messages_to_compact_input(&[
539            LlmMessage::text(LlmMessageRole::System, "rules"),
540            LlmMessage::parts(
541                LlmMessageRole::User,
542                vec![
543                    LlmContentPart::text("see"),
544                    LlmContentPart::image("https://images.example/a.png"),
545                    LlmContentPart::Audio {
546                        url: "data:audio/wav;base64,aA==".into(),
547                    },
548                ],
549            ),
550            assistant,
551            result,
552            LlmMessage::parts(
553                LlmMessageRole::User,
554                vec![LlmContentPart::text("only text")],
555            ),
556        ]);
557        assert_eq!(
558            serde_json::to_value(input).unwrap(),
559            json!([
560                {"type":"message","role":"developer","content":"rules"},
561                {"type":"message","role":"user","content":[{"type":"input_text","text":"see"},{"type":"input_image","image_url":"https://images.example/a.png"}]},
562                {"type":"configuration_update","reasoning":{"effort":"high"}},
563                {"type":"reasoning","id":"rs-1","encrypted_content":"private-replay","summary":[{"type":"summary_text","text":"first"},{"type":"summary_text","text":"second"}]},
564                {"type":"message","role":"assistant","content":"checking","phase":"commentary"},
565                {"type":"function_call","call_id":"call-1","name":"lookup","arguments":"{\"q\":1}"},
566                {"type":"function_call_output","call_id":"call-1","output":"done"},
567                {"type":"message","role":"user","content":"only text"}
568            ])
569        );
570        let mut calls_only = LlmMessage::text(LlmMessageRole::Assistant, "");
571        calls_only.tool_calls = Some(vec![crate::tool_types::ToolCall {
572            id: "call-2".into(),
573            name: "clock".into(),
574            arguments: json!({}),
575        }]);
576        assert_eq!(
577            serde_json::to_value(CompactInputItem::from_llm_message(&calls_only)).unwrap(),
578            json!([{"type":"function_call","call_id":"call-2","name":"clock","arguments":"{}"}])
579        );
580    }
581
582    #[test]
583    fn compact_file_part_preserves_data_url_and_filename() {
584        let part = CompactContentPart::InputFile {
585            file_data: "data:application/pdf;base64,JVBERi0=".to_string(),
586            filename: Some("report.pdf".to_string()),
587        };
588        let v = serde_json::to_value(&part).unwrap();
589        assert_eq!(v["type"], serde_json::json!("input_file"));
590        assert_eq!(
591            v["file_data"],
592            serde_json::json!("data:application/pdf;base64,JVBERi0=")
593        );
594        assert_eq!(v["filename"], serde_json::json!("report.pdf"));
595    }
596}