1use serde::Serialize;
2
3use crate::core::context_ledger::PressureAction;
4use crate::core::intent_engine::{IntentDimension, TaskType, classify, route_intent};
5
6#[derive(Debug, Clone, Serialize)]
9pub struct IntentRouteV2 {
10 pub inputs: IntentRouteInputsV2,
11 pub decision: IntentRouteDecisionV2,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct IntentRouteInputsV2 {
16 #[serde(rename = "query_md5")]
18 pub query_hash: String,
19 pub query_redacted: String,
20 pub role: String,
21 pub profile: String,
22 pub task_type: TaskType,
23 pub confidence: f64,
24 pub dimension: IntentDimension,
25 pub pressure: PressureSummaryV1,
26 #[serde(rename = "policy_md5")]
28 pub policy_hash: String,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct PressureSummaryV1 {
33 pub utilization_pct: u8,
34 pub remaining_tokens: usize,
35 pub action: PressureActionV1,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PressureActionV1 {
41 NoAction,
42 SuggestCompression,
43 ForceCompression,
44 EvictLeastRelevant,
45}
46
47impl PressureActionV1 {
48 fn from_action(a: PressureAction) -> Self {
49 match a {
50 PressureAction::NoAction => Self::NoAction,
51 PressureAction::SuggestCompression => Self::SuggestCompression,
52 PressureAction::ForceCompression => Self::ForceCompression,
53 PressureAction::EvictLeastRelevant => Self::EvictLeastRelevant,
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct IntentRouteDecisionV2 {
60 pub recommended_read_mode: String,
61 pub effective_read_mode: String,
62 pub degraded_by_pressure: bool,
63 pub reason: String,
64}
65
66#[derive(Debug, Clone)]
67pub(crate) struct RouteInputs {
68 pub pressure_action: PressureAction,
69 pub pressure_utilization: f64,
70 pub pressure_remaining_tokens: usize,
71}
72
73pub fn route_v2(query: &str) -> IntentRouteV2 {
74 let ledger = crate::core::context_ledger::ContextLedger::load();
75 let pressure = ledger.pressure();
76 let profile_name = crate::core::profiles::active_profile_name();
77 let profile = crate::core::profiles::active_profile();
78 let role_name = crate::core::roles::active_role_name();
79
80 let inputs = RouteInputs {
81 pressure_action: pressure.recommendation,
82 pressure_utilization: pressure.utilization,
83 pressure_remaining_tokens: pressure.remaining_tokens,
84 };
85
86 route_v2_with(query, &role_name, &profile_name, &profile.routing, &inputs)
87}
88
89fn route_v2_with(
90 query: &str,
91 role_name: &str,
92 profile_name: &str,
93 routing: &crate::core::profiles::RoutingConfig,
94 inputs: &RouteInputs,
95) -> IntentRouteV2 {
96 let classification = classify(query);
97 let base = route_intent(query, &classification);
98 let query_redacted = truncate(&crate::core::redaction::redact_text(query), 180);
99 let query_hash = crate::core::hasher::hash_str(query);
100 let policy_hash = crate::core::hasher::hash_str(&format!(
101 "context_strategy_v2;degrade_under_pressure={}",
102 routing.degrade_under_pressure_effective()
103 ));
104
105 let recommended_read_mode = read_mode_for_task(classification.task_type);
106 let (effective_read_mode, degraded_by_pressure) = if routing.degrade_under_pressure_effective()
107 {
108 apply_pressure_degrade(&recommended_read_mode, inputs.pressure_action)
109 } else {
110 (recommended_read_mode.clone(), false)
111 };
112 let reason = format!(
113 "task={} dim={} read={} pressure={:?} degraded_by_pressure={}",
114 classification.task_type.as_str(),
115 base.dimension.as_str(),
116 effective_read_mode,
117 inputs.pressure_action,
118 degraded_by_pressure
119 );
120
121 IntentRouteV2 {
122 inputs: IntentRouteInputsV2 {
123 query_hash,
124 query_redacted,
125 role: role_name.to_string(),
126 profile: profile_name.to_string(),
127 task_type: classification.task_type,
128 confidence: classification.confidence,
129 dimension: base.dimension,
130 pressure: PressureSummaryV1 {
131 utilization_pct: (inputs.pressure_utilization * 100.0).min(254.0) as u8,
132 remaining_tokens: inputs.pressure_remaining_tokens,
133 action: PressureActionV1::from_action(inputs.pressure_action),
134 },
135 policy_hash,
136 },
137 decision: IntentRouteDecisionV2 {
138 recommended_read_mode,
139 effective_read_mode,
140 degraded_by_pressure,
141 reason,
142 },
143 }
144}
145
146pub(crate) fn read_mode_for_task(task_type: TaskType) -> String {
147 match task_type {
150 TaskType::Explore | TaskType::Review => "map".to_string(),
151 _ => "full".to_string(),
152 }
153}
154
155fn apply_pressure_degrade(mode: &str, pressure: PressureAction) -> (String, bool) {
156 if let Some(downgraded) = crate::core::auto_mode_resolver::pressure_downgrade(mode, &pressure) {
157 (downgraded, true)
158 } else {
159 (mode.to_string(), false)
160 }
161}
162
163fn truncate(s: &str, max: usize) -> String {
164 if s.chars().count() <= max {
165 return s.to_string();
166 }
167 s.chars().take(max).collect()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn no_pressure() -> RouteInputs {
175 RouteInputs {
176 pressure_action: PressureAction::NoAction,
177 pressure_utilization: 0.1,
178 pressure_remaining_tokens: 1000,
179 }
180 }
181
182 #[test]
183 fn routing_is_deterministic_for_same_inputs() {
184 let routing = crate::core::profiles::RoutingConfig::default();
185 let inputs = no_pressure();
186 let a = route_v2_with(
187 "fix bug in src/lib.rs",
188 "coder",
189 "bugfix",
190 &routing,
191 &inputs,
192 );
193 let b = route_v2_with(
194 "fix bug in src/lib.rs",
195 "coder",
196 "bugfix",
197 &routing,
198 &inputs,
199 );
200 assert_eq!(
201 serde_json::to_string(&a).unwrap(),
202 serde_json::to_string(&b).unwrap()
203 );
204 }
205
206 #[test]
207 fn route_carries_no_model_or_budget_fields() {
208 let inputs = no_pressure();
209 let routing = crate::core::profiles::RoutingConfig::default();
210 for query in [
211 "explore the codebase",
212 "fix bug in src/lib.rs",
213 "implement feature x",
214 ] {
215 let route = route_v2_with(query, "coder", "same-profile", &routing, &inputs);
216 let payload = serde_json::to_value(&route).unwrap();
217 assert!(payload.get("created_at").is_none());
218 assert!(payload["inputs"].get("budgets").is_none());
219 assert_eq!(
221 payload["inputs"]["query_md5"],
222 crate::core::hasher::hash_str(query)
223 );
224 assert!(payload["inputs"]["policy_md5"].is_string());
225 for removed in [
226 "recommended_model_tier",
227 "effective_model_tier",
228 "degraded_by_budget",
229 ] {
230 assert!(payload["decision"].get(removed).is_none());
231 }
232 }
233 for task in [
234 TaskType::Generate,
235 TaskType::FixBug,
236 TaskType::Refactor,
237 TaskType::Debug,
238 ] {
239 assert_eq!(read_mode_for_task(task), "full");
240 }
241 }
242
243 #[test]
244 fn pressure_forces_degraded_mode() {
245 let routing = crate::core::profiles::RoutingConfig::default();
246 let inputs = RouteInputs {
247 pressure_action: PressureAction::EvictLeastRelevant,
248 pressure_utilization: 0.95,
249 pressure_remaining_tokens: 100,
250 };
251 let route = route_v2_with(
252 "review the auth module",
253 "coder",
254 "review",
255 &routing,
256 &inputs,
257 );
258 assert!(
259 matches!(
260 route.decision.effective_read_mode.as_str(),
261 "signatures" | "cognitive" | "reference"
262 ),
263 "expected degraded mode, got: {}",
264 route.decision.effective_read_mode
265 );
266 assert!(route.decision.degraded_by_pressure);
267 }
268}