Skip to main content

aster/providers/
cursor_agent.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use rmcp::model::Role;
4use serde_json::{json, Value};
5use std::ffi::OsString;
6use std::path::PathBuf;
7use std::process::Stdio;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10
11use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
12use super::errors::ProviderError;
13use super::utils::{filter_extensions_from_system_prompt, RequestLog};
14use crate::config::base::CursorAgentCommand;
15use crate::config::search_path::SearchPaths;
16use crate::conversation::message::{Message, MessageContent};
17use crate::model::ModelConfig;
18use crate::subprocess::configure_command_no_window;
19use rmcp::model::Tool;
20
21pub const CURSOR_AGENT_DEFAULT_MODEL: &str = "auto";
22pub const CURSOR_AGENT_KNOWN_MODELS: &[&str] = &["auto", "gpt-5", "opus-4.1", "sonnet-4"];
23
24pub const CURSOR_AGENT_DOC_URL: &str = "https://docs.cursor.com/en/cli/overview";
25
26#[derive(Debug, serde::Serialize)]
27pub struct CursorAgentProvider {
28    command: PathBuf,
29    model: ModelConfig,
30    #[serde(skip)]
31    name: String,
32}
33
34impl CursorAgentProvider {
35    pub async fn from_env(model: ModelConfig) -> Result<Self> {
36        let config = crate::config::Config::global();
37        let command: OsString = config.get_cursor_agent_command().unwrap_or_default().into();
38        let resolved_command = SearchPaths::builder().with_npm().resolve(command)?;
39
40        Ok(Self {
41            command: resolved_command,
42            model,
43            name: Self::metadata().name,
44        })
45    }
46
47    /// Get authentication status from cursor-agent
48    async fn get_authentication_status(&self) -> bool {
49        Command::new(&self.command)
50            .arg("status")
51            .output()
52            .await
53            .ok()
54            .map(|output| String::from_utf8_lossy(&output.stdout).contains("✓ Logged in as"))
55            .unwrap_or(false)
56    }
57
58    /// Convert aster messages to a simple prompt format for cursor-agent CLI
59    fn messages_to_cursor_agent_format(&self, system: &str, messages: &[Message]) -> String {
60        let mut full_prompt = String::new();
61
62        let filtered_system = filter_extensions_from_system_prompt(system);
63        full_prompt.push_str(&filtered_system);
64        full_prompt.push_str("\n\n");
65
66        // Add conversation history
67        for message in messages.iter().filter(|m| m.is_agent_visible()) {
68            let role_prefix = match message.role {
69                Role::User => "Human: ",
70                Role::Assistant => "Assistant: ",
71            };
72            full_prompt.push_str(role_prefix);
73
74            for content in &message.content {
75                match content {
76                    MessageContent::Text(text_content) => {
77                        full_prompt.push_str(&text_content.text);
78                        full_prompt.push('\n');
79                    }
80                    MessageContent::ToolRequest(tool_request) => {
81                        if let Ok(tool_call) = &tool_request.tool_call {
82                            full_prompt.push_str(&format!(
83                                "Tool Use: {} with args: {:?}\n",
84                                tool_call.name, tool_call.arguments
85                            ));
86                        }
87                    }
88                    MessageContent::ToolResponse(tool_response) => {
89                        if let Ok(result) = &tool_response.tool_result {
90                            let content_text = result
91                                .content
92                                .iter()
93                                .filter_map(|content| match &content.raw {
94                                    rmcp::model::RawContent::Text(text_content) => {
95                                        Some(text_content.text.as_str())
96                                    }
97                                    _ => None,
98                                })
99                                .collect::<Vec<&str>>()
100                                .join("\n");
101
102                            full_prompt.push_str(&format!("Tool Result: {}\n", content_text));
103                        }
104                    }
105                    _ => {
106                        // Skip other content types for now
107                    }
108                }
109            }
110            full_prompt.push('\n');
111        }
112
113        full_prompt.push_str("Assistant: ");
114        full_prompt
115    }
116
117    /// Parse the JSON response from cursor-agent CLI
118    fn parse_cursor_agent_response(
119        &self,
120        lines: &[String],
121    ) -> Result<(Message, Usage), ProviderError> {
122        // Try parsing each line as a JSON object and find the one with type="result"
123        for line in lines {
124            if let Ok(json_value) = serde_json::from_str::<Value>(line) {
125                if let Some(type_val) = json_value.get("type") {
126                    if type_val == "result" {
127                        let text_content = if let Some(result) = json_value.get("result") {
128                            let result_str = result.as_str().unwrap_or("").to_string();
129
130                            if result_str.is_empty() {
131                                if json_value
132                                    .get("is_error")
133                                    .and_then(|v| v.as_bool())
134                                    .unwrap_or(false)
135                                {
136                                    "Error: cursor-agent returned an error response".to_string()
137                                } else {
138                                    "cursor-agent completed successfully but returned no content"
139                                        .to_string()
140                                }
141                            } else {
142                                result_str
143                            }
144                        } else {
145                            format!("Raw cursor-agent response: {}", line)
146                        };
147
148                        let message_content = vec![MessageContent::text(text_content)];
149                        let response_message = Message::new(
150                            Role::Assistant,
151                            chrono::Utc::now().timestamp(),
152                            message_content,
153                        );
154
155                        let usage = Usage::default();
156
157                        return Ok((response_message, usage));
158                    }
159                }
160            }
161        }
162
163        // If no valid result line found, fallback to joining all lines
164        let response_text = lines.join("\n");
165
166        let message_content = vec![MessageContent::text(response_text)];
167        let response_message = Message::new(
168            Role::Assistant,
169            chrono::Utc::now().timestamp(),
170            message_content,
171        );
172        let usage = Usage::default();
173
174        Ok((response_message, usage))
175    }
176
177    async fn execute_command(
178        &self,
179        system: &str,
180        messages: &[Message],
181        _tools: &[Tool],
182    ) -> Result<Vec<String>, ProviderError> {
183        let prompt = self.messages_to_cursor_agent_format(system, messages);
184
185        if std::env::var("ASTER_CURSOR_AGENT_DEBUG").is_ok() {
186            println!("=== CURSOR AGENT PROVIDER DEBUG ===");
187            println!("Command: {:?}", self.command);
188            println!("Original system prompt length: {} chars", system.len());
189            println!(
190                "Filtered system prompt length: {} chars",
191                filter_extensions_from_system_prompt(system).len()
192            );
193            println!("Full prompt: {}", prompt);
194            println!("Model: {}", self.model.model_name);
195            println!("================================");
196        }
197
198        let mut cmd = Command::new(&self.command);
199        configure_command_no_window(&mut cmd);
200
201        if let Ok(path) = SearchPaths::builder().with_npm().path() {
202            cmd.env("PATH", path);
203        }
204
205        // Only pass model parameter if it's in the known models list
206        if CURSOR_AGENT_KNOWN_MODELS.contains(&self.model.model_name.as_str()) {
207            cmd.arg("--model").arg(&self.model.model_name);
208        }
209
210        cmd.arg("-p")
211            .arg(&prompt)
212            .arg("--output-format")
213            .arg("json")
214            .arg("--force");
215
216        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
217
218        let mut child = cmd
219                .spawn()
220                .map_err(|e| ProviderError::RequestFailed(format!(
221                    "Failed to spawn cursor-agent CLI command '{:?}': {}. \
222                    Make sure the cursor-agent CLI is installed and available in the configured search paths, or set CURSOR_AGENT_COMMAND in your config to the correct path.",
223                    self.command, e
224                )))?;
225
226        let stdout = child
227            .stdout
228            .take()
229            .ok_or_else(|| ProviderError::RequestFailed("Failed to capture stdout".to_string()))?;
230
231        let mut reader = BufReader::new(stdout);
232        let mut lines = Vec::new();
233        let mut line = String::new();
234
235        loop {
236            line.clear();
237            match reader.read_line(&mut line).await {
238                Ok(0) => break, // EOF
239                Ok(_) => {
240                    let trimmed = line.trim();
241                    if !trimmed.is_empty() {
242                        lines.push(trimmed.to_string());
243                    }
244                }
245                Err(e) => {
246                    return Err(ProviderError::RequestFailed(format!(
247                        "Failed to read output: {}",
248                        e
249                    )));
250                }
251            }
252        }
253
254        let exit_status = child.wait().await.map_err(|e| {
255            ProviderError::RequestFailed(format!("Failed to wait for command: {}", e))
256        })?;
257
258        if !exit_status.success() {
259            if !self.get_authentication_status().await {
260                return Err(ProviderError::Authentication(
261                    "You are not logged in to cursor-agent. Please run 'cursor-agent login' to authenticate first."
262                        .to_string()));
263            }
264            return Err(ProviderError::RequestFailed(format!(
265                "Command failed with exit code: {:?}",
266                exit_status.code()
267            )));
268        }
269
270        tracing::debug!("Command executed successfully, got {} lines", lines.len());
271        for (i, line) in lines.iter().enumerate() {
272            tracing::debug!("Line {}: {}", i, line);
273        }
274
275        Ok(lines)
276    }
277
278    /// Generate a simple session description without calling subprocess
279    fn generate_simple_session_description(
280        &self,
281        messages: &[Message],
282    ) -> Result<(Message, ProviderUsage), ProviderError> {
283        // Extract the first user message text
284        let description = messages
285            .iter()
286            .find(|m| m.role == Role::User)
287            .and_then(|m| {
288                m.content.iter().find_map(|c| match c {
289                    MessageContent::Text(text_content) => Some(&text_content.text),
290                    _ => None,
291                })
292            })
293            .map(|text| {
294                // Take first few words, limit to 4 words
295                text.split_whitespace()
296                    .take(4)
297                    .collect::<Vec<_>>()
298                    .join(" ")
299            })
300            .unwrap_or_else(|| "Simple task".to_string());
301
302        if std::env::var("ASTER_CURSOR_AGENT_DEBUG").is_ok() {
303            println!("=== CURSOR AGENT PROVIDER DEBUG ===");
304            println!("Generated simple session description: {}", description);
305            println!("Skipped subprocess call for session description");
306            println!("================================");
307        }
308
309        let message = Message::new(
310            Role::Assistant,
311            chrono::Utc::now().timestamp(),
312            vec![MessageContent::text(description.clone())],
313        );
314
315        let usage = Usage::default();
316
317        Ok((
318            message,
319            ProviderUsage::new(self.model.model_name.clone(), usage),
320        ))
321    }
322}
323
324#[async_trait]
325impl Provider for CursorAgentProvider {
326    fn metadata() -> ProviderMetadata {
327        ProviderMetadata::new(
328            "cursor-agent",
329            "Cursor Agent",
330            "Execute AI models via cursor-agent CLI tool",
331            CURSOR_AGENT_DEFAULT_MODEL,
332            CURSOR_AGENT_KNOWN_MODELS.to_vec(),
333            CURSOR_AGENT_DOC_URL,
334            vec![ConfigKey::from_value_type::<CursorAgentCommand>(
335                true, false,
336            )],
337        )
338    }
339
340    fn get_name(&self) -> &str {
341        &self.name
342    }
343
344    fn get_model_config(&self) -> ModelConfig {
345        // Return the model config with appropriate context limit for Cursor models
346        self.model.clone()
347    }
348
349    #[tracing::instrument(
350        skip(self, model_config, system, messages, tools),
351        fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
352    )]
353    async fn complete_with_model(
354        &self,
355        model_config: &ModelConfig,
356        system: &str,
357        messages: &[Message],
358        tools: &[Tool],
359    ) -> Result<(Message, ProviderUsage), ProviderError> {
360        // Check if this is a session description request (short system prompt asking for 4 words or less)
361        if system.contains("four words or less") || system.contains("4 words or less") {
362            return self.generate_simple_session_description(messages);
363        }
364
365        let lines = self.execute_command(system, messages, tools).await?;
366
367        let (message, usage) = self.parse_cursor_agent_response(&lines)?;
368
369        // Create a dummy payload for debug tracing
370        let payload = json!({
371            "command": self.command,
372            "model": model_config.model_name,
373            "system": system,
374            "messages": messages.len()
375        });
376
377        let response = json!({
378            "lines": lines.len(),
379            "usage": usage
380        });
381
382        let mut log = RequestLog::start(&self.model, &payload)?;
383        log.write(&response, Some(&usage))?;
384
385        Ok((
386            message,
387            ProviderUsage::new(model_config.model_name.clone(), usage),
388        ))
389    }
390}