Skip to main content

gate4agent_adapters/
capability.rs

1use gate4agent_types::{AdapterId, CapabilityModelSummary, CAPABILITY_MODELS_MAX};
2use std::collections::BTreeSet;
3use thiserror::Error;
4
5pub const CAPABILITY_PROBE_REVISION: &str = "gate4agent-capability-probe/orca-d8629c4/v1";
6pub const CAPABILITY_PROBE_OUTPUT_MAX_BYTES: usize = 4 * 1024 * 1024;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct CapabilityProbePlan {
10    pub program: String,
11    pub args: Vec<String>,
12}
13
14pub fn capability_probe_plan(
15    adapter_id: &AdapterId,
16) -> Result<CapabilityProbePlan, CapabilityProbeAdapterError> {
17    match adapter_id.as_str() {
18        // Pinned Orca's native-chat path delegates to commit-message model
19        // discovery, whose executed Cursor contract is `--list-models`.
20        "cursor" => Ok(CapabilityProbePlan {
21            program: "cursor-agent".to_owned(),
22            args: vec!["--list-models".to_owned()],
23        }),
24        id => Err(CapabilityProbeAdapterError::UnsupportedAdapter(
25            id.to_owned(),
26        )),
27    }
28}
29
30pub fn parse_capability_models(
31    adapter_id: &AdapterId,
32    stdout: &str,
33) -> Result<Vec<CapabilityModelSummary>, CapabilityProbeAdapterError> {
34    capability_probe_plan(adapter_id)?;
35    if stdout.len() > CAPABILITY_PROBE_OUTPUT_MAX_BYTES {
36        return Err(CapabilityProbeAdapterError::OutputTooLarge);
37    }
38
39    let mut seen = BTreeSet::new();
40    let mut models = Vec::new();
41    for raw_line in stdout.lines() {
42        let line = raw_line.trim();
43        let Some(separator) = model_separator(line) else {
44            continue;
45        };
46        let id = line[..separator].trim();
47        let label = strip_cursor_status_suffix(line[separator + 1..].trim());
48        let model = CapabilityModelSummary {
49            id: id.to_owned(),
50            label: label.to_owned(),
51        };
52        if model.validate().is_err() || !seen.insert(model.id.clone()) {
53            continue;
54        }
55        models.push(model);
56        if models.len() == CAPABILITY_MODELS_MAX {
57            break;
58        }
59    }
60    Ok(models)
61}
62
63fn model_separator(line: &str) -> Option<usize> {
64    line.char_indices().find_map(|(index, character)| {
65        if character != '-' {
66            return None;
67        }
68        let before = line[..index].chars().next_back()?;
69        let after = line[index + 1..].chars().next()?;
70        (before.is_whitespace() && after.is_whitespace()).then_some(index)
71    })
72}
73
74fn strip_cursor_status_suffix(label: &str) -> &str {
75    for suffix in ["(default)", "(current)"] {
76        if label.len() >= suffix.len()
77            && label[label.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
78        {
79            let prefix = &label[..label.len() - suffix.len()];
80            if prefix.chars().next_back().is_some_and(char::is_whitespace) {
81                return prefix.trim_end();
82            }
83        }
84    }
85    label
86}
87
88#[derive(Clone, Debug, Error, Eq, PartialEq)]
89pub enum CapabilityProbeAdapterError {
90    #[error("capability probe adapter is unavailable for {0}")]
91    UnsupportedAdapter(String),
92    #[error("capability probe output exceeds its byte bound")]
93    OutputTooLarge,
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn cursor() -> AdapterId {
101        AdapterId::new("cursor").unwrap()
102    }
103
104    #[test]
105    fn cursor_uses_the_actually_executed_orca_probe_contract() {
106        assert_eq!(
107            capability_probe_plan(&cursor()).unwrap(),
108            CapabilityProbePlan {
109                program: "cursor-agent".to_owned(),
110                args: vec!["--list-models".to_owned()],
111            }
112        );
113    }
114
115    #[test]
116    fn cursor_models_preserve_live_labels_and_drop_status_suffixes() {
117        let models = parse_capability_models(
118            &cursor(),
119            "noise\nauto - Auto (default)\ngpt-5.3-codex - GPT-5.3 Codex\ntab-model - Tab model\t(current)\nauto - Duplicate\n",
120        )
121        .unwrap();
122        assert_eq!(
123            models,
124            vec![
125                CapabilityModelSummary {
126                    id: "auto".to_owned(),
127                    label: "Auto".to_owned(),
128                },
129                CapabilityModelSummary {
130                    id: "gpt-5.3-codex".to_owned(),
131                    label: "GPT-5.3 Codex".to_owned(),
132                },
133                CapabilityModelSummary {
134                    id: "tab-model".to_owned(),
135                    label: "Tab model".to_owned(),
136                },
137            ]
138        );
139    }
140}