Skip to main content

lean_ctx/core/
compact_directive.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Store for the agent-driven conversation-compaction directive (#1570 P1).
4//!
5//! The MCP server cannot rewrite the client's conversation — only the proxy
6//! sees the messages. `ctx_session(action="compact")` therefore records a
7//! *directive*; the proxy applies it to every subsequent request (cache-safe,
8//! live-suffix only). Restore is lossless by construction: the client resends
9//! the full original history each turn, so clearing the directive is enough.
10
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14
15/// A directive older than this is ignored and cleaned up.
16const TTL_SECS: u64 = 1800;
17
18/// Anti-obsession guard (#1570 P6): a fresh compact may only be created this
19/// long after the previous one.
20const MIN_INTERVAL_SECS: u64 = 300;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CompactDirective {
24    pub created_unix: u64,
25    /// How many trailing plain-user turns stay verbatim.
26    pub keep_recent_turns: usize,
27    /// The agent-authored summary that replaces the compacted span.
28    pub summary: String,
29    /// Fingerprint of the first message AFTER the cut, pinned on first apply
30    /// so the replaced prefix stays byte-identical while the conversation
31    /// grows (provider prompt caches keep hitting).
32    pub boundary_fingerprint: Option<String>,
33    /// CCR handle of the verbatim replaced span (defense in depth — restore
34    /// itself only needs the directive cleared).
35    pub original_handle: Option<String>,
36}
37
38fn now_unix() -> u64 {
39    SystemTime::now()
40        .duration_since(UNIX_EPOCH)
41        .map(|d| d.as_secs())
42        .unwrap_or(0)
43}
44
45fn store_path() -> Option<std::path::PathBuf> {
46    crate::core::data_dir::lean_ctx_data_dir()
47        .ok()
48        .map(|d| d.join("compact_directive.json"))
49}
50
51/// Create a new directive. Fails when one was created less than
52/// `MIN_INTERVAL_SECS` ago (rate limit — nothing is ever blocked, the agent
53/// simply keeps its current, already-compacted context).
54pub fn create(keep_recent_turns: usize, summary: String) -> Result<(), String> {
55    if let Some(existing) = load_active()
56        && now_unix().saturating_sub(existing.created_unix) < MIN_INTERVAL_SECS
57    {
58        return Err(format!(
59            "a compaction from {}s ago is still active — one compact per {}s \
60             (anti-obsession guard); use ctx_session action=\"restore\" first if needed",
61            now_unix().saturating_sub(existing.created_unix),
62            MIN_INTERVAL_SECS
63        ));
64    }
65    let directive = CompactDirective {
66        created_unix: now_unix(),
67        keep_recent_turns,
68        summary,
69        boundary_fingerprint: None,
70        original_handle: None,
71    };
72    save(&directive)
73}
74
75pub fn save(directive: &CompactDirective) -> Result<(), String> {
76    let mut admitted = directive.clone();
77    admitted.summary =
78        crate::core::session::admission::admit_storable_tool_text(&directive.summary)?;
79    write_admitted(&admitted)
80}
81
82fn write_admitted(directive: &CompactDirective) -> Result<(), String> {
83    let path = store_path().ok_or_else(|| "no data dir".to_string())?;
84    let json = serde_json::to_string(directive).map_err(|e| e.to_string())?;
85    std::fs::write(path, json).map_err(|e| e.to_string())
86}
87
88/// Load the directive when it exists and is younger than `TTL_SECS`.
89pub fn load_active() -> Option<CompactDirective> {
90    let path = store_path()?;
91    let raw = std::fs::read_to_string(&path).ok()?;
92    let directive: CompactDirective = serde_json::from_str(&raw).ok()?;
93    if now_unix().saturating_sub(directive.created_unix) > TTL_SECS {
94        let _ = std::fs::remove_file(&path);
95        return None;
96    }
97    let original_summary = directive.summary.clone();
98    let Ok(admitted_summary) =
99        crate::core::session::admission::admit_storable_tool_text(&original_summary)
100    else {
101        let _ = std::fs::remove_file(path);
102        return None;
103    };
104    let mut admitted = directive;
105    admitted.summary = admitted_summary;
106    if admitted.summary != original_summary {
107        write_admitted(&admitted).ok()?;
108    }
109    Some(admitted)
110}
111
112/// Drop the directive — the client's next request carries the full original
113/// history again, so this alone is a complete, lossless restore.
114pub fn clear() -> bool {
115    store_path().is_some_and(|path| std::fs::remove_file(path).is_ok())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn directive_roundtrip_rate_limit_and_clear() {
124        let _lock = crate::core::data_dir::test_env_lock();
125        let _dir = crate::core::data_dir::isolated_data_dir();
126        assert!(load_active().is_none());
127
128        create(6, "summary one".into()).expect("first create succeeds");
129        let d = load_active().expect("directive active");
130        assert_eq!(d.keep_recent_turns, 6);
131        assert!(d.boundary_fingerprint.is_none());
132
133        // Rate limit: a second compact right away is refused with guidance.
134        let err = create(4, "summary two".into()).expect_err("rate-limited");
135        assert!(err.contains("anti-obsession"), "{err}");
136
137        assert!(clear());
138        assert!(load_active().is_none());
139        // After clear, creating again is allowed immediately.
140        create(4, "summary three".into()).expect("create after clear");
141        clear();
142    }
143
144    #[test]
145    fn expired_directive_is_ignored_and_removed() {
146        let _lock = crate::core::data_dir::test_env_lock();
147        let _dir = crate::core::data_dir::isolated_data_dir();
148        let stale = CompactDirective {
149            created_unix: now_unix() - TTL_SECS - 10,
150            keep_recent_turns: 6,
151            summary: "old".into(),
152            boundary_fingerprint: None,
153            original_handle: None,
154        };
155        save(&stale).unwrap();
156        assert!(load_active().is_none(), "expired directive must not apply");
157        assert!(load_active().is_none());
158    }
159}