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