Skip to main content

oxicode_ai/
role_switcher.rs

1//! Role-switching decision engine — the "switching layer" on top of the
2//! [`crate::roles`] registry.
3//!
4//! Given observable turn signals, [`decide_role`] picks the active
5//! [`ModelRole`], and [`resolve_role_to_model`] turns the role into a concrete
6//! [`crate::Model`] via [`RoleRegistry`] + the model registry.
7//!
8//! Inspired by claude-code-router's signal-based `getUseModel` priority
9//! (router.ts:124-200): the decision rests on *observable* signals (explicit
10//! override, the tool currently running, the thinking flag, a token-count
11//! threshold, triviality) — not on keyword-guessing the user's intent. The
12//! role→model mapping itself is omp's role model (see [`crate::roles`]).
13//!
14//! This module is deliberately pure and side-effect-free; wiring it into the
15//! live agent loop / a specific tool is the consumer's job.
16
17use crate::Model;
18use crate::roles::{ModelRole, RoleRegistry};
19
20/// Default token count above which a turn routes to the long-context role.
21///
22/// Matches claude-code-router's default `longContextThreshold`.
23pub const DEFAULT_LONG_CONTEXT_THRESHOLD: usize = 60_000;
24
25/// Observable signals for a turn, used to decide the active role.
26///
27/// All fields default sanely via [`RoleSignals::default`]; set only what the
28/// caller actually knows.
29#[derive(Debug, Clone)]
30pub struct RoleSignals<'a> {
31    /// Explicit user override (e.g. a `/model` pin). Highest priority.
32    pub explicit_override: Option<ModelRole>,
33    /// Name of the tool currently executing, if any. A tool may declare a role
34    /// (see [`role_for_tool`]).
35    pub current_tool: Option<&'a str>,
36    /// Whether extended thinking is enabled for the turn.
37    pub thinking_enabled: bool,
38    /// Estimated prompt token count for the turn.
39    pub estimated_tokens: usize,
40    /// Token threshold above which the long-context role is selected.
41    pub long_context_threshold: usize,
42    /// Whether the turn is trivially simple (short, single intent).
43    pub is_trivial: bool,
44}
45
46impl Default for RoleSignals<'_> {
47    fn default() -> Self {
48        Self {
49            explicit_override: None,
50            current_tool: None,
51            thinking_enabled: false,
52            estimated_tokens: 0,
53            long_context_threshold: DEFAULT_LONG_CONTEXT_THRESHOLD,
54            is_trivial: false,
55        }
56    }
57}
58
59/// Decide the active role from signals, in priority order.
60///
61/// 1. [`RoleSignals::explicit_override`] — a user pin always wins.
62/// 2. [`RoleSignals::current_tool`] — a tool declares its role
63///    (e.g. `commit` → [`ModelRole::Commit`]).
64/// 3. long context — `estimated_tokens > long_context_threshold` →
65///    [`ModelRole::Slow`].
66/// 4. [`RoleSignals::thinking_enabled`] → [`ModelRole::Slow`].
67/// 5. [`RoleSignals::is_trivial`] → [`ModelRole::Smol`].
68/// 6. otherwise → [`ModelRole::Default`].
69///
70/// Long-context and thinking both select `Slow` (the heavy model) but are
71/// distinct signals: a long context may need a large window even without
72/// extended thinking, and thinking may be on for a short prompt.
73#[must_use]
74pub fn decide_role(signals: &RoleSignals<'_>) -> ModelRole {
75    if let Some(role) = signals.explicit_override {
76        return role;
77    }
78    if let Some(tool) = signals.current_tool
79        && let Some(role) = role_for_tool(tool)
80    {
81        return role;
82    }
83    if signals.estimated_tokens > signals.long_context_threshold {
84        return ModelRole::Slow;
85    }
86    if signals.thinking_enabled {
87        return ModelRole::Slow;
88    }
89    if signals.is_trivial {
90        return ModelRole::Smol;
91    }
92    ModelRole::Default
93}
94
95/// Map a tool name to the role it should run under, if it declares one.
96///
97/// Currently only the `commit` tool declares a role ([`ModelRole::Commit`]).
98/// Adding a tool→role binding here is the single place to extend tool-driven
99/// switching.
100#[must_use]
101pub fn role_for_tool(tool_name: &str) -> Option<ModelRole> {
102    match tool_name {
103        "commit" => Some(ModelRole::Commit),
104        _ => None,
105    }
106}
107
108/// Resolve a role to a concrete [`Model`] via the registry + model registry.
109///
110/// Takes the first concrete pattern the role resolves to, splits it as
111/// `provider/model`, and looks the model up. Returns `None` when the role is
112/// unset, resolves to nothing, lacks a `/`, or names an unknown model.
113#[must_use]
114pub fn resolve_role_to_model(role: ModelRole, registry: &RoleRegistry) -> Option<Model> {
115    let pattern = registry.resolve(role.as_str()).into_iter().next()?;
116    let (provider, model_id) = pattern.split_once('/')?;
117    crate::lookup_model(provider, model_id)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn override_wins_over_everything() {
126        let s = RoleSignals {
127            explicit_override: Some(ModelRole::Advisor),
128            current_tool: Some("commit"),
129            thinking_enabled: true,
130            estimated_tokens: 100_000,
131            is_trivial: false,
132            ..RoleSignals::default()
133        };
134        assert_eq!(decide_role(&s), ModelRole::Advisor);
135    }
136
137    #[test]
138    fn tool_signal_selects_commit_role() {
139        let s = RoleSignals {
140            current_tool: Some("commit"),
141            ..RoleSignals::default()
142        };
143        assert_eq!(decide_role(&s), ModelRole::Commit);
144    }
145
146    #[test]
147    fn unknown_tool_falls_through() {
148        let s = RoleSignals {
149            current_tool: Some("read"),
150            ..RoleSignals::default()
151        };
152        assert_eq!(decide_role(&s), ModelRole::Default);
153    }
154
155    #[test]
156    fn long_context_selects_slow() {
157        let s = RoleSignals {
158            estimated_tokens: 80_000,
159            long_context_threshold: 60_000,
160            ..RoleSignals::default()
161        };
162        assert_eq!(decide_role(&s), ModelRole::Slow);
163    }
164
165    #[test]
166    fn long_context_respects_custom_threshold() {
167        let s = RoleSignals {
168            estimated_tokens: 5_000,
169            long_context_threshold: 4_000,
170            ..RoleSignals::default()
171        };
172        assert_eq!(decide_role(&s), ModelRole::Slow);
173    }
174
175    #[test]
176    fn thinking_selects_slow_even_when_short() {
177        let s = RoleSignals {
178            thinking_enabled: true,
179            estimated_tokens: 100,
180            ..RoleSignals::default()
181        };
182        assert_eq!(decide_role(&s), ModelRole::Slow);
183    }
184
185    #[test]
186    fn trivial_selects_smol() {
187        let s = RoleSignals {
188            is_trivial: true,
189            ..RoleSignals::default()
190        };
191        assert_eq!(decide_role(&s), ModelRole::Smol);
192    }
193
194    #[test]
195    fn default_when_no_signal() {
196        assert_eq!(decide_role(&RoleSignals::default()), ModelRole::Default);
197    }
198
199    #[test]
200    fn long_context_beats_thinking_order_independence() {
201        // Both map to Slow; either signal alone is sufficient.
202        let s = RoleSignals {
203            thinking_enabled: true,
204            estimated_tokens: 100_000,
205            ..RoleSignals::default()
206        };
207        assert_eq!(decide_role(&s), ModelRole::Slow);
208    }
209
210    #[test]
211    fn role_for_tool_bindings() {
212        assert_eq!(role_for_tool("commit"), Some(ModelRole::Commit));
213        assert_eq!(role_for_tool("generate_image"), None);
214        assert_eq!(role_for_tool(""), None);
215    }
216
217    #[test]
218    fn resolve_unconfigured_role_is_none() {
219        let r = RoleRegistry::new();
220        assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
221    }
222
223    #[test]
224    fn resolve_pattern_without_slash_is_none() {
225        let mut r = RoleRegistry::new();
226        r.set("commit", "just-a-bare-id");
227        assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
228    }
229
230    #[test]
231    fn resolve_unknown_model_is_none() {
232        let mut r = RoleRegistry::new();
233        r.set("commit", "no-such-provider/does-not-exist-xyz");
234        assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
235    }
236
237    #[test]
238    fn resolve_registered_model_is_some() {
239        // Use a unique id to stay independent of other parallel tests.
240        let model = crate::Model::new(
241            "role-switcher-test-model",
242            "Role Switcher Test",
243            crate::Api::AnthropicMessages,
244            "role-switcher-test",
245            "",
246        );
247        crate::register_model(model);
248        let mut r = RoleRegistry::new();
249        r.set("commit", "role-switcher-test/role-switcher-test-model");
250        let resolved = resolve_role_to_model(ModelRole::Commit, &r);
251        assert!(
252            resolved.is_some(),
253            "registered model must resolve, got {resolved:?}"
254        );
255    }
256}