Skip to main content

embacle/
cursor_agent.rs

1// ABOUTME: Cursor Agent CLI runner implementing the `LlmProvider` trait
2// ABOUTME: Wraps the `cursor-agent` CLI with JSON output parsing and MCP approval
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::io;
8use std::process::Stdio;
9use std::str;
10
11use crate::cli_common::{CliRunnerBase, MAX_OUTPUT_BYTES};
12use crate::types::{
13    ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError, StreamChunk,
14    TokenUsage,
15};
16use async_trait::async_trait;
17use serde::Deserialize;
18use tokio::io::{AsyncBufReadExt, BufReader};
19use tokio::process::Command;
20use tokio_stream::wrappers::LinesStream;
21use tokio_stream::StreamExt;
22use tracing::instrument;
23
24use crate::config::RunnerConfig;
25use crate::process::{read_stderr_capped, run_cli_command};
26use crate::prompt::build_user_prompt;
27use crate::sandbox::{apply_sandbox, build_policy};
28use crate::stream::{GuardedStream, MAX_STREAMING_STDERR_BYTES};
29
30/// Cursor Agent CLI response JSON structure
31#[derive(Debug, Deserialize)]
32struct CursorResponse {
33    result: Option<String>,
34    #[serde(default)]
35    is_error: bool,
36    session_id: Option<String>,
37    usage: Option<CursorUsage>,
38}
39
40/// Token usage from Cursor Agent CLI
41#[derive(Debug, Deserialize)]
42struct CursorUsage {
43    input_tokens: Option<u32>,
44    output_tokens: Option<u32>,
45}
46
47/// Default model for Cursor Agent
48const DEFAULT_MODEL: &str = "sonnet-4";
49
50/// Fallback model list when no runtime override is available
51const FALLBACK_MODELS: &[&str] = &["sonnet-4", "gpt-5", "gemini-2.5-pro"];
52
53/// Cursor Agent CLI runner
54///
55/// Implements `LlmProvider` by delegating to the `cursor-agent` binary
56/// with `--output-format json` and `--approve-mcps` for automatic MCP
57/// server approval.
58pub struct CursorAgentRunner {
59    base: CliRunnerBase,
60}
61
62impl CursorAgentRunner {
63    /// Create a new Cursor Agent runner with the given configuration
64    #[must_use]
65    pub fn new(config: RunnerConfig) -> Self {
66        Self {
67            base: CliRunnerBase::new(config, DEFAULT_MODEL, FALLBACK_MODELS),
68        }
69    }
70
71    /// Store a session ID for later resumption
72    pub async fn set_session(&self, key: &str, session_id: &str) {
73        self.base.set_session(key, session_id).await;
74    }
75
76    /// Build the base command with common arguments
77    fn build_command(&self, prompt: &str, output_format: &str) -> Command {
78        let mut cmd = Command::new(&self.base.config.binary_path);
79        cmd.args(["-p", prompt, "--output-format", output_format]);
80
81        // Cursor Agent always gets --approve-mcps
82        cmd.arg("--approve-mcps");
83
84        let model = self
85            .base
86            .config
87            .model
88            .as_deref()
89            .unwrap_or_else(|| self.base.default_model());
90        cmd.args(["--model", model]);
91
92        for arg in &self.base.config.extra_args {
93            cmd.arg(arg);
94        }
95
96        if let Ok(policy) = build_policy(
97            self.base.config.working_directory.as_deref(),
98            &self.base.config.allowed_env_keys,
99        ) {
100            apply_sandbox(&mut cmd, &policy);
101        }
102
103        cmd
104    }
105
106    /// Parse a Cursor Agent JSON response into a `ChatResponse`
107    fn parse_response(raw: &[u8]) -> Result<(ChatResponse, Option<String>), RunnerError> {
108        let text = str::from_utf8(raw).map_err(|e| {
109            RunnerError::internal(format!("Cursor Agent output is not valid UTF-8: {e}"))
110        })?;
111
112        let parsed: CursorResponse = serde_json::from_str(text).map_err(|e| {
113            RunnerError::internal(format!("Failed to parse Cursor Agent JSON response: {e}"))
114        })?;
115
116        if parsed.is_error {
117            return Err(RunnerError::external_service(
118                "cursor-agent",
119                parsed
120                    .result
121                    .as_deref()
122                    .unwrap_or("Unknown error from Cursor Agent"),
123            ));
124        }
125
126        let content = parsed.result.unwrap_or_default();
127        let usage = parsed.usage.map(|u| TokenUsage {
128            prompt_tokens: u.input_tokens.unwrap_or(0),
129            completion_tokens: u.output_tokens.unwrap_or(0),
130            total_tokens: u.input_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0),
131        });
132
133        let response = ChatResponse {
134            content,
135            model: "cursor-agent".to_owned(),
136            usage,
137            finish_reason: Some("stop".to_owned()),
138            warnings: None,
139            tool_calls: None,
140        };
141
142        Ok((response, parsed.session_id))
143    }
144}
145
146#[async_trait]
147impl LlmProvider for CursorAgentRunner {
148    crate::delegate_provider_base!(
149        "cursor-agent",
150        "Cursor Agent CLI",
151        LlmCapabilities::STREAMING | LlmCapabilities::TEMPERATURE | LlmCapabilities::MAX_TOKENS
152    );
153
154    #[instrument(skip_all, fields(runner = "cursor_agent"))]
155    async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
156        let prompt = build_user_prompt(&request.messages);
157        let mut cmd = self.build_command(&prompt, "json");
158
159        if let Some(model) = &request.model {
160            if let Some(sid) = self.base.get_session(model).await {
161                cmd.args(["--resume", &sid]);
162            }
163        }
164
165        let output = run_cli_command(&mut cmd, self.base.config.timeout, MAX_OUTPUT_BYTES).await?;
166        self.base.check_exit_code(&output, "cursor-agent")?;
167
168        let (response, session_id) = Self::parse_response(&output.stdout)?;
169
170        if let Some(sid) = session_id {
171            if let Some(model) = &request.model {
172                self.base.set_session(model, &sid).await;
173            }
174        }
175
176        Ok(response)
177    }
178
179    #[instrument(skip_all, fields(runner = "cursor_agent"))]
180    async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
181        let prompt = build_user_prompt(&request.messages);
182        let mut cmd = self.build_command(&prompt, "stream-json");
183
184        if let Some(model) = &request.model {
185            if let Some(sid) = self.base.get_session(model).await {
186                cmd.args(["--resume", &sid]);
187            }
188        }
189
190        cmd.stdout(Stdio::piped());
191        cmd.stderr(Stdio::piped());
192
193        let mut child = cmd.spawn().map_err(|e| {
194            RunnerError::internal(format!("Failed to spawn cursor-agent for streaming: {e}"))
195        })?;
196
197        let stdout = child.stdout.take().ok_or_else(|| {
198            RunnerError::internal("Failed to capture cursor-agent stdout for streaming")
199        })?;
200
201        let stderr_task = tokio::spawn(read_stderr_capped(
202            child.stderr.take(),
203            MAX_STREAMING_STDERR_BYTES,
204        ));
205
206        let reader = BufReader::new(stdout);
207        let lines = LinesStream::new(reader.lines());
208
209        let stream = lines.map(move |line_result: Result<String, io::Error>| {
210            let line = line_result.map_err(|e| {
211                RunnerError::internal(format!("Error reading cursor-agent stream: {e}"))
212            })?;
213
214            if line.trim().is_empty() {
215                return Ok(StreamChunk {
216                    delta: String::new(),
217                    is_final: false,
218                    finish_reason: None,
219                });
220            }
221
222            let value: serde_json::Value = serde_json::from_str(&line).map_err(|e| {
223                RunnerError::internal(format!("Invalid JSON in cursor-agent stream: {e}"))
224            })?;
225
226            let chunk_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
227            match chunk_type {
228                "result" => Ok(StreamChunk {
229                    delta: value
230                        .get("result")
231                        .and_then(|v| v.as_str())
232                        .unwrap_or("")
233                        .to_owned(),
234                    is_final: true,
235                    finish_reason: Some("stop".to_owned()),
236                }),
237                "content" => Ok(StreamChunk {
238                    delta: value
239                        .get("content")
240                        .and_then(|v| v.as_str())
241                        .unwrap_or("")
242                        .to_owned(),
243                    is_final: false,
244                    finish_reason: None,
245                }),
246                _ => Ok(StreamChunk {
247                    delta: String::new(),
248                    is_final: false,
249                    finish_reason: None,
250                }),
251            }
252        });
253
254        Ok(Box::pin(GuardedStream::new(stream, child, stderr_task)))
255    }
256}