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