Skip to main content

codetether_agent/session/
pages.rs

1//! Typed pages for chat-history entries (ClawVM §3).
2//!
3//! ## Role
4//!
5//! ClawVM (arXiv:2604.10352) demonstrates that fault elimination in
6//! stateful agent harnesses comes from the *enforcement layer* — typed
7//! pages with minimum-fidelity invariants and a validated writeback
8//! protocol — not from the upgrade heuristic (their LRU ablation
9//! proves this). This module is the Phase A scaffolding for that
10//! enforcement layer: every entry in [`Session::messages`] is tagged
11//! with a [`PageKind`] that declares *what it is* and *how far it can
12//! degrade under budget pressure without violating a load-bearing
13//! invariant*.
14//!
15//! ## Scope in Phase A
16//!
17//! In Phase A we populate the parallel `pages: Vec<PageKind>` sidecar
18//! and expose [`classify`] so every new entry picks up a kind at
19//! append-time. Enforcement consumers (experimental strategies,
20//! compression) come online in Phase B and consult this sidecar
21//! before degrading any page.
22//!
23//! ## Examples
24//!
25//! ```rust
26//! use codetether_agent::provider::{ContentPart, Message, Role};
27//! use codetether_agent::session::pages::{PageKind, classify};
28//!
29//! let system = Message {
30//!     role: Role::System,
31//!     content: vec![ContentPart::Text { text: "you are…".into() }],
32//! };
33//! assert_eq!(classify(&system), PageKind::Bootstrap);
34//!
35//! let tool_result = Message {
36//!     role: Role::Tool,
37//!     content: vec![ContentPart::ToolResult {
38//!         tool_call_id: "call-1".into(),
39//!         content: "file contents".into(),
40//!     }],
41//! };
42//! assert_eq!(classify(&tool_result), PageKind::Evidence);
43//! ```
44
45use serde::{Deserialize, Serialize};
46
47use crate::provider::{ContentPart, Message, Role};
48
49/// ClawVM page classification for a single chat-history entry.
50///
51/// Kinds are stable identifiers, not flags — a message has exactly one
52/// kind. The degradation path and minimum-fidelity invariant are
53/// encoded on [`PageKind`] itself (see
54/// [`min_fidelity`](Self::min_fidelity) and
55/// [`degradation_path`](Self::degradation_path)).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum PageKind {
59    /// System prompts and procedural directives. Losing them causes
60    /// "forgot its protocol" failures (ClawVM §2).
61    Bootstrap,
62    /// Hard-pinned invariants the agent must honour — codetether's
63    /// `.tasks.jsonl` goal entries map here natively.
64    Constraint,
65    /// Current objective and step. Live plan state.
66    Plan,
67    /// Scoped user or project preferences.
68    Preference,
69    /// Tool outputs. Pointer-safe (Phase B): can degrade to a handle
70    /// backed by the MinIO history sink.
71    Evidence,
72    /// User / assistant conversation spans. The bulk of the transcript.
73    Conversation,
74}
75
76/// Residency level of a page inside the derived context.
77///
78/// Multi-resolution representations (ClawVM §3): a page degrades along
79/// its [`PageKind::degradation_path`] without ever violating its
80/// [`PageKind::min_fidelity`].
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ResidencyLevel {
84    /// Verbatim original content.
85    Full,
86    /// Token-reduced text (e.g. LLMLingua-2 compression).
87    Compressed,
88    /// Typed fields sufficient to satisfy the page's invariant.
89    Structured,
90    /// Resolvable handle plus minimal metadata. Backed by the MinIO
91    /// history sink for Evidence pages.
92    Pointer,
93}
94
95impl PageKind {
96    /// The minimum-fidelity level this kind is allowed to drop to.
97    ///
98    /// Compression / eviction strategies that would drop a page below
99    /// its minimum fidelity must be **rejected** (ClawVM §3), not
100    /// silently applied.
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use codetether_agent::session::pages::{PageKind, ResidencyLevel};
106    ///
107    /// assert_eq!(PageKind::Constraint.min_fidelity(), ResidencyLevel::Structured);
108    /// assert_eq!(PageKind::Evidence.min_fidelity(), ResidencyLevel::Pointer);
109    /// ```
110    pub fn min_fidelity(self) -> ResidencyLevel {
111        match self {
112            PageKind::Bootstrap => ResidencyLevel::Structured,
113            PageKind::Constraint => ResidencyLevel::Structured,
114            PageKind::Plan => ResidencyLevel::Pointer,
115            PageKind::Preference => ResidencyLevel::Pointer,
116            PageKind::Evidence => ResidencyLevel::Pointer,
117            PageKind::Conversation => ResidencyLevel::Pointer,
118        }
119    }
120
121    /// The ordered sequence of residency levels this kind is allowed
122    /// to visit, from full down to its minimum fidelity.
123    ///
124    /// Used by Phase B's `DerivePolicy::Incremental` to pick the next
125    /// marginal downgrade when the token budget is tight.
126    pub fn degradation_path(self) -> &'static [ResidencyLevel] {
127        match self {
128            PageKind::Bootstrap | PageKind::Constraint => {
129                &[ResidencyLevel::Full, ResidencyLevel::Structured]
130            }
131            PageKind::Plan => &[
132                ResidencyLevel::Full,
133                ResidencyLevel::Structured,
134                ResidencyLevel::Pointer,
135            ],
136            PageKind::Preference | PageKind::Evidence | PageKind::Conversation => &[
137                ResidencyLevel::Full,
138                ResidencyLevel::Compressed,
139                ResidencyLevel::Structured,
140                ResidencyLevel::Pointer,
141            ],
142        }
143    }
144}
145
146/// Classify a single chat-history entry.
147///
148/// Heuristic (Phase A):
149///
150/// * [`Role::System`] → [`PageKind::Bootstrap`].
151/// * [`Role::Tool`] or any content part that is
152///   [`ContentPart::ToolResult`] → [`PageKind::Evidence`].
153/// * Everything else → [`PageKind::Conversation`].
154///
155/// [`PageKind::Constraint`], [`PageKind::Plan`], and
156/// [`PageKind::Preference`] require explicit producers (the
157/// `.tasks.jsonl` loader, swarm objectives, user prefs block) and are
158/// not reachable from this syntactic classifier. That is intentional —
159/// they are load-bearing invariant tags, not best-effort guesses.
160///
161/// # Examples
162///
163/// ```rust
164/// use codetether_agent::provider::{ContentPart, Message, Role};
165/// use codetether_agent::session::pages::{PageKind, classify};
166///
167/// let user = Message {
168///     role: Role::User,
169///     content: vec![ContentPart::Text { text: "hi".into() }],
170/// };
171/// assert_eq!(classify(&user), PageKind::Conversation);
172/// ```
173pub fn classify(msg: &Message) -> PageKind {
174    if matches!(msg.role, Role::System) {
175        return PageKind::Bootstrap;
176    }
177    if matches!(msg.role, Role::Tool) {
178        return PageKind::Evidence;
179    }
180    for part in &msg.content {
181        if matches!(part, ContentPart::ToolResult { .. }) {
182            return PageKind::Evidence;
183        }
184    }
185    PageKind::Conversation
186}
187
188/// Classify every entry in `messages` and return the parallel array.
189///
190/// Used at session load to backfill the `pages` sidecar for legacy
191/// sessions that predate the Phase A refactor.
192pub fn classify_all(messages: &[Message]) -> Vec<PageKind> {
193    messages.iter().map(classify).collect()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn user(s: &str) -> Message {
201        Message {
202            role: Role::User,
203            content: vec![ContentPart::Text {
204                text: s.to_string(),
205            }],
206        }
207    }
208
209    fn assistant(s: &str) -> Message {
210        Message {
211            role: Role::Assistant,
212            content: vec![ContentPart::Text {
213                text: s.to_string(),
214            }],
215        }
216    }
217
218    fn tool_result(id: &str, body: &str) -> Message {
219        Message {
220            role: Role::Tool,
221            content: vec![ContentPart::ToolResult {
222                tool_call_id: id.to_string(),
223                content: body.to_string(),
224            }],
225        }
226    }
227
228    #[test]
229    fn classify_routes_system_to_bootstrap() {
230        let msg = Message {
231            role: Role::System,
232            content: vec![ContentPart::Text {
233                text: "you are…".to_string(),
234            }],
235        };
236        assert_eq!(classify(&msg), PageKind::Bootstrap);
237    }
238
239    #[test]
240    fn classify_routes_tool_results_to_evidence() {
241        assert_eq!(
242            classify(&tool_result("call-1", "output")),
243            PageKind::Evidence
244        );
245    }
246
247    #[test]
248    fn classify_defaults_user_and_assistant_to_conversation() {
249        assert_eq!(classify(&user("hi")), PageKind::Conversation);
250        assert_eq!(classify(&assistant("reply")), PageKind::Conversation);
251    }
252
253    #[test]
254    fn min_fidelity_never_drops_below_structured_for_invariant_pages() {
255        assert_eq!(
256            PageKind::Bootstrap.min_fidelity(),
257            ResidencyLevel::Structured
258        );
259        assert_eq!(
260            PageKind::Constraint.min_fidelity(),
261            ResidencyLevel::Structured
262        );
263    }
264
265    #[test]
266    fn degradation_paths_start_at_full() {
267        for kind in [
268            PageKind::Bootstrap,
269            PageKind::Constraint,
270            PageKind::Plan,
271            PageKind::Preference,
272            PageKind::Evidence,
273            PageKind::Conversation,
274        ] {
275            assert_eq!(kind.degradation_path()[0], ResidencyLevel::Full);
276        }
277    }
278
279    #[test]
280    fn classify_all_is_parallel() {
281        let msgs = vec![user("a"), assistant("b"), tool_result("c", "out")];
282        let pages = classify_all(&msgs);
283        assert_eq!(pages.len(), 3);
284        assert_eq!(pages[0], PageKind::Conversation);
285        assert_eq!(pages[1], PageKind::Conversation);
286        assert_eq!(pages[2], PageKind::Evidence);
287    }
288}