| 1 | //! Auto-routing helpers: deciding when to consult the auto-route flash |
| 2 | //! model, and building the small context window it sees. |
| 3 | //! |
| 4 | //! `dispatch_user_message` calls `model_routing::resolve_auto_route_with_inventory_for_session` |
| 5 | //! directly once per user turn when `app.auto_model` is set. The remaining |
| 6 | //! helpers here build the compact recent-context summary the router sees. |
| 7 | |
| 8 | use crate::tui::app::App; |
| 9 | use codewhale_models::{ContentBlock, Message}; |
| 10 | |
| 11 | /// Whether the next turn should consult the auto-route flash model. |
| 12 | pub(super) fn should_resolve_auto_model_selection(app: &App) -> bool { |
| 13 | app.auto_model |
| 14 | } |
| 15 | |
| 16 | /// Build a compact recent-context summary for the auto-route prompt. |
| 17 | /// |
| 18 | /// Walks `api_messages` from the most recent turn back, collects up to six |
| 19 | /// non-empty rows, and reverses them so the prompt reads oldest-first. Each |
| 20 | /// row is `<role>: <truncated content>` and is capped at 900 characters. |
| 21 | /// |
| 22 | /// Both callers build this context before the draft being classified is |
| 23 | /// appended to `api_messages`, so every message here is prior context; the |
| 24 | /// old `.skip(1)` dropped the newest of them, not the draft (U08-m3). |
| 25 | pub(super) fn recent_auto_router_context(messages: &[Message]) -> String { |
| 26 | let mut rows = Vec::new(); |
| 27 | for message in messages.iter().rev() { |
| 28 | if rows.len() >= 6 { |
| 29 | break; |
| 30 | } |
| 31 | let text = content_blocks_text(&message.content); |
| 32 | let text = text.trim(); |
| 33 | if text.is_empty() { |
| 34 | continue; |
| 35 | } |
| 36 | rows.push(format!( |
| 37 | "{}: {}", |
| 38 | message.role, |
| 39 | truncate_for_auto_router(text, 900) |
| 40 | )); |
| 41 | } |
| 42 | rows.reverse(); |
| 43 | if rows.is_empty() { |
| 44 | "No prior context.".to_string() |
| 45 | } else { |
| 46 | rows.join("\n") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | fn content_blocks_text(blocks: &[ContentBlock]) -> String { |
| 51 | let mut out = String::new(); |
| 52 | for block in blocks { |
| 53 | match block { |
| 54 | ContentBlock::Text { text, .. } => { |
| 55 | append_router_text(&mut out, text); |
| 56 | } |
| 57 | ContentBlock::Thinking { .. } => {} |
| 58 | ContentBlock::ToolUse { name, .. } => { |
| 59 | append_router_text(&mut out, &format!("[tool call: {name}]")); |
| 60 | } |
| 61 | ContentBlock::ToolResult { content, .. } => { |
| 62 | append_router_text(&mut out, &format!("[tool result] {content}")); |
| 63 | } |
| 64 | _ => {} |
| 65 | } |
| 66 | } |
| 67 | out |
| 68 | } |
| 69 | |
| 70 | fn append_router_text(out: &mut String, text: &str) { |
| 71 | if !out.is_empty() { |
| 72 | out.push('\n'); |
| 73 | } |
| 74 | out.push_str(text); |
| 75 | } |
| 76 | |
| 77 | fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { |
| 78 | let mut chars = text.chars(); |
| 79 | let truncated: String = chars.by_ref().take(max_chars).collect(); |
| 80 | if chars.next().is_some() { |
| 81 | format!("{truncated}...") |
| 82 | } else { |
| 83 | truncated |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | #[cfg(test)] |
| 88 | mod tests { |
| 89 | use super::*; |
| 90 | use codewhale_models::ContentBlock; |
| 91 | use codewhale_models::Role; |
| 92 | |
| 93 | fn make_msg(role: &str, text: &str) -> Message { |
| 94 | Message { |
| 95 | role: Role::from(role), |
| 96 | content: vec![ContentBlock::Text { |
| 97 | text: text.to_string(), |
| 98 | cache_control: None, |
| 99 | }], |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | #[test] |
| 104 | fn truncate_for_auto_router_honors_char_budget() { |
| 105 | let s = "abcdefghij"; |
| 106 | assert_eq!(truncate_for_auto_router(s, 4), "abcd..."); |
| 107 | assert_eq!(truncate_for_auto_router(s, 10), "abcdefghij"); |
| 108 | assert_eq!(truncate_for_auto_router(s, 100), "abcdefghij"); |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn recent_auto_router_context_keeps_newest_prior_message_and_caps_rows() { |
| 113 | // Eight prior messages (the draft is not in `api_messages` yet): the |
| 114 | // newest one is context, and only the six most recent are kept. |
| 115 | let msgs: Vec<Message> = (0..8) |
| 116 | .map(|i| { |
| 117 | make_msg( |
| 118 | if i % 2 == 0 { "user" } else { "assistant" }, |
| 119 | &format!("turn {i}"), |
| 120 | ) |
| 121 | }) |
| 122 | .collect(); |
| 123 | let context = recent_auto_router_context(&msgs); |
| 124 | assert!( |
| 125 | context.contains("turn 7"), |
| 126 | "the newest prior message is context" |
| 127 | ); |
| 128 | let row_count = context.lines().count(); |
| 129 | assert_eq!(row_count, 6); |
| 130 | // Output is oldest-first. |
| 131 | let first = context.lines().next().unwrap(); |
| 132 | assert!(first.contains("turn 2"), "got: {context}"); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn recent_auto_router_context_handles_empty_history() { |
| 137 | assert_eq!(recent_auto_router_context(&[]), "No prior context."); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn recent_auto_router_context_excludes_hidden_thinking() { |
| 142 | let msgs = vec![Message { |
| 143 | role: Role::Assistant, |
| 144 | content: vec![ |
| 145 | ContentBlock::Thinking { |
| 146 | signature: None, |
| 147 | state: None, |
| 148 | thinking: "The user seems to be asking me to classify myself.".to_string(), |
| 149 | }, |
| 150 | ContentBlock::Text { |
| 151 | text: "Visible assistant answer.".to_string(), |
| 152 | cache_control: None, |
| 153 | }, |
| 154 | ], |
| 155 | }]; |
| 156 | |
| 157 | let context = recent_auto_router_context(&msgs); |
| 158 | |
| 159 | assert!(context.contains("Visible assistant answer.")); |
| 160 | assert!(!context.contains("The user seems")); |
| 161 | } |
| 162 | } |
| 163 |