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        // Fall back to the bare slug in two cases:
232        // 1. The model has no effort-suffixed variants at all in the probe list — it simply
233        //    doesn't support effort tiers (e.g. bare "composer").
234        // 2. The model is composer-prefixed with partial variant support — compositor models
235        //    expose some tiers but not all, so we accept any effort and fall back gracefully.
236        let has_effort_variants = prefix_matches
237            .iter()
238            .any(|slug| normalize_slug(slug) != normalized_model);
239
240        let allow_bare =
241            !has_effort_variants || cursor_effort_allows_bare_fallback(&normalized_model);
242
243        if allow_bare
244            && let Some(bare_slug) = prefix_matches
245                .iter()
246                .find(|slug| normalize_slug(slug) == normalized_model)
247        {
248            return Ok(CursorEffortResolution {
249                slug: (*bare_slug).to_string(),
250                candidate_slugs,
251            });
252        }
253
254        return Err(CursorEffortResolutionError::NoEffortMatch {
255            model_id: model_id.to_string(),
256            effort: effort.to_string(),
257        });
258    }
259
260    let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
261
262    Ok(CursorEffortResolution {
263        slug: chosen.to_string(),
264        candidate_slugs,
265    })
266}
267
268/// Cursor often exposes the default effort tier as an unsuffixed slug (e.g. `gpt-5.5`),
269/// not `gpt-5.5-medium`. Treat medium/none/auto as that default tier.
270fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
271    matches!(normalized_effort, "auto" | "default" | "medium" | "none")
272}
273
274/// Composer-prefixed models (e.g. `composer-2.5`) expose some effort tiers but not all.
275/// Allow bare-slug fallback when the exact requested tier is missing, rather than erroring.
276/// Models with *no* effort variants at all are handled by the `has_effort_variants` check above.
277fn cursor_effort_allows_bare_fallback(normalized_model: &str) -> bool {
278    normalized_model.starts_with("composer-")
279}
280
281fn slug_matches_effort(
282    normalized_model: &str,
283    normalized_slug: &str,
284    normalized_effort: &str,
285) -> bool {
286    if normalized_slug == normalized_model {
287        return cursor_effort_is_default_tier(normalized_effort);
288    }
289
290    let Some(suffix) = normalized_slug
291        .strip_prefix(normalized_model)
292        .and_then(|rest| rest.strip_prefix('-'))
293    else {
294        return false;
295    };
296
297    suffix == normalized_effort
298        || suffix.ends_with(&format!("-{normalized_effort}"))
299        || suffix.contains(&format!("-{normalized_effort}-"))
300}
301
302fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
303    if matches.len() == 1 {
304        return matches[0];
305    }
306
307    if normalized_model.starts_with("claude")
308        && let Some(thinking) = matches
309            .iter()
310            .copied()
311            .find(|slug| normalize_slug(slug).contains("-thinking-"))
312    {
313        return thinking;
314    }
315
316    matches[0]
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_parse_models_basic() {
325        let output = r#"gpt-5.5-high - GPT 5.5 (High)
326gpt-5.5-low - GPT 5.5 (Low)
327claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
328
329        let slugs = parse_cursor_models_output(output);
330
331        assert_eq!(
332            slugs,
333            vec![
334                "gpt-5.5-high".to_string(),
335                "gpt-5.5-low".to_string(),
336                "claude-opus-4-7-thinking-high".to_string()
337            ]
338        );
339    }
340
341    #[test]
342    fn test_parse_models_filters_fast() {
343        let output = r#"gpt-5.5-high - GPT 5.5 (High)
344gpt-5.5-fast - GPT 5.5 (Fast)"#;
345        let slugs = parse_cursor_models_output(output);
346        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
347    }
348
349    #[test]
350    fn test_parse_models_skips_header_and_tip() {
351        let output = r#"Available models
352
353gpt-5.5-high - GPT 5.5 (High)
354
355Tip: use --model <id> to select"#;
356        let slugs = parse_cursor_models_output(output);
357        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
358    }
359
360    #[test]
361    fn test_parse_models_strips_ansi() {
362        let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
363        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
364    }
365
366    #[test]
367    fn test_find_prefix_matches() {
368        let slugs = vec![
369            "gpt-5.5-high".to_string(),
370            "gpt-5.5-low".to_string(),
371            "claude-opus-4-7".to_string(),
372        ];
373        let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
374        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
375    }
376
377    #[test]
378    fn test_find_prefix_matches_requires_boundary() {
379        let slugs = vec![
380            "gpt-5.5-high".to_string(),
381            "gpt-55-high".to_string(),
382            "gpt-5".to_string(),
383        ];
384
385        let matches = find_cursor_prefix_matches("gpt-5", &slugs);
386
387        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
388    }
389
390    #[test]
391    fn test_normalize_slug() {
392        assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
393    }
394
395    #[test]
396    fn test_resolve_effort_slug_openai() {
397        let slugs = vec![
398            "gpt-5.5-high".to_string(),
399            "gpt-5.5-low".to_string(),
400            "gpt-55-high".to_string(),
401        ];
402        let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
403        assert_eq!(resolution.slug, "gpt-5.5-high");
404    }
405
406    #[test]
407    fn test_resolve_effort_slug_prefers_thinking_for_claude() {
408        let slugs = vec![
409            "claude-opus-4-7-high".to_string(),
410            "claude-opus-4-7-thinking-high".to_string(),
411        ];
412        let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
413        assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
414    }
415
416    #[test]
417    fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
418        let slugs = vec![
419            "gpt-5.5".to_string(),
420            "gpt-5.5-high".to_string(),
421            "gpt-5.5-low".to_string(),
422        ];
423        for effort in ["medium", "none", "auto"] {
424            let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
425            assert_eq!(
426                resolution.slug, "gpt-5.5",
427                "effort {effort} should resolve to base slug"
428            );
429        }
430    }
431
432    #[test]
433    fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
434        let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
435        let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
436        assert_eq!(
437            err,
438            CursorEffortResolutionError::NoEffortMatch {
439                model_id: "gpt-5.5".to_string(),
440                effort: "medium".to_string(),
441            }
442        );
443    }
444
445    #[test]
446    fn test_resolve_effort_slug_no_effort_match() {
447        let slugs = vec!["gpt-5.5-low".to_string()];
448        let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
449        assert_eq!(
450            err,
451            CursorEffortResolutionError::NoEffortMatch {
452                model_id: "gpt-5.5".to_string(),
453                effort: "high".to_string(),
454            }
455        );
456    }
457
458    #[test]
459    fn test_resolve_effort_slug_bare_model_no_variants_falls_back() {
460        // "composer" has no effort-suffixed variants — any effort request falls back to bare slug.
461        let slugs = vec!["composer".to_string()];
462        for effort in ["high", "low", "medium", "none"] {
463            let resolution = resolve_cursor_effort_slug("composer", effort, &slugs).unwrap();
464            assert_eq!(
465                resolution.slug, "composer",
466                "effort={effort} should fall back to bare composer slug"
467            );
468        }
469    }
470
471    #[test]
472    fn test_resolve_effort_slug_composer_falls_back_to_bare_slug() {
473        let slugs = vec!["composer-2.5".to_string(), "composer-2.5-low".to_string()];
474        let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
475        assert_eq!(resolution.slug, "composer-2.5");
476    }
477
478    #[test]
479    fn test_resolve_effort_slug_composer_prefers_exact_effort_slug_when_present() {
480        let slugs = vec![
481            "composer-2.5".to_string(),
482            "composer-2.5-low".to_string(),
483            "composer-2.5-high".to_string(),
484        ];
485        let resolution = resolve_cursor_effort_slug("composer-2.5", "high", &slugs).unwrap();
486        assert_eq!(resolution.slug, "composer-2.5-high");
487    }
488
489    #[test]
490    fn test_probe_result_round_trip() {
491        let result = CursorProbeResult {
492            slugs: vec!["gpt-5.5-high".to_string()],
493            model_probe_success: true,
494            error: None,
495        };
496        let json = serde_json::to_string(&result).unwrap();
497        let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
498        assert_eq!(back.slugs, result.slugs);
499        assert!(back.model_probe_success);
500        assert_eq!(back.error, None);
501    }
502}