Skip to main content

agent_core/core/
reasoning.rs

1//! Typed named reasoning level — single source of truth across providers.
2//!
3//! `ReasoningLevel` is a closed enum with exact parsing, display, and explicit
4//! conversion to legacy Anthropic token budgets. It lives in `synaps-core` so
5//! every crate (engine, TUI) can depend on the same type without circular deps.
6//!
7//! ## Invariants
8//! - `Max` must never alias `XHigh`.
9//! - `Ultra` must never map through a numeric bucket.
10//! - `Off` and `Adaptive` are distinct.
11//! - Unknown strings from external sources parse to `None` / are rejected at
12//!   the call-site, not silently coerced.
13
14use std::fmt;
15
16/// A named reasoning / thinking depth level.
17///
18/// Ordering is from least to most intensive; `Off` < `Adaptive` < `Low` < … < `Ultra`.
19#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum ReasoningLevel {
21    /// No reasoning / thinking output.
22    Off,
23    /// Model decides reasoning depth automatically (Anthropic adaptive path).
24    Adaptive,
25    /// Low reasoning budget.
26    Low,
27    /// Medium reasoning budget.
28    #[default]
29    Medium,
30    /// High reasoning budget.
31    High,
32    /// Extra-high reasoning budget (above high, below max).
33    XHigh,
34    /// Maximum named level (below ultra).
35    Max,
36    /// Ultra — highest intensity, only a subset of Codex models supports this.
37    Ultra,
38    /// Anthropic-only logical xhigh plus standing workflow mode.
39    UltraCode,
40}
41
42impl ReasoningLevel {
43    /// Parse a level from its canonical logical/config string.
44    ///
45    /// Returns `None` for unknown strings; callers must not silently fallback.
46    pub fn parse(s: &str) -> Option<Self> {
47        match s.trim().to_ascii_lowercase().as_str() {
48            "off" | "none" => Some(Self::Off),
49            "adaptive" => Some(Self::Adaptive),
50            "low" => Some(Self::Low),
51            "medium" | "med" => Some(Self::Medium),
52            "high" => Some(Self::High),
53            "xhigh" | "x-high" | "x_high" => Some(Self::XHigh),
54            "max" => Some(Self::Max),
55            "ultra" => Some(Self::Ultra),
56            "ultracode" => Some(Self::UltraCode),
57            _ => None,
58        }
59    }
60
61    /// The canonical logical/config representation.
62    ///
63    /// Provider request planners own wire conversion. In particular, Codex
64    /// logical Ultra uses the exact catalog's multi-agent wire effort (e.g.
65    /// Astra `xhigh`, Sol `max`) plus foreground proactive mode.
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Off => "off",
69            Self::Adaptive => "adaptive",
70            Self::Low => "low",
71            Self::Medium => "medium",
72            Self::High => "high",
73            Self::XHigh => "xhigh",
74            Self::Max => "max",
75            Self::Ultra => "ultra",
76            Self::UltraCode => "ultracode",
77        }
78    }
79
80    /// Convert to the legacy Anthropic token budget used by the enabled+budget_tokens
81    /// request shape. Returns `None` for levels that have no meaningful numeric
82    /// mapping (Max and Ultra are Codex execution modes, not numeric budgets).
83    pub fn to_legacy_budget(self) -> Option<u32> {
84        match self {
85            Self::Off | Self::Adaptive => Some(0),
86            Self::Low => Some(2048),
87            Self::Medium => Some(4096),
88            Self::High => Some(16384),
89            Self::XHigh => Some(32768),
90            Self::Max | Self::Ultra | Self::UltraCode => None,
91        }
92    }
93
94    /// Build from a legacy token budget value (Anthropic path).
95    ///
96    /// `0` → `Adaptive`, positive values → their tier. Does not produce `Max`
97    /// or `Ultra` — those are provider-named levels, never inferred from a budget.
98    pub fn from_legacy_budget(budget: u32) -> Self {
99        match budget {
100            0 => Self::Adaptive,
101            1..=2048 => Self::Low,
102            2049..=4096 => Self::Medium,
103            4097..=16384 => Self::High,
104            _ => Self::XHigh,
105        }
106    }
107
108    /// All levels that are strictly above XHigh (require Codex-specific support).
109    pub fn requires_codex_support(self) -> bool {
110        matches!(self, Self::Ultra)
111    }
112
113    /// Whether this level requires exact Anthropic mode capability evidence.
114    pub fn requires_anthropic_support(self) -> bool {
115        matches!(self, Self::Max | Self::UltraCode)
116    }
117}
118
119impl fmt::Display for ReasoningLevel {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(self.as_str())
122    }
123}
124
125/// Parse the `thinking` config key into either a named level or a raw numeric budget.
126///
127/// This is the single parse path for config files, `/thinking` commands, and
128/// session resumption. Named levels win over numeric-only parsing; numeric values
129/// that don't match a known level are treated as raw budget tokens.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum ThinkingSpec {
132    /// A recognized named level.
133    Named(ReasoningLevel),
134    /// A raw numeric budget (legacy Anthropic path, user-specified token count).
135    Budget(u32),
136}
137
138impl ThinkingSpec {
139    /// Parse from a string (config value or command argument).
140    ///
141    /// Returns `None` for strings that are neither a known name nor a non-negative integer.
142    pub fn parse(s: &str) -> Option<Self> {
143        // Named levels take priority.
144        if let Some(level) = ReasoningLevel::parse(s) {
145            return Some(Self::Named(level));
146        }
147        // Raw numeric budget (any non-negative integer).
148        if let Ok(n) = s.trim().parse::<u32>() {
149            return Some(Self::Budget(n));
150        }
151        None
152    }
153
154    /// The canonical level this spec resolves to, if deterministic.
155    /// `Budget` values are bucketized via `ReasoningLevel::from_legacy_budget`.
156    pub fn to_level(&self) -> ReasoningLevel {
157        match self {
158            Self::Named(l) => *l,
159            Self::Budget(n) => ReasoningLevel::from_legacy_budget(*n),
160        }
161    }
162
163    /// The numeric budget this spec resolves to for Anthropic requests.
164    /// Named Max/Ultra return `None` because they have no valid numeric budget.
165    pub fn to_budget(&self) -> Option<u32> {
166        match self {
167            Self::Named(l) => l.to_legacy_budget(),
168            Self::Budget(n) => Some(*n),
169        }
170    }
171}
172
173/// Named worker effort levels shared by config and tool arguments. Execution
174/// modes (ultra/ultracode), disabling thinking and numeric budgets are not
175/// subagent effort overrides.
176pub fn parse_subagent_thinking(value: &str) -> Option<ReasoningLevel> {
177    match ReasoningLevel::parse(value)? {
178        level @ (ReasoningLevel::Low | ReasoningLevel::Medium | ReasoningLevel::High
179        | ReasoningLevel::XHigh | ReasoningLevel::Max) => Some(level),
180        _ => None,
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn ultracode_is_a_distinct_canonical_non_numeric_level() {
190        let level = ReasoningLevel::parse("ultracode").expect("canonical ultracode");
191        assert_eq!(level, ReasoningLevel::UltraCode);
192        assert_eq!(level.to_string(), "ultracode");
193        assert_ne!(level, ReasoningLevel::Ultra);
194        assert_ne!(level, ReasoningLevel::Max);
195        assert_ne!(level, ReasoningLevel::XHigh);
196        assert_eq!(level.to_legacy_budget(), None);
197        assert!(!level.requires_codex_support());
198        assert!(level.requires_anthropic_support());
199    }
200
201    // ── Parse / round-trip ────────────────────────────────────────────────────
202
203    #[test]
204    fn all_canonical_strings_parse() {
205        for (s, expected) in [
206            ("off", ReasoningLevel::Off),
207            ("none", ReasoningLevel::Off),
208            ("adaptive", ReasoningLevel::Adaptive),
209            ("low", ReasoningLevel::Low),
210            ("medium", ReasoningLevel::Medium),
211            ("med", ReasoningLevel::Medium),
212            ("high", ReasoningLevel::High),
213            ("xhigh", ReasoningLevel::XHigh),
214            ("max", ReasoningLevel::Max),
215            ("ultra", ReasoningLevel::Ultra),
216            ("ultracode", ReasoningLevel::UltraCode),
217        ] {
218            assert_eq!(
219                ReasoningLevel::parse(s),
220                Some(expected),
221                "failed to parse {s:?}"
222            );
223        }
224    }
225
226    #[test]
227    fn case_insensitive_parse() {
228        assert_eq!(ReasoningLevel::parse("MAX"), Some(ReasoningLevel::Max));
229        assert_eq!(ReasoningLevel::parse("Ultra"), Some(ReasoningLevel::Ultra));
230        assert_eq!(ReasoningLevel::parse("XHIGH"), Some(ReasoningLevel::XHigh));
231        assert_eq!(ReasoningLevel::parse("  Low  "), Some(ReasoningLevel::Low));
232    }
233
234    #[test]
235    fn unknown_strings_return_none() {
236        assert_eq!(ReasoningLevel::parse(""), None);
237        assert_eq!(ReasoningLevel::parse("bogus"), None);
238        assert_eq!(ReasoningLevel::parse("maximum"), None);
239        assert_eq!(ReasoningLevel::parse("hyper"), None);
240        assert_eq!(ReasoningLevel::parse("xhigh+"), None);
241    }
242
243    #[test]
244    fn as_str_round_trips() {
245        for level in [
246            ReasoningLevel::Off,
247            ReasoningLevel::Adaptive,
248            ReasoningLevel::Low,
249            ReasoningLevel::Medium,
250            ReasoningLevel::High,
251            ReasoningLevel::XHigh,
252            ReasoningLevel::Max,
253            ReasoningLevel::Ultra,
254            ReasoningLevel::UltraCode,
255        ] {
256            let s = level.as_str();
257            assert_eq!(
258                ReasoningLevel::parse(s),
259                Some(level),
260                "as_str() → parse() failed for {:?}",
261                level
262            );
263        }
264    }
265
266    #[test]
267    fn display_matches_as_str() {
268        assert_eq!(ReasoningLevel::Max.to_string(), "max");
269        assert_eq!(ReasoningLevel::Ultra.to_string(), "ultra");
270        assert_eq!(ReasoningLevel::XHigh.to_string(), "xhigh");
271    }
272
273    // ── Max must never alias XHigh ─────────────────────────────────────────────
274
275    #[test]
276    fn max_is_distinct_from_xhigh() {
277        assert_ne!(ReasoningLevel::Max, ReasoningLevel::XHigh);
278        assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
279    }
280
281    // ── Ultra must not map through a numeric budget bucket ────────────────────
282
283    #[test]
284    fn ultra_has_no_legacy_budget() {
285        assert_eq!(ReasoningLevel::Ultra.to_legacy_budget(), None);
286    }
287
288    #[test]
289    fn max_has_no_legacy_budget() {
290        assert_eq!(ReasoningLevel::Max.to_legacy_budget(), None);
291    }
292
293    // ── Legacy budget conversion ──────────────────────────────────────────────
294
295    #[test]
296    fn known_legacy_budgets_convert_correctly() {
297        assert_eq!(ReasoningLevel::Off.to_legacy_budget(), Some(0));
298        assert_eq!(ReasoningLevel::Adaptive.to_legacy_budget(), Some(0));
299        assert_eq!(ReasoningLevel::Low.to_legacy_budget(), Some(2048));
300        assert_eq!(ReasoningLevel::Medium.to_legacy_budget(), Some(4096));
301        assert_eq!(ReasoningLevel::High.to_legacy_budget(), Some(16384));
302        assert_eq!(ReasoningLevel::XHigh.to_legacy_budget(), Some(32768));
303    }
304
305    #[test]
306    fn from_legacy_budget_never_returns_max_or_ultra() {
307        for budget in [0u32, 2048, 4096, 16384, 32768, 65536, u32::MAX] {
308            let level = ReasoningLevel::from_legacy_budget(budget);
309            assert!(
310                !matches!(level, ReasoningLevel::Max | ReasoningLevel::Ultra),
311                "from_legacy_budget({budget}) returned {level:?} which must not be Max/Ultra"
312            );
313        }
314    }
315
316    #[test]
317    fn from_legacy_budget_round_trips_for_known_values() {
318        for (budget, expected) in [
319            (0u32, ReasoningLevel::Adaptive),
320            (2048, ReasoningLevel::Low),
321            (4096, ReasoningLevel::Medium),
322            (16384, ReasoningLevel::High),
323            (32768, ReasoningLevel::XHigh),
324        ] {
325            assert_eq!(
326                ReasoningLevel::from_legacy_budget(budget),
327                expected,
328                "budget {budget}"
329            );
330        }
331    }
332
333    // ── Ordering ──────────────────────────────────────────────────────────────
334
335    #[test]
336    fn ordering_is_intensity() {
337        let levels = [
338            ReasoningLevel::Off,
339            ReasoningLevel::Adaptive,
340            ReasoningLevel::Low,
341            ReasoningLevel::Medium,
342            ReasoningLevel::High,
343            ReasoningLevel::XHigh,
344            ReasoningLevel::Max,
345            ReasoningLevel::Ultra,
346        ];
347        for w in levels.windows(2) {
348            assert!(w[0] < w[1], "{:?} should be less than {:?}", w[0], w[1]);
349        }
350    }
351
352    // ── requires_codex_support ────────────────────────────────────────────────
353
354    #[test]
355    fn only_ultra_requires_categorically_codex_support() {
356        assert!(!ReasoningLevel::Max.requires_codex_support());
357        assert!(ReasoningLevel::Ultra.requires_codex_support());
358        assert!(!ReasoningLevel::UltraCode.requires_codex_support());
359        assert!(!ReasoningLevel::XHigh.requires_codex_support());
360        assert!(!ReasoningLevel::High.requires_codex_support());
361        assert!(!ReasoningLevel::Adaptive.requires_codex_support());
362    }
363
364    // ── ThinkingSpec ──────────────────────────────────────────────────────────
365
366    #[test]
367    fn thinking_spec_named_levels_parse() {
368        assert_eq!(
369            ThinkingSpec::parse("max"),
370            Some(ThinkingSpec::Named(ReasoningLevel::Max))
371        );
372        assert_eq!(
373            ThinkingSpec::parse("ultra"),
374            Some(ThinkingSpec::Named(ReasoningLevel::Ultra))
375        );
376        assert_eq!(
377            ThinkingSpec::parse("xhigh"),
378            Some(ThinkingSpec::Named(ReasoningLevel::XHigh))
379        );
380    }
381
382    #[test]
383    fn thinking_spec_numeric_budget_parse() {
384        assert_eq!(
385            ThinkingSpec::parse("8192"),
386            Some(ThinkingSpec::Budget(8192))
387        );
388        assert_eq!(ThinkingSpec::parse("0"), Some(ThinkingSpec::Budget(0)));
389    }
390
391    #[test]
392    fn thinking_spec_parse_invalid() {
393        assert_eq!(ThinkingSpec::parse("bogus"), None);
394        assert_eq!(ThinkingSpec::parse(""), None);
395    }
396
397    #[test]
398    fn thinking_spec_max_has_no_budget() {
399        let spec = ThinkingSpec::Named(ReasoningLevel::Max);
400        assert_eq!(spec.to_budget(), None);
401        assert_eq!(spec.to_level(), ReasoningLevel::Max);
402    }
403
404    #[test]
405    fn thinking_spec_budget_round_trips() {
406        let spec = ThinkingSpec::Budget(8192);
407        assert_eq!(spec.to_budget(), Some(8192));
408        // 8192 is in the high tier (4097–16384)
409        assert_eq!(spec.to_level(), ReasoningLevel::High);
410    }
411}