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::{
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/// Result of a non-interactive run
21#[derive(Debug, Serialize, Deserialize)]
22pub struct NonInteractiveResult {
23    /// The prompt that was executed
24    pub prompt: String,
25    /// The model's response
26    pub response: String,
27    /// Actions that were executed (if any)
28    pub actions: Vec<ActionResult>,
29    /// Any errors that occurred
30    pub errors: Vec<String>,
31    /// Metadata about the execution
32    pub metadata: ExecutionMetadata,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct ActionResult {
37    /// Type of action (file_write, command, etc.)
38    pub action_type: String,
39    /// Target (file path or command)
40    pub target: String,
41    /// Whether the action was executed successfully
42    pub success: bool,
43    /// Output or error message
44    pub output: Option<String>,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48pub struct ExecutionMetadata {
49    /// Model used
50    pub model: String,
51    /// Total tokens used
52    pub tokens_used: Option<usize>,
53    /// Execution time in milliseconds
54    pub duration_ms: u128,
55    /// Whether actions were executed
56    pub actions_executed: bool,
57}
58
59/// Non-interactive runner for executing single prompts
60pub struct NonInteractiveRunner {
61    model: Arc<RwLock<Box<dyn Model>>>,
62    no_execute: bool,
63    model_config: ModelConfig,
64    /// Project instructions auto-loaded from MERMAID.md (Step 5h).
65    /// Non-interactive mode is one-shot — load once at construction,
66    /// no auto-reload (single execute() call, no per-turn loop).
67    instructions: Option<crate::app::instructions::LoadedInstructions>,
68}
69
70impl NonInteractiveRunner {
71    /// Create a new non-interactive runner
72    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        // Create model instance
80        let model = ModelFactory::create(&model_id, Some(&config)).await?;
81
82        // Build base config from app config, then apply CLI overrides
83        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        // CLI `--reasoning` wins over the config-file default. Without
88        // it, `from_app_config` already populated `reasoning` from
89        // `[default_model].reasoning` (Wave 2). Non-interactive mode
90        // historically forced thinking off; users now pick that
91        // explicitly via `--reasoning none` if that's what they want.
92        if let Some(level) = reasoning {
93            model_config.reasoning = level;
94        }
95
96        // Step 5h: discover MERMAID.md once at construction. The
97        // run-once nature of this path means there's no per-turn
98        // refresh — you get whatever's on disk when you start.
99        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    /// Execute a single prompt and return the result
112    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        // Build initial messages
118        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        // Use pre-built model config; inject the MERMAID.md suffix
123        // (Step 5h) loaded at construction. One-shot mode means no
124        // mid-run reload — suffix is whatever was on disk at startup.
125        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        // First model call. Accumulate text + tool calls from typed events;
132        // ignore reasoning chunks (`ModelResponse.thinking` is still
133        // populated and surfaced via the result if needed).
134        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 no tool calls, return immediately
178        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        // Add assistant message with tool calls to history
195        let assistant_msg =
196            ChatMessage::assistant(content.clone()).with_tool_calls(initial_tool_calls.clone());
197        messages.push(assistant_msg);
198
199        // Handle --no-execute mode: record tool calls but don't execute them
200        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        // Delegate to shared agent loop for tool execution + model re-calling
218        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        // Build result from the agent loop
230        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
272/// Format a non-interactive result according to the output format
273pub 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
348/// Extract action type and target description from an AgentAction
349fn extract_action_info(action: &AgentAction) -> (String, String) {
350    let (label, target) = action.display_info();
351    (label.to_lowercase().replace(' ', "_"), target)
352}
353
354/// Build ActionResult entries for --no-execute mode (records tool calls without executing)
355fn 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
381/// Observer that does nothing -- used by non-interactive mode
382struct 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}