Skip to main content

mermaid_cli/app/
run_non_interactive.rs

1//! Headless driver for `mermaid run <prompt>`.
2//!
3//! Same reducer + same effect runner + same providers + same tools
4//! as the interactive path. Differences: no `TerminalGuard`, no
5//! crossterm events, no tick timer, no render. One synthetic
6//! `Msg::SubmitPrompt` seeds the reducer; the loop spins until
7//! `state.turn == Idle` and the queue is empty.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::Config;
16use crate::app::lifecycle::RuntimeLifecycle;
17use crate::cli::OutputFormat;
18use crate::domain::{Msg, State, TurnState, update};
19use crate::effect::EffectRunner;
20use crate::models::MessageRole;
21use crate::providers::ToolRegistry;
22
23/// Output shape the CLI prints.
24#[derive(Debug, Default)]
25pub struct RunResult {
26    pub response: String,
27    pub reasoning: Option<String>,
28    pub total_tokens: usize,
29    pub errors: Vec<String>,
30}
31
32/// Per-invocation options for `run_non_interactive`.
33///
34/// Added as a struct so new flags can land without reshuffling the
35/// function's positional args. All fields default to "no change".
36#[derive(Debug, Default, Clone)]
37pub struct RunOptions {
38    /// When true, register an empty `ToolRegistry` — the model sees no
39    /// tools and can't take actions. Dry-run mode for
40    /// `mermaid run --no-execute`.
41    pub no_execute: bool,
42    /// Durable runtime task that owns this run, when launched through
43    /// `mermaidd` or `mermaid run` task creation.
44    pub task_id: Option<String>,
45}
46
47/// Drive one prompt to completion with explicit per-call options. Bounded by a
48/// generous 20-minute wall-clock so a runaway model doesn't hang a script.
49pub async fn run_non_interactive_with(
50    config: Config,
51    cwd: PathBuf,
52    model_id: String,
53    prompt: String,
54    opts: RunOptions,
55) -> Result<RunResult> {
56    let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
57    // F6 `--no-execute`: build an empty tool registry so the model can
58    // plan but never act. MCP init below is also skipped to match.
59    let tools = if opts.no_execute {
60        std::sync::Arc::new(ToolRegistry::new())
61    } else {
62        ToolRegistry::build(
63            &config,
64            crate::providers::TuiMode::Headless,
65            providers.clone(),
66        )
67    };
68    let (mut runner, mut msg_rx) =
69        EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
70    runner = runner.without_terminal_title();
71
72    let mut state = State::new(config.clone(), cwd.clone(), model_id, chrono::Local::now());
73    let mut lifecycle = RuntimeLifecycle::new();
74
75    // Load project instructions + the memory index synchronously. The
76    // interactive TUI gets these from the config watcher's first poll
77    // (`run.rs`), which the headless driver never spawns — so without this the
78    // model call would go out with no MERMAID.md/AGENTS.md and no memory, while
79    // `mermaid doctor` reports them loaded. `build_chat_request` reads them off
80    // `state`, so they must be in place before the seed below.
81    let (instructions, memory) =
82        crate::app::instructions::load_project_context(&cwd, &config.memory);
83    state.instructions = instructions;
84    state.memory = memory;
85
86    // Bootstrap effects (MCP init) before the first prompt.
87    //
88    // Skip MCP init when `--no-execute` — MCP tools would advertise
89    // through the registry we just emptied, so spinning up their
90    // processes is wasted work.
91    if !config.mcp_servers.is_empty() && !opts.no_execute {
92        runner.dispatch(crate::domain::Cmd::InitMcpServers(
93            config.mcp_servers.clone(),
94        ));
95    }
96
97    // Seed the turn.
98    let seed = Msg::SubmitPrompt {
99        text: prompt,
100        attachment_ids: vec![],
101    };
102    // Inject the wall clock as data so the reducer stays pure (Cause 3).
103    state.now = chrono::Local::now();
104    let (new_state, cmds) = update(state, seed);
105    state = new_state;
106    for cmd in cmds {
107        runner.dispatch(cmd);
108    }
109
110    let deadline = Duration::from_secs(20 * 60);
111
112    let drive = async {
113        while !matches!(state.turn, TurnState::Idle) || !state.ui.queued_messages.is_empty() {
114            let msg = tokio::select! {
115                m = msg_rx.recv() => match m {
116                    Some(m) => m,
117                    None => break,
118                },
119                s = lifecycle.next_msg() => match s {
120                    Some(s) => s,
121                    None => continue,
122                },
123            };
124            state.now = chrono::Local::now();
125            let (new_state, cmds) = update(state, msg);
126            state = new_state;
127            for cmd in cmds {
128                runner.dispatch(cmd);
129            }
130            if state.should_exit {
131                break;
132            }
133        }
134        state
135    };
136
137    let final_state = timeout(deadline, drive).await.map_err(|_| {
138        anyhow::anyhow!(
139            "non-interactive run exceeded {} seconds",
140            deadline.as_secs()
141        )
142    })?;
143
144    runner.shutdown().await;
145    Ok(build_result(&final_state))
146}
147
148/// Walk the committed message history and pull out the last
149/// assistant response + any errors encountered.
150fn build_result(state: &State) -> RunResult {
151    let mut out = RunResult {
152        total_tokens: state.session.cumulative_token_usage.total_tokens,
153        ..RunResult::default()
154    };
155
156    for msg in state.session.messages() {
157        for action in &msg.actions {
158            if let crate::domain::ActionResult::Error { error } = &action.result {
159                out.errors
160                    .push(format!("{}: {}", action.action_type, error));
161            }
162        }
163    }
164
165    if let Some(last) = state
166        .session
167        .messages()
168        .iter()
169        .rev()
170        .find(|m| m.role == MessageRole::Assistant)
171    {
172        out.response = last.content.clone();
173        out.reasoning = last.thinking.clone();
174    }
175
176    out
177}
178
179/// Render a `RunResult` in the requested output format.
180pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
181    match format {
182        OutputFormat::Text => {
183            if result.response.is_empty() && !result.errors.is_empty() {
184                result.errors.join("\n")
185            } else {
186                result.response.clone()
187            }
188        },
189        OutputFormat::Markdown => {
190            let mut out = result.response.clone();
191            if !result.errors.is_empty() {
192                out.push_str("\n\n---\n\n## Errors\n\n");
193                for e in &result.errors {
194                    out.push_str(&format!("- {}\n", e));
195                }
196            }
197            out
198        },
199        OutputFormat::Json => {
200            let json = serde_json::json!({
201                "response": result.response,
202                "reasoning": result.reasoning,
203                "total_tokens": result.total_tokens,
204                "errors": result.errors,
205            });
206            serde_json::to_string_pretty(&json).unwrap_or_default()
207        },
208    }
209}