1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6
7use crate::utils::MutexExt;
8
9use crate::{
10 agents::{execute_action, ActionResult as AgentActionResult, AgentAction},
11 app::Config,
12 cli::OutputFormat,
13 models::{ChatMessage, MessageRole, Model, ModelConfig, ModelFactory, parse_tool_calls, group_parallel_reads},
14};
15
16#[derive(Debug, Serialize, Deserialize)]
18pub struct NonInteractiveResult {
19 pub prompt: String,
21 pub response: String,
23 pub actions: Vec<ActionResult>,
25 pub errors: Vec<String>,
27 pub metadata: ExecutionMetadata,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct ActionResult {
33 pub action_type: String,
35 pub target: String,
37 pub success: bool,
39 pub output: Option<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct ExecutionMetadata {
45 pub model: String,
47 pub tokens_used: Option<usize>,
49 pub duration_ms: u128,
51 pub actions_executed: bool,
53}
54
55pub struct NonInteractiveRunner {
57 model: Arc<RwLock<Box<dyn Model>>>,
58 no_execute: bool,
59 max_tokens: Option<usize>,
60}
61
62impl NonInteractiveRunner {
63 pub async fn new(
65 model_id: String,
66 _project_path: PathBuf, config: Config,
68 no_execute: bool,
69 max_tokens: Option<usize>,
70 backend: Option<&str>,
71 ) -> Result<Self> {
72 let model = ModelFactory::create_with_backend(&model_id, Some(&config), backend).await?;
74
75 Ok(Self {
76 model: Arc::new(RwLock::new(model)),
77 no_execute,
78 max_tokens,
79 })
80 }
81
82 pub async fn execute(&self, prompt: String) -> Result<NonInteractiveResult> {
84 let start_time = std::time::Instant::now();
85 let mut errors = Vec::new();
86 let mut actions = Vec::new();
87
88 let system_content = "You are an AI coding assistant. Use tools to explore and modify the codebase as needed."
90 .to_string();
91
92 let system_message = ChatMessage {
93 role: MessageRole::System,
94 content: system_content,
95 timestamp: chrono::Local::now(),
96 actions: Vec::new(),
97 thinking: None,
98 images: None,
99 tool_calls: None,
100 tool_call_id: None,
101 tool_name: None,
102 };
103
104 let user_message = ChatMessage {
105 role: MessageRole::User,
106 content: prompt.clone(),
107 timestamp: chrono::Local::now(),
108 actions: Vec::new(),
109 thinking: None,
110 images: None,
111 tool_calls: None,
112 tool_call_id: None,
113 tool_name: None,
114 };
115
116 let messages = vec![system_message, user_message];
117
118 let model_guard = self.model.read().await;
120 let model_name = model_guard.name().to_string();
121 drop(model_guard);
122
123 let model_config = ModelConfig {
125 model: model_name,
126 temperature: 0.7,
127 max_tokens: self.max_tokens.unwrap_or(4096),
128 top_p: Some(1.0),
129 frequency_penalty: None,
130 presence_penalty: None,
131 system_prompt: None,
132 thinking_enabled: false, backend_options: std::collections::HashMap::new(),
134 };
135
136 let full_response;
138 let tokens_used;
139
140 let response_text = Arc::new(std::sync::Mutex::new(String::new()));
142 let response_clone = Arc::clone(&response_text);
143 let callback = Arc::new(move |chunk: &str| {
144 let mut resp = response_clone.lock_mut_safe();
145 resp.push_str(chunk);
146 });
147
148 let model_name;
150 let result = {
151 let model = self.model.write().await;
152 model_name = model.name().to_string();
153 model
154 .chat(&messages, &model_config, Some(callback))
155 .await
156 };
157
158 let parsed_actions = match result {
160 Ok(response) => {
161 let callback_content = response_text.lock_mut_safe().clone();
163 if !callback_content.is_empty() {
164 full_response = callback_content;
165 } else {
166 full_response = response.content;
167 }
168 tokens_used = response.usage.map(|u| u.total_tokens).unwrap_or(0);
169
170 if let Some(tool_calls) = response.tool_calls {
172 let parsed = parse_tool_calls(&tool_calls);
173 group_parallel_reads(parsed)
174 } else {
175 vec![]
176 }
177 },
178 Err(e) => {
179 errors.push(format!("Model error: {}", e));
180 full_response = response_text.lock_mut_safe().clone();
181 tokens_used = 0;
182 vec![]
183 },
184 };
185
186 if !self.no_execute && !parsed_actions.is_empty() {
188 for action in parsed_actions {
189 let (action_type, target) = match &action {
190 AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
191 AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
192 AgentAction::ReadFile { paths } => {
193 if paths.len() == 1 {
194 ("file_read", paths[0].clone())
195 } else {
196 ("file_read", format!("{} files", paths.len()))
197 }
198 }
199 AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
200 AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
201 AgentAction::GitDiff { paths } => {
202 if paths.len() == 1 {
203 ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
204 } else {
205 ("git_diff", format!("{} paths", paths.len()))
206 }
207 }
208 AgentAction::GitStatus => ("git_status", "git status".to_string()),
209 AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
210 AgentAction::WebSearch { queries } => {
211 if queries.len() == 1 {
212 ("web_search", queries[0].0.clone())
213 } else {
214 ("web_search", format!("{} queries", queries.len()))
215 }
216 }
217 };
218
219 let result = execute_action(&action).await;
220
221 let action_result = match result {
222 AgentActionResult::Success { output } => ActionResult {
223 action_type: action_type.to_string(),
224 target,
225 success: true,
226 output: Some(output),
227 },
228 AgentActionResult::Error { error } => ActionResult {
229 action_type: action_type.to_string(),
230 target,
231 success: false,
232 output: Some(error),
233 },
234 };
235
236 actions.push(action_result);
237 }
238 } else if !parsed_actions.is_empty() {
239 for action in parsed_actions {
241 let (action_type, target) = match &action {
242 AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
243 AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
244 AgentAction::ReadFile { paths } => {
245 if paths.len() == 1 {
246 ("file_read", paths[0].clone())
247 } else {
248 ("file_read", format!("{} files", paths.len()))
249 }
250 }
251 AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
252 AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
253 AgentAction::GitDiff { paths } => {
254 if paths.len() == 1 {
255 ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
256 } else {
257 ("git_diff", format!("{} paths", paths.len()))
258 }
259 }
260 AgentAction::GitStatus => ("git_status", "git status".to_string()),
261 AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
262 AgentAction::WebSearch { queries } => {
263 if queries.len() == 1 {
264 ("web_search", queries[0].0.clone())
265 } else {
266 ("web_search", format!("{} queries", queries.len()))
267 }
268 }
269 };
270
271 actions.push(ActionResult {
272 action_type: action_type.to_string(),
273 target,
274 success: false,
275 output: Some("Not executed (--no-execute mode)".to_string()),
276 });
277 }
278 }
279
280 let duration_ms = start_time.elapsed().as_millis();
281 let actions_executed = !self.no_execute && !actions.is_empty();
282
283 Ok(NonInteractiveResult {
284 prompt,
285 response: full_response,
286 actions,
287 errors,
288 metadata: ExecutionMetadata {
289 model: model_name,
290 tokens_used: Some(tokens_used),
291 duration_ms,
292 actions_executed,
293 },
294 })
295 }
296
297 pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
299 match format {
300 OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
301 format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
302 }),
303 OutputFormat::Text => {
304 let mut output = String::new();
305 output.push_str(&result.response);
306
307 if !result.actions.is_empty() {
308 output.push_str("\n\n--- Actions ---\n");
309 for action in &result.actions {
310 output.push_str(&format!(
311 "[{}] {} - {}\n",
312 if action.success { "OK" } else { "FAIL" },
313 action.action_type,
314 action.target
315 ));
316 if let Some(ref out) = action.output {
317 output.push_str(&format!(" {}\n", out));
318 }
319 }
320 }
321
322 if !result.errors.is_empty() {
323 output.push_str("\n--- Errors ---\n");
324 for error in &result.errors {
325 output.push_str(&format!("• {}\n", error));
326 }
327 }
328
329 output
330 },
331 OutputFormat::Markdown => {
332 let mut output = String::new();
333
334 output.push_str("## Response\n\n");
335 output.push_str(&result.response);
336 output.push_str("\n\n");
337
338 if !result.actions.is_empty() {
339 output.push_str("## Actions Executed\n\n");
340 for action in &result.actions {
341 let status = if action.success { "SUCCESS" } else { "FAILED" };
342 output.push_str(&format!(
343 "- {} **{}**: `{}`\n",
344 status, action.action_type, action.target
345 ));
346 if let Some(ref out) = action.output {
347 output.push_str(&format!(" ```\n {}\n ```\n", out));
348 }
349 }
350 output.push_str("\n");
351 }
352
353 if !result.errors.is_empty() {
354 output.push_str("## Errors\n\n");
355 for error in &result.errors {
356 output.push_str(&format!("- {}\n", error));
357 }
358 output.push_str("\n");
359 }
360
361 output.push_str("---\n");
362 output.push_str(&format!(
363 "*Model: {} | Tokens: {} | Duration: {}ms*\n",
364 result.metadata.model,
365 result.metadata.tokens_used.unwrap_or(0),
366 result.metadata.duration_ms
367 ));
368
369 output
370 },
371 }
372 }
373}