Skip to main content

mermaid_cli/runtime/
non_interactive.rs

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/// Result of a non-interactive run
19#[derive(Debug, Serialize, Deserialize)]
20pub struct NonInteractiveResult {
21    /// The prompt that was executed
22    pub prompt: String,
23    /// The model's response
24    pub response: String,
25    /// Actions that were executed (if any)
26    pub actions: Vec<ActionResult>,
27    /// Any errors that occurred
28    pub errors: Vec<String>,
29    /// Metadata about the execution
30    pub metadata: ExecutionMetadata,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct ActionResult {
35    /// Type of action (file_write, command, etc.)
36    pub action_type: String,
37    /// Target (file path or command)
38    pub target: String,
39    /// Whether the action was executed successfully
40    pub success: bool,
41    /// Output or error message
42    pub output: Option<String>,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
46pub struct ExecutionMetadata {
47    /// Model used
48    pub model: String,
49    /// Total tokens used
50    pub tokens_used: Option<usize>,
51    /// Execution time in milliseconds
52    pub duration_ms: u128,
53    /// Whether actions were executed
54    pub actions_executed: bool,
55}
56
57/// Non-interactive runner for executing single prompts
58pub struct NonInteractiveRunner {
59    model: Arc<RwLock<Box<dyn Model>>>,
60    no_execute: bool,
61    model_config: ModelConfig,
62}
63
64impl NonInteractiveRunner {
65    /// Create a new non-interactive runner
66    pub async fn new(
67        model_id: String,
68        config: Config,
69        no_execute: bool,
70        max_tokens: Option<usize>,
71    ) -> Result<Self> {
72        // Create model instance
73        let model = ModelFactory::create(&model_id, Some(&config)).await?;
74
75        // Build base config from app config, then apply CLI overrides
76        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    /// Execute a single prompt and return the result
90    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        // Build initial messages
96        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        // Use pre-built model config
101        let model_config = &self.model_config;
102        let model_name = model_config.model.clone();
103
104        // First model call to get the initial response
105        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 no tool calls, return immediately
137        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        // Add assistant message with tool calls to history
154        let assistant_msg =
155            ChatMessage::assistant(content.clone()).with_tool_calls(initial_tool_calls.clone());
156        messages.push(assistant_msg);
157
158        // Handle --no-execute mode: record tool calls but don't execute them
159        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        // Delegate to shared agent loop for tool execution + model re-calling
177        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        // Build result from the agent loop
189        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
232/// Format a non-interactive result according to the output format
233pub 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
309/// Extract action type and target description from an AgentAction
310fn extract_action_info(action: &AgentAction) -> (String, String) {
311    let (label, target) = action.display_info();
312    (label.to_lowercase().replace(' ', "_"), target)
313}
314
315/// Build ActionResult entries for --no-execute mode (records tool calls without executing)
316fn 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
342/// Observer that does nothing -- used by non-interactive mode
343struct 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}