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    constants::{DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE},
13    models::{ChatMessage, Model, ModelConfig, ModelFactory},
14    prompts,
15};
16
17use super::agent_loop::{self, AgentObserver, LoopControl, MAX_AGENT_ITERATIONS};
18
19/// Result of a non-interactive run
20#[derive(Debug, Serialize, Deserialize)]
21pub struct NonInteractiveResult {
22    /// The prompt that was executed
23    pub prompt: String,
24    /// The model's response
25    pub response: String,
26    /// Actions that were executed (if any)
27    pub actions: Vec<ActionResult>,
28    /// Any errors that occurred
29    pub errors: Vec<String>,
30    /// Metadata about the execution
31    pub metadata: ExecutionMetadata,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct ActionResult {
36    /// Type of action (file_write, command, etc.)
37    pub action_type: String,
38    /// Target (file path or command)
39    pub target: String,
40    /// Whether the action was executed successfully
41    pub success: bool,
42    /// Output or error message
43    pub output: Option<String>,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47pub struct ExecutionMetadata {
48    /// Model used
49    pub model: String,
50    /// Total tokens used
51    pub tokens_used: Option<usize>,
52    /// Execution time in milliseconds
53    pub duration_ms: u128,
54    /// Whether actions were executed
55    pub actions_executed: bool,
56}
57
58/// Non-interactive runner for executing single prompts
59pub struct NonInteractiveRunner {
60    model: Arc<RwLock<Box<dyn Model>>>,
61    no_execute: bool,
62    max_tokens: Option<usize>,
63}
64
65impl NonInteractiveRunner {
66    /// Create a new non-interactive runner
67    pub async fn new(
68        model_id: String,
69        config: Config,
70        no_execute: bool,
71        max_tokens: Option<usize>,
72    ) -> Result<Self> {
73        // Create model instance
74        let model = ModelFactory::create(&model_id, Some(&config)).await?;
75
76        Ok(Self {
77            model: Arc::new(RwLock::new(model)),
78            no_execute,
79            max_tokens,
80        })
81    }
82
83    /// Execute a single prompt and return the result
84    pub async fn execute(&self, prompt: String) -> Result<NonInteractiveResult> {
85        let start_time = std::time::Instant::now();
86        let mut errors = Vec::new();
87        let mut total_tokens = 0;
88
89        // Build initial messages
90        let system_message = ChatMessage::system(prompts::get_system_prompt());
91        let user_message = ChatMessage::user(prompt.clone());
92        let mut messages = vec![system_message, user_message];
93
94        // Build model config
95        let model_name = {
96            let model = self.model.read().await;
97            model.name().to_string()
98        };
99        let model_config = ModelConfig {
100            model: model_name.clone(),
101            temperature: DEFAULT_TEMPERATURE,
102            max_tokens: self.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
103            thinking_enabled: Some(false),
104            ..ModelConfig::default()
105        };
106
107        // First model call to get the initial response
108        let response_text = Arc::new(std::sync::Mutex::new(String::new()));
109        let response_clone = Arc::clone(&response_text);
110        let callback = Arc::new(move |chunk: &str| {
111            let mut resp = response_clone.lock_mut_safe();
112            resp.push_str(chunk);
113        });
114
115        let result = {
116            let model = self.model.read().await;
117            model.chat(&messages, &model_config, Some(callback)).await
118        };
119
120        let (content, initial_tool_calls) = match result {
121            Ok(response) => {
122                let callback_content = response_text.lock_mut_safe().clone();
123                let content = if !callback_content.is_empty() {
124                    callback_content
125                } else {
126                    response.content
127                };
128                total_tokens += response.usage.map(|u| u.total_tokens).unwrap_or(0);
129                let tool_calls = response.tool_calls.unwrap_or_default();
130                (content, tool_calls)
131            },
132            Err(e) => {
133                errors.push(format!("Model error: {}", e));
134                let content = response_text.lock_mut_safe().clone();
135                (content, vec![])
136            },
137        };
138
139        // If no tool calls, return immediately
140        if initial_tool_calls.is_empty() {
141            let duration_ms = start_time.elapsed().as_millis();
142            return Ok(NonInteractiveResult {
143                prompt,
144                response: content,
145                actions: vec![],
146                errors,
147                metadata: ExecutionMetadata {
148                    model: model_name,
149                    tokens_used: Some(total_tokens),
150                    duration_ms,
151                    actions_executed: false,
152                },
153            });
154        }
155
156        // Add assistant message with tool calls to history
157        let assistant_msg =
158            ChatMessage::assistant(content.clone()).with_tool_calls(initial_tool_calls.clone());
159        messages.push(assistant_msg);
160
161        // Handle --no-execute mode: record tool calls but don't execute them
162        if self.no_execute {
163            let actions = build_no_execute_actions(&initial_tool_calls, &mut messages);
164            let duration_ms = start_time.elapsed().as_millis();
165            return Ok(NonInteractiveResult {
166                prompt,
167                response: content,
168                actions,
169                errors,
170                metadata: ExecutionMetadata {
171                    model: model_name,
172                    tokens_used: Some(total_tokens),
173                    duration_ms,
174                    actions_executed: false,
175                },
176            });
177        }
178
179        // Delegate to shared agent loop for tool execution + model re-calling
180        let mut observer = SilentObserver;
181        let loop_result = agent_loop::run_agent_loop(
182            Arc::clone(&self.model),
183            &model_config,
184            &mut messages,
185            initial_tool_calls,
186            &mut observer,
187            MAX_AGENT_ITERATIONS,
188        )
189        .await?;
190
191        // Build result from the agent loop
192        total_tokens += loop_result.total_tokens;
193        let final_response = if loop_result.final_response.is_empty() {
194            content
195        } else {
196            loop_result.final_response
197        };
198
199        let actions: Vec<ActionResult> = loop_result
200            .tool_results
201            .iter()
202            .map(|tr| {
203                let (action_type, target) = extract_action_info(&tr.action);
204                ActionResult {
205                    action_type,
206                    target,
207                    success: tr.success,
208                    output: Some(tr.output.clone()),
209                }
210            })
211            .collect();
212
213        if loop_result.interrupted {
214            errors.push("Agent loop was interrupted".to_string());
215        }
216
217        let duration_ms = start_time.elapsed().as_millis();
218        let actions_executed = !actions.is_empty();
219        Ok(NonInteractiveResult {
220            prompt,
221            response: final_response,
222            actions,
223            errors,
224            metadata: ExecutionMetadata {
225                model: model_name,
226                tokens_used: Some(total_tokens),
227                duration_ms,
228                actions_executed,
229            },
230        })
231    }
232
233    /// Format the result according to the output format
234    pub fn format_result(&self, result: &NonInteractiveResult, format: OutputFormat) -> String {
235        match format {
236            OutputFormat::Json => serde_json::to_string_pretty(result).unwrap_or_else(|e| {
237                format!("{{\"error\": \"Failed to serialize result: {}\"}}", e)
238            }),
239            OutputFormat::Text => {
240                let mut output = String::new();
241                output.push_str(&result.response);
242
243                if !result.actions.is_empty() {
244                    output.push_str("\n\n--- Actions ---\n");
245                    for action in &result.actions {
246                        output.push_str(&format!(
247                            "[{}] {} - {}\n",
248                            if action.success { "OK" } else { "FAIL" },
249                            action.action_type,
250                            action.target
251                        ));
252                        if let Some(ref out) = action.output {
253                            output.push_str(&format!("  {}\n", out));
254                        }
255                    }
256                }
257
258                if !result.errors.is_empty() {
259                    output.push_str("\n--- Errors ---\n");
260                    for error in &result.errors {
261                        output.push_str(&format!("• {}\n", error));
262                    }
263                }
264
265                output
266            },
267            OutputFormat::Markdown => {
268                let mut output = String::new();
269
270                output.push_str("## Response\n\n");
271                output.push_str(&result.response);
272                output.push_str("\n\n");
273
274                if !result.actions.is_empty() {
275                    output.push_str("## Actions Executed\n\n");
276                    for action in &result.actions {
277                        let status = if action.success { "SUCCESS" } else { "FAILED" };
278                        output.push_str(&format!(
279                            "- {} **{}**: `{}`\n",
280                            status, action.action_type, action.target
281                        ));
282                        if let Some(ref out) = action.output {
283                            output.push_str(&format!("  ```\n  {}\n  ```\n", out));
284                        }
285                    }
286                    output.push('\n');
287                }
288
289                if !result.errors.is_empty() {
290                    output.push_str("## Errors\n\n");
291                    for error in &result.errors {
292                        output.push_str(&format!("- {}\n", error));
293                    }
294                    output.push('\n');
295                }
296
297                output.push_str("---\n");
298                output.push_str(&format!(
299                    "*Model: {} | Tokens: {} | Duration: {}ms*\n",
300                    result.metadata.model,
301                    result.metadata.tokens_used.unwrap_or(0),
302                    result.metadata.duration_ms
303                ));
304
305                output
306            },
307        }
308    }
309}
310
311/// Extract action type and target description from an AgentAction
312fn extract_action_info(action: &AgentAction) -> (String, String) {
313    let (label, target) = action.display_info();
314    (label.to_lowercase().replace(' ', "_"), target)
315}
316
317/// Build ActionResult entries for --no-execute mode (records tool calls without executing)
318fn build_no_execute_actions(
319    tool_calls: &[crate::models::ToolCall],
320    messages: &mut Vec<ChatMessage>,
321) -> Vec<ActionResult> {
322    let mut actions = Vec::new();
323    for tc in tool_calls {
324        let tool_call_id = tc.id.clone().unwrap_or_else(|| "call_noexec".to_string());
325        let tool_name = tc.function.name.clone();
326
327        let (action_type, target) = match tc.to_agent_action() {
328            Ok(action) => extract_action_info(&action),
329            Err(_) => (tool_name.clone(), String::new()),
330        };
331
332        let msg = "Not executed (--no-execute mode)".to_string();
333        messages.push(ChatMessage::tool(&tool_call_id, &tool_name, &msg));
334        actions.push(ActionResult {
335            action_type,
336            target,
337            success: false,
338            output: Some(msg),
339        });
340    }
341    actions
342}
343
344/// Observer that does nothing -- used by non-interactive mode
345struct SilentObserver;
346
347impl AgentObserver for SilentObserver {
348    fn check_interrupt(&mut self) -> LoopControl {
349        LoopControl::Continue
350    }
351    fn on_status(&mut self, _: &str) {}
352    fn on_tool_result(&mut self, _: &str, _: &str, _: &AgentAction, _: &AgentActionResult) {}
353    fn on_error(&mut self, _: &str) {}
354    fn on_generation_start(&mut self) {}
355    fn on_generation_complete(&mut self, _: usize) {}
356}