返回 CodeWhale
auto_router.rs
根目录 / crates / tui / src / tui / auto_router.rs
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
163 lines RUST