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
196    if cursor_effort_is_default_tier(&normalized_effort) {
197        if let Some(bare_slug) = prefix_matches
198            .iter()
199            .find(|slug| normalize_slug(slug) == normalized_model)
200        {
201            let candidate_slugs = slugs
202                .iter()
203                .filter(|slug| {
204                    let normalized_slug = normalize_slug(slug);
205                    normalized_slug == normalized_model
206                        || normalized_slug.starts_with(&format!("{normalized_model}-"))
207                })
208                .cloned()
209                .collect();
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        .into_iter()
224        .filter(|slug| {
225            slug_matches_effort(&normalized_model, &normalize_slug(slug), &normalized_effort)
226        })
227        .collect();
228
229    if effort_matches.is_empty() {
230        return Err(CursorEffortResolutionError::NoEffortMatch {
231            model_id: model_id.to_string(),
232            effort: effort.to_string(),
233        });
234    }
235
236    let chosen = choose_cursor_effort_slug(&normalized_model, effort_matches);
237    let candidate_slugs = slugs
238        .iter()
239        .filter(|slug| {
240            let normalized_slug = normalize_slug(slug);
241            normalized_slug == normalized_model
242                || normalized_slug.starts_with(&format!("{normalized_model}-"))
243        })
244        .cloned()
245        .collect();
246
247    Ok(CursorEffortResolution {
248        slug: chosen.to_string(),
249        candidate_slugs,
250    })
251}
252
253/// Cursor often exposes the default effort tier as an unsuffixed slug (e.g. `gpt-5.5`),
254/// not `gpt-5.5-medium`. Treat medium/none/auto as that default tier.
255fn cursor_effort_is_default_tier(normalized_effort: &str) -> bool {
256    matches!(normalized_effort, "auto" | "default" | "medium" | "none")
257}
258
259fn slug_matches_effort(
260    normalized_model: &str,
261    normalized_slug: &str,
262    normalized_effort: &str,
263) -> bool {
264    if normalized_slug == normalized_model {
265        return cursor_effort_is_default_tier(normalized_effort);
266    }
267
268    let Some(suffix) = normalized_slug
269        .strip_prefix(normalized_model)
270        .and_then(|rest| rest.strip_prefix('-'))
271    else {
272        return false;
273    };
274
275    suffix == normalized_effort
276        || suffix.ends_with(&format!("-{normalized_effort}"))
277        || suffix.contains(&format!("-{normalized_effort}-"))
278}
279
280fn choose_cursor_effort_slug<'a>(normalized_model: &str, matches: Vec<&'a str>) -> &'a str {
281    if matches.len() == 1 {
282        return matches[0];
283    }
284
285    if normalized_model.starts_with("claude")
286        && let Some(thinking) = matches
287            .iter()
288            .copied()
289            .find(|slug| normalize_slug(slug).contains("-thinking-"))
290    {
291        return thinking;
292    }
293
294    matches[0]
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_parse_models_basic() {
303        let output = r#"gpt-5.5-high - GPT 5.5 (High)
304gpt-5.5-low - GPT 5.5 (Low)
305claude-opus-4-7-thinking-high - Claude Opus 4.7"#;
306
307        let slugs = parse_cursor_models_output(output);
308
309        assert_eq!(
310            slugs,
311            vec![
312                "gpt-5.5-high".to_string(),
313                "gpt-5.5-low".to_string(),
314                "claude-opus-4-7-thinking-high".to_string()
315            ]
316        );
317    }
318
319    #[test]
320    fn test_parse_models_filters_fast() {
321        let output = r#"gpt-5.5-high - GPT 5.5 (High)
322gpt-5.5-fast - GPT 5.5 (Fast)"#;
323        let slugs = parse_cursor_models_output(output);
324        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
325    }
326
327    #[test]
328    fn test_parse_models_skips_header_and_tip() {
329        let output = r#"Available models
330
331gpt-5.5-high - GPT 5.5 (High)
332
333Tip: use --model <id> to select"#;
334        let slugs = parse_cursor_models_output(output);
335        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
336    }
337
338    #[test]
339    fn test_parse_models_strips_ansi() {
340        let slugs = parse_cursor_models_output("\x1b[32mgpt-5.5-high - GPT 5.5\x1b[0m");
341        assert_eq!(slugs, vec!["gpt-5.5-high".to_string()]);
342    }
343
344    #[test]
345    fn test_find_prefix_matches() {
346        let slugs = vec![
347            "gpt-5.5-high".to_string(),
348            "gpt-5.5-low".to_string(),
349            "claude-opus-4-7".to_string(),
350        ];
351        let matches = find_cursor_prefix_matches("gpt-5.5", &slugs);
352        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5.5-low"]);
353    }
354
355    #[test]
356    fn test_find_prefix_matches_requires_boundary() {
357        let slugs = vec![
358            "gpt-5.5-high".to_string(),
359            "gpt-55-high".to_string(),
360            "gpt-5".to_string(),
361        ];
362
363        let matches = find_cursor_prefix_matches("gpt-5", &slugs);
364
365        assert_eq!(matches, vec!["gpt-5.5-high", "gpt-5"]);
366    }
367
368    #[test]
369    fn test_normalize_slug() {
370        assert_eq!(normalize_slug("GPT.5.5-High"), "gpt-5-5-high");
371    }
372
373    #[test]
374    fn test_resolve_effort_slug_openai() {
375        let slugs = vec![
376            "gpt-5.5-high".to_string(),
377            "gpt-5.5-low".to_string(),
378            "gpt-55-high".to_string(),
379        ];
380        let resolution = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap();
381        assert_eq!(resolution.slug, "gpt-5.5-high");
382    }
383
384    #[test]
385    fn test_resolve_effort_slug_prefers_thinking_for_claude() {
386        let slugs = vec![
387            "claude-opus-4-7-high".to_string(),
388            "claude-opus-4-7-thinking-high".to_string(),
389        ];
390        let resolution = resolve_cursor_effort_slug("claude-opus-4-7", "high", &slugs).unwrap();
391        assert_eq!(resolution.slug, "claude-opus-4-7-thinking-high");
392    }
393
394    #[test]
395    fn test_resolve_effort_slug_medium_uses_unsuffixed_base_slug() {
396        let slugs = vec![
397            "gpt-5.5".to_string(),
398            "gpt-5.5-high".to_string(),
399            "gpt-5.5-low".to_string(),
400        ];
401        for effort in ["medium", "none", "auto"] {
402            let resolution = resolve_cursor_effort_slug("gpt-5.5", effort, &slugs).unwrap();
403            assert_eq!(
404                resolution.slug, "gpt-5.5",
405                "effort {effort} should resolve to base slug"
406            );
407        }
408    }
409
410    #[test]
411    fn test_resolve_effort_slug_medium_requires_base_slug_in_catalog() {
412        let slugs = vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()];
413        let err = resolve_cursor_effort_slug("gpt-5.5", "medium", &slugs).unwrap_err();
414        assert_eq!(
415            err,
416            CursorEffortResolutionError::NoEffortMatch {
417                model_id: "gpt-5.5".to_string(),
418                effort: "medium".to_string(),
419            }
420        );
421    }
422
423    #[test]
424    fn test_resolve_effort_slug_no_effort_match() {
425        let slugs = vec!["gpt-5.5-low".to_string()];
426        let err = resolve_cursor_effort_slug("gpt-5.5", "high", &slugs).unwrap_err();
427        assert_eq!(
428            err,
429            CursorEffortResolutionError::NoEffortMatch {
430                model_id: "gpt-5.5".to_string(),
431                effort: "high".to_string(),
432            }
433        );
434    }
435
436    #[test]
437    fn test_probe_result_round_trip() {
438        let result = CursorProbeResult {
439            slugs: vec!["gpt-5.5-high".to_string()],
440            model_probe_success: true,
441            error: None,
442        };
443        let json = serde_json::to_string(&result).unwrap();
444        let back: CursorProbeResult = serde_json::from_str(&json).unwrap();
445        assert_eq!(back.slugs, result.slugs);
446        assert!(back.model_probe_success);
447        assert_eq!(back.error, None);
448    }
449}