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}