| 1 | //! Bounded renderings of a To-do snapshot. |
| 2 | //! |
| 3 | //! Codewhale has exactly one To-do list. The model learns what is on it the |
| 4 | //! same way it learns anything else: from the tool result its own `todo_write` |
| 5 | //! / `work_update` call returned, which is ordinary persisted transcript |
| 6 | //! state. **Nothing in this module is appended to a provider request**, and no |
| 7 | //! step of a tool loop re-states the list. If the model wants the current |
| 8 | //! list, it reads its own last tool result or calls the tool again. |
| 9 | //! |
| 10 | //! What this module owns is the small set of places that render a snapshot |
| 11 | //! *once*, at an explicit seam a person asked for: |
| 12 | //! |
| 13 | //! 1. the `<codewhale:fork_state>` block a newly forked sub-agent is handed, |
| 14 | //! 2. `/relay` handoff instructions, |
| 15 | //! 3. the in-transcript agent card (display only). |
| 16 | //! |
| 17 | //! All three share [`todo_snapshot_body`] byte-for-byte, so no two surfaces |
| 18 | //! can disagree about what the list says. |
| 19 | //! |
| 20 | //! Rules the renderer must keep, because this text reaches a model: |
| 21 | //! |
| 22 | //! - An empty To-do renders nothing at all. Silence beats an empty list that |
| 23 | //! reads as "there is no work". |
| 24 | //! - `update_plan` strategy state is conversational reasoning, not a second |
| 25 | //! list, and never appears here. |
| 26 | //! - Items and characters are both hard-bounded, so a large list cannot eat the |
| 27 | //! context window. The in-progress item is preserved preferentially — losing |
| 28 | //! the active item is the one omission that would actively mislead. |
| 29 | //! - Truncation happens on `char` boundaries and marks the omission, so a |
| 30 | //! multi-byte item can neither panic nor silently shrink the list. |
| 31 | //! - Item text can never close its wrapper: a closing tag in the `codewhale:` |
| 32 | //! namespace is escaped before it reaches the model, and control characters |
| 33 | //! are flattened so content cannot forge a new line. |
| 34 | //! |
| 35 | //! **What this module does not do:** it does not sanitize To-do content against |
| 36 | //! prompt injection, and no caller should claim that it does. The guarantees |
| 37 | //! above are exactly three — wrapper framing cannot be closed early, control |
| 38 | //! characters cannot forge the line format, and the item/character bounds hold. |
| 39 | //! The *meaning* of arbitrary item text is not inspected, filtered, or |
| 40 | //! neutralized; a To-do item containing instructions still reaches the model as |
| 41 | //! item text. Treating that text as untrusted data is the model contract's job |
| 42 | //! (the constitution), not the renderer's. |
| 43 | |
| 44 | use crate::tools::todo::{SharedTodoList, TodoItem, TodoListSnapshot, TodoStatus}; |
| 45 | use crate::work_graph::SharedWorkRuntime; |
| 46 | |
| 47 | /// Maximum number of item lines rendered in the body. |
| 48 | pub const MAX_ITEM_LINES: usize = 24; |
| 49 | /// Hard character ceiling for the body (counted in `char`s). |
| 50 | pub const MAX_BODY_CHARS: usize = 2_000; |
| 51 | /// Per-item content ceiling before the omission marker is appended. |
| 52 | pub const MAX_ITEM_CONTENT_CHARS: usize = 160; |
| 53 | |
| 54 | /// Marks any text elided by a bound. |
| 55 | const OMISSION_MARKER: char = '…'; |
| 56 | |
| 57 | /// Escaped form of a closing wrapper tag found inside item content. |
| 58 | const ESCAPED_CLOSE_PREFIX: &str = "<\\/codewhale:"; |
| 59 | const CLOSE_PREFIX: &str = "</codewhale:"; |
| 60 | |
| 61 | /// Render the To-do snapshot body, or `None` when there is nothing on the list. |
| 62 | /// |
| 63 | /// The returned string carries no framing; each seam supplies its own, so the |
| 64 | /// body itself stays comparable across surfaces. |
| 65 | #[must_use] |
| 66 | pub fn todo_snapshot_body(snapshot: &TodoListSnapshot) -> Option<String> { |
| 67 | if snapshot.items.is_empty() { |
| 68 | return None; |
| 69 | } |
| 70 | |
| 71 | let header = format!("To-do ({}% settled)", snapshot.completion_pct); |
| 72 | let lines: Vec<String> = snapshot.items.iter().map(item_line).collect(); |
| 73 | let priority = priority_order(snapshot); |
| 74 | |
| 75 | let mut selected: Vec<usize> = Vec::new(); |
| 76 | let mut used = header.chars().count(); |
| 77 | for idx in priority { |
| 78 | if selected.len() >= MAX_ITEM_LINES { |
| 79 | break; |
| 80 | } |
| 81 | let cost = 1 + lines[idx].chars().count(); |
| 82 | if used + cost > MAX_BODY_CHARS { |
| 83 | break; |
| 84 | } |
| 85 | used += cost; |
| 86 | selected.push(idx); |
| 87 | } |
| 88 | |
| 89 | // The omission line itself costs characters, so it has to fit inside the |
| 90 | // same ceiling. Drop lowest-priority selections until it does; the active |
| 91 | // item sits at index 0 and is never the one dropped. |
| 92 | let mut omitted = lines.len() - selected.len(); |
| 93 | if omitted > 0 { |
| 94 | loop { |
| 95 | let cost = 1 + omission_line(omitted).chars().count(); |
| 96 | if used + cost <= MAX_BODY_CHARS || selected.len() <= 1 { |
| 97 | break; |
| 98 | } |
| 99 | if let Some(dropped) = selected.pop() { |
| 100 | used -= 1 + lines[dropped].chars().count(); |
| 101 | omitted += 1; |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | selected.sort_unstable(); |
| 107 | let mut body = header; |
| 108 | for idx in selected { |
| 109 | body.push('\n'); |
| 110 | body.push_str(&lines[idx]); |
| 111 | } |
| 112 | if omitted > 0 { |
| 113 | body.push('\n'); |
| 114 | body.push_str(&omission_line(omitted)); |
| 115 | } |
| 116 | |
| 117 | debug_assert!(body.chars().count() <= MAX_BODY_CHARS); |
| 118 | Some(body) |
| 119 | } |
| 120 | |
| 121 | /// The authoritative source of one agent's To-do state. |
| 122 | /// |
| 123 | /// There are two stores in play and only one of them is current. When a |
| 124 | /// [`WorkRuntime`](crate::work_graph::WorkRuntime) owns this list, a |
| 125 | /// `work_update` *stages* the new projection in the graph and the legacy |
| 126 | /// `SharedTodoList` view is only refreshed later, asynchronously, by the UI's |
| 127 | /// publish step. Reading the legacy view alone therefore shows the model its |
| 128 | /// state from before its own last write. So: read the graph projection when the |
| 129 | /// runtime owns this exact list (`Arc::ptr_eq` via |
| 130 | /// [`WorkRuntime::matches_todos`](crate::work_graph::WorkRuntime::matches_todos)), |
| 131 | /// and read the list directly otherwise. |
| 132 | /// |
| 133 | /// The ownership check is what keeps agents isolated. A child's runtime carries |
| 134 | /// its *parent's* `WorkRuntime` handle but its **own** list (#4810), so |
| 135 | /// `matches_todos` is false for every child and each child resolves against its |
| 136 | /// own store — a child can never read the parent's or a sibling's list here. |
| 137 | #[derive(Clone)] |
| 138 | pub struct TodoSource { |
| 139 | work: Option<SharedWorkRuntime>, |
| 140 | todos: SharedTodoList, |
| 141 | } |
| 142 | |
| 143 | impl std::fmt::Debug for TodoSource { |
| 144 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 145 | f.debug_struct("TodoSource") |
| 146 | .field("graph_backed", &self.is_graph_backed()) |
| 147 | .finish() |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | impl TodoSource { |
| 152 | /// Bind a source to an agent's own list plus whatever work runtime its |
| 153 | /// tool context carries. |
| 154 | #[must_use] |
| 155 | pub fn new(work: Option<SharedWorkRuntime>, todos: SharedTodoList) -> Self { |
| 156 | Self { work, todos } |
| 157 | } |
| 158 | |
| 159 | /// Whether the attached runtime actually owns this list. |
| 160 | #[must_use] |
| 161 | pub fn is_graph_backed(&self) -> bool { |
| 162 | self.work |
| 163 | .as_ref() |
| 164 | .is_some_and(|work| work.matches_todos(&self.todos)) |
| 165 | } |
| 166 | |
| 167 | /// Current authoritative snapshot. |
| 168 | /// |
| 169 | /// Never omits and never fails: a graph read error degrades to the legacy |
| 170 | /// view with a warning rather than dropping the list from a fork handoff, |
| 171 | /// because a silently missing list reads to the model as "no work". |
| 172 | pub async fn snapshot(&self) -> TodoListSnapshot { |
| 173 | if let Some(work) = self.work.as_ref().filter(|_| self.is_graph_backed()) { |
| 174 | match work.current_todos().await { |
| 175 | Ok(snapshot) => return snapshot, |
| 176 | Err(err) => tracing::warn!( |
| 177 | target: "todo_snapshot", |
| 178 | error = %err, |
| 179 | "work graph projection unavailable; falling back to the legacy To-do view" |
| 180 | ), |
| 181 | } |
| 182 | } |
| 183 | self.todos.lock().await.snapshot() |
| 184 | } |
| 185 | |
| 186 | /// Body for the current authoritative snapshot. |
| 187 | pub async fn body(&self) -> Option<String> { |
| 188 | todo_snapshot_body(&self.snapshot().await) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /// Maximum item rows an in-transcript agent card renders (#4810). Narrower |
| 193 | /// than the shared bound: a card is a glance, not the whole list. |
| 194 | pub const MAX_CARD_ITEM_LINES: usize = 3; |
| 195 | /// Per-item content ceiling on a card row. |
| 196 | pub const MAX_CARD_ITEM_CONTENT_CHARS: usize = 72; |
| 197 | |
| 198 | /// Bounded, display-only projection of **one agent's own** To-do snapshot for |
| 199 | /// its delegate/agent card. |
| 200 | /// |
| 201 | /// Same list, same priority order, same sanitizer as [`todo_snapshot_body`] — |
| 202 | /// only the framing and the bounds differ. Nothing here derives new work: every |
| 203 | /// row corresponds to an item that exists in the snapshot it was built from. |
| 204 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 205 | pub struct TodoCardProjection { |
| 206 | /// Bounded progress, e.g. `To-do 1/4 · 25% settled`. |
| 207 | pub header: String, |
| 208 | /// Item rows in document order, e.g. `[~] #2 Write the renderer`. |
| 209 | pub items: Vec<String>, |
| 210 | /// Items that exist in the snapshot but did not fit the card bound. |
| 211 | pub omitted: usize, |
| 212 | } |
| 213 | |
| 214 | /// Project one agent's To-do snapshot onto its card, or `None` when that agent |
| 215 | /// has no work to show. |
| 216 | /// |
| 217 | /// An empty list returns `None` rather than a placeholder row — the same rule |
| 218 | /// [`todo_snapshot_body`] follows. A card that has never received a snapshot |
| 219 | /// and a card whose agent reported an empty list both render nothing, because |
| 220 | /// neither one has a task to name. |
| 221 | #[must_use] |
| 222 | pub fn card_todo_projection(snapshot: &TodoListSnapshot) -> Option<TodoCardProjection> { |
| 223 | if snapshot.items.is_empty() { |
| 224 | return None; |
| 225 | } |
| 226 | |
| 227 | let total = snapshot.items.len(); |
| 228 | let settled = snapshot |
| 229 | .items |
| 230 | .iter() |
| 231 | .filter(|item| item.status.is_settled()) |
| 232 | .count(); |
| 233 | let header = format!( |
| 234 | "To-do {settled}/{total} · {}% settled", |
| 235 | snapshot.completion_pct |
| 236 | ); |
| 237 | |
| 238 | let mut selected: Vec<usize> = priority_order(snapshot) |
| 239 | .into_iter() |
| 240 | .take(MAX_CARD_ITEM_LINES) |
| 241 | .collect(); |
| 242 | selected.sort_unstable(); |
| 243 | let items: Vec<String> = selected |
| 244 | .iter() |
| 245 | .map(|idx| card_item_line(&snapshot.items[*idx])) |
| 246 | .collect(); |
| 247 | |
| 248 | Some(TodoCardProjection { |
| 249 | omitted: total - items.len(), |
| 250 | header, |
| 251 | items, |
| 252 | }) |
| 253 | } |
| 254 | |
| 255 | fn card_item_line(item: &TodoItem) -> String { |
| 256 | format!( |
| 257 | "{} #{} {}", |
| 258 | status_marker(item.status), |
| 259 | item.id, |
| 260 | sanitize_to(&item.content, MAX_CARD_ITEM_CONTENT_CHARS) |
| 261 | ) |
| 262 | } |
| 263 | |
| 264 | /// Row appended when the card bound elided items. |
| 265 | #[must_use] |
| 266 | pub fn card_omission_line(count: usize) -> String { |
| 267 | format!("{OMISSION_MARKER} +{count} more") |
| 268 | } |
| 269 | |
| 270 | /// Heading the fork-state block uses for its To-do section. |
| 271 | pub const FORK_TODO_SECTION_HEADING: &str = "### To-do"; |
| 272 | |
| 273 | /// Render the To-do section of a `<codewhale:fork_state>` block. |
| 274 | /// |
| 275 | /// This is the one place a To-do snapshot is handed to a model that did not |
| 276 | /// produce it, and it happens exactly once — when a sub-agent is forked, as |
| 277 | /// part of the context block stored in that child's own history. It is not |
| 278 | /// refreshed, re-sent, or appended to later requests. |
| 279 | #[must_use] |
| 280 | pub fn fork_state_todo_section(body: &str) -> String { |
| 281 | format!("{FORK_TODO_SECTION_HEADING}\n\n{body}\n") |
| 282 | } |
| 283 | |
| 284 | /// Item indexes in render priority: the active (in-progress) item first, then |
| 285 | /// document order. Shared by every bounded projection so no two surfaces can |
| 286 | /// disagree about which item matters most. |
| 287 | fn priority_order(snapshot: &TodoListSnapshot) -> Vec<usize> { |
| 288 | let active = active_index(snapshot); |
| 289 | let mut priority: Vec<usize> = Vec::with_capacity(snapshot.items.len()); |
| 290 | if let Some(active) = active { |
| 291 | priority.push(active); |
| 292 | } |
| 293 | priority.extend((0..snapshot.items.len()).filter(|idx| Some(*idx) != active)); |
| 294 | priority |
| 295 | } |
| 296 | |
| 297 | fn active_index(snapshot: &TodoListSnapshot) -> Option<usize> { |
| 298 | snapshot |
| 299 | .in_progress_id |
| 300 | .and_then(|id| snapshot.items.iter().position(|item| item.id == id)) |
| 301 | .or_else(|| { |
| 302 | snapshot |
| 303 | .items |
| 304 | .iter() |
| 305 | .position(|item| item.status == TodoStatus::InProgress) |
| 306 | }) |
| 307 | } |
| 308 | |
| 309 | fn status_marker(status: TodoStatus) -> &'static str { |
| 310 | match status { |
| 311 | TodoStatus::Pending => "[ ]", |
| 312 | TodoStatus::InProgress => "[~]", |
| 313 | TodoStatus::Completed => "[x]", |
| 314 | TodoStatus::Cancelled => "[-]", |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | fn item_line(item: &TodoItem) -> String { |
| 319 | // IDs stay visible: `work_update` addresses later transitions by stable |
| 320 | // item identity, so a body without IDs is not actionable. |
| 321 | format!( |
| 322 | "- {} #{} {}", |
| 323 | status_marker(item.status), |
| 324 | item.id, |
| 325 | sanitize(&item.content) |
| 326 | ) |
| 327 | } |
| 328 | |
| 329 | fn omission_line(count: usize) -> String { |
| 330 | format!("- {OMISSION_MARKER} +{count} more To-do items omitted") |
| 331 | } |
| 332 | |
| 333 | fn sanitize(content: &str) -> String { |
| 334 | sanitize_to(content, MAX_ITEM_CONTENT_CHARS) |
| 335 | } |
| 336 | |
| 337 | fn sanitize_to(content: &str, max_chars: usize) -> String { |
| 338 | let flattened: String = content |
| 339 | .chars() |
| 340 | .map(|ch| if ch.is_control() { ' ' } else { ch }) |
| 341 | .collect(); |
| 342 | let escaped = escape_wrapper(&flattened); |
| 343 | truncate_chars(escaped.trim(), max_chars) |
| 344 | } |
| 345 | |
| 346 | /// Neutralize any closing tag in the `codewhale:` namespace so item content |
| 347 | /// cannot terminate a wrapper early and smuggle instructions past it. |
| 348 | fn escape_wrapper(content: &str) -> String { |
| 349 | if !content.to_ascii_lowercase().contains(CLOSE_PREFIX) { |
| 350 | return content.to_string(); |
| 351 | } |
| 352 | |
| 353 | let lower = content.to_ascii_lowercase(); |
| 354 | let mut out = String::with_capacity(content.len() + 8); |
| 355 | let mut cursor = 0usize; |
| 356 | while let Some(found) = lower[cursor..].find(CLOSE_PREFIX) { |
| 357 | let at = cursor + found; |
| 358 | out.push_str(&content[cursor..at]); |
| 359 | out.push_str(ESCAPED_CLOSE_PREFIX); |
| 360 | cursor = at + CLOSE_PREFIX.len(); |
| 361 | } |
| 362 | out.push_str(&content[cursor..]); |
| 363 | out |
| 364 | } |
| 365 | |
| 366 | /// Truncate on `char` boundaries, marking the omission. Never splits a |
| 367 | /// multi-byte scalar. |
| 368 | fn truncate_chars(text: &str, max_chars: usize) -> String { |
| 369 | if text.chars().count() <= max_chars { |
| 370 | return text.to_string(); |
| 371 | } |
| 372 | let keep = max_chars.saturating_sub(1); |
| 373 | let mut out: String = text.chars().take(keep).collect(); |
| 374 | out.push(OMISSION_MARKER); |
| 375 | out |
| 376 | } |
| 377 | |
| 378 | #[cfg(test)] |
| 379 | mod tests; |
| 380 |