| 1 | //! `/cache` command — portable per-turn cache telemetry and inspection. |
| 2 | //! |
| 3 | //! The host resolves the request route and pricing partitions once. This |
| 4 | //! handler checks flags, observes, formats and commits that same inspection; |
| 5 | //! no prompt builder or App state is reachable from the command leaf. |
| 6 | |
| 7 | use super::CommandResult; |
| 8 | use super::DebugAction as AppAction; |
| 9 | use super::cache_format::{ |
| 10 | format_cache_history, format_cache_stats, format_cache_zones, format_first_divergence, |
| 11 | format_static_prefix_status, format_verbose_diff, format_warmup_status, |
| 12 | session_cache_rates_line, |
| 13 | }; |
| 14 | use codewhale_command_contract::facets::{ |
| 15 | CommandDebugDiagnosticsContext, CommandPresentationContext, DebugCacheInspectionObservation, |
| 16 | DebugCacheInspectionUnavailable, |
| 17 | }; |
| 18 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 19 | use codewhale_command_contract::metadata::{ |
| 20 | CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, |
| 21 | }; |
| 22 | |
| 23 | pub(in crate::commands) struct CacheCmd; |
| 24 | |
| 25 | const CONTRACT_INFO: ContractInfo = ContractInfo { |
| 26 | name: "cache", |
| 27 | aliases: &[], |
| 28 | usage: "/cache [count|inspect|stats|zones|warmup]", |
| 29 | description_key: "cmd_cache_description", |
| 30 | }; |
| 31 | |
| 32 | impl ContractRegisterCommand<CommandResult> for CacheCmd { |
| 33 | fn info() -> &'static ContractInfo { |
| 34 | &CONTRACT_INFO |
| 35 | } |
| 36 | |
| 37 | fn handler() -> CommandHandler<CommandResult> { |
| 38 | CommandHandler::Contextual { |
| 39 | capabilities: CommandCapabilities::DEBUG_DIAGNOSTICS |
| 40 | .union(CommandCapabilities::PRESENTATION), |
| 41 | handler: cache, |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// Show per-turn prefix-cache telemetry, a static status or an inspection. |
| 47 | pub fn cache(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 48 | let mut parts = contexts.into_parts(); |
| 49 | let Some(diagnostics) = parts.debug_diagnostics.as_deref_mut() else { |
| 50 | return CommandResult::error("Command capability unavailable: debug_diagnostics"); |
| 51 | }; |
| 52 | let Some(presentation) = parts.presentation.as_deref_mut() else { |
| 53 | return CommandResult::error("Command capability unavailable: presentation"); |
| 54 | }; |
| 55 | cache_portable(diagnostics, presentation, arg) |
| 56 | } |
| 57 | |
| 58 | fn cache_portable( |
| 59 | diagnostics: &mut dyn CommandDebugDiagnosticsContext, |
| 60 | presentation: &dyn CommandPresentationContext, |
| 61 | arg: Option<&str>, |
| 62 | ) -> CommandResult { |
| 63 | let arg = arg.map(str::trim).filter(|s| !s.is_empty()); |
| 64 | let inspect_flags = arg.and_then(|a| { |
| 65 | if a == "inspect" { |
| 66 | Some("") |
| 67 | } else { |
| 68 | a.strip_prefix("inspect") |
| 69 | .filter(|rest| rest.starts_with(char::is_whitespace)) |
| 70 | } |
| 71 | }); |
| 72 | if let Some(flags) = inspect_flags { |
| 73 | let flags = flags.trim(); |
| 74 | let verbose = flags.split_whitespace().any(|flag| flag == "--verbose"); |
| 75 | let json_mode = flags.split_whitespace().any(|flag| flag == "--json"); |
| 76 | return CommandResult::message(format_cache_inspect(diagnostics, verbose, json_mode)); |
| 77 | } |
| 78 | if matches!(arg, Some("warmup")) { |
| 79 | return CommandResult::action(AppAction::CacheWarmup); |
| 80 | } |
| 81 | if matches!(arg, Some("stats")) { |
| 82 | return CommandResult::message(format_cache_stats(&diagnostics.cache_telemetry())); |
| 83 | } |
| 84 | if matches!(arg, Some("zones")) { |
| 85 | return CommandResult::message(format_cache_zones(&diagnostics.cache_telemetry())); |
| 86 | } |
| 87 | |
| 88 | let want = match arg { |
| 89 | None => 10, |
| 90 | Some(raw) => match raw.parse::<usize>() { |
| 91 | Ok(n) => n, |
| 92 | Err(_) => { |
| 93 | return CommandResult::error(format!( |
| 94 | "Unknown /cache argument `{raw}`. Usage: /cache [count|inspect [--verbose|--json]|stats|zones|warmup]" |
| 95 | )); |
| 96 | } |
| 97 | }, |
| 98 | }; |
| 99 | let telemetry = diagnostics.cache_telemetry(); |
| 100 | if telemetry.history.is_empty() { |
| 101 | let message = (|| { |
| 102 | let mut text = presentation.translate("cmd_cache_no_data", &[])?; |
| 103 | if let Some(line) = session_cache_rates_line(&telemetry, presentation)? { |
| 104 | text.push_str("\n\n"); |
| 105 | text.push_str(&line); |
| 106 | } |
| 107 | Ok::<_, String>(text) |
| 108 | })(); |
| 109 | return match message { |
| 110 | Ok(text) => CommandResult::message(text), |
| 111 | Err(error) => CommandResult::error(error), |
| 112 | }; |
| 113 | } |
| 114 | let count = want |
| 115 | .min(telemetry.history.len()) |
| 116 | .min(telemetry.history_capacity); |
| 117 | let cost = diagnostics.cost_projection(); |
| 118 | match format_cache_history(&telemetry, count, &cost, presentation) { |
| 119 | Ok(text) => CommandResult::message(text), |
| 120 | Err(error) => CommandResult::error(error), |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | fn format_cache_inspect( |
| 125 | diagnostics: &mut dyn CommandDebugDiagnosticsContext, |
| 126 | verbose: bool, |
| 127 | json_mode: bool, |
| 128 | ) -> String { |
| 129 | if verbose && json_mode { |
| 130 | return "cache inspect: --json and --verbose cannot be combined".to_string(); |
| 131 | } |
| 132 | let observation = match diagnostics.inspect_cache() { |
| 133 | Ok(value) => value, |
| 134 | Err(DebugCacheInspectionUnavailable::NoConcreteRoute) => { |
| 135 | return "cache inspect: Auto has no concrete route yet; send a turn first".to_string(); |
| 136 | } |
| 137 | Err(DebugCacheInspectionUnavailable::MissingCapturedEndpoint) => { |
| 138 | return "cache inspect: the restored Auto route has no captured endpoint; send a turn first" |
| 139 | .to_string(); |
| 140 | } |
| 141 | }; |
| 142 | let output = render_inspection(&observation, verbose, json_mode); |
| 143 | commit_rendered_inspection(diagnostics, observation, output) |
| 144 | } |
| 145 | |
| 146 | pub(super) fn commit_rendered_inspection( |
| 147 | diagnostics: &mut dyn CommandDebugDiagnosticsContext, |
| 148 | observation: DebugCacheInspectionObservation, |
| 149 | output: String, |
| 150 | ) -> String { |
| 151 | diagnostics.remember_cache_inspection(observation.current); |
| 152 | output |
| 153 | } |
| 154 | |
| 155 | pub(super) fn json_or_fallback(rendered: Result<String, serde_json::Error>) -> String { |
| 156 | rendered.unwrap_or_else(|_| "{\"error\":\"cache inspection serialization failed\"}".to_string()) |
| 157 | } |
| 158 | |
| 159 | fn render_inspection( |
| 160 | observation: &DebugCacheInspectionObservation, |
| 161 | verbose: bool, |
| 162 | json_mode: bool, |
| 163 | ) -> String { |
| 164 | let inspection = &observation.current; |
| 165 | let previous = observation.previous.as_ref(); |
| 166 | let warmup_status = format_warmup_status( |
| 167 | observation.last_warmup_key.as_ref(), |
| 168 | &observation.current_warmup_key, |
| 169 | observation.last_warmup_hash_short.as_deref(), |
| 170 | &observation.current_warmup_hash_short, |
| 171 | ); |
| 172 | if json_mode { |
| 173 | return json_or_fallback(serde_json::to_value(inspection).and_then(|mut value| { |
| 174 | if let serde_json::Value::Object(ref mut object) = value { |
| 175 | object.insert( |
| 176 | "current_warmup_key".to_string(), |
| 177 | serde_json::to_value(&observation.current_warmup_key)?, |
| 178 | ); |
| 179 | object.insert( |
| 180 | "warmup_status".to_string(), |
| 181 | serde_json::Value::String(warmup_status.trim_end().to_string()), |
| 182 | ); |
| 183 | } |
| 184 | serde_json::to_string_pretty(&value) |
| 185 | })); |
| 186 | } |
| 187 | |
| 188 | let mut out = String::new(); |
| 189 | out.push_str("Cache Inspect\n"); |
| 190 | out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n"); |
| 191 | out.push_str(&format!( |
| 192 | "Base static prefix hash: {}\n", |
| 193 | inspection.base_static_prefix_hash |
| 194 | )); |
| 195 | out.push_str(&format!( |
| 196 | "Full request prefix hash: {}\n", |
| 197 | inspection.full_request_prefix_hash |
| 198 | )); |
| 199 | out.push_str(&format!( |
| 200 | "Tool catalog hash: {}\n", |
| 201 | if inspection.tool_catalog_hash.is_empty() { |
| 202 | "(no tools registered)".to_string() |
| 203 | } else { |
| 204 | inspection.tool_catalog_hash.clone() |
| 205 | } |
| 206 | )); |
| 207 | out.push_str(&format_static_prefix_status(previous, inspection)); |
| 208 | out.push_str(&format_first_divergence(previous, inspection)); |
| 209 | out.push_str(&warmup_status); |
| 210 | let total_tokens: usize = inspection |
| 211 | .layers |
| 212 | .iter() |
| 213 | .map(|layer| layer.token_estimate) |
| 214 | .sum(); |
| 215 | out.push_str(&format!("Estimated reusable tokens: ~{total_tokens}\n")); |
| 216 | out.push('\n'); |
| 217 | |
| 218 | for layer in &inspection.layers { |
| 219 | let mut line = format!( |
| 220 | "{}: {}, chars={}, bytes={}, ~{}tok, hash={}\n", |
| 221 | layer.name, |
| 222 | layer.stability.label(), |
| 223 | layer.char_len, |
| 224 | layer.byte_len, |
| 225 | layer.token_estimate, |
| 226 | layer.sha256 |
| 227 | ); |
| 228 | if let Some(tool_result) = &layer.tool_result { |
| 229 | let trimmed = line.trim_end_matches('\n').to_string(); |
| 230 | line = format!( |
| 231 | "{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n", |
| 232 | tool_result.original_chars, |
| 233 | tool_result.sent_chars, |
| 234 | tool_result.truncated, |
| 235 | tool_result.deduplicated |
| 236 | ); |
| 237 | } |
| 238 | if let Some(turn_meta) = &layer.turn_meta { |
| 239 | let trimmed = line.trim_end_matches('\n').to_string(); |
| 240 | line = format!( |
| 241 | "{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n", |
| 242 | turn_meta.original_chars, |
| 243 | turn_meta.sent_chars, |
| 244 | turn_meta.deduplicated, |
| 245 | turn_meta.sha256 |
| 246 | ); |
| 247 | } |
| 248 | out.push_str(&line); |
| 249 | } |
| 250 | if verbose { |
| 251 | out.push_str("\nVerbose diff\n"); |
| 252 | if let Some(previous) = previous { |
| 253 | out.push_str(&format_verbose_diff(previous, inspection)); |
| 254 | } else { |
| 255 | out.push_str("No previous inspection to compare against.\n"); |
| 256 | } |
| 257 | } |
| 258 | out |
| 259 | } |
| 260 |