返回 CodeWhale
fragment.rs
根目录 / crates / tui / src / model_context / fragment.rs
1 //! One typed, capped, marker-stable ModelContext fragment.
2 //!
3 //! Caps and identities are unified with `codewhale_core::fragments` — the
4 //! single `crates/core` owner for the bounded fragment system (issue #5264).
5 //! This crate re-exports the core caps so every injection goes through a
6 //! typed fragment with a stable marker and the hard caps
7 //! (per-fragment size, 10K-token ceiling, injected-item count) are enforced
8 //! in one place.
9
10 use std::collections::hash_map::DefaultHasher;
11 use std::hash::{Hash, Hasher};
12
13 /// Re-export the core hard caps — single source of truth.
14 pub use codewhale_core::fragments::{
15 DEFAULT_FRAGMENT_MAX_BYTES, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_TOKENS, MAX_FRAGMENTS_PER_CONTEXT,
16 };
17
18 /// Stable identity for a WorldState concern. Markers are public contract —
19 /// do not rename without a migration note (prefix-cache + tests pin them).
20 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21 pub enum FragmentId {
22 Workspace,
23 Permissions,
24 Route,
25 AgentTopology,
26 SkillsTools,
27 TokenBudget,
28 ProjectInstructions,
29 Constitution,
30 }
31
32 impl FragmentId {
33 #[must_use]
34 #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Self::Workspace => "workspace",
38 Self::Permissions => "permissions",
39 Self::Route => "route",
40 Self::AgentTopology => "agent_topology",
41 Self::SkillsTools => "skills_tools",
42 Self::TokenBudget => "token_budget",
43 Self::ProjectInstructions => "project_instructions",
44 Self::Constitution => "constitution",
45 }
46 }
47
48 /// Stable HTML-comment marker wrapping the fragment body.
49 #[must_use]
50 pub fn marker(self) -> &'static str {
51 match self {
52 Self::Workspace => "<!-- cw:ctx:workspace -->",
53 Self::Permissions => "<!-- cw:ctx:permissions -->",
54 Self::Route => "<!-- cw:ctx:route -->",
55 Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
56 Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
57 Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
58 Self::ProjectInstructions => "<!-- cw:ctx:project_instructions -->",
59 Self::Constitution => "<!-- cw:ctx:constitution -->",
60 }
61 }
62
63 #[must_use]
64 #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
65 pub fn role(self) -> FragmentRole {
66 match self {
67 Self::Workspace => FragmentRole::Workspace,
68 Self::Permissions => FragmentRole::Permissions,
69 Self::Route => FragmentRole::Route,
70 Self::AgentTopology => FragmentRole::AgentTopology,
71 Self::SkillsTools => FragmentRole::SkillsTools,
72 Self::TokenBudget => FragmentRole::TokenBudget,
73 Self::ProjectInstructions => FragmentRole::ProjectInstructions,
74 Self::Constitution => FragmentRole::Constitution,
75 }
76 }
77
78 #[must_use]
79 #[allow(dead_code)] // ordered enumeration for host rebuilds / inspectors (TUI-DOG-011)
80 pub fn all() -> &'static [FragmentId] {
81 &[
82 Self::Workspace,
83 Self::Permissions,
84 Self::Route,
85 Self::AgentTopology,
86 Self::SkillsTools,
87 Self::TokenBudget,
88 Self::ProjectInstructions,
89 Self::Constitution,
90 ]
91 }
92 }
93
94 /// Explicit role of a fragment relative to the cache-stable constitution.
95 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96 pub enum FragmentRole {
97 /// Workspace / repo working-set facts (volatile across sessions).
98 Workspace,
99 /// Approval / permission posture.
100 Permissions,
101 /// Active route, model, and app mode.
102 Route,
103 /// Sub-agent topology and recent completion notices.
104 AgentTopology,
105 /// Skills, plugins, and tool availability summary.
106 SkillsTools,
107 /// Token budget and compaction status.
108 TokenBudget,
109 /// Project instructions (AGENTS.md + imported instruction files).
110 ProjectInstructions,
111 /// Codewhale-specific repo constitution.
112 Constitution,
113 }
114
115 impl FragmentRole {
116 #[must_use]
117 #[allow(dead_code)] // public role labels for inspectors / diffs (TUI-DOG-011)
118 pub fn as_str(self) -> &'static str {
119 match self {
120 Self::Workspace => "workspace",
121 Self::Permissions => "permissions",
122 Self::Route => "route",
123 Self::AgentTopology => "agent_topology",
124 Self::SkillsTools => "skills_tools",
125 Self::TokenBudget => "token_budget",
126 Self::ProjectInstructions => "project_instructions",
127 Self::Constitution => "constitution",
128 }
129 }
130 }
131
132 /// Result of comparing a fragment against its previous render.
133 #[derive(Debug, Clone, PartialEq, Eq)]
134 pub enum FragmentRender {
135 /// Content hash matches previous — retain bytes; do not reinject.
136 Unchanged { marker: String, content_hash: u64 },
137 /// New or changed content — inject the capped body.
138 Updated { fragment: ModelContextFragment },
139 /// Fragment was present before and is now absent.
140 #[allow(dead_code)] // produced by WorldState::clear; hosts wire clear next (TUI-DOG-011)
141 Cleared { marker: String },
142 }
143
144 /// One capped WorldState section with a stable marker and content hash.
145 #[derive(Debug, Clone, PartialEq, Eq)]
146 pub struct ModelContextFragment {
147 pub id: FragmentId,
148 pub role: FragmentRole,
149 pub marker: &'static str,
150 pub max_bytes: usize,
151 pub content: String,
152 pub content_hash: u64,
153 }
154
155 impl ModelContextFragment {
156 #[must_use]
157 pub fn new(id: FragmentId, role: FragmentRole, raw: impl Into<String>) -> Self {
158 Self::with_max_bytes(id, role, raw, DEFAULT_FRAGMENT_MAX_BYTES)
159 }
160
161 #[must_use]
162 pub fn with_max_bytes(
163 id: FragmentId,
164 role: FragmentRole,
165 raw: impl Into<String>,
166 max_bytes: usize,
167 ) -> Self {
168 // Clamp to the global 10K-token ceiling so callers cannot opt out.
169 let clamped_max = max_bytes.min(MAX_FRAGMENT_BYTES);
170 let mut content = enforce_byte_cap(raw.into(), clamped_max);
171 // Token-ceiling safety net (4 bytes ≈ 1 token).
172 if content.len().div_ceil(4) > MAX_FRAGMENT_TOKENS {
173 content = enforce_byte_cap(content, MAX_FRAGMENT_BYTES);
174 }
175 let content_hash = hash_content(&content);
176 Self {
177 id,
178 role,
179 marker: id.marker(),
180 max_bytes: clamped_max,
181 content,
182 content_hash,
183 }
184 }
185
186 /// Return whether `haystack` contains this fragment's stable marker.
187 #[must_use]
188 pub fn matches_text(&self, haystack: &str) -> bool {
189 haystack.contains(self.marker)
190 }
191
192 /// Compare against a previous fragment of the same id.
193 #[must_use]
194 pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender {
195 match previous {
196 Some(prev) if prev.content_hash == self.content_hash && prev.marker == self.marker => {
197 FragmentRender::Unchanged {
198 marker: self.marker.to_string(),
199 content_hash: self.content_hash,
200 }
201 }
202 _ => FragmentRender::Updated {
203 fragment: self.clone(),
204 },
205 }
206 }
207
208 /// Full render including the stable marker header.
209 #[must_use]
210 pub fn render_marked(&self) -> String {
211 let rendered = format!("{}\n{}", self.marker, self.content.trim_end());
212 debug_assert!(self.matches_text(&rendered));
213 rendered
214 }
215 }
216
217 fn hash_content(content: &str) -> u64 {
218 let mut hasher = DefaultHasher::new();
219 content.hash(&mut hasher);
220 hasher.finish()
221 }
222
223 fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
224 if max_bytes == 0 {
225 return String::new();
226 }
227 if raw.len() <= max_bytes {
228 return raw;
229 }
230 let omitted = raw.len().saturating_sub(max_bytes);
231 let marker = format!("\n[…truncated: {omitted} bytes omitted]");
232 if marker.len() >= max_bytes {
233 return marker.chars().take(max_bytes).collect();
234 }
235 let keep = max_bytes.saturating_sub(marker.len());
236 // Truncate on a char boundary.
237 let mut end = keep;
238 while end > 0 && !raw.is_char_boundary(end) {
239 end -= 1;
240 }
241 let mut out = raw[..end].to_string();
242 out.push_str(&marker);
243 out
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249
250 #[test]
251 fn render_diff_detects_change_and_retain() {
252 let a = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
253 let b = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
254 let c = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=b");
255 assert!(matches!(
256 b.render_diff(Some(&a)),
257 FragmentRender::Unchanged { .. }
258 ));
259 assert!(matches!(
260 c.render_diff(Some(&a)),
261 FragmentRender::Updated { .. }
262 ));
263 assert!(matches!(
264 a.render_diff(None),
265 FragmentRender::Updated { .. }
266 ));
267 }
268 }
269
269 lines RUST