Skip to main content

mermaid_cli/runtime/
non_interactive.rs

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, parse_tool_calls, group_parallel_reads},
14};
15
16/// Result of a non-interactive run
17#[derive(Debug, Serialize, Deserialize)]
18pub struct NonInteractiveResult {
19    /// The prompt that was executed
20    pub prompt: String,
21    /// The model's response
22    pub response: String,
23    /// Actions that were executed (if any)
24    pub actions: Vec<ActionResult>,
25    /// Any errors that occurred
26    pub errors: Vec<String>,
27    /// Metadata about the execution
28    pub metadata: ExecutionMetadata,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct ActionResult {
33    /// Type of action (file_write, command, etc.)
34    pub action_type: String,
35    /// Target (file path or command)
36    pub target: String,
37    /// Whether the action was executed successfully
38    pub success: bool,
39    /// Output or error message
40    pub output: Option<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct ExecutionMetadata {
45    /// Model used
46    pub model: String,
47    /// Total tokens used
48    pub tokens_used: Option<usize>,
49    /// Execution time in milliseconds
50    pub duration_ms: u128,
51    /// Whether actions were executed
52    pub actions_executed: bool,
53}
54
55/// Non-interactive runner for executing single prompts
56pub struct NonInteractiveRunner {
57    model: Arc<RwLock<Box<dyn Model>>>,
58    no_execute: bool,
59    max_tokens: Option<usize>,
60}
61
62impl NonInteractiveRunner {
63    /// Create a new non-interactive runner
64    pub async fn new(
65        model_id: String,
66        _project_path: PathBuf,  // Unused - LLM explores via tools
67        config: Config,
68        no_execute: bool,
69        max_tokens: Option<usize>,
70        backend: Option<&str>,
71    ) -> Result<Self> {
72        // Create model instance with optional backend preference
73        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    /// Execute a single prompt and return the result
83    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        // Build messages - LLM explores codebase via tools, no context injection
89        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        // Get model name from the model
119        let model_guard = self.model.read().await;
120        let model_name = model_guard.name().to_string();
121        drop(model_guard);
122
123        // Create model config
124        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, // Non-interactive mode doesn't need thinking
133            backend_options: std::collections::HashMap::new(),
134        };
135
136        // Send prompt to model
137        let full_response;
138        let tokens_used;
139
140        // Create a callback to capture the response
141        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        // Call the model
149        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        // Parse actions from tool calls (Ollama native function calling)
159        let parsed_actions = match result {
160            Ok(response) => {
161                // Try to get content from the callback first
162                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                // Parse actions from tool_calls
171                if let Some(tool_calls) = response.tool_calls {
172                    let parsed = parse_tool_calls(&tool_calls);
173                    group_parallel_reads(parsed)
174                } else {
175                    vec![]
176                }
177            },
178            Err(e) => {
179                errors.push(format!("Model error: {}", e));
180                full_response = response_text.lock_mut_safe().clone();
181                tokens_used = 0;
182                vec![]
183            },
184        };
185
186        // Execute actions if not in no-execute mode
187        if !self.no_execute && !parsed_actions.is_empty() {
188            for action in parsed_actions {
189                let (action_type, target) = match &action {
190                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
191                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
192                    AgentAction::ReadFile { paths } => {
193                        if paths.len() == 1 {
194                            ("file_read", paths[0].clone())
195                        } else {
196                            ("file_read", format!("{} files", paths.len()))
197                        }
198                    }
199                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
200                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
201                    AgentAction::GitDiff { paths } => {
202                        if paths.len() == 1 {
203                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
204                        } else {
205                            ("git_diff", format!("{} paths", paths.len()))
206                        }
207                    }
208                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
209                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
210                    AgentAction::WebSearch { queries } => {
211                        if queries.len() == 1 {
212                            ("web_search", queries[0].0.clone())
213                        } else {
214                            ("web_search", format!("{} queries", queries.len()))
215                        }
216                    }
217                };
218
219                let result = execute_action(&action).await;
220
221                let action_result = match result {
222                    AgentActionResult::Success { output } => ActionResult {
223                        action_type: action_type.to_string(),
224                        target,
225                        success: true,
226                        output: Some(output),
227                    },
228                    AgentActionResult::Error { error } => ActionResult {
229                        action_type: action_type.to_string(),
230                        target,
231                        success: false,
232                        output: Some(error),
233                    },
234                };
235
236                actions.push(action_result);
237            }
238        } else if !parsed_actions.is_empty() {
239            // Actions were found but not executed (no-execute mode)
240            for action in parsed_actions {
241                let (action_type, target) = match &action {
242                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
243                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
244                    AgentAction::ReadFile { paths } => {
245                        if paths.len() == 1 {
246                            ("file_read", paths[0].clone())
247                        } else {
248                            ("file_read", format!("{} files", paths.len()))
249                        }
250                    }
251                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
252                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
253                    AgentAction::GitDiff { paths } => {
254                        if paths.len() == 1 {
255                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
256                        } else {
257                            ("git_diff", format!("{} paths", paths.len()))
258                        }
259                    }
260                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
261                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
262                    AgentAction::WebSearch { queries } => {
263                        if queries.len() == 1 {
264                            ("web_search", queries[0].0.clone())
265                        } else {
266                            ("web_search", format!("{} queries", queries.len()))
267                        }
268                    }
269                };
270
271                actions.push(ActionResult {
272                    action_type: action_type.to_string(),
273                    target,
274                    success: false,
275                    output: Some("Not executed (--no-execute mode)".to_string()),
276                });
277            }
278        }
279
280        let duration_ms = start_time.elapsed().as_millis();
281        let actions_executed = !self.no_execute && !actions.is_empty();
282
283        Ok(NonInteractiveResult {
284            prompt,
285            response: full_response,
286            actions,
287            errors,
288            metadata: ExecutionMetadata {
289                model: model_name,
290                tokens_used: Some(tokens_used),
291                duration_ms,
292                actions_executed,
293            },
294        })
295    }
296
297    /// Format the result according to the output format
298    pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
299        match format {
300            OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
301                format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
302            }),
303            OutputFormat::Text => {
304                let mut output = String::new();
305                output.push_str(&result.response);
306
307                if !result.actions.is_empty() {
308                    output.push_str("\n\n--- Actions ---\n");
309                    for action in &result.actions {
310                        output.push_str(&format!(
311                            "[{}] {} - {}\n",
312                            if action.success { "OK" } else { "FAIL" },
313                            action.action_type,
314                            action.target
315                        ));
316                        if let Some(ref out) = action.output {
317                            output.push_str(&format!("  {}\n", out));
318                        }
319                    }
320                }
321
322                if !result.errors.is_empty() {
323                    output.push_str("\n--- Errors ---\n");
324                    for error in &result.errors {
325                        output.push_str(&format!("• {}\n", error));
326                    }
327                }
328
329                output
330            },
331            OutputFormat::Markdown => {
332                let mut output = String::new();
333
334                output.push_str("## Response\n\n");
335                output.push_str(&result.response);
336                output.push_str("\n\n");
337
338                if !result.actions.is_empty() {
339                    output.push_str("## Actions Executed\n\n");
340                    for action in &result.actions {
341                        let status = if action.success { "SUCCESS" } else { "FAILED" };
342                        output.push_str(&format!(
343                            "- {} **{}**: `{}`\n",
344                            status, action.action_type, action.target
345                        ));
346                        if let Some(ref out) = action.output {
347                            output.push_str(&format!("  ```\n  {}\n  ```\n", out));
348                        }
349                    }
350                    output.push_str("\n");
351                }
352
353                if !result.errors.is_empty() {
354                    output.push_str("## Errors\n\n");
355                    for error in &result.errors {
356                        output.push_str(&format!("- {}\n", error));
357                    }
358                    output.push_str("\n");
359                }
360
361                output.push_str("---\n");
362                output.push_str(&format!(
363                    "*Model: {} | Tokens: {} | Duration: {}ms*\n",
364                    result.metadata.model,
365                    result.metadata.tokens_used.unwrap_or(0),
366                    result.metadata.duration_ms
367                ));
368
369                output
370            },
371        }
372    }
373}