Skip to main content

construct/agent/
thinking.rs

1//! Thinking/Reasoning Level Control
2//!
3//! Allows users to control how deeply the model reasons per message,
4//! trading speed for depth. Levels range from `Off` (fastest, most concise)
5//! to `Max` (deepest reasoning, slowest).
6//!
7//! Users can set the level via:
8//! - Inline directive: `/think:high` at the start of a message
9//! - Agent config: `[agent.thinking]` section with `default_level`
10//!
11//! Resolution hierarchy (highest priority first):
12//! 1. Inline directive (`/think:<level>`)
13//! 2. Session override (reserved for future use)
14//! 3. Agent config (`agent.thinking.default_level`)
15//! 4. Global default (`Medium`)
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// How deeply the model should reason for a given message.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
22#[serde(rename_all = "lowercase")]
23pub enum ThinkingLevel {
24    /// No chain-of-thought. Fastest, most concise responses.
25    Off,
26    /// Minimal reasoning. Brief, direct answers.
27    Minimal,
28    /// Light reasoning. Short explanations when needed.
29    Low,
30    /// Balanced reasoning (default). Moderate depth.
31    #[default]
32    Medium,
33    /// Deep reasoning. Thorough analysis and step-by-step thinking.
34    High,
35    /// Maximum reasoning depth. Exhaustive analysis.
36    Max,
37}
38
39impl ThinkingLevel {
40    /// Parse a thinking level from a string (case-insensitive).
41    pub fn from_str_insensitive(s: &str) -> Option<Self> {
42        match s.to_lowercase().as_str() {
43            "off" | "none" => Some(Self::Off),
44            "minimal" | "min" => Some(Self::Minimal),
45            "low" => Some(Self::Low),
46            "medium" | "med" | "default" => Some(Self::Medium),
47            "high" => Some(Self::High),
48            "max" | "maximum" => Some(Self::Max),
49            _ => None,
50        }
51    }
52}
53
54/// Configuration for thinking/reasoning level control.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct ThinkingConfig {
57    /// Default thinking level when no directive is present.
58    #[serde(default)]
59    pub default_level: ThinkingLevel,
60}
61
62impl Default for ThinkingConfig {
63    fn default() -> Self {
64        Self {
65            default_level: ThinkingLevel::Medium,
66        }
67    }
68}
69
70/// Parameters derived from a thinking level, applied to the LLM request.
71#[derive(Debug, Clone, PartialEq)]
72pub struct ThinkingParams {
73    /// Temperature adjustment (added to the base temperature, clamped to 0.0..=2.0).
74    pub temperature_adjustment: f64,
75    /// Maximum tokens adjustment (added to any existing max_tokens setting).
76    pub max_tokens_adjustment: i64,
77    /// Optional system prompt prefix injected before the existing system prompt.
78    pub system_prompt_prefix: Option<String>,
79}
80
81/// Parse a `/think:<level>` directive from the start of a message.
82///
83/// Returns `Some((level, remaining_message))` if a directive is found,
84/// or `None` if no directive is present. The remaining message has
85/// leading whitespace after the directive trimmed.
86pub fn parse_thinking_directive(message: &str) -> Option<(ThinkingLevel, String)> {
87    let trimmed = message.trim_start();
88    if !trimmed.starts_with("/think:") {
89        return None;
90    }
91
92    // Extract the level token (everything between `/think:` and the next whitespace or end).
93    let after_prefix = &trimmed["/think:".len()..];
94    let level_end = after_prefix
95        .find(|c: char| c.is_whitespace())
96        .unwrap_or(after_prefix.len());
97    let level_str = &after_prefix[..level_end];
98
99    let level = ThinkingLevel::from_str_insensitive(level_str)?;
100
101    let remaining = after_prefix[level_end..].trim_start().to_string();
102    Some((level, remaining))
103}
104
105/// Convert a `ThinkingLevel` into concrete parameters for the LLM request.
106pub fn apply_thinking_level(level: ThinkingLevel) -> ThinkingParams {
107    match level {
108        ThinkingLevel::Off => ThinkingParams {
109            temperature_adjustment: -0.2,
110            max_tokens_adjustment: -1000,
111            system_prompt_prefix: Some(
112                "Be extremely concise. Give direct answers without explanation \
113                 unless explicitly asked. No preamble."
114                    .into(),
115            ),
116        },
117        ThinkingLevel::Minimal => ThinkingParams {
118            temperature_adjustment: -0.1,
119            max_tokens_adjustment: -500,
120            system_prompt_prefix: Some(
121                "Be concise and fast. Keep explanations brief. \
122                 Prioritize speed over thoroughness."
123                    .into(),
124            ),
125        },
126        ThinkingLevel::Low => ThinkingParams {
127            temperature_adjustment: -0.05,
128            max_tokens_adjustment: 0,
129            system_prompt_prefix: Some("Keep reasoning light. Explain only when helpful.".into()),
130        },
131        ThinkingLevel::Medium => ThinkingParams {
132            temperature_adjustment: 0.0,
133            max_tokens_adjustment: 0,
134            system_prompt_prefix: None,
135        },
136        ThinkingLevel::High => ThinkingParams {
137            temperature_adjustment: 0.05,
138            max_tokens_adjustment: 1000,
139            system_prompt_prefix: Some(
140                "Think step by step. Provide thorough analysis and \
141                 consider edge cases before answering."
142                    .into(),
143            ),
144        },
145        ThinkingLevel::Max => ThinkingParams {
146            temperature_adjustment: 0.1,
147            max_tokens_adjustment: 2000,
148            system_prompt_prefix: Some(
149                "Think very carefully and exhaustively. Break down the problem \
150                 into sub-problems, consider all angles, verify your reasoning, \
151                 and provide the most thorough analysis possible."
152                    .into(),
153            ),
154        },
155    }
156}
157
158/// Resolve the effective thinking level using the priority hierarchy:
159/// 1. Inline directive (if present)
160/// 2. Session override (reserved, currently always `None`)
161/// 3. Agent config default
162/// 4. Global default (`Medium`)
163pub fn resolve_thinking_level(
164    inline_directive: Option<ThinkingLevel>,
165    session_override: Option<ThinkingLevel>,
166    config: &ThinkingConfig,
167) -> ThinkingLevel {
168    inline_directive
169        .or(session_override)
170        .unwrap_or(config.default_level)
171}
172
173/// Clamp a temperature value to the valid range `[0.0, 2.0]`.
174pub fn clamp_temperature(temp: f64) -> f64 {
175    temp.clamp(0.0, 2.0)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    // ── ThinkingLevel parsing ────────────────────────────────────
183
184    #[test]
185    fn thinking_level_from_str_canonical_names() {
186        assert_eq!(
187            ThinkingLevel::from_str_insensitive("off"),
188            Some(ThinkingLevel::Off)
189        );
190        assert_eq!(
191            ThinkingLevel::from_str_insensitive("minimal"),
192            Some(ThinkingLevel::Minimal)
193        );
194        assert_eq!(
195            ThinkingLevel::from_str_insensitive("low"),
196            Some(ThinkingLevel::Low)
197        );
198        assert_eq!(
199            ThinkingLevel::from_str_insensitive("medium"),
200            Some(ThinkingLevel::Medium)
201        );
202        assert_eq!(
203            ThinkingLevel::from_str_insensitive("high"),
204            Some(ThinkingLevel::High)
205        );
206        assert_eq!(
207            ThinkingLevel::from_str_insensitive("max"),
208            Some(ThinkingLevel::Max)
209        );
210    }
211
212    #[test]
213    fn thinking_level_from_str_aliases() {
214        assert_eq!(
215            ThinkingLevel::from_str_insensitive("none"),
216            Some(ThinkingLevel::Off)
217        );
218        assert_eq!(
219            ThinkingLevel::from_str_insensitive("min"),
220            Some(ThinkingLevel::Minimal)
221        );
222        assert_eq!(
223            ThinkingLevel::from_str_insensitive("med"),
224            Some(ThinkingLevel::Medium)
225        );
226        assert_eq!(
227            ThinkingLevel::from_str_insensitive("default"),
228            Some(ThinkingLevel::Medium)
229        );
230        assert_eq!(
231            ThinkingLevel::from_str_insensitive("maximum"),
232            Some(ThinkingLevel::Max)
233        );
234    }
235
236    #[test]
237    fn thinking_level_from_str_case_insensitive() {
238        assert_eq!(
239            ThinkingLevel::from_str_insensitive("HIGH"),
240            Some(ThinkingLevel::High)
241        );
242        assert_eq!(
243            ThinkingLevel::from_str_insensitive("Max"),
244            Some(ThinkingLevel::Max)
245        );
246        assert_eq!(
247            ThinkingLevel::from_str_insensitive("OFF"),
248            Some(ThinkingLevel::Off)
249        );
250    }
251
252    #[test]
253    fn thinking_level_from_str_invalid_returns_none() {
254        assert_eq!(ThinkingLevel::from_str_insensitive("turbo"), None);
255        assert_eq!(ThinkingLevel::from_str_insensitive(""), None);
256        assert_eq!(ThinkingLevel::from_str_insensitive("super-high"), None);
257    }
258
259    // ── Directive parsing ────────────────────────────────────────
260
261    #[test]
262    fn parse_directive_extracts_level_and_remaining_message() {
263        let result = parse_thinking_directive("/think:high What is Rust?");
264        assert!(result.is_some());
265        let (level, remaining) = result.unwrap();
266        assert_eq!(level, ThinkingLevel::High);
267        assert_eq!(remaining, "What is Rust?");
268    }
269
270    #[test]
271    fn parse_directive_handles_directive_only() {
272        let result = parse_thinking_directive("/think:off");
273        assert!(result.is_some());
274        let (level, remaining) = result.unwrap();
275        assert_eq!(level, ThinkingLevel::Off);
276        assert_eq!(remaining, "");
277    }
278
279    #[test]
280    fn parse_directive_strips_leading_whitespace() {
281        let result = parse_thinking_directive("  /think:low  Tell me about Rust");
282        assert!(result.is_some());
283        let (level, remaining) = result.unwrap();
284        assert_eq!(level, ThinkingLevel::Low);
285        assert_eq!(remaining, "Tell me about Rust");
286    }
287
288    #[test]
289    fn parse_directive_returns_none_for_no_directive() {
290        assert!(parse_thinking_directive("Hello world").is_none());
291        assert!(parse_thinking_directive("").is_none());
292        assert!(parse_thinking_directive("/think").is_none());
293    }
294
295    #[test]
296    fn parse_directive_returns_none_for_invalid_level() {
297        assert!(parse_thinking_directive("/think:turbo What?").is_none());
298    }
299
300    #[test]
301    fn parse_directive_not_triggered_mid_message() {
302        assert!(parse_thinking_directive("Hello /think:high world").is_none());
303    }
304
305    // ── Level application ────────────────────────────────────────
306
307    #[test]
308    fn apply_thinking_level_off_is_concise() {
309        let params = apply_thinking_level(ThinkingLevel::Off);
310        assert!(params.temperature_adjustment < 0.0);
311        assert!(params.max_tokens_adjustment < 0);
312        assert!(params.system_prompt_prefix.is_some());
313        assert!(
314            params
315                .system_prompt_prefix
316                .unwrap()
317                .to_lowercase()
318                .contains("concise")
319        );
320    }
321
322    #[test]
323    fn apply_thinking_level_medium_is_neutral() {
324        let params = apply_thinking_level(ThinkingLevel::Medium);
325        assert!((params.temperature_adjustment - 0.0).abs() < f64::EPSILON);
326        assert_eq!(params.max_tokens_adjustment, 0);
327        assert!(params.system_prompt_prefix.is_none());
328    }
329
330    #[test]
331    fn apply_thinking_level_high_adds_step_by_step() {
332        let params = apply_thinking_level(ThinkingLevel::High);
333        assert!(params.temperature_adjustment > 0.0);
334        assert!(params.max_tokens_adjustment > 0);
335        let prefix = params.system_prompt_prefix.unwrap();
336        assert!(prefix.to_lowercase().contains("step by step"));
337    }
338
339    #[test]
340    fn apply_thinking_level_max_is_most_thorough() {
341        let params = apply_thinking_level(ThinkingLevel::Max);
342        assert!(params.temperature_adjustment > 0.0);
343        assert!(params.max_tokens_adjustment > 0);
344        let prefix = params.system_prompt_prefix.unwrap();
345        assert!(prefix.to_lowercase().contains("exhaustively"));
346    }
347
348    // ── Resolution hierarchy ─────────────────────────────────────
349
350    #[test]
351    fn resolve_inline_directive_takes_priority() {
352        let config = ThinkingConfig {
353            default_level: ThinkingLevel::Low,
354        };
355        let result =
356            resolve_thinking_level(Some(ThinkingLevel::Max), Some(ThinkingLevel::High), &config);
357        assert_eq!(result, ThinkingLevel::Max);
358    }
359
360    #[test]
361    fn resolve_session_override_takes_priority_over_config() {
362        let config = ThinkingConfig {
363            default_level: ThinkingLevel::Low,
364        };
365        let result = resolve_thinking_level(None, Some(ThinkingLevel::High), &config);
366        assert_eq!(result, ThinkingLevel::High);
367    }
368
369    #[test]
370    fn resolve_falls_back_to_config_default() {
371        let config = ThinkingConfig {
372            default_level: ThinkingLevel::Minimal,
373        };
374        let result = resolve_thinking_level(None, None, &config);
375        assert_eq!(result, ThinkingLevel::Minimal);
376    }
377
378    #[test]
379    fn resolve_default_config_uses_medium() {
380        let config = ThinkingConfig::default();
381        let result = resolve_thinking_level(None, None, &config);
382        assert_eq!(result, ThinkingLevel::Medium);
383    }
384
385    // ── Temperature clamping ─────────────────────────────────────
386
387    #[test]
388    fn clamp_temperature_within_range() {
389        assert!((clamp_temperature(0.7) - 0.7).abs() < f64::EPSILON);
390        assert!((clamp_temperature(0.0) - 0.0).abs() < f64::EPSILON);
391        assert!((clamp_temperature(2.0) - 2.0).abs() < f64::EPSILON);
392    }
393
394    #[test]
395    fn clamp_temperature_below_minimum() {
396        assert!((clamp_temperature(-0.5) - 0.0).abs() < f64::EPSILON);
397    }
398
399    #[test]
400    fn clamp_temperature_above_maximum() {
401        assert!((clamp_temperature(3.0) - 2.0).abs() < f64::EPSILON);
402    }
403
404    // ── Serde round-trip ─────────────────────────────────────────
405
406    #[test]
407    fn thinking_config_deserializes_from_toml() {
408        let toml_str = r#"default_level = "high""#;
409        let config: ThinkingConfig = toml::from_str(toml_str).unwrap();
410        assert_eq!(config.default_level, ThinkingLevel::High);
411    }
412
413    #[test]
414    fn thinking_config_default_level_deserializes() {
415        let toml_str = "";
416        let config: ThinkingConfig = toml::from_str(toml_str).unwrap();
417        assert_eq!(config.default_level, ThinkingLevel::Medium);
418    }
419
420    #[test]
421    fn thinking_level_serializes_lowercase() {
422        let level = ThinkingLevel::High;
423        let json = serde_json::to_string(&level).unwrap();
424        assert_eq!(json, "\"high\"");
425    }
426}