1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::sync::Arc;
4use tokio::sync::RwLock;
5
6use crate::utils::MutexExt;
7
8use crate::{
9 agents::{ActionResult as AgentActionResult, AgentAction},
10 app::Config,
11 cli::OutputFormat,
12 models::{
13 ChatMessage, Model, ModelConfig, ModelFactory, StreamCallback, StreamEvent, ToolCall,
14 },
15 prompts,
16};
17
18use super::agent_loop::{self, AgentObserver, LoopControl, MAX_AGENT_ITERATIONS};
19
20#[derive(Debug, Serialize, Deserialize)]
22pub struct NonInteractiveResult {
23 pub prompt: String,
25 pub response: String,
27 pub actions: Vec<ActionResult>,
29 pub errors: Vec<String>,
31 pub metadata: ExecutionMetadata,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct ActionResult {
37 pub action_type: String,
39 pub target: String,
41 pub success: bool,
43 pub output: Option<String>,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48pub struct ExecutionMetadata {
49 pub model: String,
51 pub tokens_used: Option<usize>,
53 pub duration_ms: u128,
55 pub actions_executed: bool,
57}
58
59pub struct NonInteractiveRunner {
61 model: Arc<RwLock<Box<dyn Model>>>,
62 no_execute: bool,
63 model_config: ModelConfig,
64 instructions: Option<crate::app::instructions::LoadedInstructions>,
68}
69
70impl NonInteractiveRunner {
71 pub async fn new(
73 model_id: String,
74 config: Config,
75 no_execute: bool,
76 max_tokens: Option<usize>,
77 reasoning: Option<crate::models::ReasoningLevel>,
78 ) -> Result<Self> {
79 let model = ModelFactory::create(&model_id, Some(&config)).await?;
81
82 let mut model_config = ModelConfig::from_app_config(&config, &model_id);
84 if let Some(mt) = max_tokens {
85 model_config.max_tokens = mt;
86 }
87 if let Some(level) = reasoning {
93 model_config.reasoning = level;
94 }
95
96 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
100 let instructions = crate::app::instructions::find_mermaid_md(&cwd)
101 .and_then(|p| crate::app::instructions::load_from_path(&p));
102
103 Ok(Self {
104 model: Arc::new(RwLock::new(model)),
105 no_execute,
106 model_config,
107 instructions,
108 })
109 }
110
111 pub async fn execute(&self, prompt: String) -> Result<NonInteractiveResult> {
113 let start_time = std::time::Instant::now();
114 let mut errors = Vec::new();
115 let mut total_tokens = 0;
116
117 let system_message = ChatMessage::system(prompts::get_system_prompt());
119 let user_message = ChatMessage::user(prompt.clone());
120 let mut messages = vec![system_message, user_message];
121
122 let mut effective_config = self.model_config.clone();
126 effective_config.dynamic_system_suffix =
127 self.instructions.as_ref().map(|i| i.content.clone());
128 let model_config = &effective_config;
129 let model_name = model_config.model.clone();
130
131 let response_text = Arc::new(std::sync::Mutex::new(String::new()));
135 let typed_tool_calls = Arc::new(std::sync::Mutex::new(Vec::<ToolCall>::new()));
136 let text_clone = Arc::clone(&response_text);
137 let tool_clone = Arc::clone(&typed_tool_calls);
138 let callback: StreamCallback = Arc::new(move |event| match event {
139 StreamEvent::Text(chunk) => {
140 text_clone.lock_mut_safe().push_str(&chunk);
141 },
142 StreamEvent::ToolCall(tc) => {
143 tool_clone.lock_mut_safe().push(tc);
144 },
145 StreamEvent::Reasoning(_) | StreamEvent::Done { .. } => {},
146 });
147
148 let result = {
149 let model = self.model.read().await;
150 model.chat(&messages, model_config, Some(callback)).await
151 };
152
153 let (content, initial_tool_calls) = match result {
154 Ok(response) => {
155 let streamed_text = response_text.lock_mut_safe().clone();
156 let content = if !streamed_text.is_empty() {
157 streamed_text
158 } else {
159 response.content
160 };
161 total_tokens += response.usage.map(|u| u.total_tokens).unwrap_or(0);
162 let streamed_tool_calls = std::mem::take(&mut *typed_tool_calls.lock_mut_safe());
163 let tool_calls = if !streamed_tool_calls.is_empty() {
164 streamed_tool_calls
165 } else {
166 response.tool_calls.unwrap_or_default()
167 };
168 (content, tool_calls)
169 },
170 Err(e) => {
171 errors.push(format!("Model error: {}", e));
172 let content = response_text.lock_mut_safe().clone();
173 (content, vec![])
174 },
175 };
176
177 if initial_tool_calls.is_empty() {
179 let duration_ms = start_time.elapsed().as_millis();
180 return Ok(NonInteractiveResult {
181 prompt,
182 response: content,
183 actions: vec![],
184 errors,
185 metadata: ExecutionMetadata {
186 model: model_name,
187 tokens_used: Some(total_tokens),
188 duration_ms,
189 actions_executed: false,
190 },
191 });
192 }
193
194 let assistant_msg =
196 ChatMessage::assistant(content.clone()).with_tool_calls(initial_tool_calls.clone());
197 messages.push(assistant_msg);
198
199 if self.no_execute {
201 let actions = build_no_execute_actions(&initial_tool_calls, &mut messages);
202 let duration_ms = start_time.elapsed().as_millis();
203 return Ok(NonInteractiveResult {
204 prompt,
205 response: content,
206 actions,
207 errors,
208 metadata: ExecutionMetadata {
209 model: model_name,
210 tokens_used: Some(total_tokens),
211 duration_ms,
212 actions_executed: false,
213 },
214 });
215 }
216
217 let mut observer = SilentObserver;
219 let loop_result = agent_loop::run_agent_loop(
220 Arc::clone(&self.model),
221 model_config,
222 &mut messages,
223 initial_tool_calls,
224 &mut observer,
225 MAX_AGENT_ITERATIONS,
226 )
227 .await?;
228
229 total_tokens += loop_result.total_tokens;
231 let final_response = if loop_result.final_response.is_empty() {
232 content
233 } else {
234 loop_result.final_response
235 };
236
237 let actions: Vec<ActionResult> = loop_result
238 .tool_results
239 .iter()
240 .map(|tr| {
241 let (action_type, target) = extract_action_info(&tr.action);
242 ActionResult {
243 action_type,
244 target,
245 success: tr.success,
246 output: Some(tr.output.clone()),
247 }
248 })
249 .collect();
250
251 if loop_result.interrupted {
252 errors.push("Agent loop was interrupted".to_string());
253 }
254
255 let duration_ms = start_time.elapsed().as_millis();
256 let actions_executed = !actions.is_empty();
257 Ok(NonInteractiveResult {
258 prompt,
259 response: final_response,
260 actions,
261 errors,
262 metadata: ExecutionMetadata {
263 model: model_name,
264 tokens_used: Some(total_tokens),
265 duration_ms,
266 actions_executed,
267 },
268 })
269 }
270}
271
272pub fn format_result(result: &NonInteractiveResult, format: OutputFormat) -> String {
274 match format {
275 OutputFormat::Json => serde_json::to_string_pretty(result)
276 .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)),
277 OutputFormat::Text => {
278 let mut output = String::new();
279 output.push_str(&result.response);
280
281 if !result.actions.is_empty() {
282 output.push_str("\n\n--- Actions ---\n");
283 for action in &result.actions {
284 output.push_str(&format!(
285 "[{}] {} - {}\n",
286 if action.success { "OK" } else { "FAIL" },
287 action.action_type,
288 action.target
289 ));
290 if let Some(ref out) = action.output {
291 output.push_str(&format!(" {}\n", out));
292 }
293 }
294 }
295
296 if !result.errors.is_empty() {
297 output.push_str("\n--- Errors ---\n");
298 for error in &result.errors {
299 output.push_str(&format!("• {}\n", error));
300 }
301 }
302
303 output
304 },
305 OutputFormat::Markdown => {
306 let mut output = String::new();
307
308 output.push_str("## Response\n\n");
309 output.push_str(&result.response);
310 output.push_str("\n\n");
311
312 if !result.actions.is_empty() {
313 output.push_str("## Actions Executed\n\n");
314 for action in &result.actions {
315 let status = if action.success { "SUCCESS" } else { "FAILED" };
316 output.push_str(&format!(
317 "- {} **{}**: `{}`\n",
318 status, action.action_type, action.target
319 ));
320 if let Some(ref out) = action.output {
321 output.push_str(&format!(" ```\n {}\n ```\n", out));
322 }
323 }
324 output.push('\n');
325 }
326
327 if !result.errors.is_empty() {
328 output.push_str("## Errors\n\n");
329 for error in &result.errors {
330 output.push_str(&format!("- {}\n", error));
331 }
332 output.push('\n');
333 }
334
335 output.push_str("---\n");
336 output.push_str(&format!(
337 "*Model: {} | Tokens: {} | Duration: {}ms*\n",
338 result.metadata.model,
339 result.metadata.tokens_used.unwrap_or(0),
340 result.metadata.duration_ms
341 ));
342
343 output
344 },
345 }
346}
347
348fn extract_action_info(action: &AgentAction) -> (String, String) {
350 let (label, target) = action.display_info();
351 (label.to_lowercase().replace(' ', "_"), target)
352}
353
354fn build_no_execute_actions(
356 tool_calls: &[crate::models::ToolCall],
357 messages: &mut Vec<ChatMessage>,
358) -> Vec<ActionResult> {
359 let mut actions = Vec::new();
360 for tc in tool_calls {
361 let tool_call_id = tc.id.clone().unwrap_or_else(|| "call_noexec".to_string());
362 let tool_name = tc.function.name.clone();
363
364 let (action_type, target) = match tc.to_agent_action() {
365 Ok(action) => extract_action_info(&action),
366 Err(_) => (tool_name.clone(), String::new()),
367 };
368
369 let msg = "Not executed (--no-execute mode)".to_string();
370 messages.push(ChatMessage::tool(&tool_call_id, &tool_name, &msg));
371 actions.push(ActionResult {
372 action_type,
373 target,
374 success: false,
375 output: Some(msg),
376 });
377 }
378 actions
379}
380
381struct SilentObserver;
383
384impl AgentObserver for SilentObserver {
385 fn check_interrupt(&mut self) -> LoopControl {
386 LoopControl::Continue
387 }
388 fn on_status(&mut self, _: &str) {}
389 fn on_tool_result(&mut self, _: &str, _: &str, _: &AgentAction, _: &AgentActionResult) {}
390 fn on_error(&mut self, _: &str) {}
391 fn on_generation_start(&mut self) {}
392 fn on_generation_complete(&mut self, _: usize) {}
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::agents::AgentAction;
399
400 fn sample_result() -> NonInteractiveResult {
401 NonInteractiveResult {
402 prompt: "Fix the bug".to_string(),
403 response: "I fixed the bug.".to_string(),
404 actions: vec![ActionResult {
405 action_type: "write_file".to_string(),
406 target: "src/main.rs".to_string(),
407 success: true,
408 output: Some("File written".to_string()),
409 }],
410 errors: vec![],
411 metadata: ExecutionMetadata {
412 model: "test-model".to_string(),
413 tokens_used: Some(100),
414 duration_ms: 1234,
415 actions_executed: true,
416 },
417 }
418 }
419
420 fn sample_result_with_errors() -> NonInteractiveResult {
421 NonInteractiveResult {
422 prompt: "Do something".to_string(),
423 response: "Tried but failed.".to_string(),
424 actions: vec![ActionResult {
425 action_type: "bash".to_string(),
426 target: "cargo test".to_string(),
427 success: false,
428 output: Some("tests failed".to_string()),
429 }],
430 errors: vec!["Command failed".to_string()],
431 metadata: ExecutionMetadata {
432 model: "test-model".to_string(),
433 tokens_used: Some(50),
434 duration_ms: 500,
435 actions_executed: true,
436 },
437 }
438 }
439
440 #[test]
441 fn test_extract_action_info_read() {
442 let action = AgentAction::ReadFile {
443 paths: vec!["foo.rs".to_string()],
444 };
445 let (action_type, target) = extract_action_info(&action);
446 assert_eq!(action_type, "read");
447 assert_eq!(target, "foo.rs");
448 }
449
450 #[test]
451 fn test_extract_action_info_bash() {
452 let action = AgentAction::ExecuteCommand {
453 command: "cargo test".to_string(),
454 working_dir: None,
455 timeout: None,
456 };
457 let (action_type, target) = extract_action_info(&action);
458 assert_eq!(action_type, "bash");
459 assert_eq!(target, "cargo test");
460 }
461
462 #[test]
463 fn test_extract_action_info_web_search() {
464 let action = AgentAction::WebSearch {
465 queries: vec![("rust async".to_string(), 5)],
466 };
467 let (action_type, target) = extract_action_info(&action);
468 assert_eq!(action_type, "web_search");
469 assert_eq!(target, "rust async");
470 }
471
472 #[test]
473 fn test_extract_action_info_write() {
474 let action = AgentAction::WriteFile {
475 path: "out.txt".to_string(),
476 content: "hello".to_string(),
477 };
478 let (action_type, target) = extract_action_info(&action);
479 assert_eq!(action_type, "write");
480 assert_eq!(target, "out.txt");
481 }
482
483 #[test]
484 fn test_format_result_json() {
485 let result = sample_result();
486 let json = format_result(&result, OutputFormat::Json);
487 assert!(json.contains("\"prompt\": \"Fix the bug\""));
488 assert!(json.contains("\"success\": true"));
489 assert!(json.contains("\"model\": \"test-model\""));
490 }
491
492 #[test]
493 fn test_format_result_text() {
494 let result = sample_result();
495 let text = format_result(&result, OutputFormat::Text);
496 assert!(text.contains("I fixed the bug."));
497 assert!(text.contains("[OK] write_file - src/main.rs"));
498 assert!(text.contains("--- Actions ---"));
499 }
500
501 #[test]
502 fn test_format_result_text_with_errors() {
503 let result = sample_result_with_errors();
504 let text = format_result(&result, OutputFormat::Text);
505 assert!(text.contains("[FAIL] bash - cargo test"));
506 assert!(text.contains("--- Errors ---"));
507 assert!(text.contains("Command failed"));
508 }
509
510 #[test]
511 fn test_format_result_markdown() {
512 let result = sample_result();
513 let md = format_result(&result, OutputFormat::Markdown);
514 assert!(md.contains("## Response"));
515 assert!(md.contains("I fixed the bug."));
516 assert!(md.contains("## Actions Executed"));
517 assert!(md.contains("SUCCESS **write_file**"));
518 assert!(md.contains("*Model: test-model"));
519 }
520
521 #[test]
522 fn test_format_result_text_no_actions() {
523 let result = NonInteractiveResult {
524 prompt: "hi".to_string(),
525 response: "hello".to_string(),
526 actions: vec![],
527 errors: vec![],
528 metadata: ExecutionMetadata {
529 model: "m".to_string(),
530 tokens_used: None,
531 duration_ms: 10,
532 actions_executed: false,
533 },
534 };
535 let text = format_result(&result, OutputFormat::Text);
536 assert_eq!(text, "hello");
537 assert!(!text.contains("Actions"));
538 }
539}