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},
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: Vec<AgentAction> = 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                // Convert tool_calls to AgentActions
171                if let Some(tool_calls) = response.tool_calls {
172                    tool_calls
173                        .iter()
174                        .filter_map(|tc| tc.to_agent_action().ok())
175                        .collect()
176                } else {
177                    vec![]
178                }
179            },
180            Err(e) => {
181                errors.push(format!("Model error: {}", e));
182                full_response = response_text.lock_mut_safe().clone();
183                tokens_used = 0;
184                vec![]
185            },
186        };
187
188        // Execute actions if not in no-execute mode
189        if !self.no_execute && !parsed_actions.is_empty() {
190            for action in parsed_actions {
191                let (action_type, target) = match &action {
192                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
193                    AgentAction::EditFile { path, .. } => ("edit_file", path.clone()),
194                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
195                    AgentAction::ReadFile { paths } => {
196                        if paths.len() == 1 {
197                            ("file_read", paths[0].clone())
198                        } else {
199                            ("file_read", format!("{} files", paths.len()))
200                        }
201                    }
202                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
203                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
204                    AgentAction::GitDiff { paths } => {
205                        if paths.len() == 1 {
206                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
207                        } else {
208                            ("git_diff", format!("{} paths", paths.len()))
209                        }
210                    }
211                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
212                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
213                    AgentAction::WebSearch { queries } => {
214                        if queries.len() == 1 {
215                            ("web_search", queries[0].0.clone())
216                        } else {
217                            ("web_search", format!("{} queries", queries.len()))
218                        }
219                    }
220                    AgentAction::WebFetch { url } => ("web_fetch", url.clone()),
221                };
222
223                let result = execute_action(&action).await;
224
225                let action_result = match result {
226                    AgentActionResult::Success { output } => ActionResult {
227                        action_type: action_type.to_string(),
228                        target,
229                        success: true,
230                        output: Some(output),
231                    },
232                    AgentActionResult::Error { error } => ActionResult {
233                        action_type: action_type.to_string(),
234                        target,
235                        success: false,
236                        output: Some(error),
237                    },
238                };
239
240                actions.push(action_result);
241            }
242        } else if !parsed_actions.is_empty() {
243            // Actions were found but not executed (no-execute mode)
244            for action in parsed_actions {
245                let (action_type, target) = match &action {
246                    AgentAction::WriteFile { path, .. } => ("file_write", path.clone()),
247                    AgentAction::EditFile { path, .. } => ("edit_file", path.clone()),
248                    AgentAction::ExecuteCommand { command, .. } => ("command", command.clone()),
249                    AgentAction::ReadFile { paths } => {
250                        if paths.len() == 1 {
251                            ("file_read", paths[0].clone())
252                        } else {
253                            ("file_read", format!("{} files", paths.len()))
254                        }
255                    }
256                    AgentAction::CreateDirectory { path } => ("create_dir", path.clone()),
257                    AgentAction::DeleteFile { path } => ("delete_file", path.clone()),
258                    AgentAction::GitDiff { paths } => {
259                        if paths.len() == 1 {
260                            ("git_diff", paths[0].as_deref().unwrap_or("*").to_string())
261                        } else {
262                            ("git_diff", format!("{} paths", paths.len()))
263                        }
264                    }
265                    AgentAction::GitStatus => ("git_status", "git status".to_string()),
266                    AgentAction::GitCommit { message, .. } => ("git_commit", message.clone()),
267                    AgentAction::WebSearch { queries } => {
268                        if queries.len() == 1 {
269                            ("web_search", queries[0].0.clone())
270                        } else {
271                            ("web_search", format!("{} queries", queries.len()))
272                        }
273                    }
274                    AgentAction::WebFetch { url } => ("web_fetch", url.clone()),
275                };
276
277                actions.push(ActionResult {
278                    action_type: action_type.to_string(),
279                    target,
280                    success: false,
281                    output: Some("Not executed (--no-execute mode)".to_string()),
282                });
283            }
284        }
285
286        let duration_ms = start_time.elapsed().as_millis();
287        let actions_executed = !self.no_execute && !actions.is_empty();
288
289        Ok(NonInteractiveResult {
290            prompt,
291            response: full_response,
292            actions,
293            errors,
294            metadata: ExecutionMetadata {
295                model: model_name,
296                tokens_used: Some(tokens_used),
297                duration_ms,
298                actions_executed,
299            },
300        })
301    }
302
303    /// Format the result according to the output format
304    pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
305        match format {
306            OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
307                format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
308            }),
309            OutputFormat::Text => {
310                let mut output = String::new();
311                output.push_str(&result.response);
312
313                if !result.actions.is_empty() {
314                    output.push_str("\n\n--- Actions ---\n");
315                    for action in &result.actions {
316                        output.push_str(&format!(
317                            "[{}] {} - {}\n",
318                            if action.success { "OK" } else { "FAIL" },
319                            action.action_type,
320                            action.target
321                        ));
322                        if let Some(ref out) = action.output {
323                            output.push_str(&format!("  {}\n", out));
324                        }
325                    }
326                }
327
328                if !result.errors.is_empty() {
329                    output.push_str("\n--- Errors ---\n");
330                    for error in &result.errors {
331                        output.push_str(&format!("• {}\n", error));
332                    }
333                }
334
335                output
336            },
337            OutputFormat::Markdown => {
338                let mut output = String::new();
339
340                output.push_str("## Response\n\n");
341                output.push_str(&result.response);
342                output.push_str("\n\n");
343
344                if !result.actions.is_empty() {
345                    output.push_str("## Actions Executed\n\n");
346                    for action in &result.actions {
347                        let status = if action.success { "SUCCESS" } else { "FAILED" };
348                        output.push_str(&format!(
349                            "- {} **{}**: `{}`\n",
350                            status, action.action_type, action.target
351                        ));
352                        if let Some(ref out) = action.output {
353                            output.push_str(&format!("  ```\n  {}\n  ```\n", out));
354                        }
355                    }
356                    output.push_str("\n");
357                }
358
359                if !result.errors.is_empty() {
360                    output.push_str("## Errors\n\n");
361                    for error in &result.errors {
362                        output.push_str(&format!("- {}\n", error));
363                    }
364                    output.push_str("\n");
365                }
366
367                output.push_str("---\n");
368                output.push_str(&format!(
369                    "*Model: {} | Tokens: {} | Duration: {}ms*\n",
370                    result.metadata.model,
371                    result.metadata.tokens_used.unwrap_or(0),
372                    result.metadata.duration_ms
373                ));
374
375                output
376            },
377        }
378    }
379}