Skip to main content

mars_agents/models/probes/
cursor.rs

1use std::io::Read;
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4use std::thread;
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use wait_timeout::ChildExt;
9
10/// Result of probing cursor's runtime model catalog.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct CursorProbeResult {
13    /// Raw slug strings from `cursor agent --list-models` (fast variants excluded).
14    pub slugs: Vec<String>,
15    /// Whether the model list probe succeeded.
16    pub model_probe_success: bool,
17    /// Redacted error message if probing failed.
18    pub error: Option<String>,
19}
20
21const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 5;
22
23/// Probe cursor with the configured timeout.
24pub fn probe() -> CursorProbeResult {
25    probe_with_timeout(probe_timeout())
26}
27
28/// Probe cursor with a specific timeout.
29pub fn probe_with_timeout(timeout: Duration) -> CursorProbeResult {
30    let mut result = CursorProbeResult::default();
31
32    match run_command("cursor", &["agent", "--list-models"], timeout) {
33        Ok(stdout) => {
34            result.slugs = parse_cursor_models_output(&stdout);
35            result.model_probe_success = true;
36        }
37        Err(error) => {
38            result.model_probe_success = false;
39            result.error = Some(format!("model probe failed: {error}"));
40        }
41    }
42
43    result
44}
45
46fn probe_timeout() -> Duration {
47    std::env::var("MARS_PROBE_TIMEOUT_SECS")
48        .ok()
49        .and_then(|value| value.parse::<u64>().ok())
50        .map(Duration::from_secs)
51        .unwrap_or(Duration::from_secs(DEFAULT_PROBE_TIMEOUT_SECS))
52}
53
54fn run_command(cmd: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
55    let program = resolve_command(cmd);
56    let mut child = Command::new(&program)
57        .args(args)
58        .stdout(Stdio::piped())
59        .stderr(Stdio::null())
60        .spawn()
61        .map_err(|error| format!("spawn failed: {error}"))?;
62
63    let stdout = child
64        .stdout
65        .take()
66        .ok_or_else(|| "stdout capture unavailable".to_string())?;
67    let stdout_reader = thread::spawn(move || {
68        let mut stdout = stdout;
69        let mut output = Vec::new();
70        stdout
71            .read_to_end(&mut output)
72            .map(|_| output)
73            .map_err(|error| format!("stdout read failed: {error}"))
74    });
75
76    match child
77        .wait_timeout(timeout)
78        .map_err(|error| format!("wait failed: {error}"))?
79    {
80        Some(status) if status.success() => {
81            let stdout = stdout_reader
82                .join()
83                .map_err(|_| "stdout reader panicked".to_string())??;
84            String::from_utf8(stdout).map_err(|error| format!("invalid utf8: {error}"))
85        }
86        Some(status) => {
87            let _ = stdout_reader.join();
88            Err(format!("exit code {}", status.code().unwrap_or(-1)))
89        }
90        None => {
91            let _ = child.kill();
92            let _ = child.wait();
93            let _ = stdout_reader.join();
94            Err("timeout".to_string())
95        }
96    }
97}
98
99fn resolve_command(cmd: &str) -> PathBuf {
100    let resolver = crate::harness::host::PathExecutableResolver;
101    crate::harness::host::resolve_binary_path(cmd, &resolver).unwrap_or_else(|| cmd.into())
102}
103
104fn strip_ansi(s: &str) -> String {
105    let mut result = String::with_capacity(s.len());
106    let mut chars = s.chars().peekable();
107
108    while let Some(ch) = chars.next() {
109        if ch == '\x1b' {
110            while let Some(&next) = chars.peek() {
111                chars.next();
112                if next.is_ascii_alphabetic() {
113                    break;
114                }
115            }
116        } else {
117            result.push(ch);
118        }
119    }
120
121    result
122}
123
124/// Parse `cursor agent --list-models` output into raw slug strings.
125fn parse_cursor_models_output(stdout: &str) -> Vec<String> {
126    stdout
127        .lines()
128        .filter_map(|line| {
129            let clean = strip_ansi(line.trim());
130            if clean.is_empty()
131                || clean.eq_ignore_ascii_case("available models")
132                || clean.starts_with("Tip:")
133            {
134                return None;
135            }
136
137            let (slug, _) = clean.split_once(" - ")?;
138            let slug = slug.trim();
139            if slug.is_empty() || slug.ends_with("-fast") {
140                return None;
141            }
142
143            Some(slug.to_string())
144        })
145        .collect()
146}
147
148pub fn normalize_slug(s: &str) -> String {
149    s.to_ascii_lowercase().replace('.', "-")
150}
151
152pub fn find_cursor_prefix_matches<'a>(model_id: &str, slugs: &'a [String]) -> Vec<&'a str> {
153    let normalized_model = normalize_slug(model_id);
154    slugs
155        .iter()
156        .filter(|slug| {
157            let normalized_slug = normalize_slug(slug);
158            normalized_slug == normalized_model
159                || normalized_slug.starts_with(&format!("{normalized_model}-"))
160        })
161        .map(String::as_str)
162        .collect()
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct CursorEffortResolution {
167    pub slug: String,
168    pub candidate_slugs: Vec<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum CursorEffortResolutionError {
173    NoProbeSlugs,
174    NoModelPrefixMatch,
175    NoEffortMatch { model_id: String, effort: String },
176}
177
178/// Resolve a Cursor catalog slug from base model id + effort tier.
179pub fn resolve_cursor_effort_slug(
180    model_id: &str,
181    effort: &str,
182    slugs: &[String],
183) -> Result<CursorEffortResolution, CursorEffortResolutionError> {
184    if slugs.is_empty() {
185        return Err(CursorEffortResolutionError::NoProbeSlugs);
186    }
187
188    let prefix_matches = find_cursor_prefix_matches(model_id, slugs);
189    if prefix_matches.is_empty() {
190        return Err(CursorEffortResolutionError::NoModelPrefixMatch);
191    }
192
193    let normalized_model = normalize_slug(model_id);
194    let normalized_effort = normalize_slug(effort);
195    let candidate_slugs: Vec<String> = slugs
196        .iter()
197        .filter(|slug| {
198            let normalized_slug = normalize_slug(slug);
199            normalized_slug == normalized_model
200                || normalized_slug.starts_with(&format!("{normalized_model}-"))
201        })
202        .cloned()
203        .collect();
204
205    if cursor_effort_is_default_tier(&normalized_effort) {
206        if let Some(bare_slug) = prefix_matches
207            .iter()
208            .find(|slug| normalize_slug(slug) == normalized_model)
209        {
210            return Ok(CursorEffortResolution {
211                slug: (*bare_slug).to_string(),
212                candidate_slugs,
213            });
214        }
215
216        return Err(CursorEffortResolutionError::NoEffortMatch {
217            model_id: model_id.to_string(),
218            effort: effort.to_string(),
219        });
220    }
221
222    let effort_matches: Vec<&str> = prefix_matches
223        .iter()
224        .copied()
225        .filter(|slug| {
226            slug_matches_effort(&normalized_model, &normalize_slug(slug), &normalized_effort)
227        })
228        .collect();
229
230    if effort_matches.is_empty() {
231        if cursor_effort_allows_bare_fallback(&normalized_model)
232            && let Some(bare_slug) = prefix_matches
233                .iter()
234                .find(|slug| normalize_slug(slug) == normalized_model)
235        {
236            return Ok(CursorEffortResolution {
237                slug: (*bare_slug).to_string(),
238                candidate_slugs,
239            });
240        }
241
242        return Err(CursorEffortResolutionError::NoEffortMatch {
243            model_id: model_id.to_string(),
244            effort: effort.to_string(),
245        });
246    }
247
248    let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
249
250    Ok(CursorEffortResolution {
251        slug: chosen.to_string(),
252        candidate_slugs,
253    })
254}
255
256/// Cursor often exposes the default effort tier as an unsuffixed slug (e.g. `gpt-5.5`),
257/// not `gpt-5.5-medium`. Treat medium/none/auto as that default tier.
258fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
259    matches!(normalized_effort, "auto" | "default" | "medium" | "none")
260}
261
262fn cursor_effort_allows_bare_fallback(normalized_model: &str) -> bool {
263    normalized_model.starts_with("composer-")
264}
265
266fn slug_matches_effort(
267    normalized_model: &str,
268    normalized_slug: &str,
269    normalized_effort: &str,
270) -> bool {
271    if normalized_slug == normalized_model {
272        return cursor_effort_is_default_tier(normalized_effort);
273    }
274
275    let Some(suffix) = normalized_slug
276        .strip_prefix(normalized_model)
277        .and_then(|rest| rest.strip_prefix('-'))
278    else {
279        return false;
280    };
281
282    suffix == normalized_effort
283        || suffix.ends_with(&format!("-{normalized_effort}"))
284        || suffix.contains(&format!("-{normalized_effort}-"))
285}
286
287fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
288    if matches.len() == 1 {
289        return matches[0];
290    }
291
292    if normalized_model.starts_with("claude")
293        && let Some(thinking) = matches
294            .iter()
295            .copied()
296            .find(|slug| normalize_slug(slug).contains("-thinking-"))
297    {
298        return thinking;
299    }
300
301    matches[0]
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_parse_models_basic() {
310        let output = r#"gpt-5.5-high - GPT 5.5 (High)
311gpt-5.5-low - GPT 5.5 (Low)
312claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
313
314        let slugs = parse_cursor_models_output(output);
315
316        assert_eq!(
317            slugs,
318            vec![
319                "gpt-5.5-high".to_string(),
320                "gpt-5.5-low".to_string(),
321                "claude-opus-4-7-thinking-high".to_string()
322            ]
323        );
324    }
325
326    #[test]
327    fn test_parse_models_filters_fast() {
328        let output = r#"gpt-5.5-high - GPT 5.5 (High)
329gpt-5.5-fast - GPT 5.5 (Fast)"#;
330        let slugs = parse_cursor_models_output(output);
331        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
332    }
333
334    #[test]
335    fn test_parse_models_skips_header_and_tip() {
336        let output = r#"Available models
337
338gpt-5.5-high - GPT 5.5 (High)
339
340Tip: use --model <id> to select"#;
341        let slugs = parse_cursor_models_output(output);
342        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
343    }
344
345    #[test]
346    fn test_parse_models_strips_ansi() {
347        let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
348        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
349    }
350
351    #[test]
352    fn test_find_prefix_matches() {
353        let slugs = vec![
354            "gpt-5.5-high".to_string(),
355            "gpt-5.5-low".to_string(),
356            "claude-opus-4-7".to_string(),
357        ];
358        let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
359        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
360    }
361
362    #[test]
363    fn test_find_prefix_matches_requires_boundary() {
364        let slugs = vec![
365            "gpt-5.5-high".to_string(),
366            "gpt-55-high".to_string(),
367            "gpt-5".to_string(),
368        ];
369
370        let matches = find_cursor_prefix_matches("gpt-5", &slugs);
371
372        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
373    }
374
375    #[test]
376    fn test_normalize_slug() {
377        assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
378    }
379
380    #[test]
381    fn test_resolve_effort_slug_openai() {
382        let slugs = vec![
383            "gpt-5.5-high".to_string(),
384            "gpt-5.5-low".to_string(),
385            "gpt-55-high".to_string(),
386        ];
387        let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
388        assert_eq!(resolution.slug, "gpt-5.5-high");
389    }
390
391    #[test]
392    fn test_resolve_effort_slug_prefers_thinking_for_claude() {
393        let slugs = vec![
394            "claude-opus-4-7-high".to_string(),
395            "claude-opus-4-7-thinking-high".to_string(),
396        ];
397        let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
398        assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
399    }
400
401    #[test]
402    fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
403        let slugs = vec![
404            "gpt-5.5".to_string(),
405            "gpt-5.5-high".to_string(),
406            "gpt-5.5-low".to_string(),
407        ];
408        for effort in ["medium", "none", "auto"] {
409            let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
410            assert_eq!(
411                resolution.slug, "gpt-5.5",
412                "effort {effort} should resolve to base slug"
413            );
414        }
415    }
416
417    #[test]
418    fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
419        let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
420        let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
421        assert_eq!(
422            err,
423            CursorEffortResolutionError::NoEffortMatch {
424                model_id: "gpt-5.5".to_string(),
425                effort: "medium".to_string(),
426            }
427        );
428    }
429
430    #[test]
431    fn test_resolve_effort_slug_no_effort_match() {
432        let slugs = vec!["gpt-5.5-low".to_string()];
433        let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
434        assert_eq!(
435            err,
436            CursorEffortResolutionError::NoEffortMatch {
437                model_id: "gpt-5.5".to_string(),
438                effort: "high".to_string(),
439            }
440        );
441    }
442
443    #[test]
444    fn test_resolve_effort_slug_composer_falls_back_to_bare_slug() {
445        let slugs = vec!["composer-2.5".to_string(), "composer-2.5-low".to_string()];
446        let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
447        assert_eq!(resolution.slug, "composer-2.5");
448    }
449
450    #[test]
451    fn test_resolve_effort_slug_composer_prefers_exact_effort_slug_when_present() {
452        let slugs = vec![
453            "composer-2.5".to_string(),
454            "composer-2.5-low".to_string(),
455            "composer-2.5-high".to_string(),
456        ];
457        let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
458        assert_eq!(resolution.slug, "composer-2.5-high");
459    }
460
461    #[test]
462    fn test_probe_result_round_trip() {
463        let result = CursorProbeResult {
464            slugs: vec!["gpt-5.5-high".to_string()],
465            model_probe_success: true,
466            error: None,
467        };
468        let json = serde_json::to_string(&result).unwrap();
469        let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
470        assert_eq!(back.slugs, result.slugs);
471        assert!(back.model_probe_success);
472        assert_eq!(back.error, None);
473    }
474}