Skip to main content

lean_ctx/core/
intent_router.rs

1use serde::Serialize;
2
3use crate::core::budget_tracker::{BudgetLevel, BudgetSnapshot};
4use crate::core::context_ledger::PressureAction;
5use crate::core::intent_engine::{classify, route_intent, IntentDimension, ModelTier, TaskType};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct IntentRouteV1 {
9    pub schema_version: u32,
10    pub created_at: String,
11    pub inputs: IntentRouteInputsV1,
12    pub decision: IntentRouteDecisionV1,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct IntentRouteInputsV1 {
17    pub query_md5: String,
18    pub query_redacted: String,
19    pub role: String,
20    pub profile: String,
21    pub task_type: TaskType,
22    pub confidence: f64,
23    pub dimension: IntentDimension,
24    pub budgets: BudgetSnapshot,
25    pub pressure: PressureSummaryV1,
26    pub policy_md5: String,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct PressureSummaryV1 {
31    pub utilization_pct: u8,
32    pub remaining_tokens: usize,
33    pub action: PressureActionV1,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "snake_case")]
38pub enum PressureActionV1 {
39    NoAction,
40    SuggestCompression,
41    ForceCompression,
42    EvictLeastRelevant,
43}
44
45impl PressureActionV1 {
46    fn from_action(a: PressureAction) -> Self {
47        match a {
48            PressureAction::NoAction => Self::NoAction,
49            PressureAction::SuggestCompression => Self::SuggestCompression,
50            PressureAction::ForceCompression => Self::ForceCompression,
51            PressureAction::EvictLeastRelevant => Self::EvictLeastRelevant,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct IntentRouteDecisionV1 {
58    pub recommended_model_tier: ModelTier,
59    pub effective_model_tier: ModelTier,
60    pub recommended_read_mode: String,
61    pub effective_read_mode: String,
62    pub degraded_by_budget: bool,
63    pub degraded_by_pressure: bool,
64    pub reason: String,
65}
66
67#[derive(Debug, Clone)]
68pub struct RouteInputs {
69    pub tokens_level: BudgetLevel,
70    pub cost_level: BudgetLevel,
71    pub pressure_action: PressureAction,
72    pub pressure_utilization: f64,
73    pub pressure_remaining_tokens: usize,
74}
75
76pub fn route_v1(query: &str) -> IntentRouteV1 {
77    let budgets = crate::core::budget_tracker::BudgetTracker::global().check();
78    let ledger = crate::core::context_ledger::ContextLedger::load();
79    let pressure = ledger.pressure();
80    let profile_name = crate::core::profiles::active_profile_name();
81    let profile = crate::core::profiles::active_profile();
82    let role_name = crate::core::roles::active_role_name();
83
84    let inputs = RouteInputs {
85        tokens_level: budgets.tokens.level.clone(),
86        cost_level: budgets.cost.level.clone(),
87        pressure_action: pressure.recommendation,
88        pressure_utilization: pressure.utilization,
89        pressure_remaining_tokens: pressure.remaining_tokens,
90    };
91
92    route_v1_with(
93        query,
94        &role_name,
95        &profile_name,
96        &profile.routing,
97        budgets,
98        &inputs,
99        None,
100    )
101}
102
103pub fn route_v1_with(
104    query: &str,
105    role_name: &str,
106    profile_name: &str,
107    routing: &crate::core::profiles::RoutingConfig,
108    budgets: BudgetSnapshot,
109    inputs: &RouteInputs,
110    created_at_override: Option<&str>,
111) -> IntentRouteV1 {
112    let created_at = created_at_override.map_or_else(
113        || chrono::Utc::now().to_rfc3339(),
114        std::string::ToString::to_string,
115    );
116
117    let classification = classify(query);
118    let base = route_intent(query, &classification);
119
120    let query_redacted = truncate(&crate::core::redaction::redact_text(query), 180);
121    let query_md5 = crate::core::hasher::hash_str(query);
122
123    let policy_md5 = crate::core::hasher::hash_str(&format!(
124        "max_model_tier={};degrade_under_pressure={}",
125        routing.max_model_tier_effective(),
126        routing.degrade_under_pressure_effective()
127    ));
128
129    let recommended_model_tier = base.model_tier;
130    let tokens_level = inputs.tokens_level.clone();
131    let cost_level = inputs.cost_level.clone();
132    let (effective_model_tier, degraded_by_budget) = apply_budget_caps(
133        recommended_model_tier,
134        tokens_level.clone(),
135        cost_level.clone(),
136        routing,
137    );
138
139    let recommended_read_mode =
140        read_mode_for_tier(recommended_model_tier, classification.task_type);
141    let (effective_read_mode, degraded_by_pressure) = if routing.degrade_under_pressure_effective()
142    {
143        apply_pressure_degrade(&recommended_read_mode, inputs.pressure_action)
144    } else {
145        (recommended_read_mode.clone(), false)
146    };
147
148    let reason = build_reason(&ReasonInputs {
149        task_type: classification.task_type,
150        dimension: base.dimension,
151        recommended_tier: recommended_model_tier,
152        effective_tier: effective_model_tier,
153        read_mode: effective_read_mode.clone(),
154        degraded_by_budget,
155        degraded_by_pressure,
156        tokens_level,
157        cost_level,
158        pressure: inputs.pressure_action,
159    });
160
161    IntentRouteV1 {
162        schema_version: crate::core::contracts::INTENT_ROUTE_V1_SCHEMA_VERSION,
163        created_at,
164        inputs: IntentRouteInputsV1 {
165            query_md5,
166            query_redacted,
167            role: role_name.to_string(),
168            profile: profile_name.to_string(),
169            task_type: classification.task_type,
170            confidence: classification.confidence,
171            dimension: base.dimension,
172            budgets,
173            pressure: PressureSummaryV1 {
174                utilization_pct: (inputs.pressure_utilization * 100.0).min(254.0) as u8,
175                remaining_tokens: inputs.pressure_remaining_tokens,
176                action: PressureActionV1::from_action(inputs.pressure_action),
177            },
178            policy_md5,
179        },
180        decision: IntentRouteDecisionV1 {
181            recommended_model_tier,
182            effective_model_tier,
183            recommended_read_mode,
184            effective_read_mode,
185            degraded_by_budget,
186            degraded_by_pressure,
187            reason,
188        },
189    }
190}
191
192fn apply_budget_caps(
193    tier: ModelTier,
194    tokens_level: BudgetLevel,
195    cost_level: BudgetLevel,
196    routing: &crate::core::profiles::RoutingConfig,
197) -> (ModelTier, bool) {
198    let mut out = tier;
199    let mut degraded = false;
200
201    // Hard cap from profile policy.
202    out = cap_to(out, parse_tier_cap(routing.max_model_tier_effective()));
203    if out != tier {
204        degraded = true;
205    }
206
207    // Budget-based caps (recommendation only; never blocks).
208    let max_budget = worst_budget_level(tokens_level, cost_level);
209    out = match max_budget {
210        BudgetLevel::Ok => out,
211        BudgetLevel::Warning => cap_to(out, ModelTier::Standard),
212        BudgetLevel::Exhausted => cap_to(out, ModelTier::Fast),
213    };
214    if out != tier {
215        degraded = true;
216    }
217
218    (out, degraded)
219}
220
221fn worst_budget_level(a: BudgetLevel, b: BudgetLevel) -> BudgetLevel {
222    match (a, b) {
223        (BudgetLevel::Exhausted, _) | (_, BudgetLevel::Exhausted) => BudgetLevel::Exhausted,
224        (BudgetLevel::Warning, _) | (_, BudgetLevel::Warning) => BudgetLevel::Warning,
225        _ => BudgetLevel::Ok,
226    }
227}
228
229fn parse_tier_cap(s: &str) -> ModelTier {
230    match s.trim().to_lowercase().as_str() {
231        "fast" => ModelTier::Fast,
232        "standard" => ModelTier::Standard,
233        _ => ModelTier::Premium,
234    }
235}
236
237fn cap_to(tier: ModelTier, cap: ModelTier) -> ModelTier {
238    match cap {
239        ModelTier::Fast => ModelTier::Fast,
240        ModelTier::Standard => match tier {
241            ModelTier::Premium => ModelTier::Standard,
242            _ => tier,
243        },
244        ModelTier::Premium => tier,
245    }
246}
247
248pub fn read_mode_for_tier(tier: ModelTier, task_type: TaskType) -> String {
249    // Editing tasks need the real, complete file — never an abbreviated,
250    // signature-only, or identifier-obfuscated view — otherwise the agent is
251    // forced into follow-up re-reads mid-edit. This holds across every tier
252    // (a Fast-tier bugfix still has to see the code it changes).
253    if matches!(
254        task_type,
255        TaskType::Refactor | TaskType::FixBug | TaskType::Generate
256    ) {
257        return "full".to_string();
258    }
259    match (tier, task_type) {
260        (ModelTier::Fast, _) => "signatures".to_string(),
261        (ModelTier::Standard, TaskType::Explore | TaskType::Review) => "map".to_string(),
262        (ModelTier::Standard, _) => "full".to_string(),
263        (ModelTier::Premium, _) => "auto".to_string(),
264    }
265}
266
267fn apply_pressure_degrade(mode: &str, pressure: PressureAction) -> (String, bool) {
268    if let Some(downgraded) = crate::core::auto_mode_resolver::pressure_downgrade(mode, &pressure) {
269        (downgraded, true)
270    } else {
271        (mode.to_string(), false)
272    }
273}
274
275struct ReasonInputs {
276    task_type: TaskType,
277    dimension: IntentDimension,
278    recommended_tier: ModelTier,
279    effective_tier: ModelTier,
280    read_mode: String,
281    degraded_by_budget: bool,
282    degraded_by_pressure: bool,
283    tokens_level: BudgetLevel,
284    cost_level: BudgetLevel,
285    pressure: PressureAction,
286}
287
288fn build_reason(i: &ReasonInputs) -> String {
289    format!(
290        "task={} dim={} tier={}→{} read={} budget={} pressure={:?} degrade(budget={},pressure={})",
291        i.task_type.as_str(),
292        i.dimension.as_str(),
293        i.recommended_tier.as_str(),
294        i.effective_tier.as_str(),
295        i.read_mode,
296        worst_budget_level(i.tokens_level.clone(), i.cost_level.clone()),
297        i.pressure,
298        i.degraded_by_budget,
299        i.degraded_by_pressure
300    )
301}
302
303fn truncate(s: &str, max: usize) -> String {
304    if s.chars().count() <= max {
305        return s.to_string();
306    }
307    s.chars().take(max).collect()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::core::budget_tracker::{BudgetLevel, CostStatus, DimensionStatus};
314
315    fn budget(level: BudgetLevel) -> BudgetSnapshot {
316        BudgetSnapshot {
317            role: "coder".to_string(),
318            tokens: DimensionStatus {
319                used: 1,
320                limit: 1,
321                percent: 100,
322                level: level.clone(),
323            },
324            shell: DimensionStatus {
325                used: 0,
326                limit: 0,
327                percent: 0,
328                level: BudgetLevel::Ok,
329            },
330            cost: CostStatus {
331                used_usd: 0.0,
332                limit_usd: 0.0,
333                percent: 0,
334                level,
335            },
336        }
337    }
338
339    #[test]
340    fn routing_is_deterministic_for_same_inputs() {
341        let r = crate::core::profiles::RoutingConfig::default();
342        let b = budget(BudgetLevel::Ok);
343        let inputs = RouteInputs {
344            tokens_level: BudgetLevel::Ok,
345            cost_level: BudgetLevel::Ok,
346            pressure_action: PressureAction::NoAction,
347            pressure_utilization: 0.1,
348            pressure_remaining_tokens: 1000,
349        };
350        let a = route_v1_with(
351            "fix bug in src/lib.rs",
352            "coder",
353            "bugfix",
354            &r,
355            b.clone(),
356            &inputs,
357            Some("2026-01-01T00:00:00Z"),
358        );
359        let b2 = route_v1_with(
360            "fix bug in src/lib.rs",
361            "coder",
362            "bugfix",
363            &r,
364            b,
365            &inputs,
366            Some("2026-01-01T00:00:00Z"),
367        );
368        assert_eq!(
369            serde_json::to_string(&a).unwrap(),
370            serde_json::to_string(&b2).unwrap()
371        );
372    }
373
374    #[test]
375    fn budget_caps_premium_to_fast_when_exhausted() {
376        let routing = crate::core::profiles::RoutingConfig {
377            max_model_tier: Some("premium".to_string()),
378            ..Default::default()
379        };
380        let b = budget(BudgetLevel::Exhausted);
381        let inputs = RouteInputs {
382            tokens_level: BudgetLevel::Exhausted,
383            cost_level: BudgetLevel::Ok,
384            pressure_action: PressureAction::NoAction,
385            pressure_utilization: 0.1,
386            pressure_remaining_tokens: 1000,
387        };
388        let r = route_v1_with(
389            "implement feature x",
390            "coder",
391            "exploration",
392            &routing,
393            b,
394            &inputs,
395            Some("2026-01-01T00:00:00Z"),
396        );
397        assert_eq!(r.decision.effective_model_tier, ModelTier::Fast);
398        assert!(r.decision.degraded_by_budget);
399    }
400
401    #[test]
402    fn pressure_forces_degraded_mode() {
403        let routing = crate::core::profiles::RoutingConfig::default();
404        let b = budget(BudgetLevel::Ok);
405        let inputs = RouteInputs {
406            tokens_level: BudgetLevel::Ok,
407            cost_level: BudgetLevel::Ok,
408            pressure_action: PressureAction::EvictLeastRelevant,
409            pressure_utilization: 0.95,
410            pressure_remaining_tokens: 100,
411        };
412        let r = route_v1_with(
413            "review the auth module",
414            "coder",
415            "review",
416            &routing,
417            b,
418            &inputs,
419            Some("2026-01-01T00:00:00Z"),
420        );
421        assert!(
422            r.decision.effective_read_mode == "signatures"
423                || r.decision.effective_read_mode == "reference",
424            "expected degraded mode, got: {}",
425            r.decision.effective_read_mode
426        );
427        assert!(r.decision.degraded_by_pressure);
428    }
429}