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