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