1use 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#[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#[derive(Debug, Deserialize)]
42struct CursorUsage {
43 input_tokens: Option<u32>,
44 output_tokens: Option<u32>,
45}
46
47const DEFAULT_MODEL: &str = "sonnet-4";
49
50const FALLBACK_MODELS: &[&str] = &["sonnet-4", "gpt-5", "gemini-2.5-pro"];
52
53pub struct CursorAgentRunner {
59 base: CliRunnerBase,
60}
61
62impl CursorAgentRunner {
63 #[must_use]
65 pub fn new(config: RunnerConfig) -> Self {
66 Self {
67 base: CliRunnerBase::new(config, DEFAULT_MODEL, FALLBACK_MODELS),
68 }
69 }
70
71 pub async fn set_session(&self, key: &str, session_id: &str) {
73 self.base.set_session(key, session_id).await;
74 }
75
76 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 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 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}