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},
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: Vec<AgentAction> = 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 tool_calls
173 .iter()
174 .filter_map(|tc| tc.to_agent_action().ok())
175 .collect()
176 } else {
177 vec![]
178 }
179 },
180 Err(e) => {
181 errors.push(format!("Model error: {}", e));
182 full_response = response_text.lock_mut_safe().clone();
183 tokens_used = 0;
184 vec![]
185 },
186 };
187
188 if !self.no_execute && !parsed_actions.is_empty() {
190 for action in parsed_actions {
191 let (action_type, target) = match &action {
192 AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
193 AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
194 AgentAction::ReadFile { paths } => {
195 if paths.len() == 1 {
196 ("file_read", paths[0].clone())
197 } else {
198 ("file_read", format!("{} files", paths.len()))
199 }
200 }
201 AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
202 AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
203 AgentAction::GitDiff { paths } => {
204 if paths.len() == 1 {
205 ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
206 } else {
207 ("git_diff", format!("{} paths", paths.len()))
208 }
209 }
210 AgentAction::GitStatus => ("git_status", "git status".to_string()),
211 AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
212 AgentAction::WebSearch { queries } => {
213 if queries.len() == 1 {
214 ("web_search", queries[0].0.clone())
215 } else {
216 ("web_search", format!("{} queries", queries.len()))
217 }
218 }
219 };
220
221 let result = execute_action(&action).await;
222
223 let action_result = match result {
224 AgentActionResult::Success { output } => ActionResult {
225 action_type: action_type.to_string(),
226 target,
227 success: true,
228 output: Some(output),
229 },
230 AgentActionResult::Error { error } => ActionResult {
231 action_type: action_type.to_string(),
232 target,
233 success: false,
234 output: Some(error),
235 },
236 };
237
238 actions.push(action_result);
239 }
240 } else if !parsed_actions.is_empty() {
241 for action in parsed_actions {
243 let (action_type, target) = match &action {
244 AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
245 AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
246 AgentAction::ReadFile { paths } => {
247 if paths.len() == 1 {
248 ("file_read", paths[0].clone())
249 } else {
250 ("file_read", format!("{} files", paths.len()))
251 }
252 }
253 AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
254 AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
255 AgentAction::GitDiff { paths } => {
256 if paths.len() == 1 {
257 ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
258 } else {
259 ("git_diff", format!("{} paths", paths.len()))
260 }
261 }
262 AgentAction::GitStatus => ("git_status", "git status".to_string()),
263 AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
264 AgentAction::WebSearch { queries } => {
265 if queries.len() == 1 {
266 ("web_search", queries[0].0.clone())
267 } else {
268 ("web_search", format!("{} queries", queries.len()))
269 }
270 }
271 };
272
273 actions.push(ActionResult {
274 action_type: action_type.to_string(),
275 target,
276 success: false,
277 output: Some("Not executed (--no-execute mode)".to_string()),
278 });
279 }
280 }
281
282 let duration_ms = start_time.elapsed().as_millis();
283 let actions_executed = !self.no_execute && !actions.is_empty();
284
285 Ok(NonInteractiveResult {
286 prompt,
287 response: full_response,
288 actions,
289 errors,
290 metadata: ExecutionMetadata {
291 model: model_name,
292 tokens_used: Some(tokens_used),
293 duration_ms,
294 actions_executed,
295 },
296 })
297 }
298
299 pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
301 match format {
302 OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
303 format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
304 }),
305 OutputFormat::Text => {
306 let mut output = String::new();
307 output.push_str(&result.response);
308
309 if !result.actions.is_empty() {
310 output.push_str("\n\n--- Actions ---\n");
311 for action in &result.actions {
312 output.push_str(&format!(
313 "[{}] {} - {}\n",
314 if action.success { "OK" } else { "FAIL" },
315 action.action_type,
316 action.target
317 ));
318 if let Some(ref out) = action.output {
319 output.push_str(&format!(" {}\n", out));
320 }
321 }
322 }
323
324 if !result.errors.is_empty() {
325 output.push_str("\n--- Errors ---\n");
326 for error in &result.errors {
327 output.push_str(&format!("• {}\n", error));
328 }
329 }
330
331 output
332 },
333 OutputFormat::Markdown => {
334 let mut output = String::new();
335
336 output.push_str("## Response\n\n");
337 output.push_str(&result.response);
338 output.push_str("\n\n");
339
340 if !result.actions.is_empty() {
341 output.push_str("## Actions Executed\n\n");
342 for action in &result.actions {
343 let status = if action.success { "SUCCESS" } else { "FAILED" };
344 output.push_str(&format!(
345 "- {} **{}**: `{}`\n",
346 status, action.action_type, action.target
347 ));
348 if let Some(ref out) = action.output {
349 output.push_str(&format!(" ```\n {}\n ```\n", out));
350 }
351 }
352 output.push_str("\n");
353 }
354
355 if !result.errors.is_empty() {
356 output.push_str("## Errors\n\n");
357 for error in &result.errors {
358 output.push_str(&format!("- {}\n", error));
359 }
360 output.push_str("\n");
361 }
362
363 output.push_str("---\n");
364 output.push_str(&format!(
365 "*Model: {} | Tokens: {} | Duration: {}ms*\n",
366 result.metadata.model,
367 result.metadata.tokens_used.unwrap_or(0),
368 result.metadata.duration_ms
369 ));
370
371 output
372 },
373 }
374 }
375}